<?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: AI Dev Hub</title>
    <description>The latest articles on DEV Community by AI Dev Hub (@aidevhub).</description>
    <link>https://dev.to/aidevhub</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%2F3769170%2F51b2c1be-6090-4a70-b86f-000759e46929.png</url>
      <title>DEV Community: AI Dev Hub</title>
      <link>https://dev.to/aidevhub</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/aidevhub"/>
    <language>en</language>
    <item>
      <title>LangChain vs LlamaIndex vs Chonkie: same 94-page PDF</title>
      <dc:creator>AI Dev Hub</dc:creator>
      <pubDate>Thu, 20 Aug 2026 14:00:03 +0000</pubDate>
      <link>https://dev.to/aidevhub/langchain-vs-llamaindex-vs-chonkie-same-94-page-pdf-4a58</link>
      <guid>https://dev.to/aidevhub/langchain-vs-llamaindex-vs-chonkie-same-94-page-pdf-4a58</guid>
      <description>&lt;h1&gt;
  
  
  LangChain vs LlamaIndex vs Chonkie: same 94-page PDF
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;LlamaIndex. Its SentenceSplitter gave me the best recall@5 (0.86, against 0.79 for LangChain's recursive splitter) on a 94-page policy PDF, without writing a custom separator list. LangChain wins if you're already deep in LCEL. Chonkie is the fastest by a wide margin and the one I'd pick for a batch job over a million documents. Chunk size mattered more than the library did.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Quick disclosure before anything else: the RAG chunk size calculator I link to below is one I built. I got tired of re-deriving the same token math in a scratch file, and the four existing pages I found all assumed OpenAI's tokenizer and 1,000-character chunks. Mine is free, runs entirely in your browser, no signup, nothing uploaded. If you know a better one, tell me and I'll link it instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  The task: one 94-page policy PDF and 38 questions
&lt;/h2&gt;

&lt;p&gt;Last Tuesday I got handed a commercial property insurance policy and a support inbox. The ask was ordinary: answer questions like "what's the deductible for wind damage during a named storm" without a human reading 94 pages every time.&lt;/p&gt;

&lt;p&gt;I pulled the text with &lt;code&gt;pdftotext -layout&lt;/code&gt;, which gave me 214,883 characters. Then I sat down and wrote 38 questions by hand, each paired with a "needle" string that appears on exactly one page. That labelling took 47 minutes and it's the only reason any number below means anything. A chunking benchmark without labels is just vibes with decimal places.&lt;/p&gt;

&lt;p&gt;Same setup for every run: &lt;code&gt;BAAI/bge-small-en-v1.5&lt;/code&gt; as the embedding model, normalized vectors, cosine similarity, top 5 results. The metric is recall@5, the fraction of questions where at least one of the top 5 chunks contains the needle.&lt;/p&gt;

&lt;p&gt;Here's the scoring script. No framework, no vector database, just numpy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# eval_chunks.py - score a chunker by recall@5 on hand-labelled questions
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;numpy&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sentence_transformers&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;SentenceTransformer&lt;/span&gt;

&lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SentenceTransformer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;BAAI/bge-small-en-v1.5&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;recall_at_k&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;questions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;cv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;normalize_embeddings&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;batch_size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;qv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;questions&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;normalize_embeddings&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;questions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;qv&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;top&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;argsort&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="n"&gt;cv&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;))[:&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;needle&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;top&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;questions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;split_fn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;questions&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;t0&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perf_counter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;chunks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;split_fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;dt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perf_counter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;t0&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;recall_at_k&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;questions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;11&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; chunks=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  split=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;dt&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;6.2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;s  recall@5=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;policy.txt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;questions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;questions.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

    &lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;langchain_text_splitters&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;RecursiveCharacterTextSplitter&lt;/span&gt;
    &lt;span class="n"&gt;lc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;RecursiveCharacterTextSplitter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_tiktoken_encoder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;chunk_size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;512&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chunk_overlap&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;langchain&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;split_text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;questions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;llama_index.core.node_parser&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;SentenceSplitter&lt;/span&gt;
    &lt;span class="n"&gt;li&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SentenceSplitter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunk_size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;512&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chunk_overlap&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;llamaindex&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;li&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;split_text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;questions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;chonkie&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;RecursiveChunker&lt;/span&gt;
    &lt;span class="n"&gt;ch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;RecursiveChunker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunk_size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;512&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;chonkie&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;questions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three lines of output. Then you get to argue with them.&lt;/p&gt;

&lt;h2&gt;
  
  
  LangChain: I was wrong about this one for an hour
&lt;/h2&gt;

&lt;p&gt;First run, LangChain came dead last. 0.44 recall against LlamaIndex's 0.86. That gap felt too big to be real, and it wasn't.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;RecursiveCharacterTextSplitter(chunk_size=512)&lt;/code&gt; counts characters. &lt;code&gt;SentenceSplitter(chunk_size=512)&lt;/code&gt; counts tokens. Same parameter name, same value, and for English prose that's roughly a 4x difference in how much text lands in each chunk. My careful apples-to-apples comparison was quietly pitting 512-character chunks against chunks of about 2,000 characters. I'd made this exact mistake in April on a different project and still walked straight into it again.&lt;/p&gt;

&lt;p&gt;Switching to &lt;code&gt;RecursiveCharacterTextSplitter.from_tiktoken_encoder(chunk_size=512, chunk_overlap=64)&lt;/code&gt; fixed it: 137 chunks, 0.79 recall, 2.8 seconds. Perfectly respectable.&lt;/p&gt;

&lt;p&gt;The thing that still bugs me is what that helper counts with. It pulls in tiktoken and defaults to &lt;code&gt;cl100k_base&lt;/code&gt;, an OpenAI tokenizer. I'm embedding with a BERT-family model that uses WordPiece. On plain English the two agree within about 8%, so nothing explodes, but on code, JSON blobs or non-Latin scripts the drift gets ugly and your "512-token" chunks start overflowing a 512-token encoder. Silent truncation, no warning.&lt;/p&gt;

&lt;p&gt;Import paths are also a mess if you're following an older tutorial. It's &lt;code&gt;langchain_text_splitters&lt;/code&gt; now, and half the search results still say &lt;code&gt;from langchain.text_splitter import&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  LlamaIndex: the boring one that won
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;SentenceSplitter&lt;/code&gt; at 512 tokens with 64 overlap: 129 chunks, 0.86 recall, 4.1 seconds. It was the slowest of the three on a single document because it actually runs a sentence tokenizer before doing anything else.&lt;/p&gt;

&lt;p&gt;That sentence tokenizer is the whole reason it won. Insurance definitions read like "Named Storm means any storm or weather disturbance that is named by the National Weather Service." Cut that in the middle and neither half retrieves for a question about named storms. The recursive splitters get this right most of the time via their separator list, but "most of the time" across 129 chunks is a handful of dead ones.&lt;/p&gt;

&lt;p&gt;Two things I didn't love. The install is heavy: &lt;code&gt;llama-index-core&lt;/code&gt; was 41 MB in a fresh venv against 2.9 MB for chonkie. If chunking is the only thing you want, that's a lot of framework to carry.&lt;/p&gt;

&lt;p&gt;Bigger issue is the defaults. &lt;code&gt;SentenceSplitter()&lt;/code&gt; with no arguments is chunk_size=1024, chunk_overlap=200. On my document that scored 0.68. Nobody's default is tuned for your corpus, and this one is off by enough to matter.&lt;/p&gt;

&lt;h2&gt;
  
  
  Chonkie: fast, small, and it surprised me
&lt;/h2&gt;

&lt;p&gt;Chonkie is the small library in this comparison and I expected it to lose. It didn't. &lt;code&gt;RecursiveChunker&lt;/code&gt; at 512 tokens gave 133 chunks and 0.82 recall in 0.38 seconds. That's within noise of LlamaIndex on quality and roughly 10x faster.&lt;/p&gt;

&lt;p&gt;Speed stops being an academic concern once the corpus grows. I ran all three over the client's full document set (1,240 files, 2.1 GB of extracted text). Chonkie finished in 47 seconds. LangChain took 3 minutes 4 seconds. LlamaIndex took 6 minutes 12 seconds. For a one-time ingest, who cares. For a nightly re-index that has to finish before the morning, that's the difference between a cron job you forget about and a Slack alert at 2am.&lt;/p&gt;

&lt;p&gt;I also tried &lt;code&gt;SemanticChunker&lt;/code&gt;, which embeds each sentence and splits where similarity drops. 0.84 recall, 11.3 seconds on one document. Thirty times the cost for two points I can't distinguish from measurement noise on 38 questions. On a genuinely mixed corpus it might earn its keep. Here it didn't.&lt;/p&gt;

&lt;p&gt;The rough edge is polish. The API moved between the version most blog posts describe and the 0.5.x I installed, so half the snippets I found were wrong. And when I passed a tokenizer name it didn't recognise, I got a bare &lt;code&gt;KeyError&lt;/code&gt; out of a dict lookup with no hint about what the valid names are. That cost me twenty minutes I'd rather have spent elsewhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  The scores, and which one I'd actually ship
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Criterion&lt;/th&gt;
&lt;th&gt;LangChain&lt;/th&gt;
&lt;th&gt;LlamaIndex&lt;/th&gt;
&lt;th&gt;Chonkie&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Best recall@5, 38 questions&lt;/td&gt;
&lt;td&gt;0.79&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.86&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.82&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What &lt;code&gt;chunk_size&lt;/code&gt; counts by default&lt;/td&gt;
&lt;td&gt;characters&lt;/td&gt;
&lt;td&gt;tokens&lt;/td&gt;
&lt;td&gt;tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chunk one 94-page doc&lt;/td&gt;
&lt;td&gt;2.8s&lt;/td&gt;
&lt;td&gt;4.1s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.38s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chunk 1,240 docs&lt;/td&gt;
&lt;td&gt;3m 04s&lt;/td&gt;
&lt;td&gt;6m 12s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;47s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Install size, fresh venv&lt;/td&gt;
&lt;td&gt;8.4 MB&lt;/td&gt;
&lt;td&gt;41 MB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.9 MB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Error on a bad tokenizer name&lt;/td&gt;
&lt;td&gt;named ValueError&lt;/td&gt;
&lt;td&gt;named ValueError&lt;/td&gt;
&lt;td&gt;bare KeyError&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Found the answer in docs under 2 min&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Defaults I'd ship unchanged&lt;/td&gt;
&lt;td&gt;no (characters)&lt;/td&gt;
&lt;td&gt;no (1024/200)&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I'd use LlamaIndex's &lt;code&gt;SentenceSplitter&lt;/code&gt; for anything under roughly ten thousand documents where answer quality is the point. Above that, or anywhere re-indexing runs on a schedule, Chonkie. If your pipeline already lives in LangChain, stay there and just call &lt;code&gt;from_tiktoken_encoder&lt;/code&gt; explicitly, because the character default will bite you and it won't be loud about it.&lt;/p&gt;

&lt;p&gt;Here's the finding I keep coming back to, though. Across the three libraries at their best settings the spread was 0.07. Across chunk sizes with a single library, it was 0.18: 256 tokens scored 0.74, 512 scored 0.86, 768 scored 0.81, 1024 scored 0.68. The parameter beat the vendor by more than double. If you're agonising over which splitter to import before you've swept chunk size, you're optimising the wrong variable.&lt;/p&gt;

&lt;p&gt;That gap is why I built the &lt;a href="https://aidevhub.io/rag-chunk-calculator/" rel="noopener noreferrer"&gt;RAG chunk size calculator&lt;/a&gt;. Feed it your embedding model and the kind of document you're indexing, and it hands back a starting chunk size and overlap along with the token math (context window, characters per token for your tokenizer, how many chunks that means for a document of size N). It's a starting point, not an oracle. You still need your own 30 labelled questions.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Does overlap actually help, or is it cargo cult?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; It helps, less than people assume. Zero overlap scored 0.79, 64 tokens scored 0.86, 128 tokens scored 0.87 while producing 22% more chunks to store and search. I settled on 64, about 12.5% of chunk size, and that ratio has held up on two other projects since.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Why not just use semantic chunking everywhere?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Because on this document it bought 0.02 recall for 30x the processing time. Semantic chunking pays off when a single file jumps between unrelated topics. A structured policy document already has that structure in its headings.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Will these numbers hold for my documents?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Almost certainly not, and I'd be suspicious of anyone who told you otherwise. Legal and insurance prose is dense, repetitive and full of defined terms, which flatters sentence-aware splitting. Chat logs or source code behave differently. Copy the script above, label 30 questions of your own, rerun it. Mine took 47 minutes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; What about markdown and code files?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Different tools. Use a header-aware splitter for markdown so you keep section context attached, and Chonkie's &lt;code&gt;CodeChunker&lt;/code&gt; (or a tree-sitter based splitter) for source, so functions stay whole. Splitting code on blank lines destroys exactly the boundaries you want to retrieve on.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written with AI assistance and human review. Try the tool at &lt;a href="https://aidevhub.io/rag-chunk-calculator/" rel="noopener noreferrer"&gt;aidevhub.io/rag-chunk-calculator&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>machinelearning</category>
      <category>rag</category>
    </item>
    <item>
      <title>A valid sitemap.xml for 1,247 URLs with a free generator in 2026</title>
      <dc:creator>AI Dev Hub</dc:creator>
      <pubDate>Thu, 13 Aug 2026 14:00:03 +0000</pubDate>
      <link>https://dev.to/aidevhub/a-valid-sitemapxml-for-1247-urls-with-a-free-generator-in-2026-4f15</link>
      <guid>https://dev.to/aidevhub/a-valid-sitemapxml-for-1247-urls-with-a-free-generator-in-2026-4f15</guid>
      <description>&lt;h1&gt;
  
  
  A valid sitemap.xml for 1,247 URLs with a free generator in 2026
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;Paste your URLs into a client-side sitemap generator, add lastmod, and ignore changefreq and priority. Google ignores both, and has for years. A valid sitemap.xml is nine lines of boilerplate plus one url block per page, capped at 50,000 URLs or 50MB uncompressed. The XML is the easy part. Keeping the list free of redirects and 404s is what actually moves crawl budget.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Up front: the sitemap generator I link to below is one I built. I tried six existing ones in March 2026 and every one either uploaded my URL list to a server or capped the free tier at 100 URLs. Mine is free, runs entirely in the browser, no signup, and nothing leaves your machine. If you know a better one, tell me and I'll link it instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  The migration that shipped 412 dead URLs
&lt;/h2&gt;

&lt;p&gt;In February 2026 I moved a docs site off a flat &lt;code&gt;/guides/&lt;/code&gt; structure and onto &lt;code&gt;/docs/&amp;lt;version&amp;gt;/&lt;/code&gt;. Around 1,247 pages. The sitemap came from a shell pipeline I wrote back in 2023: &lt;code&gt;find&lt;/code&gt; the build output and wrap each line in &lt;code&gt;&amp;lt;loc&amp;gt;&lt;/code&gt; tags with &lt;code&gt;sed&lt;/code&gt;. It had worked for three years without a single complaint.&lt;/p&gt;

&lt;p&gt;It kept working after the migration. That was the problem.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;find&lt;/code&gt; walked the old &lt;code&gt;dist/guides/&lt;/code&gt; directory, which the new build never cleaned out. So the sitemap listed 412 URLs that returned 404 sitting next to the 1,247 real ones. Search Console flagged it 9 days later under "Submitted URL not found". By then Googlebot had burned a chunk of its crawl budget on pages that didn't exist, and half the new &lt;code&gt;/docs/&lt;/code&gt; tree was still unindexed.&lt;/p&gt;

&lt;p&gt;I fixed it the dumb way first: &lt;code&gt;rm -rf dist/&lt;/code&gt; at the top of the build. That killed the stale files and immediately created a different problem. My &lt;code&gt;sed&lt;/code&gt; pipeline had no concept of &lt;code&gt;lastmod&lt;/code&gt;, so every entry either got an identical timestamp or none at all, depending on which branch of the script ran. A sitemap claiming all 1,247 pages changed at the same second tells a crawler nothing. I was wrong to treat lastmod as optional decoration. It's the one optional field Google actually reads.&lt;/p&gt;

&lt;p&gt;Attempt two was a Node script using the &lt;code&gt;sitemap&lt;/code&gt; npm package. That's still what runs in CI today and I have no complaints about it. It's the wrong tool when a colleague drops a CSV of 90 URLs in Slack and wants a sitemap before lunch. Installing a dependency and re-reading the API docs for a one-off costs 20 minutes I'd rather not spend.&lt;/p&gt;

&lt;p&gt;That's the gap a paste-in generator fills. Paste, configure, download. 47 seconds, nothing to maintain afterward.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a valid sitemap.xml actually needs
&lt;/h2&gt;

&lt;p&gt;The spec is smaller than most people expect. sitemaps.org froze at version 0.9 and never moved. You need one &lt;code&gt;urlset&lt;/code&gt; element carrying the namespace, and inside it one &lt;code&gt;url&lt;/code&gt; element per page with a required &lt;code&gt;loc&lt;/code&gt;. Everything else is optional.&lt;/p&gt;

&lt;p&gt;The rules that actually bite:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;loc&lt;/code&gt; has to be absolute and fully qualified, under 2,048 characters. Relative paths are invalid, and plenty of generators emit them anyway.&lt;/li&gt;
&lt;li&gt;Ampersands and angle brackets inside URLs must be entity-escaped. &lt;code&gt;?a=1&amp;amp;b=2&lt;/code&gt; becomes &lt;code&gt;?a=1&amp;amp;amp;b=2&lt;/code&gt;. This is the most common reason a hand-rolled sitemap fails validation.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;lastmod&lt;/code&gt; must be W3C Datetime. &lt;code&gt;2026-08-10&lt;/code&gt; is legal. &lt;code&gt;2026-08-10T14:32:00+00:00&lt;/code&gt; is legal. &lt;code&gt;08/10/2026&lt;/code&gt; gets the file rejected.&lt;/li&gt;
&lt;li&gt;50,000 URLs or 50MB uncompressed per file, whichever hits first. Past that you need a sitemap index pointing at multiple files.&lt;/li&gt;
&lt;li&gt;Every URL has to share a host with the sitemap's own location, unless you've verified cross-domain ownership in Search Console.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;changefreq&lt;/code&gt; and &lt;code&gt;priority&lt;/code&gt; are still valid elements, and Google ignores both. Bing too. I still emit priority out of habit, which is probably pointless and definitely harmless.&lt;/p&gt;

&lt;p&gt;Here's the whole thing as a script, which is what every sitemap generator does underneath, browser-based ones included:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// build-sitemap.mjs  -&amp;gt;  node build-sitemap.mjs urls.txt &amp;gt; sitemap.xml&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;readFileSync&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:fs&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;esc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;amp;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;amp;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
   &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;lt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;lt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
   &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
   &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/"/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;quot;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
   &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/'/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;apos;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;urls&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;50000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; URLs: split these into a sitemap index`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;today&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;urls&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`  &amp;lt;url&amp;gt;\n    &amp;lt;loc&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;esc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/loc&amp;gt;\n    &amp;lt;lastmod&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;today&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/lastmod&amp;gt;\n  &amp;lt;/url&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stdout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s2"&gt;`&amp;lt;?xml version="1.0" encoding="UTF-8"?&amp;gt;\n`&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
  &lt;span class="s2"&gt;`&amp;lt;urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"&amp;gt;\n&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;\n&amp;lt;/urlset&amp;gt;\n`&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// $ printf 'https://example.com/\nhttps://example.com/s?a=1&amp;amp;b=2\n' &amp;gt; urls.txt&lt;/span&gt;
&lt;span class="c1"&gt;// $ node build-sitemap.mjs urls.txt&lt;/span&gt;
&lt;span class="c1"&gt;// &amp;lt;?xml version="1.0" encoding="UTF-8"?&amp;gt;&lt;/span&gt;
&lt;span class="c1"&gt;// &amp;lt;urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"&amp;gt;&lt;/span&gt;
&lt;span class="c1"&gt;//   &amp;lt;url&amp;gt;&lt;/span&gt;
&lt;span class="c1"&gt;//     &amp;lt;loc&amp;gt;https://example.com/&amp;lt;/loc&amp;gt;&lt;/span&gt;
&lt;span class="c1"&gt;//     &amp;lt;lastmod&amp;gt;2026-08-10&amp;lt;/lastmod&amp;gt;&lt;/span&gt;
&lt;span class="c1"&gt;//   &amp;lt;/url&amp;gt;&lt;/span&gt;
&lt;span class="c1"&gt;//   &amp;lt;url&amp;gt;&lt;/span&gt;
&lt;span class="c1"&gt;//     &amp;lt;loc&amp;gt;https://example.com/s?a=1&amp;amp;amp;b=2&amp;lt;/loc&amp;gt;&lt;/span&gt;
&lt;span class="c1"&gt;//     &amp;lt;lastmod&amp;gt;2026-08-10&amp;lt;/lastmod&amp;gt;&lt;/span&gt;
&lt;span class="c1"&gt;//   &amp;lt;/url&amp;gt;&lt;/span&gt;
&lt;span class="c1"&gt;// &amp;lt;/urlset&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's 25 lines and it covers escaping plus the 50k guard. The browser version I built adds per-URL lastmod editing and a validation pass before download, which is the piece I kept missing in the script. &lt;a href="https://aidevhub.io/sitemap-generator/" rel="noopener noreferrer"&gt;The sitemap generator on aidevhub&lt;/a&gt; parses its own output back before handing you the file, so a malformed URL fails in the tab instead of in Search Console 9 days later. It all runs client-side, so the list never leaves the browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it compares to the alternatives
&lt;/h2&gt;

&lt;p&gt;I checked four options in March 2026 before deciding to build anything. Here's how they line up.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;aidevhub generator&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;sitemap&lt;/code&gt; npm package&lt;/th&gt;
&lt;th&gt;XML-Sitemaps.com&lt;/th&gt;
&lt;th&gt;Screaming Frog&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cost&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;Free to 500 pages, ~$20/yr after&lt;/td&gt;
&lt;td&gt;Free to 500 URLs, GBP 199/yr after&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Where your URLs go&lt;/td&gt;
&lt;td&gt;Stays in the browser&lt;/td&gt;
&lt;td&gt;Stays local&lt;/td&gt;
&lt;td&gt;Uploaded to their server&lt;/td&gt;
&lt;td&gt;Stays local&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Setup time&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;npm install plus a script&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;250MB desktop install&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Finds URLs for you&lt;/td&gt;
&lt;td&gt;No, you paste them&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes, it crawls&lt;/td&gt;
&lt;td&gt;Yes, full crawl&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best fit&lt;/td&gt;
&lt;td&gt;One-off lists up to 50k&lt;/td&gt;
&lt;td&gt;CI pipelines&lt;/td&gt;
&lt;td&gt;Small sites, no dev on hand&lt;/td&gt;
&lt;td&gt;Audits and large sites&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The real split is URL discovery. Screaming Frog and XML-Sitemaps crawl your site and find the pages for you, which matters a lot when nobody has an authoritative list. That crawl is also the expensive part, and it's exactly where both hit you with limits: 500 URLs free, money after.&lt;/p&gt;

&lt;p&gt;Paste-in tools skip discovery and assume you already have the list. If you run any static site generator, you do. My build already knows every route it emitted. Making a crawler rediscover them is work I've done once already.&lt;/p&gt;

&lt;p&gt;The privacy column matters more than people admit. A URL list from staging or an internal tool leaks structure: admin paths and unreleased feature routes. Uploading that to a third-party server to get 40 lines of XML back is a trade I stopped making after the second time a security review asked me about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  When you shouldn't use a paste-in generator
&lt;/h2&gt;

&lt;p&gt;The list of bad fits is longer than the pitch usually admits.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You don't have a URL list.&lt;/strong&gt; The tool takes URLs. It doesn't crawl. If you've inherited a WordPress install with an unknown page count, run a crawler first and paste the output in second.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The sitemap has to regenerate on every deploy.&lt;/strong&gt; Automate it instead. The script above is 25 lines and costs nothing to run in CI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You're past 50,000 URLs.&lt;/strong&gt; Now you need a sitemap index plus per-shard files, and that's a build-time job.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You need the image or video extensions.&lt;/strong&gt; Those pull in extra namespaces that simple generators don't emit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your CMS already ships one.&lt;/strong&gt; WordPress with Yoast, or Next.js with its sitemap route, already handles this. Hand-generating on top of that guarantees it goes stale the first time someone publishes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Honestly, the case for a manual generator is narrower than I'd like: one-off lists and migrations, plus sites without a build step that emits XML. It just happens to be a case that lands on me 4 or 5 times a year and annoys me every single time.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Will a sitemap improve my rankings?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; No. A sitemap affects discovery, so it helps a crawler find pages it might otherwise miss (deep pages, weak internal linking, fresh content). Ranking is a separate question entirely. If your pages are already indexed, adding a sitemap changes nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Do I still need changefreq and priority in 2026?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; No. Google has said publicly for years that it ignores both, and Bing treats them the same way. They're valid XML, so including them won't break anything. I leave priority in because muscle memory is hard to unlearn.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; How do I actually submit the file?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Drop it at &lt;code&gt;https://yoursite.com/sitemap.xml&lt;/code&gt;, add &lt;code&gt;Sitemap: https://yoursite.com/sitemap.xml&lt;/code&gt; to your robots.txt, then submit the URL in Search Console under Sitemaps. The robots.txt line is what other crawlers use, so don't skip it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Is a browser-based generator safe for internal URLs?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Depends on the tool. Client-side ones do the string building in JavaScript in your tab, so nothing is transmitted. Open devtools, switch to the Network panel, and generate a file. If you see zero requests, it's local. That check takes 10 seconds and I'd run it on any tool before pasting a staging URL list.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written with AI assistance and human review. Try the tool at &lt;a href="https://aidevhub.io/sitemap-generator/" rel="noopener noreferrer"&gt;aidevhub.io/sitemap-generator&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>seo</category>
      <category>tools</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Debugging a 41-second regex hang with a regex tester in 2026</title>
      <dc:creator>AI Dev Hub</dc:creator>
      <pubDate>Tue, 11 Aug 2026 14:00:06 +0000</pubDate>
      <link>https://dev.to/aidevhub/debugging-a-41-second-regex-hang-with-a-regex-tester-in-2026-2fog</link>
      <guid>https://dev.to/aidevhub/debugging-a-41-second-regex-hang-with-a-regex-tester-in-2026-2fog</guid>
      <description>&lt;h1&gt;
  
  
  Debugging a 41-second regex hang with a regex tester in 2026
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;Use a regex tester that shows capture groups live and warns about catastrophic backtracking, because the pattern that takes production down is rarely a wrong match. It's usually a pattern that works fine on 20 characters and hangs forever on 40. Paste the expression, paste a real input line, read the group table, then check the backtracking warning before you ship anything.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Full disclosure: the regex tester I link to below is one I built. I'd been bouncing between four online testers for years and every one of them either skipped Go's RE2 flavor or only told me about catastrophic backtracking after the engine had already timed out. Mine is free, runs entirely in the browser, needs no account, and never sends your pattern to a server. If you've got a better one, tell me and I'll switch.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 41 seconds that broke our log parser
&lt;/h2&gt;

&lt;p&gt;Late April 2026, a Tuesday afternoon. We had a Node service chewing through nginx access logs and turning them into structured events. It had been running in staging for six weeks without a complaint. Then the pod started getting OOM-killed on a loop, and the container logs went completely quiet right before each restart.&lt;/p&gt;

&lt;p&gt;The culprit was one line of code. Line 238, a field validator that looked harmless:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;^(\s*\w+\s*)+$&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;That pattern is fine. It matches. It also happens to be exponential, because &lt;code&gt;\s*&lt;/code&gt; and &lt;code&gt;\w+&lt;/code&gt; can both consume the same characters, and the outer &lt;code&gt;+&lt;/code&gt; lets the engine try every possible split. Feed it a string that almost matches (say, a request path with a trailing character that isn't a word character) and the backtracking explodes.&lt;/p&gt;

&lt;p&gt;One malformed log line. 1,247 requests already queued behind it. 41 seconds pinned at 100% CPU on a single call to &lt;code&gt;.test()&lt;/code&gt;, and then the kernel took the process out.&lt;/p&gt;

&lt;p&gt;Here's the part that annoyed me for most of a day: I debugged it wrong. I did what everyone does. Console.log, a Node REPL, some print statements around the call site. That took 47 minutes and told me absolutely nothing, because a REPL only answers "does this match", and the answer was "yes, eventually." The question I actually needed answered was "how many steps does the engine take to get there, and how does that scale with input length."&lt;/p&gt;

&lt;p&gt;A REPL will not tell you that. It just sits there.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a regex tester actually does under the hood
&lt;/h2&gt;

&lt;p&gt;A good tester isn't a wrapper around &lt;code&gt;String.prototype.match&lt;/code&gt;. Three things happen when you paste a pattern in.&lt;/p&gt;

&lt;p&gt;First, the pattern gets compiled against the flavor you picked. This matters more than people expect. JavaScript, Python's &lt;code&gt;re&lt;/code&gt;, and Go's &lt;code&gt;regexp&lt;/code&gt; disagree on lookbehind support, on named group syntax (&lt;code&gt;(?&amp;lt;name&amp;gt;...)&lt;/code&gt; vs &lt;code&gt;(?P&amp;lt;name&amp;gt;...)&lt;/code&gt;), and on whether backtracking exists at all. Go uses RE2, which has no backtracking, so the pattern that killed our Node service would have run in linear time there. Same regex, wildly different runtime characteristics.&lt;/p&gt;

&lt;p&gt;Second, matching runs in a loop with &lt;code&gt;lastIndex&lt;/code&gt; tracked manually, so every match and every capture group gets collected instead of just the first. That's what fills the group table. You see group 1, group 2, the named ones, and the exact character offsets, updated as you type.&lt;/p&gt;

&lt;p&gt;Third, and this is the one that would have saved me a day: static analysis for nested quantifiers over overlapping character classes. That's the ReDoS signature. &lt;code&gt;(a+)+&lt;/code&gt;, &lt;code&gt;(\s*\w+\s*)+&lt;/code&gt;, &lt;code&gt;(\w|\d)*$&lt;/code&gt; and friends. The check is a heuristic, so it produces false positives on patterns that are technically safe, but I'd rather see a yellow warning I can dismiss than find out from a pager.&lt;/p&gt;

&lt;p&gt;You can reproduce the actual blowup yourself. Save this and run it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// backtrack.mjs - run with: node backtrack.mjs&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pattern&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;(\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;\w&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;+$/&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;22&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;a &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;repeat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;!&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;     &lt;span class="c1"&gt;// "!" guarantees a failed match&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t0&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hrtime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bigint&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ms&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hrtime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bigint&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;t0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;e6&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; words: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt; ms`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// 14 words:    1.02 ms&lt;/span&gt;
&lt;span class="c1"&gt;// 16 words:    3.91 ms&lt;/span&gt;
&lt;span class="c1"&gt;// 18 words:   15.62 ms&lt;/span&gt;
&lt;span class="c1"&gt;// 20 words:   62.45 ms&lt;/span&gt;
&lt;span class="c1"&gt;// 22 words:  249.80 ms&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Roughly 4x per two extra words. Extrapolate to a 60-character path and you get my 41 seconds. Nothing in that output requires a debugger or a profiler, but you do have to know to go looking for it, and you won't go looking if your tool only says "no match."&lt;/p&gt;

&lt;h2&gt;
  
  
  Regex101, RegExr, or a five-line node script
&lt;/h2&gt;

&lt;p&gt;I used regex101 for years and still open it for PCRE work. It has the best debugger of anything in this space, full stop. But the workflow I wanted was different: paste, see the risk immediately, no account, no network round trip, and Go's flavor in the same dropdown as JavaScript. That's the gap I built &lt;a href="https://aidevhub.io/regex-tester/" rel="noopener noreferrer"&gt;the aidevhub regex tester&lt;/a&gt; to fill, and it's where I'd start if you're chasing a pattern that behaves badly rather than one that's simply wrong.&lt;/p&gt;

&lt;p&gt;Checked against the others in July 2026:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What I checked&lt;/th&gt;
&lt;th&gt;aidevhub Regex Tester&lt;/th&gt;
&lt;th&gt;regex101&lt;/th&gt;
&lt;th&gt;RegExr&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;node -e&lt;/code&gt; one-liner&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Flavors&lt;/td&gt;
&lt;td&gt;JavaScript, Python, Go&lt;/td&gt;
&lt;td&gt;PCRE2, JS, Python, Go, Java, .NET&lt;/td&gt;
&lt;td&gt;JS, PCRE&lt;/td&gt;
&lt;td&gt;JS only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backtracking warning&lt;/td&gt;
&lt;td&gt;Static check, before you run it&lt;/td&gt;
&lt;td&gt;Reports it after the engine times out&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Plain-English breakdown&lt;/td&gt;
&lt;td&gt;Yes, per token&lt;/td&gt;
&lt;td&gt;Yes, token list plus debugger&lt;/td&gt;
&lt;td&gt;Yes, on hover&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Capture group table&lt;/td&gt;
&lt;td&gt;Yes, live&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Partial&lt;/td&gt;
&lt;td&gt;Manual &lt;code&gt;console.log&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Account needed to save&lt;/td&gt;
&lt;td&gt;No, nothing leaves the tab&lt;/td&gt;
&lt;td&gt;Yes, to save permalinks&lt;/td&gt;
&lt;td&gt;Yes, to save patterns&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Step-by-step debugger&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes, best in class&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That "No" in the debugger row is real and I'm not going to hide it. If you need to walk the engine's backtracking path step by step to understand &lt;em&gt;why&lt;/em&gt; a pattern fails, regex101 does that and mine doesn't. Different jobs.&lt;/p&gt;

&lt;h2&gt;
  
  
  When a browser regex tester is the wrong tool
&lt;/h2&gt;

&lt;p&gt;I'd skip all of this in a few cases, and I say that as the person who built one of them.&lt;/p&gt;

&lt;p&gt;If your pattern is longer than about 200 characters, the honest answer is that you have a parser, and you should write a parser. I've watched a team maintain a 600-character email validation regex for two years. Every tester in the table above will happily render it, and none of them will make it a good idea.&lt;/p&gt;

&lt;p&gt;If you're working against a flavor nobody supports well (Oracle's &lt;code&gt;REGEXP_LIKE&lt;/code&gt;, or the subset that ships in some embedded Lua runtimes), test in the actual engine. A JS-flavored tester giving you a green checkmark on a pattern that Oracle parses differently is worse than no tool, because now you're confident and wrong.&lt;/p&gt;

&lt;p&gt;If the input is sensitive, read the tool's docs before pasting. Mine runs client-side and I'll say so plainly, but "client-side" is a claim you should verify in the network tab rather than take on faith from a blog post. Open devtools, type a pattern, watch for requests. Thirty seconds.&lt;/p&gt;

&lt;p&gt;And if you're already deep in a debugging session with a profiler attached, don't context-switch to a browser tab. The five-line script above lives in your repo and answers the scaling question directly.&lt;/p&gt;

&lt;p&gt;One thing I got wrong for a long time: I assumed ReDoS was an exotic security-research problem, something that mattered for input parsers exposed to hostile users. It isn't. Our log line wasn't hostile. It was a truncated request path from a client that hung up mid-request, and it hit a validator I'd written myself and never once tested with input longer than a sample string.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Does a ReDoS warning mean my pattern is definitely vulnerable?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; No. The detection is static and errs toward false positives, so nested quantifiers get flagged even when the surrounding anchors make the bad path unreachable. Treat it as a prompt to run the timing loop above, not a verdict.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Why do JavaScript, Python, and Go produce different results for the same pattern?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Different engines. Go's &lt;code&gt;regexp&lt;/code&gt; package uses RE2, which guarantees linear time and drops backreferences and lookaheads entirely. JS and Python both backtrack. A pattern that's a landmine in Node is safe in Go and won't even compile if it uses lookbehind.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Can I test replacement strings too?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Yes, including &lt;code&gt;$1&lt;/code&gt;-style group references in JS mode and &lt;code&gt;\1&lt;/code&gt; in Python mode. This is where the flavor selector earns its place, since getting the replacement syntax backwards is the most common silent bug I hit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; What's the fastest way to fix a catastrophic pattern once I've found one?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Usually by making the inner quantifier possessive or atomic where the flavor supports it, or by rewriting so the alternatives can't match the same characters. For our validator, &lt;code&gt;^\w+(\s+\w+)*$&lt;/code&gt; fixed it. Same matches, linear time.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written with AI assistance and human review. Try the tool at &lt;a href="https://aidevhub.io/regex-tester/" rel="noopener noreferrer"&gt;aidevhub.io/regex-tester&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>debugging</category>
      <category>performance</category>
      <category>regex</category>
      <category>tools</category>
    </item>
    <item>
      <title>Building an MCP tool-call test rig with the Python SDK in 2026</title>
      <dc:creator>AI Dev Hub</dc:creator>
      <pubDate>Thu, 06 Aug 2026 14:00:01 +0000</pubDate>
      <link>https://dev.to/aidevhub/building-an-mcp-tool-call-test-rig-with-the-python-sdk-in-2026-4iln</link>
      <guid>https://dev.to/aidevhub/building-an-mcp-tool-call-test-rig-with-the-python-sdk-in-2026-4iln</guid>
      <description>&lt;h1&gt;
  
  
  Building an MCP tool-call test rig with the Python SDK in 2026
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;You can test an agent's tool-call loop without a model. Write down the calls the model would have made, replay them against your real MCP server over stdio, and assert on what comes back. It runs offline in about two seconds, costs nothing per run, and catches renamed tools and schema drift before a customer does. The model is the last thing you should be faking.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The Function Call Flow Simulator I link to below is one I built. I tried five existing playgrounds first and every one wanted an API key before it would render a single tool_use block, which is backwards when the whole point is sketching a flow you haven't paid for yet. Mine runs in the browser, free, no signup, nothing leaves the tab. If you know a better one, tell me and I'll link to it instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  The goal
&lt;/h2&gt;

&lt;p&gt;Here's the picture I wanted on my screen. One command, one JSON file, six lines of output: each tool call my agent would make during a customer refund, run against the same MCP server that handles production traffic, with a failure line and an exit code of 1 the moment something breaks. No API key. Nothing over the network. Under two seconds.&lt;/p&gt;

&lt;p&gt;The model is the easy part to fake. Everything around it is where I keep getting hurt. The loop that reads tool_use blocks, dispatches them, feeds results back, and decides when to stop is ordinary code, and it fails in ordinary ways. Someone renamed &lt;code&gt;refund_order&lt;/code&gt; to &lt;code&gt;issue_refund&lt;/code&gt; on the server and my agent quietly degraded into apologising to people instead of paying them. A required field got added to a schema and half the calls started coming back with &lt;code&gt;isError&lt;/code&gt; set, which my loop passed straight back to the model as though it were a normal result.&lt;/p&gt;

&lt;p&gt;Testing that against a live model doesn't work the way people hope. Run the same prompt twice and you get different arguments, sometimes a different tool, sometimes a friendly paragraph and no call at all. That's correct behaviour from the model and useless behaviour for a test. So pin the model's output. Write it down as data. Then the only moving part left is your code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup and the auth you don't need
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;pip install "mcp==1.13.1"&lt;/code&gt; is the entire dependency list. I pin the version because the stdio client's environment handling shifted twice inside the 1.x line and I lost most of an afternoon to it.&lt;/p&gt;

&lt;p&gt;For replay you need no key whatsoever. That's the point: no model is in the room. You need a key exactly once, on the day you record a real conversation to seed your first transcript, and after that the JSON file is the fixture and CI never sees a credential.&lt;/p&gt;

&lt;p&gt;Three environment gotchas worth knowing before you start:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The server is spawned as a subprocess over stdio, so it inherits the working directory you launched pytest from, not the directory your test file lives in. Relative paths inside your server config will resolve somewhere surprising.&lt;/li&gt;
&lt;li&gt;Anything your server writes to stdout that isn't JSON-RPC corrupts the stream. One stray &lt;code&gt;print()&lt;/code&gt; left in a tool handler kills the session with a parse error that names no file and no line.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;StdioServerParameters.env&lt;/code&gt; replaces the child environment rather than merging into it. More on that below, because it cost me 41 minutes and my dignity.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Before I hand-write a transcript I sketch the flow in the &lt;a href="https://aidevhub.io/function-call-simulator/" rel="noopener noreferrer"&gt;Function Call Flow Simulator&lt;/a&gt;, which lets me lay out the tool_use and tool_result pairs across several turns and copy the JSON straight out. Faster than getting the nesting right by hand at 1am, which is how I used to do it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The code
&lt;/h2&gt;

&lt;p&gt;This is the whole runner. It reads a transcript, checks each tool still exists on the server, calls it, and stops on the first thing that looks wrong.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# replay.py - drive a real MCP server with a scripted tool-call transcript.
# pip install "mcp==1.13.1"
# usage: python replay.py flows/refund.json
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;mcp&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ClientSession&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;StdioServerParameters&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;mcp.client.stdio&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;stdio_client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;get_default_environment&lt;/span&gt;

&lt;span class="c1"&gt;# What the model would have emitted. Hand-written, or recorded from one real run:
# [{"name": "search_orders", "input": {"customer_id": "cus_8812", "status": "open"}},
#  {"name": "refund_order",  "input": {"order_id": "ord_4471", "amount_cents": 9164}}]
&lt;/span&gt;&lt;span class="n"&gt;TRANSCRIPT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;

&lt;span class="n"&gt;SERVER&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;StdioServerParameters&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;executable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-m&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;billing_mcp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;                 &lt;span class="c1"&gt;# your real server, unmodified
&lt;/span&gt;    &lt;span class="c1"&gt;# gotcha: env REPLACES the child environment, it does not merge. Drop the
&lt;/span&gt;    &lt;span class="c1"&gt;# get_default_environment() spread and the child loses PATH and dies quietly.
&lt;/span&gt;    &lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="nf"&gt;get_default_environment&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;BILLING_MODE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sandbox&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;stdio_client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SERVER&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;as &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;read&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;write&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nc"&gt;ClientSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;read&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;write&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="n"&gt;declared&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list_tools&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

            &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;step&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;call&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TRANSCRIPT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;declared&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;step &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;step&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: server no longer declares &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
                &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call_tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;input&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
                &lt;span class="c1"&gt;# gotcha: a failed tool call does NOT raise. isError is a field.
&lt;/span&gt;                &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;step &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;step&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; returned isError&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
                &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;step &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;step&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; ok  &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;

&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;declared&lt;/code&gt; set is doing more work than it looks like. &lt;code&gt;list_tools()&lt;/code&gt; is the server telling you what it actually exposes right now, so comparing your transcript against it turns "the model will call a tool that vanished" from a production surprise into a failing test. The &lt;code&gt;amount_cents: 9164&lt;/code&gt; in the sample transcript is deliberate. Round numbers hide off-by-one and unit bugs, and I want $91.64 flowing through, not $100.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scripted replay against the alternatives
&lt;/h2&gt;

&lt;p&gt;I've run all three of these in anger. The numbers in the cost row are mine, from a week in February when our CI ran the agent suite on every push.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Axis&lt;/th&gt;
&lt;th&gt;Scripted replay&lt;/th&gt;
&lt;th&gt;Live model in CI&lt;/th&gt;
&lt;th&gt;Recorded HTTP cassettes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cost per 1,000 runs&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;td&gt;$63.18&lt;/td&gt;
&lt;td&gt;$0 after recording&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Same result every run&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Catches renamed or dropped tools&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;sometimes&lt;/td&gt;
&lt;td&gt;no, the cassette hides it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Catches bad model arguments&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Works with no network&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Time to first green test&lt;/td&gt;
&lt;td&gt;~20 min&lt;/td&gt;
&lt;td&gt;~5 min&lt;/td&gt;
&lt;td&gt;~90 min&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Cassettes look like the obvious answer and they're the one I'd steer you away from. They freeze the server's responses too, so the day your MCP server changes its schema the cassette keeps replaying the old world and your suite stays green while production burns. Scripted replay freezes only the model side and lets the server be real, which is the exact split you want.&lt;/p&gt;

&lt;p&gt;The live-model column has one row nobody else can fill: whether the model picks sensible arguments. That's a real question. It's just a different suite, on a different schedule, with a budget cap.&lt;/p&gt;

&lt;h2&gt;
  
  
  What went wrong the first time
&lt;/h2&gt;

&lt;p&gt;I was wrong about &lt;code&gt;isError&lt;/code&gt;. I assumed a failing tool call would raise an exception, because that's what every HTTP client I've used does. It doesn't. &lt;code&gt;call_tool&lt;/code&gt; returns a &lt;code&gt;CallToolResult&lt;/code&gt; with &lt;code&gt;isError=True&lt;/code&gt; and content that reads like a normal text block. My runner was green for three days straight while every single refund step came back with "customer cus_8812 not found". Three days of a passing suite that was testing nothing. I only caught it because a teammate asked why the sandbox ledger was empty.&lt;/p&gt;

&lt;p&gt;Then the environment thing. I passed &lt;code&gt;env={"BILLING_MODE": "sandbox"}&lt;/code&gt; on its own, assuming it merged with the parent process environment the way &lt;code&gt;subprocess.run&lt;/code&gt; does with &lt;code&gt;env=None&lt;/code&gt;. It doesn't merge. The child got an environment containing exactly one variable, lost &lt;code&gt;PATH&lt;/code&gt;, and failed to launch. What I saw was &lt;code&gt;session.initialize()&lt;/code&gt; hanging until the 30 second timeout with zero output, because the child's stderr goes nowhere unless you wire it up. I spent 41 minutes convinced my server had a deadlock in its startup handler. The fix is the two-character spread in the code above.&lt;/p&gt;

&lt;p&gt;Last Tuesday I added a sixth step to the refund flow and hit the third one: I'd been asserting with &lt;code&gt;result.content[0].text&lt;/code&gt; and a substring check. The server had started returning &lt;code&gt;structuredContent&lt;/code&gt; alongside a JSON dump in the text block, so my substring &lt;code&gt;"refunded"&lt;/code&gt; matched a field name rather than a status value. The assertion passed for entirely the wrong reason. I don't have a clean rule for this yet beyond "parse the JSON, assert on a key", and honestly I'm still annoyed that the loose version survived as long as it did.&lt;/p&gt;

&lt;p&gt;None of these three are bugs in the MCP SDK. They're all me assuming a library behaved like a different library I knew better. Worth budgeting an hour for that on any new protocol.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Isn't this just testing my own mocks?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; No, and that's the whole design. The server is your actual MCP server, running as a real subprocess, hitting your real sandbox database. The only mocked thing is the model's choice of tool and arguments, which is the one component you can't assert on deterministically anyway.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; How do I get the first transcript?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Run the conversation once for real, log every tool_use block your loop receives, and dump the list to JSON. About 15 lines of throwaway code. After that you edit the file by hand to add edge cases the model never happened to produce.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Does this work for HTTP or SSE servers instead of stdio?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Yes. Swap &lt;code&gt;stdio_client&lt;/code&gt; for &lt;code&gt;streamablehttp_client&lt;/code&gt; and pass a URL. Everything inside the &lt;code&gt;ClientSession&lt;/code&gt; block stays byte-for-byte identical, which is the nicest property of the client API.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; How do I still test that the model picks the right tool?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Separate suite, run nightly rather than per-push, with a hard spend cap and assertions loose enough to tolerate variation (did it call any refund-shaped tool, did it stop after four turns). Keep it away from your fast suite.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written with AI assistance and human review. Try the tool at &lt;a href="https://aidevhub.io/function-call-simulator/" rel="noopener noreferrer"&gt;aidevhub.io/function-call-simulator&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>python</category>
      <category>testing</category>
      <category>tooling</category>
    </item>
    <item>
      <title>Jinja2 vs Claude vs SkillSpec in 2026: same skill, 3 ways</title>
      <dc:creator>AI Dev Hub</dc:creator>
      <pubDate>Thu, 30 Jul 2026 14:00:03 +0000</pubDate>
      <link>https://dev.to/aidevhub/jinja2-vs-claude-vs-skillspec-in-2026-same-skill-3-ways-dam</link>
      <guid>https://dev.to/aidevhub/jinja2-vs-claude-vs-skillspec-in-2026-same-skill-3-ways-dam</guid>
      <description>&lt;h1&gt;
  
  
  Jinja2 vs Claude vs SkillSpec in 2026: same skill, 3 ways
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;SkillSpec Converter wins for anything I need in more than two output formats at once. The Jinja2 script is still faster for a single format I fully control, and asking Claude directly is fine for a one-off, though it quietly drops required fields. I timed the same skill through all three last week. Below are the actual outputs and the exact spot each one broke.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The SkillSpec Converter I link to below is one I built. I got tired of hand-porting the same skill across OpenClaw, Claude, Codex, and MCP, and the four converters I tried each handled one target well and mangled the rest. It's free, runs entirely in your browser, needs no signup, and uploads nothing. If you know a better one, tell me and I'll switch to it happily.&lt;/p&gt;

&lt;h2&gt;
  
  
  The task: one skill, four target formats
&lt;/h2&gt;

&lt;p&gt;Last Tuesday I had a skill that worked in exactly one place and needed to run in four by the end of the week. The canonical definition is small. It's a PR summarizer that takes a pull request URL and returns five bullet points, gated behind a single read-only GitHub tool. That's the whole spec, and I keep it as one YAML file that acts as my source of truth.&lt;/p&gt;

&lt;p&gt;The four targets were OpenClaw's &lt;code&gt;SKILL.md&lt;/code&gt;, a Claude system block, a Codex scaffold, and an MCP manifest snippet. My expected output was four files that all agree: same inputs, same tool allowlist, and the &lt;code&gt;required: true&lt;/code&gt; on &lt;code&gt;pr_url&lt;/code&gt;, because a summarizer with no URL is just an apology generator. The test was drift. If any of the four copies disagreed with the YAML, I'd call the run a failure, because that's the bug you don't catch until it breaks in production three weeks later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round 1: the Jinja2 script I already had
&lt;/h2&gt;

&lt;p&gt;I reached for the script first because it already existed. It's roughly 60 lines of Python that loads the YAML spec and renders one target through a Jinja2 template. Here's the trimmed version that produces the OpenClaw &lt;code&gt;SKILL.md&lt;/code&gt;, and you can run it as-is after &lt;code&gt;pip install pyyaml jinja2&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# pip install pyyaml jinja2
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;yaml&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;jinja2&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Template&lt;/span&gt;

&lt;span class="n"&gt;CANONICAL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
name: pr-summarizer
description: Summarize a GitHub pull request into five bullet points
inputs:
  - name: pr_url
    type: string
    required: true
  - name: max_bullets
    type: integer
    required: true
  - name: tone
    type: string
    required: true
tools:
  - github_read
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

&lt;span class="n"&gt;SKILL_MD&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Template&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;---
name: {{ name }}
description: &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{ description }}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;
allowed-tools: {{ tools | join(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;) }}
---

