<?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: Wiktor Małyska</title>
    <description>The latest articles on DEV Community by Wiktor Małyska (@wiktormalyska).</description>
    <link>https://dev.to/wiktormalyska</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%2F3838408%2Fe021ffb0-c28c-4737-a5ce-a769ecae79be.jpeg</url>
      <title>DEV Community: Wiktor Małyska</title>
      <link>https://dev.to/wiktormalyska</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/wiktormalyska"/>
    <language>en</language>
    <item>
      <title>Virga: run anonymous Apex and SOQL from a VS Code tab with Ctrl+S</title>
      <dc:creator>Wiktor Małyska</dc:creator>
      <pubDate>Tue, 20 Oct 2026 08:00:00 +0000</pubDate>
      <link>https://dev.to/wiktormalyska/virga-run-anonymous-apex-and-soql-from-a-vs-code-tab-with-ctrls-2ih6</link>
      <guid>https://dev.to/wiktormalyska/virga-run-anonymous-apex-and-soql-from-a-vs-code-tab-with-ctrls-2ih6</guid>
      <description>&lt;p&gt;Most of the Apex I run by hand is throwaway. A few lines to check a query, a &lt;code&gt;System.debug&lt;/code&gt; to see what a method returns, a quick look at some records. I wanted that to be one key press in VS Code, with the result readable at a glance and nothing left behind in the project afterwards.&lt;/p&gt;

&lt;p&gt;So I built Virga, a free, MIT-licensed VS Code extension for Apex developers, and the first version is out. This post covers what it does and a few decisions behind it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpr2204vx457ktaiqurlr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpr2204vx457ktaiqurlr.png" alt="An anonymous Apex scratch run with Ctrl+S, with debug lines, levels and limits in color in the Virga output" width="800" height="468"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Install
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;VS Code&lt;/strong&gt;: &lt;a href="https://marketplace.visualstudio.com/items?itemName=WiktorMayska.virga" rel="noopener noreferrer"&gt;Visual Studio Marketplace&lt;/a&gt;, or &lt;code&gt;code --install-extension WiktorMayska.virga&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cursor, VSCodium, Windsurf&lt;/strong&gt; and other Open VSX editors: &lt;a href="https://open-vsx.org/extension/WiktorMayska/virga" rel="noopener noreferrer"&gt;Open VSX&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Any VS Code build&lt;/strong&gt;: the &lt;code&gt;.vsix&lt;/code&gt; from &lt;a href="https://github.com/wiktormalyska/virga/releases" rel="noopener noreferrer"&gt;GitHub Releases&lt;/a&gt;, then &lt;strong&gt;Extensions: Install from VSIX...&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You need VS Code 1.90 or newer, the Salesforce CLI (&lt;code&gt;sf&lt;/code&gt;) on your &lt;code&gt;PATH&lt;/code&gt; with an authorized org set as the project's default, and a Salesforce DX project. The Salesforce Extension Pack is recommended. It brings highlighting and the language servers that work inside scratches, and the local type completion, the signature checks and the snippets attach to its &lt;code&gt;apex&lt;/code&gt; language, so those three need it. Scratches run without it, which is why Virga doesn't declare the pack as a dependency.&lt;/p&gt;

&lt;h2&gt;
  
  
  A scratch, from click to result
&lt;/h2&gt;

&lt;p&gt;In a folder with &lt;code&gt;sfdx-project.json&lt;/code&gt;, Virga adds two buttons to the status bar: &lt;strong&gt;▷ Apex&lt;/strong&gt; and &lt;strong&gt;SOQL&lt;/strong&gt;. Each click opens a new tab (&lt;code&gt;scratch-1.apex&lt;/code&gt;, &lt;code&gt;scratch-2.apex&lt;/code&gt;, &lt;code&gt;scratch-1.soql&lt;/code&gt;, ...). The script in the screenshot above is this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight apex"&gt;&lt;code&gt;&lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Account&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;Id&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;Industry&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;Account&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;Industry&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;Name&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="n"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;debug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;accounts&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;size&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s2"&gt; accounts with an industry'&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="n"&gt;Account&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;debug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;LoggingLevel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="py"&gt;INFO&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;debug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;LoggingLevel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="py"&gt;WARN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Limits&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getQueries&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s2"&gt; of 100 queries used'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Press &lt;strong&gt;Ctrl+S&lt;/strong&gt; (Cmd+S on macOS). An &lt;code&gt;.apex&lt;/code&gt; scratch goes to &lt;code&gt;sf apex run&lt;/code&gt;, a &lt;code&gt;.soql&lt;/code&gt; scratch to &lt;code&gt;sf data query&lt;/code&gt;. The org is the one &lt;code&gt;sf&lt;/code&gt; itself would pick: the &lt;code&gt;SF_TARGET_ORG&lt;/code&gt; environment variable, then &lt;code&gt;target-org&lt;/code&gt; in the project's &lt;code&gt;.sf/config.json&lt;/code&gt;, then your global config. Virga passes it to &lt;code&gt;sf&lt;/code&gt; as &lt;code&gt;--target-org&lt;/code&gt;, so the header of every result names the org the code really ran in.&lt;/p&gt;

&lt;p&gt;The result goes to the &lt;strong&gt;Virga&lt;/strong&gt; output channel. For Apex that means &lt;code&gt;✔ Compiled ✔ Executed&lt;/code&gt;, every debug line with its line number and log level, and the limits the run used. When something goes wrong, the output points at it. Here is a run that divides by zero:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight apex"&gt;&lt;code&gt;&lt;span class="n"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;debug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s2"&gt;before'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;Integer&lt;/span&gt; &lt;span class="n"&gt;z&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;Integer&lt;/span&gt; &lt;span class="n"&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="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;z&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✔ Compiled  ✖ Exception: System.MathException: Divide by 0
  at AnonymousBlock: line 3, column 1

  3 │ Integer i = 1 / z;
    │ ^

  [line 1] DEBUG  before

  Limits  SOQL queries 0/100 · query rows 0/50000 · DML statements 0/150 · DML rows 0/10000
          CPU time 0/10000 ms · heap 0/10000000 bytes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A SOQL scratch prints the record count and a table. Relationship fields are flattened to dotted names, subqueries show as &lt;code&gt;[2 rows]&lt;/code&gt;, and long values are cut with &lt;code&gt;…&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;Id&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;Account&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;Account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;Owner&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;Email&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;Contact&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;Name&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✔ 4 records
┌────────────────────┬──────────────┬────────────────────────┬────────────────────┬───────────────────────┐
│ Id                 │ Name         │ Account.Name           │ Account.Owner.Name │ Email                 │
├────────────────────┼──────────────┼────────────────────────┼────────────────────┼───────────────────────┤
│ 003Dm0000AbC011AAA │ Andy Young   │ Dickenson plc          │ OrgFarm EPIC       │ a_young@exampleco.com │
│ 003Dm0000AbC013AAA │ Arthur Song  │ United Oil &amp;amp; Gas Corp. │ OrgFarm EPIC       │ asong@abc.com         │
│ 003Dm0000AbC015AAA │ Ashley James │ United Oil &amp;amp; Gas, UK   │ OrgFarm EPIC       │ ajames@abc.com        │
│ 003Dm0000AbC017AAA │ Avi Green    │ United Oil &amp;amp; Gas Corp. │ OrgFarm EPIC       │ agreen@abc.com        │
└────────────────────┴──────────────┴────────────────────────┴────────────────────┴───────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Columns come from the &lt;code&gt;SELECT&lt;/code&gt; list as well as from the records, so a relationship that is &lt;code&gt;null&lt;/code&gt; in every row still keeps its own dotted columns.&lt;/p&gt;