# {{ name }}

{{ description }}

## Inputs
{% for i in inputs -%}
- `{{ i.name }}` ({{ i.type }}){% if i.required %}, required{% endif %}
{% endfor %}&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;spec&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;yaml&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;safe_load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CANONICAL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SKILL_MD&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works, and the output was correct on the first pass. I trust it more than anything else here because I wrote every line of the template, so when something looks wrong I know where to look. The catch is the other three targets. Each one needs its own template, and the Codex scaffold and the MCP manifest have structural quirks that don't fall out of a flat YAML file cleanly. Building and debugging all four templates took me 43 minutes. I burned a chunk of that on a bug at line 41, where a tool with no parameters rendered an empty &lt;code&gt;properties: {}&lt;/code&gt; block that the MCP validator flat-out rejected. Great for a format I'll render a hundred times. Rough for something I need once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round 2: just asking Claude to convert it
&lt;/h2&gt;

&lt;p&gt;Next I pasted the same YAML into a chat and asked for all four formats in one go. Six minutes, four clean-looking code blocks, done. Or so I thought.&lt;/p&gt;

&lt;p&gt;Two of the three inputs came back without their &lt;code&gt;required&lt;/code&gt; flag. I don't fully know why. The description text came through fine and the tool allowlist was right, but the one boolean I actually cared about just wasn't there. I ran it twice more with a stricter prompt. It behaved once, then dropped the flag again on the third try. The MCP manifest it produced used &lt;code&gt;input_schema&lt;/code&gt; where the spec wants &lt;code&gt;inputSchema&lt;/code&gt;, which is the kind of casing slip that reads fine to a human and dies in a parser. If I were shipping a throwaway skill I'd have taken it and moved on. For four files that have to stay in lockstep, I couldn't trust the output without diffing every field against the YAML by hand, and at that point six minutes isn't really six minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round 3: SkillSpec Converter
&lt;/h2&gt;