&lt;h2&gt;
  
  
  What runs is what you see
&lt;/h2&gt;

&lt;p&gt;A run uses exactly the text in the editor at the moment of Ctrl+S. Virga writes a copy of it to a temporary folder and hands that to &lt;code&gt;sf&lt;/code&gt;, never the scratch file itself and never on the command line. Keys you type after Ctrl+S are not part of that run.&lt;/p&gt;

&lt;p&gt;Saving never runs a scratch. Auto save doesn't, and &lt;strong&gt;Save All&lt;/strong&gt; from another file doesn't either. Only Ctrl+S in the scratch, the ▶ button in its editor title, or &lt;strong&gt;Virga: Run Scratch&lt;/strong&gt; does. A second Ctrl+S during a run is queued, and a newer Ctrl+S in the same tab replaces the queued text.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing without "save changes?"
&lt;/h2&gt;

&lt;p&gt;Closing a scratch tab discards it, with no dialog. The trick behind it is small. Virga sets a short auto save, &lt;code&gt;afterDelay&lt;/code&gt; with 100 ms, as the language default for &lt;code&gt;apex-anon&lt;/code&gt; and &lt;code&gt;soql&lt;/code&gt;. With a delay that short, VS Code treats the document as being saved: no dirty dot on the tab, and closing it never asks. If a background save fails because something else has the file locked, Virga keeps retrying it, with a pause that grows up to 5 seconds, until it goes through.&lt;/p&gt;

&lt;p&gt;Since the run uses its own copy, a run saves nothing, and no format on save or whitespace trimming ever happens because of Ctrl+S.&lt;/p&gt;

&lt;p&gt;The cost is that VS Code lets an extension set this default only per language, so other &lt;code&gt;apex-anon&lt;/code&gt; files (for example &lt;code&gt;scripts/apex/hello.apex&lt;/code&gt;) save themselves 100 ms after a change too. Classes and triggers are not affected. To switch it off:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"[apex-anon]"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"files.autoSave"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"off"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"[soql]"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"files.autoSave"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"off"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With that, closing a scratch with unsaved text asks again. Whatever you answer, the scratch file is deleted.&lt;/p&gt;

&lt;p&gt;A scratch is a real file in &lt;code&gt;.sfdx/virga-scratch/&lt;/code&gt;, which Salesforce DX projects already ignore in git. The language servers of the Extension Pack therefore complete and underline it like any other file. Tabs that VS Code restores after a restart are closed at startup, and two windows on the same project never delete each other's scratches.&lt;/p&gt;

&lt;p&gt;If you use the IntelliJ IDEA keymap extension, where Ctrl+S is &lt;strong&gt;Save All&lt;/strong&gt;, Virga's Ctrl+S still wins inside scratch editors, and only there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three smaller things for regular Apex files
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Completion for types declared in the same file.&lt;/strong&gt; The Apex language server doesn't offer an inner class, interface or enum by its simple name, even when it's declared a few lines below. Virga adds the types declared in the current file, inner ones at any depth, with a detail such as &lt;code&gt;inner class of OrderIntake&lt;/code&gt;. It stays out of the way after a dot, in comments and strings, after &lt;code&gt;@&lt;/code&gt;, and inside inline SOQL.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Signatures that break completion.&lt;/strong&gt; A raw &lt;code&gt;List&lt;/code&gt; in an &lt;code&gt;@InvocableMethod&lt;/code&gt; parameter (and a few related shapes) makes the language server throw internally and stop suggesting members after &lt;code&gt;.&lt;/code&gt; in the whole file. All you see is a syntax error. Virga marks the real cause and offers a quick fix that types the collection from the classes in your file. I measured which shapes do this on 274 signatures, and I'll write that up in a separate post.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Snippets the Extension Pack doesn't have.&lt;/strong&gt; Annotations (type &lt;code&gt;@&lt;/code&gt;, then &lt;code&gt;Inv&lt;/code&gt;, and get &lt;code&gt;InvocableMethod(...)&lt;/code&gt;), keywords and modifiers such as &lt;code&gt;return&lt;/code&gt;, &lt;code&gt;else if&lt;/code&gt; and &lt;code&gt;with sharing&lt;/code&gt;, DML statements, and user mode: &lt;code&gt;insert as user&lt;/code&gt;, &lt;code&gt;Database.queryWithBinds&lt;/code&gt; with &lt;code&gt;AccessLevel.USER_MODE&lt;/code&gt;, &lt;code&gt;WITH USER_MODE&lt;/code&gt;. No Virga prefix is one the pack already uses, so a suggestion list never shows the same name twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Privacy
&lt;/h2&gt;

&lt;p&gt;Virga has no telemetry and no network code. Runs call your local &lt;code&gt;sf&lt;/code&gt; against the org you chose. &lt;code&gt;sf&lt;/code&gt; has usage telemetry of its own, so Virga starts every run with &lt;code&gt;SF_DISABLE_TELEMETRY=true&lt;/code&gt;. To turn it off for your own &lt;code&gt;sf&lt;/code&gt; commands too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sf config &lt;span class="nb"&gt;set &lt;/span&gt;disable-telemetry &lt;span class="nb"&gt;true&lt;/span&gt; &lt;span class="nt"&gt;--global&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why "Virga"
&lt;/h2&gt;

&lt;p&gt;Virga is rain that evaporates before it reaches the ground. The code you try in a scratch never lands in your project either.&lt;/p&gt;

&lt;h2&gt;
  
  
  Feedback and contributions
&lt;/h2&gt;

&lt;p&gt;The code is at &lt;a href="https://github.com/wiktormalyska/virga" rel="noopener noreferrer"&gt;github.com/wiktormalyska/virga&lt;/a&gt;. It has no runtime dependencies and no build step, and &lt;code&gt;npm test&lt;/code&gt; runs the unit tests in plain Node. Issues and small pull requests with a test are welcome. If something behaves differently on your setup than described here, an issue with the Virga output attached helps most.&lt;/p&gt;

&lt;p&gt;Salesforce, Apex and related marks are trademarks of Salesforce, Inc. Virga is an independent project and is not affiliated with or endorsed by Salesforce.&lt;/p&gt;

</description>
      <category>vscode</category>
      <category>salesforce</category>
      <category>apex</category>
      <category>opensource</category>
    </item>
    <item>
      <title>node --test only learned about globs in Node 21</title>
      <dc:creator>Wiktor Małyska</dc:creator>
      <pubDate>Tue, 01 Sep 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/wiktormalyska/node-test-only-learned-about-globs-in-node-21-252k</link>
      <guid>https://dev.to/wiktormalyska/node-test-only-learned-about-globs-in-node-21-252k</guid>
      <description>&lt;p&gt;My package declares support for Node 18 and up. Its own test suite could not run on two of the three versions I claimed to support. Everything passed on my machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  The script
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scripts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"test"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"node --test &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;test/**/*.test.mjs&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"engines"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"node"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;gt;=18"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That looks fine. It is quoted, so the shell does not expand it, which is what you want when a tool does its own glob handling. On my Node 22 it ran 35 tests and went green.&lt;/p&gt;

&lt;h2&gt;
  
  
  What CI said
&lt;/h2&gt;

&lt;p&gt;The workflow was nine jobs: Ubuntu, Windows and macOS across Node 18, 20 and 22.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;Could not find '/home/runner/work/pkg/pkg/test/**/*.test.mjs'
&lt;/span&gt;&lt;span class="gp"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;node &lt;span class="nt"&gt;--test&lt;/span&gt; &lt;span class="s2"&gt;"test/**/*.test.mjs"&lt;/span&gt;
&lt;span class="go"&gt;Error: Process completed with exit code 1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;macOS 22 green. Ubuntu 18 red. Windows 20 red. The pattern was not the operating system, it was the Node version.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why
&lt;/h2&gt;