&lt;p&gt;Then I ran it through my own tool, which is the honest reason this comparison exists. I pasted the same canonical YAML into the box, and it rendered all four targets into tabs in under a second. The &lt;code&gt;required&lt;/code&gt; flags survived intact. The MCP manifest came out with the camelCase key. The Codex scaffold handled the empty-properties case, which is the precise bug I'd hit by hand at line 41 an hour earlier.&lt;/p&gt;

&lt;p&gt;I want to be fair about the limits. It only knows the four targets I taught it, so the moment you need a fifth output format you're back to a template or a model. There's no clever inference happening under the hood. But for the narrow job of keeping one skill definition consistent across OpenClaw, Claude, Codex, and MCP, it did in one paste what my script did in 43 minutes and what the chat couldn't do reliably at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The scores, and which one I keep open
&lt;/h2&gt;

&lt;p&gt;Here's how the three landed on the things I actually measured:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Criterion&lt;/th&gt;
&lt;th&gt;Jinja2 script&lt;/th&gt;
&lt;th&gt;Ask Claude&lt;/th&gt;
&lt;th&gt;SkillSpec Converter&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Time to get all four formats&lt;/td&gt;
&lt;td&gt;43 min&lt;/td&gt;
&lt;td&gt;6 min&lt;/td&gt;
&lt;td&gt;under 1 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Required fields kept&lt;/td&gt;
&lt;td&gt;all&lt;/td&gt;
&lt;td&gt;1 of 3&lt;/td&gt;
&lt;td&gt;all&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Valid MCP manifest out of the box&lt;/td&gt;
&lt;td&gt;after a fix&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Setup cost&lt;/td&gt;
&lt;td&gt;high, four templates&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Customization ceiling&lt;/td&gt;
&lt;td&gt;total&lt;/td&gt;
&lt;td&gt;prompt-dependent&lt;/td&gt;
&lt;td&gt;the four built-in targets&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost&lt;/td&gt;
&lt;td&gt;free&lt;/td&gt;
&lt;td&gt;API tokens&lt;/td&gt;
&lt;td&gt;free&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So which one do I reach for? It depends on the day, and I'll give you my honest split instead of pretending one tool won outright.&lt;/p&gt;

&lt;p&gt;If I need exactly one format and I'll be generating it over and over, the Jinja2 script takes it. Full control, and I can version the template right next to the code it belongs to. If I need a rough draft and I'm going to read every line anyway, asking Claude is the quickest way to a starting point, as long as I treat its output as a draft and diff it against the spec.&lt;/p&gt;

&lt;p&gt;For the case that actually stung (one skill, four formats, zero drift allowed), I keep &lt;a href="https://aidevhub.io/skill-spec-converter/" rel="noopener noreferrer"&gt;the SkillSpec Converter&lt;/a&gt; open in a tab. It's free, and it runs client-side. It also stopped me from shipping a manifest with the wrong casing twice this month. I'm biased here, plainly. I built it after the four alternatives I tried each nailed one target and butchered the rest. If yours does this better, I want to know.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Does converting a skill lose information between formats?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; It can. The formats don't have identical feature sets, so a field in one target may have no home in another. A good converter maps what it can and leaves a comment where a target can't represent something, which beats silently dropping it and finding out in prod.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Can I use the Jinja2 approach for MCP manifests too?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Yes, and you should if MCP is your only target. Write one template, test it against the manifest validator, and you're set for good. The pain only shows up once you're maintaining three or four templates in parallel and they start disagreeing with each other.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Why did Claude drop the required flags?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; I don't have a clean answer. My guess is that boolean flags on nested objects are easy to lose across a long generation, especially when the surrounding prose is more salient to the model. A stricter prompt helped but didn't fix it every single time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Which format is hardest to get right by hand?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; The MCP manifest, easily. The nesting runs deep and the key casing is unforgiving. An empty &lt;code&gt;properties&lt;/code&gt; object trips the validator, which is the same trap I fell into at line 41. It's the one target where I stopped trusting my own templates first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Is my skill definition uploaded anywhere?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Not with SkillSpec Converter, since it runs in the browser and the spec never leaves your machine. For the Claude route, your spec does go to the API, which matters if the skill contains anything you'd rather keep off a server.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written with AI assistance and human review. Try the tool at &lt;a href="https://aidevhub.io/skill-spec-converter/" rel="noopener noreferrer"&gt;aidevhub.io/skill-spec-converter&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>automation</category>
      <category>claude</category>
      <category>llm</category>
    </item>
    <item>
      <title>Reading 50MB JSONL logs with a viewer in 2026</title>
      <dc:creator>AI Dev Hub</dc:creator>
      <pubDate>Thu, 23 Jul 2026 14:00:04 +0000</pubDate>
      <link>https://dev.to/aidevhub/reading-50mb-jsonl-logs-with-a-viewer-in-2026-262k</link>
      <guid>https://dev.to/aidevhub/reading-50mb-jsonl-logs-with-a-viewer-in-2026-262k</guid>
      <description>&lt;h1&gt;
  
  
  Reading 50MB JSONL logs with a viewer in 2026
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;Use a browser-based JSONL viewer that parses each line as its own JSON object and lays the results out in a filterable, sortable table. That's the fastest way to read newline-delimited logs without writing a throwaway script. Paste the file, get columns, filter to the rows you care about, export what's left. No terminal gymnastics and no 2GB file crashing your editor. Works offline because everything runs client-side.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The jsonl viewer I link to below is one I built. I got tired of it: I tried six different online JSON tools last spring and every one of them choked the moment I pasted newline-delimited data, because they all assume a single JSON document and JSONL is a stream of them. Mine runs entirely in your browser. No signup, no upload, nothing leaves your machine, and it's free. If you've got a better one, please tell me.&lt;/p&gt;

&lt;h2&gt;
  
  
  The log file that killed my text editor
&lt;/h2&gt;

&lt;p&gt;Last Tuesday, around 2am, I was chasing a production timeout. The only evidence I had was an NDJSON log the service had been streaming to disk: 340 MB, roughly 1.2 million lines, one JSON object per line. I did the obvious thing first and opened it in my editor. It thought about that for a while. The fans spun up. Then the window went white and stopped responding. Cool.&lt;/p&gt;

&lt;p&gt;So I fell back to jq. &lt;code&gt;jq 'select(.level == "error")' app.log&lt;/code&gt; does work, and honestly jq is a wonderful tool, but I couldn't remember the exact field names, I kept getting the filter slightly wrong, and every failed guess re-streamed the whole file from the top. Twenty minutes in I still hadn't seen a single row. The error turned out to be on line 811,406, but I didn't know that yet. All I wanted was to see the shape of the data first, then decide what to filter. That's the gap.&lt;/p&gt;