&lt;p&gt;Glob expansion in the Node test runner arrived in &lt;strong&gt;Node 21&lt;/strong&gt;. Before that, a path argument is treated as a literal path. So on 18 and 20 the runner looks for a directory literally named &lt;code&gt;**&lt;/code&gt;, does not find it, and dies before a single test executes.&lt;/p&gt;

&lt;p&gt;It never even reached the tests. The suite was not failing; it was not running.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scripts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"test"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"node --test"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With no path argument, the runner discovers test files itself using its default patterns, which include &lt;code&gt;**/*.test.mjs&lt;/code&gt; and anything under a &lt;code&gt;test/&lt;/code&gt; directory. That behaviour has been there since Node 18, and it does not depend on the shell, so it works the same on Windows.&lt;/p&gt;

&lt;p&gt;Same 35 tests, now on all nine jobs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part worth keeping
&lt;/h2&gt;

&lt;p&gt;This is the entire argument for a build matrix, and it is not thoroughness for its own sake.&lt;/p&gt;

&lt;p&gt;"Works on my machine" is a true statement about exactly one row of that matrix. I had declared support for a range I had never once executed. The matrix did not find a rare edge case; it found that two thirds of my stated support was fiction, about ninety seconds after I pushed the workflow.&lt;/p&gt;

&lt;p&gt;If your package declares an &lt;code&gt;engines&lt;/code&gt; range, run your tests across that range. Otherwise the range is a guess you are asking your users to verify for you.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/wiktormalyska/mcp-secrets-runner" rel="noopener noreferrer"&gt;https://github.com/wiktormalyska/mcp-secrets-runner&lt;/a&gt;&lt;/p&gt;

</description>
      <category>node</category>
      <category>testing</category>
      <category>javascript</category>
      <category>devops</category>
    </item>
    <item>
      <title>1Password's secret masking silently corrupts MCP servers</title>
      <dc:creator>Wiktor Małyska</dc:creator>
      <pubDate>Sun, 30 Aug 2026 13:00:01 +0000</pubDate>
      <link>https://dev.to/wiktormalyska/1passwords-secret-masking-silently-corrupts-mcp-servers-312b</link>
      <guid>https://dev.to/wiktormalyska/1passwords-secret-masking-silently-corrupts-mcp-servers-312b</guid>
      <description>&lt;p&gt;I added 1Password support to an MCP secrets runner and the first thing I had to do was turn one of its security features off. Here is why that was the right call.&lt;/p&gt;

&lt;h2&gt;
  
  
  What op run does
&lt;/h2&gt;

&lt;p&gt;1Password's CLI resolves &lt;code&gt;op://&lt;/code&gt; references and hands the values to a child process:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;op run &lt;span class="nt"&gt;--env-file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;./mcp.env &lt;span class="nt"&gt;--&lt;/span&gt; npx &lt;span class="nt"&gt;-y&lt;/span&gt; @stripe/mcp@latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It also does something thoughtful on top. It watches the child's output and rewrites anything matching a resolved secret to &lt;code&gt;&amp;lt;concealed by 1Password&amp;gt;&lt;/code&gt;. If your program accidentally logs a token, or you are screen sharing, the secret does not leave the machine.&lt;/p&gt;

&lt;p&gt;For a normal CLI this is a good default. I would keep it on.&lt;/p&gt;

&lt;h2&gt;
  
  
  What an MCP server is
&lt;/h2&gt;

&lt;p&gt;An MCP server on the stdio transport speaks JSON-RPC over stdout. Not log lines. A framed protocol stream, where the client parses every byte:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"jsonrpc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"id"&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="nl"&gt;"result"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"tools"&lt;/span&gt;&lt;span class="p"&gt;:[&lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="p"&gt;]}}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;stdout is not a place for human-readable output. It is a wire.&lt;/p&gt;

&lt;h2&gt;
  
  
  The collision
&lt;/h2&gt;

&lt;p&gt;Put those two together. Masking rewrites bytes in a stream that a parser is reading with exact expectations. If a secret value ever appears inside a frame, the frame changes length, and the parse fails.&lt;/p&gt;

&lt;p&gt;What makes this genuinely nasty is &lt;em&gt;where&lt;/em&gt; it fails. The MCP client reports a protocol error. Nothing in that error mentions 1Password, masking, or secrets. You would go and read your server's serialisation code, which is fine, for a long time.&lt;/p&gt;

&lt;p&gt;So the provider passes &lt;code&gt;--no-masking&lt;/code&gt; by default, and the README says why in as many words. There is an opt-in flag to turn masking back on for servers that do not use stdio.&lt;/p&gt;

&lt;h2&gt;
  
  
  The general shape
&lt;/h2&gt;

&lt;p&gt;What I find worth writing down is that &lt;strong&gt;nothing here is a bug&lt;/strong&gt;. Not in 1Password, not in MCP, not in my code.&lt;/p&gt;

&lt;p&gt;A security feature assumes it may rewrite a child's output. A wire protocol assumes it owns stdout exclusively. Both assumptions are reasonable in isolation. The failure lives in the space between them, and it is silent, because neither component is in a position to notice the other exists.&lt;/p&gt;

&lt;p&gt;Every integration you write has a few of these. They do not show up in either project's issue tracker, because from each side nothing is wrong. They show up in yours.&lt;/p&gt;

&lt;p&gt;The practical takeaway: when you wrap a process that speaks a protocol on stdout, audit everything in the chain for the assumption that stdout is for humans. Log prefixes, colour codes, progress bars, and yes, secret masking.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.npmjs.com/package/mcp-secrets-runner" rel="noopener noreferrer"&gt;https://www.npmjs.com/package/mcp-secrets-runner&lt;/a&gt;&lt;/p&gt;

</description>
      <category>1password</category>
      <category>security</category>
      <category>debugging</category>
      <category>node</category>
    </item>
    <item>
      <title>Your .mcp.json probably has a live API key in it</title>
      <dc:creator>Wiktor Małyska</dc:creator>
      <pubDate>Fri, 28 Aug 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/wiktormalyska/your-mcpjson-probably-has-a-live-api-key-in-it-4ge5</link>
      <guid>https://dev.to/wiktormalyska/your-mcpjson-probably-has-a-live-api-key-in-it-4ge5</guid>
      <description>&lt;p&gt;Nearly every guide to setting up an MCP server tells you to do this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"stripe"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"-y"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"@stripe/mcp@latest"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"env"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"STRIPE_SECRET_KEY"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sk_live_51J..."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a live production key sitting in a file that gets committed to repositories, synced between machines, screenshotted for tutorials and pasted into bug reports. I have seen &lt;code&gt;sk_live_&lt;/code&gt; keys in all four.&lt;/p&gt;

&lt;p&gt;It is also, in my experience, the single biggest reason MCP stalls at a company's security review. Not the protocol. Not the tooling. The config file.&lt;/p&gt;

&lt;h2&gt;
  
  
  The obvious fix has its own failure mode
&lt;/h2&gt;

&lt;p&gt;So you wrap the server in your secret manager's CLI instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"run"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"--env=prod"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"--"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"-y"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"@stripe/mcp@latest"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Better. The key is gone from the file. But those CLIs authenticate with a developer session stored in your OS keyring, and that session expires.&lt;/p&gt;

&lt;p&gt;When it does, &lt;strong&gt;every&lt;/strong&gt; MCP server wrapped this way stops starting at the same moment. And the error surfaces in your MCP client, which knows nothing about secret managers or expired logins. You get a generic startup failure on ten servers at once and go looking in entirely the wrong place.&lt;/p&gt;

&lt;p&gt;I lost an afternoon to this before the pattern clicked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Machine identities do not expire
&lt;/h2&gt;

&lt;p&gt;Every serious secret manager has a concept for unattended access: a credential meant for machines rather than humans, with no TTL of its own. Infisical calls it a machine identity, 1Password calls it a service account, Vault calls it AppRole.&lt;/p&gt;

&lt;p&gt;The credential is long-lived; what it produces is short-lived. You exchange it for a token at process start, use the token, and never think about it again.&lt;/p&gt;

&lt;p&gt;That is the whole idea behind &lt;code&gt;mcp-secrets-runner&lt;/code&gt;. It sits between the MCP client and the server, authenticates with a machine credential, fetches the secrets and execs the real server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"stripe"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"stdio"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"-y"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"mcp-secrets-runner"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"--env=prod"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"--path=/mcp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
               &lt;/span&gt;&lt;span class="s2"&gt;"--"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"-y"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"@stripe/mcp@latest"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No secret values in the file. No session to renew. The same config works on every machine that has the machine credentials in its environment, which is what makes it safe to commit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three backends, three different shapes
&lt;/h2&gt;

&lt;p&gt;The interesting part of building this was that the three supported backends work nothing alike:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Infisical&lt;/strong&gt; mints a token over HTTPS, then hands off to the Infisical CLI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;1Password&lt;/strong&gt; hands off to &lt;code&gt;op run&lt;/code&gt;, which authenticates itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vault&lt;/strong&gt; reads the secret over the HTTP API and starts your MCP server directly, with no CLI involved at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A provider only has to answer one question: which child process do I start, and what environment does it get. That the three answers look so different is the best evidence I have that the interface is not overfitted to the first one I wrote.&lt;/p&gt;

&lt;h2&gt;
  
  
  When it does not work
&lt;/h2&gt;

&lt;p&gt;"My MCP server won't start" is almost always one of four things, and an MCP client reports all four identically. So there is a &lt;code&gt;doctor&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;mcp-secrets-runner doctor &lt;span class="nt"&gt;--provider&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;vault
&lt;span class="go"&gt;
mcp-secrets-runner doctor (provider: vault)

  [ok  ] Node 22.14.0
  [ok  ] instance
         http://127.0.0.1:8200
  [ok  ] AppRole credentials present
  [ok  ] cached token available
  [ok  ] authentication succeeded
         token valid for 20 min

All checks passed.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Missing CLI, missing credentials, wrong instance URL, unreadable secret path. One line each, instead of one opaque failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two details worth stealing
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;All diagnostics go to stderr.&lt;/strong&gt; stdout belongs to the MCP protocol. One stray byte there corrupts the JSON-RPC stream and the client reports a parse error pointing nowhere useful.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It fails closed.&lt;/strong&gt; If credentials are set but authentication fails, the runner exits. The alternative is worse than it sounds: a credential-less CLI invocation opens an interactive browser login, which hangs the MCP server on stdin forever while the client waits.&lt;/p&gt;

&lt;p&gt;MIT, zero dependencies, Node 18+.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/wiktormalyska/mcp-secrets-runner" rel="noopener noreferrer"&gt;https://github.com/wiktormalyska/mcp-secrets-runner&lt;/a&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>security</category>
      <category>devops</category>
      <category>ai</category>
    </item>
    <item>
      <title>Stop Hardcoding Your Portfolio Projects. I Built an API to Turn GitHub into a Headless CMS</title>
      <dc:creator>Wiktor Małyska</dc:creator>
      <pubDate>Sun, 22 Mar 2026 13:27:32 +0000</pubDate>
      <link>https://dev.to/wiktormalyska/stop-hardcoding-your-portfolio-projects-i-built-an-api-to-turn-github-into-a-headless-cms-4n3k</link>
      <guid>https://dev.to/wiktormalyska/stop-hardcoding-your-portfolio-projects-i-built-an-api-to-turn-github-into-a-headless-cms-4n3k</guid>
      <description>&lt;p&gt;Every time I finished a new side project, I faced the exact same annoying routine:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open my portfolio repository.&lt;/li&gt;
&lt;li&gt;Find the &lt;code&gt;projects.json&lt;/code&gt; or hardcoded array.&lt;/li&gt;
&lt;li&gt;Manually type in the new project name, description, and tech stack.&lt;/li&gt;
&lt;li&gt;Download a screenshot, put it in the &lt;code&gt;public&lt;/code&gt; folder, and link it.&lt;/li&gt;
&lt;li&gt;Push changes and wait for the deployment.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;As developers, we automate everything, yet we still update our portfolios like it's 2010. I got tired of this, so I built a solution: &lt;strong&gt;PortfolioAPI&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is PortfolioAPI?
&lt;/h2&gt;

&lt;p&gt;It’s a simple tool that turns your GitHub repositories into a Headless CMS for your portfolio. Instead of manually updating your frontend code, your portfolio fetches data directly from your GitHub repos via one fast API call.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Live Link:&lt;/strong&gt; &lt;a href="https://portfolioapi.wiktormalyska.ovh/" rel="noopener noreferrer"&gt;PortfolioAPI&lt;/a&gt; &lt;/p&gt;

&lt;h2&gt;
  
  
  How it works (It takes 2 minutes)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Step 1: Drop a &lt;code&gt;meta.json&lt;/code&gt; in your repo&lt;/strong&gt;&lt;br&gt;
You just create a &lt;code&gt;meta.json&lt;/code&gt; file in the root of any public GitHub repository you want to showcase.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"My Awesome Project"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"A short description shown on the portfolio page."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"imageUrl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"[https://raw.githubusercontent.com/your-username/your-repo/main/assets/screenshot.png](https://raw.githubusercontent.com/your-username/your-repo/main/assets/screenshot.png)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"websiteUrl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"[https://my-project.example.com](https://my-project.example.com)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"technologies"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"React"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"TypeScript"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"TailwindCSS"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 2: Fetch the data in your frontend&lt;/strong&gt;&lt;br&gt;
Make a single GET request using your API key. The API scans your GitHub, finds all repos with a &lt;code&gt;meta.json&lt;/code&gt;, and returns a clean, structured array of your projects.&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="nx"&gt;fetchProjects&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &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;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[https://api.portfolioapi.wiktormalyska.ovh/projects](https://api.portfolioapi.wiktormalyska.ovh/projects)&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;headers&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="s1"&gt;X-API-KEY&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;your_api_key_here&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Boom! Ready to map over in React/Vue/Svelte&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why did I build it this way?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Zero Database Needed:&lt;/strong&gt; Your code lives on GitHub anyway, why not keep its metadata there too?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Always Up to Date:&lt;/strong&gt; Update the &lt;code&gt;meta.json&lt;/code&gt; in your project repo, and your portfolio updates instantly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Blazing Fast:&lt;/strong&gt; I implemented server-side caching so the GitHub data is delivered in milliseconds. No unnecessary noise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tech Agnostic:&lt;/strong&gt; It doesn't matter if your portfolio is built in React, Vue, Next.js, or vanilla HTML. It’s just a standard REST API.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's next?
&lt;/h2&gt;

&lt;p&gt;Right now, the API handles reading GitHub portfolios perfectly. I'm currently working on adding a dedicated Node.js package and an email contact form endpoint to make it an all-in-one backend for developer portfolios.&lt;/p&gt;

&lt;p&gt;I’m an indie developer trying to build tools that actually save time. I’d love to hear your feedback. What do you think about managing portfolio content directly from GitHub repos? &lt;/p&gt;

&lt;p&gt;Let me know in the comments! 👇&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Psst... with code FREETRIAL you can get first month for free! (for first 10 customers)&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>productivity</category>
      <category>github</category>
    </item>
  </channel>
</rss>