&lt;p&gt;JSONL (also called NDJSON or JSON Lines, same idea under different names) is a stream of JSON values with one per line. Log pipelines love it because you can append a line without rewriting the file, and a crash mid-write only costs you the last line instead of the whole document. The catch is that most JSON tooling assumes a single document, so it reads your 1.2 million lines and immediately throws on the second &lt;code&gt;{&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How a JSONL viewer parses a stream of objects
&lt;/h2&gt;

&lt;p&gt;The core of a JSONL viewer is almost embarrassingly small. You split on newlines and parse each non-blank line on its own. The one trick that matters is not letting a single bad line kill the whole render, so you catch per line and keep going.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Split, drop blanks, parse each line independently.&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;parseJSONL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;row&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;line&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sample&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`{"ts":"2026-04-11T02:14:03Z","level":"error","msg":"timeout","ms":9812}
{"ts":"2026-04-11T02:14:04Z","level":"info","msg":"retry","attempt":2}
not valid json
{"ts":"2026-04-11T02:14:06Z","level":"info","msg":"ok"}`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;parseJSONL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sample&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run that and you get four results back. Three parse cleanly into row objects, and the &lt;code&gt;not valid json&lt;/code&gt; line comes back as &lt;code&gt;{ ok: false, line: 3, ... }&lt;/code&gt; instead of blowing up the other three. A viewer takes that array, unions all the keys it sees to build columns (&lt;code&gt;ts&lt;/code&gt;, &lt;code&gt;level&lt;/code&gt;, &lt;code&gt;msg&lt;/code&gt;, &lt;code&gt;ms&lt;/code&gt;, &lt;code&gt;attempt&lt;/code&gt;), and paints a table. Now &lt;code&gt;level&lt;/code&gt; is a column you can filter, not a string you have to grep for.&lt;/p&gt;

&lt;p&gt;You don't have to run this yourself. Paste your file into the &lt;a href="https://aidevhub.io/jsonl-viewer/" rel="noopener noreferrer"&gt;JSONL viewer&lt;/a&gt; and it does exactly this in your browser, then hands you a sortable, filterable table with the broken lines flagged in red so you can spot corruption instead of silently dropping it. That red-flag behavior is the part I use most, weirdly. Half my "bug" reports turn out to be one truncated log line.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the table actually gets you
&lt;/h2&gt;

&lt;p&gt;Parsing is the boring half. The reason a table beats a wall of text is what you can do to it once it's there. I filter first, almost always. Type &lt;code&gt;error&lt;/code&gt; into the level column and 1.2 million rows collapse to the 3,000 that matter. Then I sort by timestamp to find the first one, because the first error is usually the real cause and the rest are fallout.&lt;/p&gt;

&lt;p&gt;Sorting on a numeric field (say a duration in ms) is where the table earns its keep. In an editor I'd be eyeballing numbers by hand. Here I click the &lt;code&gt;ms&lt;/code&gt; header and the slowest request floats to the top. Last week that surfaced a single 14,203 ms outlier I'd never have spotted by scrolling, and it was the whole bug.&lt;/p&gt;

&lt;p&gt;Export closes the loop. Once I've filtered down to the rows I care about, I pull them out as JSON or CSV and drop that slice into a ticket, so the person picking it up sees 40 relevant lines instead of a 340 MB file and a shrug.&lt;/p&gt;

&lt;h2&gt;
  
  
  jq versus a spreadsheet versus a text editor
&lt;/h2&gt;

&lt;p&gt;Each of these has a place. Here's how I actually pick between them:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Reads JSONL as-is&lt;/th&gt;
&lt;th&gt;Live filtering&lt;/th&gt;
&lt;th&gt;Big files&lt;/th&gt;
&lt;th&gt;Setup cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Browser JSONL viewer&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes, instant&lt;/td&gt;
&lt;td&gt;Fine to ~100 MB&lt;/td&gt;
&lt;td&gt;None, it's a web page&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;jq on the CLI&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No, re-run per query&lt;/td&gt;
&lt;td&gt;Excellent, it streams&lt;/td&gt;
&lt;td&gt;Install plus learn the syntax&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Import into a spreadsheet&lt;/td&gt;
&lt;td&gt;No, needs flattening&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Poor past a few MB&lt;/td&gt;
&lt;td&gt;Manual conversion step&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Text editor plus grep&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Text search only&lt;/td&gt;
&lt;td&gt;Bad, loads it all&lt;/td&gt;
&lt;td&gt;Already open&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The viewer wins when I'm exploring and don't yet know what I'm looking for. jq wins when I know the exact query and want it in a script or a pipe. I reach for jq inside CI, and the viewer at 2am when my brain is half offline. Different jobs, and I stopped feeling guilty about using both.&lt;/p&gt;

&lt;h2&gt;
  
  
  When you shouldn't reach for it
&lt;/h2&gt;

&lt;p&gt;I built this thing and I still won't use it for everything. A few honest limits.&lt;/p&gt;

&lt;p&gt;If your file is genuinely huge (multiple gigabytes), keep it in jq or a streaming parser. A browser tab has a memory ceiling, and loading 3 GB into a table will hang the page the same way it hung my editor. The viewer is for the range where an editor struggles but the data still fits in RAM, so call it a few hundred MB and under.&lt;/p&gt;

&lt;p&gt;If the task is automated, a viewer is the wrong shape entirely. Anything that runs on a schedule or inside a build should be jq or a small script. A human clicking a web page doesn't belong in a cron job, and you'll hate maintaining it if you try.&lt;/p&gt;

&lt;p&gt;And if you're dealing with deeply nested objects, a flat table gets awkward fast. The viewer flattens what it can and shows nested blobs as collapsed JSON, which is readable but not magic. For heavy nesting I still drop back to jq's path expressions. No tool wins every round, and pretending otherwise is how you end up with the wrong one open at 2am.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Is JSONL the same thing as NDJSON?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Effectively yes. JSONL, NDJSON, and JSON Lines all name the same format: one JSON value per line, separated by &lt;code&gt;\n&lt;/code&gt;. There are pedantic edge cases around trailing newlines and empty lines, but any decent viewer handles the three names identically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Does my data get uploaded anywhere?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; No. The parsing runs client-side in your browser, so the file never leaves your machine. That was the entire reason I built it that way. I didn't want to paste production logs into someone else's server and hope for the best.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; How big a file can it actually handle?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; It depends on your available RAM more than anything else. I've thrown 90 MB files at it without trouble on a normal laptop. Past a few hundred MB you'll feel the tab get heavy, and that's your cue to switch to jq.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Can I export the filtered rows?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Yes. Once you've narrowed down to the rows you want, you can export just the visible set as JSON or CSV, which is handy for handing a teammate a clean slice instead of the raw dump.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Why not just use jq for all of it?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; You can, and plenty of people do. I like jq for known queries and pipelines. The viewer is for the messier moment before that, when I don't yet know the field names or what I'm even looking for and want to poke at the data with my eyes.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written with AI assistance and human review. Try the tool at &lt;a href="https://aidevhub.io/jsonl-viewer/" rel="noopener noreferrer"&gt;aidevhub.io/jsonl-viewer&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>tools</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How I verify 4.7 GB downloads with a hash generator in 2026</title>
      <dc:creator>AI Dev Hub</dc:creator>
      <pubDate>Tue, 21 Jul 2026 14:00:05 +0000</pubDate>
      <link>https://dev.to/aidevhub/how-i-verify-47-gb-downloads-with-a-hash-generator-in-2026-1lhg</link>
      <guid>https://dev.to/aidevhub/how-i-verify-47-gb-downloads-with-a-hash-generator-in-2026-1lhg</guid>
      <description>&lt;h1&gt;
  
  
  How I verify 4.7 GB downloads with a hash generator in 2026
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;Use SHA-256 for anything you actually care about, and paste the vendor's published checksum into a comparison field rather than eyeballing 64 hex characters. MD5 and SHA-1 still catch accidental corruption fine, they just fall apart against a deliberate attacker. A browser-based generator hashes files locally through the Web Crypto API, so the bytes never leave your laptop.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The hash generator I link to below is one I built. I'd been through six or seven online checksum pages and every one of them either uploaded the file to somebody's server or quietly choked past a few megabytes. Mine is free, runs entirely client-side, no signup, no upload. If you know a better one, tell me and I'll link to that instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 43 minutes I lost to a half-downloaded archive
&lt;/h2&gt;

&lt;p&gt;On March 11, 2026 I spent 43 minutes debugging a Postgres restore that kept dying at exactly the same point in the dump. Same error every time. I rebuilt the container. I bumped the memory limit. I read the pg_restore source, which is a thing I do when I've run out of reasonable ideas.&lt;/p&gt;

&lt;p&gt;The dump file was 4.7 GB. The one I'd downloaded was 4.68 GB. Our S3-fronted CDN had truncated it, returned a 200, and everything downstream treated it as a complete file. There was a &lt;code&gt;.sha256&lt;/code&gt; sitting right next to it in the bucket the whole time. I'd never checked it, because checking it meant either remembering the exact &lt;code&gt;shasum&lt;/code&gt; flag or finding one of those upload-your-file websites, and both of those felt like more friction than just retrying the restore.&lt;/p&gt;

&lt;p&gt;That instinct cost me 43 minutes. It's a bad instinct and I've mostly trained it out of myself since.&lt;/p&gt;

&lt;p&gt;The general shape of the problem: you have a file, someone published a hash for it, and you need to know whether those two agree. That's it. It's a five-second operation that people skip constantly because the tooling around it is worse than it needs to be. Windows users get &lt;code&gt;certutil -hashfile file SHA256&lt;/code&gt;, which nobody remembers. macOS gives you &lt;code&gt;shasum -a 256&lt;/code&gt;. Linux gives you &lt;code&gt;sha256sum&lt;/code&gt;. Three different commands for one job, and then you still have to compare two 64-character strings by staring at them, which is exactly the kind of task human eyes are terrible at.&lt;/p&gt;

&lt;p&gt;I've watched competent engineers check the first four and last four characters and call it a match. Honestly I've done it too.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's actually happening when you hash a file
&lt;/h2&gt;

&lt;p&gt;A hash function eats arbitrary bytes and spits out a fixed-length digest. SHA-256 always produces 256 bits, 64 hex characters, whether you feed it an empty file or a 4.7 GB one. Change a single bit anywhere in the input and roughly half the output bits flip. That avalanche property is what makes hashes useful for integrity checks: there's no such thing as a "close" match.&lt;/p&gt;

&lt;p&gt;The part that matters for tooling is that hashing is streaming. You don't need the whole file in memory. You feed the hasher chunks, it updates internal state, and at the end you ask for the digest. That's why a well-built browser tool can hash a multi-gigabyte file without the tab dying, and why the upload-based sites are doing something unnecessary.&lt;/p&gt;

&lt;p&gt;Here's the CLI version I keep in my dotfiles, which is what I reach for when I'm already in a terminal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createHash&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:crypto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createReadStream&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:fs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;hashFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;algo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;algo&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createReadStream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;data&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;chunk&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;end&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nf"&gt;hashFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;digest&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;MATCH&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;MISMATCH&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it as &lt;code&gt;node hash.js backup.dump a1b2c3...&lt;/code&gt; and it exits non-zero on a mismatch, which means you can drop it straight into a CI step or a deploy script. On my machine that hashes the 4.7 GB dump in about 11 seconds, almost all of it disk-bound.&lt;/p&gt;

&lt;p&gt;In the browser the equivalent is &lt;code&gt;crypto.subtle.digest('SHA-256', buffer)&lt;/code&gt;, with one annoying catch: &lt;code&gt;SubtleCrypto&lt;/code&gt; has no streaming interface. For big files you either read the whole thing into an ArrayBuffer (fine up to a point, then the tab gets unhappy) or ship a WASM implementation that does support incremental updates. I went with WASM after the naive version fell over on a 2 GB file, which took me most of a Saturday to figure out and was genuinely irritating at the time.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it stacks up against the alternatives
&lt;/h2&gt;

&lt;p&gt;Three realistic options if you need a hash right now. I've used all three in anger.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Browser tool (client-side)&lt;/th&gt;
&lt;th&gt;Native CLI (&lt;code&gt;shasum&lt;/code&gt;, &lt;code&gt;certutil&lt;/code&gt;)&lt;/th&gt;
&lt;th&gt;Upload-based web tools&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;File leaves your machine&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Works on a locked-down work laptop&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Usually&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Command to memorize&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;One per OS&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Compare against expected hash&lt;/td&gt;
&lt;td&gt;Built in&lt;/td&gt;
&lt;td&gt;Manual, or &lt;code&gt;--check&lt;/code&gt; with a file&lt;/td&gt;
&lt;td&gt;Rarely&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Practical file size ceiling&lt;/td&gt;
&lt;td&gt;~4 GB in-tab&lt;/td&gt;
&lt;td&gt;Unlimited&lt;/td&gt;
&lt;td&gt;Often 50-100 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multiple algorithms at once&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;One flag per run&lt;/td&gt;
&lt;td&gt;Sometimes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The CLI wins on raw capability and it's what I use for anything scripted. The browser tool wins on the specific case that comes up most often for me: I'm on a machine I don't fully control, I have one file, I have one expected hash, and I want an unambiguous yes or no in under ten seconds. That's the &lt;a href="https://aidevhub.io/hash-generator/" rel="noopener noreferrer"&gt;hash generator&lt;/a&gt; I built. Drop the file in, paste the published checksum into the compare field, and it goes green or red. No eyeballing hex.&lt;/p&gt;

&lt;p&gt;The upload-based sites are the ones I'd actively steer people away from. Sending a database dump or a signed binary to a stranger's server so they can run &lt;code&gt;hashlib.sha256()&lt;/code&gt; on it is a trade nobody should make, and several of those sites are ad-funded, which tells you what the business model is.&lt;/p&gt;

&lt;h2&gt;
  
  
  When a hash generator is the wrong tool
&lt;/h2&gt;

&lt;p&gt;This is where I'd push back on my own article a bit, because checksums get reached for in situations they don't fit.&lt;/p&gt;

&lt;p&gt;Password storage is the big one. Never store a raw SHA-256 of a password. Fast hashes are fast for attackers too, and a modern GPU rig will do billions of SHA-256 guesses per second. Use bcrypt, scrypt, or Argon2id, which are deliberately slow and salted. If you find yourself typing &lt;code&gt;sha256(password)&lt;/code&gt; in production code, stop.&lt;/p&gt;

&lt;p&gt;Verifying a download against an attacker is subtler. A published SHA-256 only helps if the attacker couldn't also edit the page that publishes it. If someone compromises the mirror, they change the file and the listed hash together and you're none the wiser. What actually defends against that is a GPG signature over the checksum file, verified against a key you obtained separately. The hash is one layer. Treat it as protection against corruption and truncation, which is what it's genuinely excellent at.&lt;/p&gt;

&lt;p&gt;MD5 and SHA-1 both have practical collision attacks. Two different files with the same MD5 can be constructed in seconds on a laptop. That doesn't make MD5 useless for detecting a flaky network transfer, and plenty of internal systems still use it as a cheap content key. It does make it worthless as a security boundary. If a vendor in 2026 publishes only an MD5, that tells you something about how much attention they pay to this.&lt;/p&gt;

&lt;p&gt;Also: if you're comparing thousands of files, don't do it by hand in a browser. Write the loop. Use &lt;code&gt;sha256sum --check&lt;/code&gt; against a manifest. The tool is for the one-off case, and I'd rather say that plainly than pretend it scales.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Which algorithm should I default to?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; SHA-256. It's fast enough that the difference from MD5 is irrelevant on any modern CPU, and it has no known practical collision attacks. Use SHA-512 if a vendor publishes it, since it's actually faster than SHA-256 on 64-bit hardware.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Is a client-side browser tool really not uploading my file?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Open the network tab and watch. There should be zero requests while hashing. That's the test I'd apply to any tool making this claim, including mine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Why do my hash and the published one differ in case?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Hex digests are case-insensitive. Some tools output uppercase, some lowercase. Any comparison worth using normalizes both sides before checking.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Can I hash text instead of a file?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Yes, and it's a common way to sanity-check an API's signing logic. Watch for a trailing newline, which is the cause of maybe 80% of "why doesn't my HMAC match" bugs I've helped debug.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written with AI assistance and human review. Try the tool at &lt;a href="https://aidevhub.io/hash-generator/" rel="noopener noreferrer"&gt;aidevhub.io/hash-generator&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>security</category>
      <category>showdev</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Unleash vs Claude vs a canary planner: which one I'd use in 2026</title>
      <dc:creator>AI Dev Hub</dc:creator>
      <pubDate>Thu, 09 Jul 2026 14:00:04 +0000</pubDate>
      <link>https://dev.to/aidevhub/unleash-vs-claude-vs-a-canary-planner-which-one-id-use-in-2026-9ge</link>
      <guid>https://dev.to/aidevhub/unleash-vs-claude-vs-a-canary-planner-which-one-id-use-in-2026-9ge</guid>
      <description>&lt;h1&gt;
  
  
  Unleash vs Claude vs a canary planner: which one I'd use in 2026
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;The planner, for the planning at least. I'd use the Skill Release Canary Planner to generate stages, stop conditions, and the rollback checklist, then Unleash to actually move traffic. The raw Claude prompt lost this one: it gave me a different plan on every run, and release plans need to survive an audit. Total cost of the winning combo: $0 and about 4 minutes of form filling.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Quick disclosure before the comparison: the canary planner I link to below is one I built. I went through four release-plan templates in April and every single one assumed I was shipping containers, so their stop conditions were about pod health instead of output quality. The planner is free and runs client-side in your browser; there's no signup, and nothing you type leaves the page. If you know a better one, genuinely, tell me in the comments.&lt;/p&gt;

&lt;h2&gt;
  
  
  The task: rolling out v3 of a ticket triage skill
&lt;/h2&gt;

&lt;p&gt;On June 16 I shipped v3 of the ticket triage skill that runs inside our support agent. It classifies incoming tickets and drafts a first reply, and on a normal weekday it handles around 38,900 calls. The v3 change looked small on paper: a rewritten system prompt and one tool schema swap (the customer lookup tool now returns plan tier). Small on paper is exactly how last October went wrong. Back then I pushed v2 straight to 100% of traffic, the new prompt started refusing any ticket containing an order ID it mistook for a card number, and the thumbs-down rate sat at triple baseline for six hours before anyone connected it to the release.&lt;/p&gt;

&lt;p&gt;So this time I wanted a real canary, with the plan written down before the first request moved. The test I set was identical for each tool: given the traffic volume and the metrics we already collect (error rate, p95 latency, and thumbs-down rate), produce a rollout plan I could hand to a coworker. That means stages with percentages, a bake time for each stage, stop conditions with actual numbers in them, and a rollback checklist. The bar for done: our on-call engineer, who didn't write the skill, should be able to run the whole release from the document alone.&lt;/p&gt;

&lt;p&gt;The candidates. Unleash, the open source feature flag platform we already self-host. Claude with a from-scratch prompt. And the Skill Release Canary Planner, which is the tool from the disclosure above.&lt;/p&gt;

&lt;h2&gt;
  
  
  Unleash: moves traffic, won't write your plan
&lt;/h2&gt;

&lt;p&gt;Setup took 1 hour 40 minutes, most of it wiring the SDK check into the skill dispatcher so the flag decides between v2 and v3 per ticket. The flexibleRollout strategy with stickiness on the ticket ID worked exactly as documented. I set 1%, watched it for an afternoon, bumped to 5%. No complaints about any of that; moving a percentage of traffic is the thing Unleash is for, and it does it well.&lt;/p&gt;

&lt;p&gt;Here's what it doesn't do. Unleash answers "who sees v3" and stops there. It has no opinion on how many stages you need or how long to sit in each one, and a stop condition just isn't a concept the flag has. All of that lived in a Google Doc I wrote by hand, plus a small script I now run from cron every 30 minutes while a rollout is live:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="n"&gt;PROM&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://prometheus.internal:9090/api/v1/query&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;BASELINE_ERR&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0083&lt;/span&gt;  &lt;span class="c1"&gt;# v2 trailing 7-day error rate
&lt;/span&gt;&lt;span class="n"&gt;MAX_DELTA&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.004&lt;/span&gt;      &lt;span class="c1"&gt;# halt when canary exceeds baseline by 0.4 points
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expr&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PROM&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;query&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;expr&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;result&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;

&lt;span class="n"&gt;errs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;sum(rate(skill_errors_total{skill=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ticket-triage&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;,version=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;v3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;}[30m]))&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;calls&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;sum(rate(skill_calls_total{skill=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ticket-triage&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;,version=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;v3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;}[30m]))&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;canary_err&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;errs&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;calls&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;calls&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;canary_err&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;BASELINE_ERR&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;MAX_DELTA&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HALT rollout: v3 error rate &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;canary_err&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, limit &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;BASELINE_ERR&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;MAX_DELTA&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ok: v3 error rate &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;canary_err&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That script is the load-bearing part of the whole release, and no tool handed it to me. I picked 0.4 points as the allowed delta, and honestly I picked it by eyeballing the October incident graphs. I still don't know if it's the right number. It hasn't fired a false halt yet, which is all I can say for it.&lt;/p&gt;

&lt;p&gt;One genuine point for Unleash: the audit log of every percentage change is gold in a postmortem. One grumble: nothing reminded me to clean up afterward, and I found the v3 flag still evaluating on July 3, seventeen days after the release closed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Claude with a blank prompt: a different plan every time
&lt;/h2&gt;

&lt;p&gt;I gave Claude the same brief I'd give a coworker: what the skill does, what changed in v3, which metrics exist, and how October went. The first plan that came back was honestly good. Five stages with sensible bake times, and it pointed out that a prompt change deserves a quality signal in the stop conditions, which matches my scar tissue exactly.&lt;/p&gt;

&lt;p&gt;Then I ran the identical prompt a second time, because release plans get audited and I wanted to know what an auditor would see. The second run produced four stages with different thresholds. It also wrote "monitor error rates closely" where a number used to be. A third run went to six stages and suggested an A/B test nobody asked for. Twelve minutes of prompting and about $0.09 in API cost bought me three plans that disagree with each other.&lt;/p&gt;

&lt;p&gt;I want to be fair: this is what happens when you prompt without a rigid template, and the loose constraints were mine. I half-built a locked-down template with pinned examples before deciding I didn't want to own release infrastructure written in prose. It would also drift the next time the underlying model version changed. When a postmortem asks why we halted at 25%, "the model felt differently that day" is a career-limiting answer.&lt;/p&gt;

&lt;p&gt;Where Claude did win: I pasted the final plan into it and asked for a two-paragraph announcement for the on-call channel. Ninety seconds and zero edits needed. That job it keeps.&lt;/p&gt;

&lt;h2&gt;
  
  
  The canary planner: same inputs, same plan
&lt;/h2&gt;

&lt;p&gt;The Skill Release Canary Planner is a form, and I mean that as a compliment. You give it the daily call volume, the blast radius (user-facing, in my case), which metrics you actually collect, and the rollback mechanism you have (flag flip for me, since Unleash was already in place). It emits a staged plan with a number everywhere a number belongs.&lt;/p&gt;

&lt;p&gt;For my inputs it produced five stages: 1% for 4 hours, then 5% overnight, then 25% for a full business day, then 50%, then 100%. Each stage carries stop conditions derived from the baselines I typed in. Error rate gets halted at baseline plus 0.4 points, which landed close to what I'd picked by gut feel for my cron script. Latency uses p95 at 15% over baseline, and thumbs-down halts anything past 2.1%. The rollback checklist came out at nine items, and two of them are things I've personally forgotten before: announce the rollback in the channel before flipping the flag, and delete the stale flag once the release closes.&lt;/p&gt;

&lt;p&gt;I reran the form a week later with the same inputs while drafting this post. Byte-identical output. That's the entire pitch, and it held.&lt;/p&gt;

&lt;p&gt;What it won't do: it doesn't move traffic and it doesn't watch your metrics. Enforcement was still my cron script, and the traffic split was still Unleash's job. I also can't promise the bake times fit every service. Four hours at 1% was fine at 38,900 calls a day; at 200 calls a day that window sees maybe eight canary requests, and while the planner does stretch stages for low traffic, I haven't tested that path in a real release.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scores and the one I'd actually use
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Criterion&lt;/th&gt;
&lt;th&gt;Unleash&lt;/th&gt;
&lt;th&gt;Claude prompt&lt;/th&gt;
&lt;th&gt;Canary planner&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Time to a usable plan&lt;/td&gt;
&lt;td&gt;1h 40m, and the plan was still handwritten&lt;/td&gt;
&lt;td&gt;12 min, plus edits every run&lt;/td&gt;
&lt;td&gt;4 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Same inputs, same output&lt;/td&gt;
&lt;td&gt;n/a (doesn't produce plans)&lt;/td&gt;
&lt;td&gt;No; three runs gave three plans&lt;/td&gt;
&lt;td&gt;Yes; verified a week apart&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stop conditions&lt;/td&gt;
&lt;td&gt;You write them yourself&lt;/td&gt;
&lt;td&gt;Vague on 2 of 3 runs&lt;/td&gt;
&lt;td&gt;Exact numbers per stage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rollback checklist&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Sometimes; contents vary&lt;/td&gt;
&lt;td&gt;Nine items, every time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Actually shifts traffic&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost for this release&lt;/td&gt;
&lt;td&gt;Free, self-hosted&lt;/td&gt;
&lt;td&gt;About $0.09&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The honest answer is a pairing. The &lt;a href="https://aidevhub.io/skill-release-canary-planner/" rel="noopener noreferrer"&gt;Skill Release Canary Planner&lt;/a&gt; writes the plan and the thresholds. Unleash moves the traffic, and the cron script from earlier enforces the halts. Since June 16 I've run two more skill releases this way, and the on-call engineer ran the second one without me in the room, which was the original bar.&lt;/p&gt;

&lt;p&gt;If you can only adopt one: it depends on what's already installed. Teams with feature flags in place are one form away from deterministic release plans, so add the planner. Teams with nothing should stand up Unleash (or any flag system) first, because a plan with no way to split traffic is a wish. Keep an LLM around for the communication layer, where it beats both of the others without trying.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Does the planner only make sense for AI skills, or for normal deploys too?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; The plans are shaped around prompt and skill updates, which is why quality signals like thumbs-down sit next to error rate in the stop conditions. Nothing stops you from feeding it a config rollout. For container deploys I'd reach for Argo Rollouts instead, since it plans the stages and also executes them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Couldn't a strict prompt template make the LLM deterministic enough?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Probably close, and I half-built one before giving up. You end up maintaining release infrastructure written in prose, and the output drifts when the model underneath changes versions. A form with fixed math doesn't have that failure mode.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; What if I don't collect a quality metric like thumbs-down?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; The planner asks which metrics you have and only writes conditions for those. Error rate and latency alone make a workable plan, but a regression that answers quickly and politely while being wrong will sail straight through it. That failure mode is why skill rollouts scare me more than code deploys do.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Is deterministic output really the headline feature?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; For me, yes. When someone asks in a postmortem why the release halted at 25%, the answer is a line in a document that anyone can regenerate from the same inputs. Try getting that out of a chat transcript from three weeks ago.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written with AI assistance and human review. Try the tool at &lt;a href="https://aidevhub.io/skill-release-canary-planner/" rel="noopener noreferrer"&gt;aidevhub.io/skill-release-canary-planner&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>cicd</category>
      <category>claude</category>
      <category>devops</category>
    </item>
    <item>
      <title>Auditing Chrome extension permissions in 2026: a 3-minute check</title>
      <dc:creator>AI Dev Hub</dc:creator>
      <pubDate>Tue, 07 Jul 2026 14:00:05 +0000</pubDate>
      <link>https://dev.to/aidevhub/auditing-chrome-extension-permissions-in-2026-a-3-minute-check-4bh2</link>
      <guid>https://dev.to/aidevhub/auditing-chrome-extension-permissions-in-2026-a-3-minute-check-4bh2</guid>
      <description>&lt;h1&gt;
  
  
  Auditing Chrome extension permissions in 2026: a 3-minute check
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;Yes, you can audit a Chrome extension in about three minutes: open chrome://extensions, turn on Developer mode, copy the extension ID, and read the &lt;code&gt;permissions&lt;/code&gt; and &lt;code&gt;host_permissions&lt;/code&gt; arrays in its manifest.json. Score what you find, and treat pairs like &lt;code&gt;cookies&lt;/code&gt; plus &lt;code&gt;&amp;lt;all_urls&amp;gt;&lt;/code&gt; as red flags. A scanner automates the judgment, but the manifest is the ground truth.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Full disclosure: the Extension Guard tool I link to further down is one I built. I tried four permission checkers in 2025 and every single one wanted me to upload the extension's CRX file to their server, which felt backwards for a security tool. Extension Guard is free, runs client-side in your browser, needs no signup, and never uploads anything. If you've found a better one, honestly, tell me in the comments.&lt;/p&gt;

&lt;h2&gt;
  
  
  The extension that made me paranoid
&lt;/h2&gt;

&lt;p&gt;In December 2024, attackers phished the developer of the Cyberhaven extension and shipped a malicious update straight through the Chrome Web Store. The same campaign compromised around 36 extensions with roughly 2.6 million combined users. Nobody clicked a shady download link. The extensions people already trusted changed underneath them, silently, on autoupdate.&lt;/p&gt;

&lt;p&gt;It wasn't the first time either. In January 2021, The Great Suspender, a tab manager with over two million users, got yanked from the Web Store after its new owner shipped tracking code in an update. The original developer had quietly sold the extension in mid-2020, and most users had no idea anything changed hands until Chrome flagged it as malware. The pattern repeated with Cyberhaven: an update landed on permissions people had granted years earlier, and those permissions did all the work.&lt;/p&gt;

&lt;p&gt;Then it got personal. Three weeks ago I was debugging why a client's checkout page behaved differently in my normal browser than in Incognito, and after 47 minutes of blaming my own code I found the real cause: a coupon extension I'd installed sometime in 2023 was injecting a content script into every page I visited. It had permission to do that from day one. I'd granted it myself. I'd just never read the manifest.&lt;/p&gt;

&lt;p&gt;Chrome's install prompt deserves some blame. It tells you an extension can "read and change all your data on all websites", and it says exactly the same thing for a password manager, an ad blocker, and a keylogger. When one sentence covers everything, people stop reading it. The useful signal lives one layer down, in manifest.json: which permissions the developer asked for, and which ones appear together.&lt;/p&gt;

&lt;p&gt;That last part matters more than any single permission. &lt;code&gt;storage&lt;/code&gt; is harmless. &lt;code&gt;cookies&lt;/code&gt; alone usually means session handling. Pair &lt;code&gt;cookies&lt;/code&gt; with &lt;code&gt;&amp;lt;all_urls&amp;gt;&lt;/code&gt;, though, and the extension can read your logged-in session on every site you visit, which is exactly what the Cyberhaven payload went after.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the scoring works under the hood
&lt;/h2&gt;

&lt;p&gt;The core idea fits in a script. Every permission gets a weight, and host patterns get scored by breadth (&lt;code&gt;*://*.example.com/*&lt;/code&gt; is a different animal from &lt;code&gt;&amp;lt;all_urls&amp;gt;&lt;/code&gt;). Dangerous pairs then add points on top of the sum. Here's a stripped-down version of the logic you can run against any manifest right now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// score-extension.js&lt;/span&gt;
&lt;span class="c1"&gt;// usage: node score-extension.js path/to/manifest.json&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fs&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;WEIGHTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;all_urls&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;debugger&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;nativeMessaging&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;35&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;webRequest&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cookies&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;scripting&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tabs&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;history&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;clipboardRead&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;management&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;downloads&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;storage&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="c1"&gt;// pairs that are worse together than apart&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;COMBOS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cookies&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;all_urls&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;can read session cookies on every site&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;webRequest&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;all_urls&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;can watch every request you make&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;scripting&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;all_urls&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;can inject code into any page&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tabs&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;history&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;can build a full browsing profile&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;manifest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;perms&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;...(&lt;/span&gt;&lt;span class="nx"&gt;manifest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;permissions&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;[]),&lt;/span&gt;
  &lt;span class="p"&gt;...(&lt;/span&gt;&lt;span class="nx"&gt;manifest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;host_permissions&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;[]),&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;perms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;WEIGHTS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;why&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;COMBOS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;perms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;perms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`combo: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; + &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; -&amp;gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;why&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;band&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;high&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;medium&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;low&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`risk score: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;score&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; (&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;band&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;)`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it with &lt;code&gt;node score-extension.js manifest.json&lt;/code&gt;. The coupon extension that burned me asked for &lt;code&gt;scripting&lt;/code&gt;, &lt;code&gt;cookies&lt;/code&gt;, &lt;code&gt;storage&lt;/code&gt;, and host access to &lt;code&gt;&amp;lt;all_urls&amp;gt;&lt;/code&gt;. This script flags two combos on it and scores it 127, which would've been useful to see in 2023 before I clicked install.&lt;/p&gt;

&lt;p&gt;Why score pairs instead of just summing weights? Because permissions compose. Post-MV3, &lt;code&gt;webRequest&lt;/code&gt; on its own mostly lets an extension observe traffic. &lt;code&gt;&amp;lt;all_urls&amp;gt;&lt;/code&gt; on its own is reach with nothing attached to it. Together they mean every request you make, from your bank session to your company's admin panel, is visible to the extension. A plain sum understates that badly. I tuned the weights against a few hundred real manifests, and the combo bonus was the single change that made the scores match my gut.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://aidevhub.io/extension-guard/" rel="noopener noreferrer"&gt;Extension Guard&lt;/a&gt; is this script grown up: a weight table covering all 60-plus Chrome permissions, combo detection with plain-English explanations of what each pairing enables, and a paste-in box so you don't need Node installed. You paste the manifest and it scores locally in your browser. The weights started as a JSON file I kept for vetting my own installs, and the tool is mostly a UI wrapped around that file.&lt;/p&gt;

&lt;p&gt;Getting the manifest is the only fiddly step. On macOS, installed extensions live under &lt;code&gt;~/Library/Application Support/Google/Chrome/Default/Extensions/&amp;lt;id&amp;gt;/&amp;lt;version&amp;gt;/manifest.json&lt;/code&gt;. On Windows, look in &lt;code&gt;%LOCALAPPDATA%\Google\Chrome\User Data\Default\Extensions&lt;/code&gt;. For something you haven't installed yet, the CRX Viewer extension (ironic, I know) shows you the manifest straight from a Web Store listing.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it stacks up against the alternatives
&lt;/h2&gt;

&lt;p&gt;I used three other approaches before building my own, and I still use two of them. Here's the honest comparison:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What&lt;/th&gt;
&lt;th&gt;Extension Guard&lt;/th&gt;
&lt;th&gt;ExtensionTotal&lt;/th&gt;
&lt;th&gt;CRX Viewer&lt;/th&gt;
&lt;th&gt;Manual review&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Where it runs&lt;/td&gt;
&lt;td&gt;Client-side, in your browser&lt;/td&gt;
&lt;td&gt;Their servers&lt;/td&gt;
&lt;td&gt;Client-side&lt;/td&gt;
&lt;td&gt;Your editor&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Signup&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;For full reports&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Combo detection&lt;/td&gt;
&lt;td&gt;Yes, with explanations&lt;/td&gt;
&lt;td&gt;Partial&lt;/td&gt;
&lt;td&gt;No scoring at all&lt;/td&gt;
&lt;td&gt;Only if you know the pairs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sees actual code&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;Free tier, paid API&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;Your time&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;ExtensionTotal is genuinely good, and I still reach for it when I want signals beyond the manifest, like developer reputation and code analysis. The trade-off is that you're querying their backend, and the deeper reports sit behind an account. CRX Viewer is the opposite: full source access with zero interpretation. It shows you everything and explains nothing, which is fine if you already know what &lt;code&gt;chrome.debugger&lt;/code&gt; buried in a minified bundle means.&lt;/p&gt;

&lt;p&gt;CRXcavator used to be the obvious answer here. Duo Security built it, and it was excellent right up until they retired it. That shutdown is a big part of why the surviving options went cloud-side, and why I built mine to run locally instead. Cloud scanners can do more (they fetch the code, they track version history), but a manifest score shouldn't require sending anything anywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  When you shouldn't use it
&lt;/h2&gt;

&lt;p&gt;A permission scanner reads declared capability. It can't see intent and it can't see runtime behavior, and that puts some hard limits on when Extension Guard is the right call.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A malicious update can keep the same permission set.&lt;/strong&gt; The Cyberhaven attackers didn't request anything new; the extension already had the access they needed. Extension Guard would've scored the compromised version identically to the clean one. A scan tells you the blast radius if an extension ever goes bad. Whether it actually has gone bad is a different investigation, one that involves reading code and watching network traffic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Open source extensions deserve an actual code read.&lt;/strong&gt; If the repo is public and reasonably small, an hour with the source beats any score. The manifest tells you what the extension may do. The code tells you what it does.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Enterprise fleets need policy, and a scanner is the wrong layer.&lt;/strong&gt; If you manage 200 machines, use Chrome's ExtensionInstallAllowlist and force-install policies. Scan candidates before they go on the allowlist, sure, but enforcement has to live in policy or people will quietly work around you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Low scores can still hurt you.&lt;/strong&gt; An extension with nothing beyond &lt;code&gt;activeTab&lt;/code&gt; can still draw a convincing fake login popup. I don't have a good automated answer for that class of attack, and I'm suspicious of anyone who claims they do.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Does a high risk score mean an extension is malicious?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; No. My password manager scores 88. A high score measures capability: how much damage the extension could do if it went rogue or got compromised. Score is blast radius. Whether you trust the developer is a separate judgment, and no scanner can make it for you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Where do I find the manifest for an installed extension?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Open chrome://extensions with Developer mode on and copy the extension's ID. Then find that ID inside your Chrome profile's Extensions folder (exact paths are in the section above) and open the manifest.json in the newest version folder. CRX Viewer works if you'd rather check without installing anything first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Didn't Manifest V3 fix all this?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; I assumed it would, and I was mostly wrong. MV3 removed blocking &lt;code&gt;webRequest&lt;/code&gt; for regular extensions and banned remotely hosted code, which are real improvements. But pairing &lt;code&gt;cookies&lt;/code&gt; and &lt;code&gt;scripting&lt;/code&gt; with &lt;code&gt;&amp;lt;all_urls&amp;gt;&lt;/code&gt; is still perfectly legal in MV3, and the Cyberhaven attack happened well into the MV3 era.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Does the scoring work for Firefox or Edge add-ons?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Mostly. Edge uses the same manifest format, so everything applies there. Firefox overlaps on most permission names; the scoring still runs, but a few Firefox-specific permissions fall back to a default weight, so treat those scores as a rough draft.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Can I use this in CI for extensions my team ships?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Yes. Add &lt;code&gt;process.exit(score &amp;gt; 60 ? 1 : 0)&lt;/code&gt; to the script above and wire it into your build. We run a version of that check before every store submission, and it caught an accidental &lt;code&gt;&amp;lt;all_urls&amp;gt;&lt;/code&gt; grant in a pull request back in April 2026.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written with AI assistance and human review. Try the tool at &lt;a href="https://aidevhub.io/extension-guard/" rel="noopener noreferrer"&gt;aidevhub.io/extension-guard&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>showdev</category>
      <category>tutorial</category>
      <category>webdev</category>
    </item>
    <item>
      <title>MCP Inspector vs Postman in 2026: which one I actually use</title>
      <dc:creator>AI Dev Hub</dc:creator>
      <pubDate>Tue, 23 Jun 2026 14:00:03 +0000</pubDate>
      <link>https://dev.to/aidevhub/mcp-inspector-vs-postman-in-2026-which-one-i-actually-use-3j37</link>
      <guid>https://dev.to/aidevhub/mcp-inspector-vs-postman-in-2026-which-one-i-actually-use-3j37</guid>
      <description>&lt;h1&gt;
  
  
  MCP Inspector vs Postman in 2026: which one I actually use
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;I use two of the three: MCP Inspector for live calls, and a small client-side validator for checking definitions before I ever start a server. Postman's MCP support works, but it was too much setup for the quick checks I do most. Same broken tool, run through all three, below.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Full disclosure: the MCP Tool Tester I link to below is one I built. I'd tried four other validators and every one either needed a running server, an npm install, or an account before it'd tell me my inputSchema had a typo. Mine doesn't. It's free, runs entirely in your browser, no signup, nothing uploaded. Paste it in, get an answer. If you've got a better one, tell me.&lt;/p&gt;

&lt;h2&gt;
  
  
  The task: a broken currency tool
&lt;/h2&gt;

&lt;p&gt;Three weeks ago I was wiring up an MCP server for a currency tool, and the agent kept refusing to call it. No error message. It just ignored the tool. After 47 minutes of squinting I found it: my handler read a field called &lt;code&gt;from_currency&lt;/code&gt;, but the schema I advertised defined &lt;code&gt;currency_from&lt;/code&gt;. The model saw a contract it couldn't satisfy and quietly walked away.&lt;/p&gt;

&lt;p&gt;Here's the thing about MCP tool definitions: the schema you advertise and the handler you write live in two different places, and nothing forces them to agree. JSON Schema will happily describe a field your code never reads. Most agents won't tell you why they skipped a tool, they just skip it. The expected behavior here was simple: send 100 USD with EUR as the target, get a converted amount back. What I actually got was nothing, no call attempted, which is the worst kind of bug because there's no stack trace to follow.&lt;/p&gt;

&lt;p&gt;So I rebuilt that broken tool on purpose and ran it through three things people reach for when testing MCP: the official Inspector, Postman, and the validator I made. Here's the server, mismatch and all.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// server.js  (run: npm i @modelcontextprotocol/sdk zod &amp;amp;&amp;amp; node server.js)&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;McpServer&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@modelcontextprotocol/sdk/server/mcp.js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;StdioServerTransport&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@modelcontextprotocol/sdk/server/stdio.js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;zod&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;server&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;McpServer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fx&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1.0.0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;registerTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;convert_currency&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Convert an amount between two ISO 4217 currency codes&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;inputSchema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;number&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;amount to convert&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;currency_from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;length&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;currency_to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;length&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="c1"&gt;// Bug: the schema defines currency_from, the handler reads from_currency.&lt;/span&gt;
  &lt;span class="c1"&gt;// The names never line up, so the agent sees a field it can't supply.&lt;/span&gt;
  &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;from_currency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;currency_to&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;from_currency&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; -&amp;gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;currency_to&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;StdioServerTransport&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  MCP Inspector: what happened
&lt;/h2&gt;

&lt;p&gt;MCP Inspector is the official debugger. You run &lt;code&gt;npx @modelcontextprotocol/inspector node server.js&lt;/code&gt; and it opens a local UI where you can list a server's tools and call them by hand. I ran it last Tuesday against the broken server above.&lt;/p&gt;

&lt;p&gt;It connected in about two seconds and the tool showed up. Inspector reads your inputSchema and renders a form, so the field labels it expected (including &lt;code&gt;currency_from&lt;/code&gt;) were right there on screen. That's where the bug got visible to me, because I knew my handler wanted &lt;code&gt;from_currency&lt;/code&gt;. When I filled the form and hit call, my own handler threw on an undefined field. Honest, but late: I had to boot a full server to learn something a static check could have told me in seconds.&lt;/p&gt;

&lt;p&gt;One more thing worth flagging. Inspector caches the tool list per session, so when I edited the schema and restarted the server, I had to reconnect to see the change. Minor, but I lost a couple of minutes the first time wondering why my fix wasn't showing. Once you learn to reconnect after every restart, it's fine. The history panel is also handy for replaying a call you already got working.&lt;/p&gt;

&lt;p&gt;Inspector's strength is that it talks to a real, running server over the actual transport. Its limit is the same thing. It can't say a word about a definition until there's a live process to connect to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Postman: what happened
&lt;/h2&gt;

&lt;p&gt;Postman shipped MCP support in 2025, and for HTTP-based servers it's solid. I pointed it at the same tool after switching the transport to streamable HTTP, because Postman won't drive a stdio process. It discovered the tool, showed the schema, and let me send a call.&lt;/p&gt;

&lt;p&gt;The request builder is nicer than Inspector's plain form, I'll give it that. Two things bugged me, though. I had to change my transport just to test, so I was poking at a slightly different server than the one I ship. And the validation is shallow: it happily sent garbage and reported the failure as a generic error response instead of pointing at the field that was wrong. Setup ate about 15 minutes. If you already live in Postman, that cost is mostly paid. For a fast definition check, it's heavy.&lt;/p&gt;

&lt;p&gt;To be fair to Postman, the collection sharing is real value if you're on a team. I could save the MCP connection and hand it to a coworker, and they'd get the same setup without me writing a README. That's something neither Inspector nor my validator does. It just doesn't help the specific thing I was testing, which was whether a definition is correct before anyone runs it.&lt;/p&gt;

&lt;h2&gt;
  
  
  MCP Tool Tester: what happened
&lt;/h2&gt;

&lt;p&gt;This one I built, so weigh that accordingly. The workflow is intentionally dumb: paste the tool definition (the JSON a server advertises, or a WebMCP &lt;code&gt;tools&lt;/code&gt; array) and it checks the shape against the MCP schema plus a handful of lint rules I kept tripping over in real projects.&lt;/p&gt;

&lt;p&gt;On the broken currency tool it flagged the mismatch in under a second: a required name with no matching property. It also caught two issues the other two never looked at, a description longer than the cutoff where some clients truncate (I still don't know the exact limit for every client, but I've watched it break around 1,024 characters) and an enum with a duplicated value. No server to boot, no install. It runs in the browser, so the definition never leaves your machine, which I care about because my tool descriptions leak internal endpoint names.&lt;/p&gt;

&lt;p&gt;The lint rules came straight from bugs that cost me time. A required field with no property to back it. A description left empty, which makes some clients drop the tool entirely. I keep adding rules as I get burned, so the list grows in an embarrassingly autobiographical way. A few days ago a teammate's PR defined a tool that required &lt;code&gt;user_id&lt;/code&gt; but only declared &lt;code&gt;userId&lt;/code&gt;. Same family of bug as mine. The validator flagged it before review started, which saved a confused back-and-forth in comments.&lt;/p&gt;

&lt;p&gt;What it won't do is execute your tool. It checks definitions, not behavior. Think of it as the step you run before Inspector.&lt;/p&gt;

&lt;h2&gt;
  
  
  The scorecard, and which one I reach for
&lt;/h2&gt;

&lt;p&gt;Here's the same task scored across the things I actually care about:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Criterion&lt;/th&gt;
&lt;th&gt;MCP Inspector&lt;/th&gt;
&lt;th&gt;Postman&lt;/th&gt;
&lt;th&gt;MCP Tool Tester&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Setup to first result&lt;/td&gt;
&lt;td&gt;about 10s via npx&lt;/td&gt;
&lt;td&gt;about 15 min&lt;/td&gt;
&lt;td&gt;about 3s, paste only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Needs a running server&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Flags a bad definition pre-deploy&lt;/td&gt;
&lt;td&gt;only as a runtime error&lt;/td&gt;
&lt;td&gt;shallow&lt;/td&gt;
&lt;td&gt;yes, field-level&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Actually calls the tool&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Validates WebMCP tool arrays&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;partial&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Price&lt;/td&gt;
&lt;td&gt;free, open source&lt;/td&gt;
&lt;td&gt;free tier, paid plans&lt;/td&gt;
&lt;td&gt;free&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The pattern is clear enough: Postman never wins a row outright for my use case. It's competent everywhere and best nowhere, which is a perfectly respectable place to be for a general tool that happens to speak MCP.&lt;/p&gt;

&lt;p&gt;So which do I actually use? Two of them, at different moments. While I'm authoring a definition or reviewing a teammate's pull request, I paste it into the validator first, because catching a name mismatch in three seconds beats catching it after a 90-second server boot and a confused agent. Once the definition is clean, I bring up Inspector and call the thing for real over the transport I'll ship. Postman stays in the box unless a project already runs on it.&lt;/p&gt;

&lt;p&gt;If I had to give up two of the three and keep one, I'd actually struggle, because they solve different halves of the problem. Definition correctness and runtime behavior aren't the same question. The validator answers the first in seconds; Inspector answers the second properly. That split is why I run both rather than picking a single winner.&lt;/p&gt;

&lt;p&gt;If you want to throw your own definitions at the validator, it's here: &lt;a href="https://aidevhub.io/mcp-tool-tester/" rel="noopener noreferrer"&gt;MCP Tool Tester&lt;/a&gt;. Paste one in, read what's wrong, move on. I added the WebMCP checks last month after a reader pointed out that browser tool arrays carry their own sharp edges.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Does MCP Inspector validate my schema without running the server?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; No. It connects to a live server over stdio or HTTP, lists the advertised tools, and lets you call them. If the server won't start, you get nothing to inspect. For static definition checks you want a validator that reads the raw JSON.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; What's the difference between MCP and WebMCP here?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; MCP tools are advertised by a server you run over stdio or HTTP. WebMCP exposes tools from inside a web page to a browser-side agent. The definition shape is similar, but WebMCP adds constraints that most server-focused testers skip.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Can I trust a browser-based validator with internal tool descriptions?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Only if it's genuinely client-side. Open the network tab and confirm nothing leaves your machine. The one I built runs entirely in-page for that reason. Don't take my word for it, check the requests.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Is Postman a bad choice for MCP?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Not at all. If your server is HTTP and your team already uses Postman, the request history and sharing are genuinely useful. It's just heavier than I want for a quick sanity check on a definition.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Which one is fastest for a quick check?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; The validator, by a wide margin, because there's no server to start. Paste and read. For anything involving real calls and real responses, that speed stops mattering and Inspector's live connection is what you want.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written with AI assistance and human review. Try the tool at &lt;a href="https://aidevhub.io/mcp-tool-tester/" rel="noopener noreferrer"&gt;aidevhub.io/mcp-tool-tester&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>mcp</category>
      <category>testing</category>
      <category>tooling</category>
    </item>
    <item>
      <title>Stop hand-aligning markdown tables in 2026</title>
      <dc:creator>AI Dev Hub</dc:creator>
      <pubDate>Thu, 18 Jun 2026 14:00:01 +0000</pubDate>
      <link>https://dev.to/aidevhub/stop-hand-aligning-markdown-tables-in-2026-1eig</link>
      <guid>https://dev.to/aidevhub/stop-hand-aligning-markdown-tables-in-2026-1eig</guid>
      <description>&lt;h1&gt;
  
  
  Stop hand-aligning markdown tables in 2026
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;Use a visual, spreadsheet-style editor that exports GitHub-Flavored Markdown so you stop counting spaces by hand. You paste a CSV export, tweak the alignment per column, and the markdown updates as you type. The editor I cover here runs in your browser and never uploads your data. It cut a 47-minute chore down to about two minutes for me.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Quick disclosure: the Markdown Table Generator I link to below is one I built. I'd tried five other generators. Every one either logged my table data to a server or buried the alignment controls behind a paywall, and a couple choked on cells that contained a pipe character. So I wrote my own. It's free and runs entirely client-side. There's no signup, and nothing gets uploaded. If you know a better one, tell me.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 47 minutes I'll never get back
&lt;/h2&gt;

&lt;p&gt;Three weeks ago I sat down to update the README for an internal CLI tool. It had grown to fourteen flags, and a teammate asked for a table documenting each one. I typed it by hand the way I always had, lining up the pipes with spaces so the raw file looked tidy in my editor.&lt;/p&gt;

&lt;p&gt;Then I added one more flag. Every column shifted. I spent the next stretch nudging spaces around like it was 2011, and when I finally pushed, the diff came back as a wall of red and green because the renderer had touched every line in the block. The actual change was a single row.&lt;/p&gt;

&lt;p&gt;I checked the clock afterward. 47 minutes on one table. Not the docs, not the code. The table itself.&lt;/p&gt;

&lt;p&gt;Here's the thing that really gets me about those diffs. When you re-pad a whole table to fit one new row, every line registers as changed, so a reviewer can't see what you actually edited. They either skip the table review entirely or waste time eyeballing forty lines to find your one real change. Both outcomes are bad, and both come from a formatting quirk rather than anything meaningful.&lt;/p&gt;

&lt;p&gt;Markdown tables look simple, and for a 2x2 they are. The trouble starts once a table grows past a handful of rows: manual alignment stops paying for itself, and any single edit reshuffles the whole block. The alignment markers don't help my memory either. Left is &lt;code&gt;:---&lt;/code&gt;, right is &lt;code&gt;---:&lt;/code&gt;, center is &lt;code&gt;:---:&lt;/code&gt;, and I still open the docs every time to remember which side the colon goes on.&lt;/p&gt;

&lt;p&gt;This is a tiny problem, which is exactly why it irritates me so much. Nobody plans for it. You don't budget time to reformat a table the way you'd budget for a refactor, so it sneaks up during work you thought was basically finished and quietly eats real minutes. Multiply that across every doc and changelog you touch in a year, and the total stops being small.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the generator actually does
&lt;/h2&gt;

&lt;p&gt;There's no magic under the hood. A table generator holds your data as a 2D array and re-renders the text output on every keystroke. The fiddly part is the padding. Each column's width equals the length of its longest cell. Every other cell in that column gets padded to match, and the separator row carries the alignment colons.&lt;/p&gt;

&lt;p&gt;Here's the core of that logic in plain JavaScript. Save it as &lt;code&gt;table.js&lt;/code&gt; and run it with Node:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;toMarkdownTable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;aligns&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;widths&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pad&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;align&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;gap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;align&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;right&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;repeat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;gap&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;align&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;center&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;left&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;gap&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;repeat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;left&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;repeat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;gap&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;left&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;repeat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;gap&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cells&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;| &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;cells&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;pad&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;widths&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;aligns&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;])).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; | &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; |&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sep&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;widths&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;repeat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;aligns&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;right&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;dash&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;aligns&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;center&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;dash&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;dash&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;header&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nf"&gt;row&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;header&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;| &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;sep&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; | &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; |&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;flag&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;default&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;--verbose&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bool&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;false&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;--retries&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;int&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;3&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;toMarkdownTable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;left&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;left&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;right&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run &lt;code&gt;node table.js&lt;/code&gt; and you get a clean, aligned table out the other side. A visual editor wraps this same logic in a spreadsheet grid, so you can tab between cells and paste a block of CSV while the markdown updates live in a pane beside the data. You can &lt;a href="https://aidevhub.io/markdown-table-generator/" rel="noopener noreferrer"&gt;try the editor here&lt;/a&gt; and drop in a CSV export straight from a spreadsheet app; it parses the rows in the browser and keeps the data on your machine.&lt;/p&gt;

&lt;p&gt;The part I care about most is that CSV import. Most of my tables already exist somewhere, usually as a spreadsheet export or a query result. Retyping them by hand is the step that actually wastes my afternoon, so being able to paste raw CSV and get GFM back is the whole point for me.&lt;/p&gt;

&lt;p&gt;One detail that took me longer than I'd like to admit: cells that contain a pipe character. In GFM a raw &lt;code&gt;|&lt;/code&gt; inside a cell breaks the table unless you escape it as &lt;code&gt;\|&lt;/code&gt;. The generator does that escaping for you on the way out, which is the single bug that pushed me off two of the hosted tools I'd been using. I don't know why so many of them skip it, but they do.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it stacks up against the alternatives
&lt;/h2&gt;

&lt;p&gt;I didn't build this in a vacuum. There are several common ways to make a markdown table, and each comes with a tradeoff. Here's how I'd line up the ones I actually reached for before I gave up and wrote my own:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;CSV import&lt;/th&gt;
&lt;th&gt;Alignment controls&lt;/th&gt;
&lt;th&gt;Works offline&lt;/th&gt;
&lt;th&gt;Keeps data local&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;This generator&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Per-column buttons&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;tablesgenerator.com&lt;/td&gt;
&lt;td&gt;Partial&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Sends to a server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VS Code table extension&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Auto-format only&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Typing it by hand&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Manual colons&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The VS Code formatter is genuinely good if you already have the rows typed out. It'll re-align an existing table for you on save, which covers the "I added a row and everything shifted" pain nicely. What it won't do is take a CSV dump and build the table from scratch, and that build step is exactly where my time goes. The hosted generators do handle CSV, but the ones I tried either round-tripped my data through their backend or put alignment behind an account wall. For a thirty-second job, that's too much friction and too much trust to ask.&lt;/p&gt;

&lt;p&gt;None of these are bad tools, to be clear. The hand-typed table is fine until it grows. The VS Code extension is the right answer if your rows are already in the file. My complaint is narrow: I wanted one workflow that started from a CSV and ended at GFM I could trust, without an account in the middle.&lt;/p&gt;

&lt;p&gt;So the gap I kept hitting was a tool that could import CSV and give me real alignment controls without shipping my data off my laptop. That combination is what I ended up building.&lt;/p&gt;

&lt;h2&gt;
  
  
  When I don't reach for it
&lt;/h2&gt;

&lt;p&gt;A generator isn't always the right call, and pretending it is would be dishonest.&lt;/p&gt;

&lt;p&gt;If the table is tiny, say two columns and two rows, just type it. Opening any tool is slower than the keystrokes, and you won't fight alignment at that size anyway.&lt;/p&gt;

&lt;p&gt;If the table is produced from data inside a script or a CI job, generate the markdown in code instead. The function above is a fine starting point, and a GUI parked in the middle of an automated pipeline defeats the purpose.&lt;/p&gt;

&lt;p&gt;If you need merged cells or row spans, plain markdown can't express either of those. You'll have to drop down to raw HTML inside your markdown, or pick a different format. No generator can paper over a limitation that lives in the spec itself.&lt;/p&gt;

&lt;p&gt;And honestly, I still hand-write a quick table now and then when I'm offline and can't be bothered to open a browser tab. Old habits stick around.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Does it output GitHub-Flavored Markdown specifically?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Yes. The separator row uses the colon syntax GitHub renders, so whatever alignment you set shows up correctly once the table lands in a repo or an issue.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Can I paste data from Excel or Google Sheets?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Yes. Copy a range and paste it straight in. Spreadsheet copy usually arrives as tab-separated text, and the editor reads that the same way it reads CSV, so you don't need to export a file first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Is any of my table data sent anywhere?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; No. Parsing and rendering both happen in your browser, and nothing about the table leaves your machine. That was the main reason I stopped using the hosted options I'd been relying on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; What about very wide tables?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; They work, but think about whoever reads the result. A table with twelve columns is painful in a rendered doc and it'll scroll sideways on a phone. I try to keep mine under six columns when the data lets me.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Does it handle alignment per column or all at once?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Per column. Each column gets its own alignment toggle, so a numbers column can sit right-aligned while the text label beside it stays left. That mixed alignment is the part raw typing makes genuinely tedious, since every change recomputes the padding by hand.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written with AI assistance and human review. Try the tool at &lt;a href="https://aidevhub.io/markdown-table-generator/" rel="noopener noreferrer"&gt;aidevhub.io/markdown-table-generator&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>showdev</category>
      <category>tooling</category>
      <category>writing</category>
    </item>
    <item>
      <title>Building a rules-file MCP server in 2026</title>
      <dc:creator>AI Dev Hub</dc:creator>
      <pubDate>Thu, 11 Jun 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/aidevhub/building-a-rules-file-mcp-server-in-2026-2j74</link>
      <guid>https://dev.to/aidevhub/building-a-rules-file-mcp-server-in-2026-2j74</guid>
      <description>&lt;h1&gt;
  
  
  Building a rules-file MCP server in 2026
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;You can build an MCP server that writes CLAUDE.md, .cursorrules, and copilot-instructions.md from one prompt in about 30 lines of Python with FastMCP. Register the tool, point your assistant's config at it, and ask. The code is the easy part. Keeping three formats in sync is the actual work. Here's the server and the part that broke first.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The rules-file generator I link to below is one I built. I'd opened four separate template repos last spring trying to bootstrap a CLAUDE.md, and every one assumed a single assistant. Cursor users had .cursorrules. The Claude crowd had their own file. Nobody emitted every format from one input. So I wrote a small web page that does. It's free, runs entirely in your browser, no signup, nothing gets uploaded. If you've got a better one, tell me.&lt;/p&gt;

&lt;h2&gt;
  
  
  The goal
&lt;/h2&gt;

&lt;p&gt;A local MCP server that any MCP-aware assistant can call to write its own rules file. You say 'set up the rules for this repo,' the assistant calls the tool with what it already knows about your stack, and three files land on disk:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;CLAUDE.md at the repo root&lt;/li&gt;
&lt;li&gt;.cursorrules beside it&lt;/li&gt;
&lt;li&gt;.github/copilot-instructions.md for the Copilot users&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No copy-paste between editor tabs. The outcome you'd screenshot: your assistant printing &lt;code&gt;wrote 412 bytes to CLAUDE.md&lt;/code&gt; right in the chat, and the file appearing in your sidebar a half second later.&lt;/p&gt;

&lt;p&gt;A rules file is just instructions your assistant loads before it reads your code: your conventions, plus the things it keeps getting wrong. Most teams write one by hand, then never touch it again, and it rots. Generating it from a tool means you can regenerate when the stack changes instead of editing prose at midnight. The sync problem is why I cared. I'd update CLAUDE.md and forget the .cursorrules copy, and then two teammates on different assistants would get different rules. That drift is quiet and it's expensive.&lt;/p&gt;

&lt;p&gt;Why a server and not a plain script? Because the assistant can call it mid-conversation, with context it already has. It knows you're on Postgres and Vite before you say a word. That's the whole reason to bother.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup and auth
&lt;/h2&gt;

&lt;p&gt;Here's the part nobody warns you about: there's no auth. I spent a chunk of last Thursday hunting for where an API key goes, and there isn't one. A local MCP server talks to your assistant over stdio, on your machine, as your user. The "credential" is filesystem permission. I was wrong about needing a token, and it took me 47 minutes of reading the spec before I believed it.&lt;/p&gt;

&lt;p&gt;Install the SDK with &lt;code&gt;pip install "mcp[cli]"&lt;/code&gt;. Then register the server. For Claude Code, that's an &lt;code&gt;.mcp.json&lt;/code&gt; file in the project root with a &lt;code&gt;command&lt;/code&gt; and &lt;code&gt;args&lt;/code&gt; pointing at your Python file. Cursor and Claude Desktop use their own JSON config, same shape. One gotcha that cost me real time: a stdio server must never write to stdout, because that stream carries the JSON-RPC frames. Any stray &lt;code&gt;print()&lt;/code&gt; corrupts the protocol. Log to stderr or to a file.&lt;/p&gt;

&lt;p&gt;You can pass a default through the environment too. I set &lt;code&gt;RULES_DEFAULT_STACK&lt;/code&gt; in the &lt;code&gt;.mcp.json&lt;/code&gt; &lt;code&gt;env&lt;/code&gt; block so the tool has a fallback when the model forgets to send one. Small thing, but it turned a class of empty-stack files into a sane default. Restart the assistant after editing the config, by the way: most of them read MCP settings once at launch and won't pick up changes live, which confused me for a good ten minutes the first time.&lt;/p&gt;

&lt;p&gt;If you just want the files without running a server, the rules-file generator at &lt;a href="https://aidevhub.io/rules-file-generator/" rel="noopener noreferrer"&gt;aidevhub.io/rules-file-generator&lt;/a&gt; produces the same multi-format output in the browser. I reach for it when I'm scaffolding a repo I'll touch once and forget.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core code
&lt;/h2&gt;

&lt;p&gt;The whole server fits in one file. Here it is, with the guard I added only after it bit me (more on that in the last section):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# server.py
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;mcp.server.fastmcp&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;FastMCP&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;

&lt;span class="n"&gt;mcp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FastMCP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rules-file-generator&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;TEMPLATES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;claude&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CLAUDE.md&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cursor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.cursorrules&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;copilot&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.github/copilot-instructions.md&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;@mcp.tool&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;generate_rules&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;project_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stack&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Write an AI rules file. target: claude | cursor | copilot.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;filename&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;TEMPLATES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;filename&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="c1"&gt;# guard added after the bug in the last section
&lt;/span&gt;        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unknown target &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="si"&gt;!r}&lt;/span&gt;&lt;span class="s"&gt;; pick claude, cursor, or copilot&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;# &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;project_name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Stack: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;stack&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;## Conventions&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;- Keep functions under 40 lines.&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;- Put tests next to the code they cover.&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;- No new dependency without a note in the PR.&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mkdir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parents&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;exist_ok&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wrote &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; bytes to &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;@mcp.tool()&lt;/code&gt; decorator does the heavy lifting. FastMCP reads your type hints (&lt;code&gt;target: str&lt;/code&gt;, and the rest) and generates the JSON schema the assistant uses to call the tool correctly. You write a normal Python function and the protocol wiring is generated for you. Return a string and it shows up in the chat as the tool result.&lt;/p&gt;

&lt;p&gt;To call it, you don't do anything special. You ask the assistant in plain language ('generate a Claude rules file for this project, we're on FastAPI and Postgres') and it maps that to the &lt;code&gt;generate_rules&lt;/code&gt; arguments on its own. The first time you watch a model fill in &lt;code&gt;stack="FastAPI + Postgres"&lt;/code&gt; without you naming the parameter, it feels like cheating. That mapping is exactly what the type hints buy you.&lt;/p&gt;

&lt;p&gt;One design choice worth calling out: the tool writes the file itself instead of returning the text for the assistant to write. I went back and forth on this. Returning text keeps the server side-effect free, which is cleaner, but then the assistant has to turn around and call its own file-write tool, and you've added a round trip and a chance for it to mangle the content. Writing directly from the server means the bytes that get validated are the bytes that hit disk. For a generator like this, I'd rather own the write.&lt;/p&gt;

&lt;h2&gt;
  
  
  How FastMCP compares to the alternatives
&lt;/h2&gt;

&lt;p&gt;I tried the TypeScript SDK first and bounced off it. It's fine. I just had a Python repo open and didn't want a Node toolchain to write three files. Here's how the realistic options stack up on the axes I cared about:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Lines to first working tool&lt;/th&gt;
&lt;th&gt;Schema validation&lt;/th&gt;
&lt;th&gt;Best when&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;FastMCP (Python)&lt;/td&gt;
&lt;td&gt;~12&lt;/td&gt;
&lt;td&gt;inferred from type hints&lt;/td&gt;
&lt;td&gt;you want a tool running before lunch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TypeScript SDK&lt;/td&gt;
&lt;td&gt;~40&lt;/td&gt;
&lt;td&gt;explicit, via Zod&lt;/td&gt;
&lt;td&gt;your stack is already Node&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Raw JSON-RPC over stdio&lt;/td&gt;
&lt;td&gt;~120&lt;/td&gt;
&lt;td&gt;hand-written&lt;/td&gt;
&lt;td&gt;you need zero dependencies&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The Python row wins for this job because the template logic is a dozen lines of string formatting and the SDK gets out of the way. If your assistant tooling already lives in Node, the gap closes and the TypeScript SDK is the saner pick. Raw JSON-RPC is there if you enjoy pain or have a runtime the SDKs don't support yet.&lt;/p&gt;

&lt;p&gt;One axis I left off the table on purpose: performance. None of these matter at this scale. You're writing a few hundred bytes once. If you're benchmarking a rules-file generator you've taken a wrong turn somewhere. Pick the SDK that matches the language your tooling already speaks and move on.&lt;/p&gt;

&lt;h2&gt;
  
  
  What broke the first time I ran it
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;KeyError&lt;/code&gt;, and it cost me an afternoon. The first version looked up &lt;code&gt;TEMPLATES[target]&lt;/code&gt; with a plain bracket, no guard. The model called the tool with &lt;code&gt;target="claude-code"&lt;/code&gt; instead of &lt;code&gt;"claude"&lt;/code&gt;, Python raised a bare &lt;code&gt;KeyError&lt;/code&gt;, and MCP wrapped it into a generic "tool failed" with nothing useful in the chat. I sat there convinced the stdio transport was broken. It was a typo in one argument.&lt;/p&gt;

&lt;p&gt;The fix is the &lt;code&gt;.get()&lt;/code&gt; plus an explicit &lt;code&gt;ValueError&lt;/code&gt; that names the valid options. That error string goes straight back to the model, which reads it and retries with &lt;code&gt;"claude"&lt;/code&gt;. That feedback loop is the real point of returning good errors from a tool: the assistant self-corrects if you let it. Honestly this annoyed me for most of a day before it clicked.&lt;/p&gt;

&lt;p&gt;The second thing: that stray &lt;code&gt;print()&lt;/code&gt; I mentioned. I'd left one in to debug the KeyError, and it quietly corrupted the JSON-RPC frames. The server "connected" and then every call hung with no error at all. Pull debug output to stderr and it comes back to life. Both bugs were mine, and both took longer to find than the whole server took to write.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Do I need a separate server for Cursor and Claude Code?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; No. It's the same server and the same stdio protocol. You register it in each tool's config file, but the server code doesn't change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Can the tool read my existing code to infer conventions?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Yes. Add a second tool that globs the repo and feeds a short summary into the template. Keep it read-only so a bad prompt can't rewrite files you didn't mean to touch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Why three files instead of one shared format?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Because the assistants haven't agreed on a format. CLAUDE.md is Markdown prose. The .cursorrules format is its own shape, and Copilot wants a specific path under .github. Until that converges, you generate each one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Is FastMCP ready for real use in 2026?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; For local developer tooling, yes. I wouldn't expose one to the public internet without real auth in front of it, since the local model assumes a single trusted user.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written with AI assistance and human review. Try the tool at &lt;a href="https://aidevhub.io/rules-file-generator/" rel="noopener noreferrer"&gt;aidevhub.io/rules-file-generator&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>automation</category>
      <category>mcp</category>
      <category>python</category>
      <category>tooling</category>
    </item>
  </channel>
</rss>
