<?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: Calix</title>
    <description>The latest articles on DEV Community by Calix (@calixph).</description>
    <link>https://dev.to/calixph</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%2F4202056%2F9b93a5b6-2a47-4a87-9b24-8ae625913e58.png</url>
      <title>DEV Community: Calix</title>
      <link>https://dev.to/calixph</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/calixph"/>
    <language>en</language>
    <item>
      <title>Building a read-only MCP server where a connector token can never become a login</title>
      <dc:creator>Calix</dc:creator>
      <pubDate>Sat, 31 Oct 2026 11:46:25 +0000</pubDate>
      <link>https://dev.to/calixph/building-a-read-only-mcp-server-where-a-connector-token-can-never-become-a-login-1fcm</link>
      <guid>https://dev.to/calixph/building-a-read-only-mcp-server-where-a-connector-token-can-never-become-a-login-1fcm</guid>
      <description>&lt;p&gt;I run a read-only crypto portfolio tracker (last time I wrote about &lt;a href="https://dev.to/calixph/how-far-back-each-exchanges-api-actually-lets-you-go-11-exchanges-compared-1nj9"&gt;how far back each exchange's API actually lets you go&lt;/a&gt;), and I wanted people to ask ChatGPT, Claude or Cursor questions about their own portfolio. That meant an MCP server, and an MCP server means handing a token to a third party whose context window I do not control. This post is about the one design decision that matters most there: making sure that token can never be used as a login to the rest of the app.&lt;/p&gt;

&lt;p&gt;The code is a Fastify API. Everything below is simplified from it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap: any Bearer is a session
&lt;/h2&gt;

&lt;p&gt;Our auth hook runs on every &lt;code&gt;/api&lt;/code&gt; request. It reads a token from the session cookie, or failing that from &lt;code&gt;Authorization: Bearer&lt;/code&gt;, and verifies it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// simplified from the session path&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;readAccessToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;          &lt;span class="c1"&gt;// cookie first, then Bearer header&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;claims&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;verifyAccessToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;claims&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;reply&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;code&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;send&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;authentication required&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;row&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;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;byId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;uid&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="o"&gt;!&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;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;token_version&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ver&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;reply&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;code&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;send&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;session expired&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;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;toSafeUser&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="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tenantId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;active_profile_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is fine for a browser and for the iOS app. The problem is what it implies: a token that passes &lt;code&gt;verifyAccessToken&lt;/code&gt; is accepted on every route. So if I had minted the MCP connector's token with the same signer, then handing it to ChatGPT would have handed ChatGPT a full session. "Read-only" would have been a description of what I hoped the client would do, not a property of the credential.&lt;/p&gt;

&lt;p&gt;You can scope tokens with claims, but then every route has to remember to check the claim. I did not want a security property that depends on every call site remembering.&lt;/p&gt;

&lt;h2&gt;
  
  
  A separate HMAC domain
&lt;/h2&gt;

&lt;p&gt;So MCP tokens are signed in their own domain. Same secret, different signed input:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PREFIX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mcp_&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;DOMAIN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mcp.v1:&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;mac&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="kr"&gt;string&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="nf"&gt;createHmac&lt;/span&gt;&lt;span class="p"&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="nf"&gt;authSecret&lt;/span&gt;&lt;span class="p"&gt;())&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;DOMAIN&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;// session tokens sign just `body`&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;base64url&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;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;signMcpToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;McpClaims&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;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&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;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;base64url&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="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;PREFIX&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;.&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;mac&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;`&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;The &lt;code&gt;mcp_&lt;/code&gt; prefix is only a marker so we can reject obvious strangers before running any crypto. The real separation is the &lt;code&gt;mcp.v1:&lt;/code&gt; string folded into the MAC. A session verifier computes its MAC over &lt;code&gt;body&lt;/code&gt;, the MCP verifier over &lt;code&gt;mcp.v1:body&lt;/code&gt;, so neither signature can satisfy the other. An MCP token presented to &lt;code&gt;/api/trades&lt;/code&gt; is not a weaker session. It is not a session.&lt;/p&gt;

&lt;p&gt;I wrote the tests in both directions, because only one of them is scary:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;an MCP token is NOT accepted by the session verifier&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="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;verifyAccessToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;signMcpToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;CLAIMS&lt;/span&gt;&lt;span class="p"&gt;))).&lt;/span&gt;&lt;span class="nf"&gt;toBeNull&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stripping the prefix does not help&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stripped&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;signMcpToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;CLAIMS&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mcp_&lt;/span&gt;&lt;span class="dl"&gt;'&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="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;verifyAccessToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stripped&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBeNull&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;a session token with mcp_ bolted on is not an MCP token&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="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;verifyMcpToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`mcp_&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;signAccessToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;u1&lt;/span&gt;&lt;span class="dl"&gt;'&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="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBeNull&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;The honest tradeoff: one secret signs both domains. I chose domain separation over a second secret because it removes a rotation and storage problem, and the tests pin the property. If you would rather have two secrets, that is a reasonable call too.&lt;/p&gt;

&lt;h2&gt;
  
  
  The MCP endpoint authenticates differently
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;/api/mcp&lt;/code&gt; is branched out of the normal hook before the session check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;isMcpEndpoint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&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;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;OPTIONS&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="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// preflights carry no credentials&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;authenticateMcp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reply&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;authenticateMcp&lt;/code&gt; does four things, and the order is deliberate:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Verifies the &lt;code&gt;mcp_&lt;/code&gt; token and its expiry.&lt;/li&gt;
&lt;li&gt;Requires the read scope.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Looks up the grant row on every call&lt;/strong&gt; and rejects it if it is revoked or expired.&lt;/li&gt;
&lt;li&gt;Checks the user's &lt;code&gt;token_version&lt;/code&gt; against the one in the token.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;It never falls back to the session cookie. A browser signed in to the app should not be able to drive the MCP endpoint through ambient authority. The grant means "the user approved this connector", and a cookie carries no such approval. It would also be a CSRF surface on a POST endpoint.&lt;/p&gt;

&lt;h2&gt;
  
  
  Re-dispatch with a freshly minted session token
&lt;/h2&gt;

&lt;p&gt;Here is the awkward part. The read tools do not query the database themselves. Almost every one calls an existing read endpoint in-process with &lt;code&gt;app.inject()&lt;/code&gt;, so the MCP surface inherits tenancy scoping and Pro gating instead of reimplementing them. (The one exception, the portfolio overview, builds its summary directly and applies its Pro gate by hand, which is exactly why I wanted the rest to inherit theirs.) But that injected request runs the real auth hook again, and the hook would reject an MCP token, because it is deliberately not a session.&lt;/p&gt;

&lt;p&gt;So the route mints a fresh, short-lived session token for the same user and forwards that:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;redispatchAuth&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tokenVersion&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;profileId&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;authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;signProfileScopedAccessToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tokenVersion&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;profileId&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="c1"&gt;// inside a tool&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;inject&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;GET&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;url&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="nf"&gt;injectHeaders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;auth&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;The injected request is indistinguishable from the user's own browser, with the same tenant and the same capability gates, and it exists only inside the process for the length of one tool call. The MCP token itself stays useless anywhere but &lt;code&gt;/api/mcp&lt;/code&gt;. The portfolio the call targets travels inside the minted token as a signed claim, and the hook re-checks ownership of it rather than trusting the signature alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read-only, as a property of the executor
&lt;/h2&gt;

&lt;p&gt;The server exposes 15 tools: 13 reads and 2 writes that can only propose. The reads are grouped by the question they answer, with a &lt;code&gt;view&lt;/code&gt; argument that picks the endpoint, so &lt;code&gt;get_money_flows&lt;/code&gt; with view &lt;code&gt;capital_flow&lt;/code&gt; returns deposit and withdrawal totals in dollars. Every endpoint-backed view names a path, and every one runs through the same executor, which refuses any path outside an allowlist and hard-codes the method:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&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;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;inject&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;GET&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;url&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="nf"&gt;injectHeaders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;auth&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;There is no &lt;code&gt;method&lt;/code&gt; parameter to widen, and a test checks every view's path against the allowlist, so a new view cannot quietly reach a route that is not on it. The server instructions tell the model plainly that it cannot trade, move funds or read exchange API keys. Annotations are set per tool rather than as a blanket default: &lt;code&gt;readOnlyHint: true&lt;/code&gt; on the reads, &lt;code&gt;false&lt;/code&gt; on the two writes. A client reads that hint to decide whether to ask the user before calling, so stamping it on a write would switch off the very confirmation the next part relies on.&lt;/p&gt;

&lt;p&gt;The two writes, &lt;code&gt;set_target_allocation&lt;/code&gt; and &lt;code&gt;propose_alert_change&lt;/code&gt;, still cannot write. They need a separate &lt;code&gt;portfolio:write&lt;/code&gt; scope that the user approves on the consent screen, and even then the tool only validates, renders a diff, parks the payload and returns a link. The change applies only when the user opens that link in their own browser, under their own session cookie, reads a server-rendered before and after, and clicks Apply. The confirm routes are session-authed and origin-checked, and an MCP token is not a session (that is the whole point of the separate domain), so a connector cannot approve its own proposal. Prompt injection into the AI client can produce a perfectly authentic tool call. It cannot produce my browser session.&lt;/p&gt;

&lt;p&gt;What this does not defend against is a user who clicks Apply without reading. It turns an invisible write into a visible one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Revocation
&lt;/h2&gt;

&lt;p&gt;Three directions, because "disconnect" has to mean disconnect:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Per connector.&lt;/strong&gt; Revoking one grant flips its row, and since the row is read on every call, the next request from that app fails. Refresh tokens are stored only as hashes and rotate on use.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Account-wide.&lt;/strong&gt; &lt;code&gt;token_version&lt;/code&gt; is bumped on "sign out everywhere", and it is checked against both session and MCP tokens. Otherwise logging out of everything would quietly leave an AI client still reading your portfolio.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Account deletion.&lt;/strong&gt; The grant row cascades.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And it has to be findable. Grants are listed in Settings, each with its client label, scope, last used time and the portfolio it reads, with a revoke button. A grant with no visible off switch is a leak with a consent screen in front of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would check if you build one
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Do your session and connector credentials verify against each other? Test both directions, and test the stripped-prefix case.&lt;/li&gt;
&lt;li&gt;Is the grant checked per call, or only when the token is minted?&lt;/li&gt;
&lt;li&gt;Does "sign out everywhere" reach connectors?&lt;/li&gt;
&lt;li&gt;Can a connector reach the endpoint that approves its own writes?&lt;/li&gt;
&lt;li&gt;Does the redirect URI match exactly, with no prefix or wildcard matching?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want to try the connector, the public setup page is here: &lt;a href="https://whereitgoes.money/connect-ai?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=mcp-token-domains" rel="noopener noreferrer"&gt;whereitgoes.money/connect-ai&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I build &lt;a href="https://whereitgoes.money/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=mcp-token-domains" rel="noopener noreferrer"&gt;WhereItGoes&lt;/a&gt;; this article was drafted with AI assistance and checked against the code.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>security</category>
      <category>oauth</category>
      <category>ai</category>
    </item>
    <item>
      <title>How far back each exchange's API actually lets you go (11 exchanges compared)</title>
      <dc:creator>Calix</dc:creator>
      <pubDate>Sun, 25 Oct 2026 07:40:00 +0000</pubDate>
      <link>https://dev.to/calixph/how-far-back-each-exchanges-api-actually-lets-you-go-11-exchanges-compared-1nj9</link>
      <guid>https://dev.to/calixph/how-far-back-each-exchanges-api-actually-lets-you-go-11-exchanges-compared-1nj9</guid>
      <description>&lt;p&gt;I build a read-only crypto portfolio tracker, which means I spend a lot of time asking exchange APIs for old trades and finding out how little they will give me. We integrate 11 exchanges, and I keep one manifest of how far back each one will actually serve history. This post is that manifest, with the endpoints behind it.&lt;/p&gt;

&lt;p&gt;The short version: two different limits get confused, and only one of them is a problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Window caps versus retention walls
&lt;/h2&gt;

&lt;p&gt;A &lt;strong&gt;window cap&lt;/strong&gt; limits how much time a single request may span. HTX's match results accept at most a 48 hour span. Bybit's execution list works in 7 day windows. Gate.io allows 30 days, Bitget 90, Upbit 7. These are annoying, but they are not limits on your history. You loop backwards in chunks and eventually reach the start of the account. If your tool says "only 7 days available" because of a window cap, the tool is lazy, not the exchange.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;retention wall&lt;/strong&gt; means the data no longer exists behind the API. Nothing you write can recover it. OKX serves about 90 days of fills. MEXC serves about a month. Bitget about 90 days. HTX about 120. A wall is a fact about the exchange, and it decides how much of your cost basis can be rebuilt from an API key.&lt;/p&gt;

&lt;p&gt;Conflating the two would put a warning on six venues about caps we walk through without trouble, so the manifest deliberately lists only walls. Splitting them is the most useful thing in it.&lt;/p&gt;

&lt;p&gt;One more distinction that bit me: the same venue can have a wall on one surface and none on another. OKX serves its funding ledger (deposits, withdrawals, fiat, rewards) back to February 2021, while its savings interest is a rolling 30 days. A year-old month shows real deposits next to a confident zero of earnings.&lt;/p&gt;

&lt;h2&gt;
  
  
  The table
&lt;/h2&gt;

&lt;p&gt;Numbers come from the manifest in our repo (&lt;code&gt;packages/ui/src/history-limits.ts&lt;/code&gt;), which a test keeps in sync with the adapter registry, so a new venue cannot ship without declaring its limits. "Full" means no retention wall: the API reaches account opening. "About" is on purpose; exchanges do not promise these figures, and I would rather round than imply a contract.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Exchange&lt;/th&gt;
&lt;th&gt;Trade history&lt;/th&gt;
&lt;th&gt;Deposits / withdrawals&lt;/th&gt;
&lt;th&gt;Endpoints and quirks&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Binance&lt;/td&gt;
&lt;td&gt;Full&lt;/td&gt;
&lt;td&gt;Full&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;/api/v3/myTrades&lt;/code&gt; requires a symbol, so there is no account-wide trade feed: you have to know which pairs to ask about. A coin bought and fully sold, with no deposit or withdrawal pointing at it, is named only by the daily account snapshot (&lt;code&gt;/sapi/v1/accountSnapshot&lt;/code&gt;), which reaches about 30 days back. Transfers (&lt;code&gt;/sapi/v1/capital/deposit/hisrec&lt;/code&gt;, &lt;code&gt;withdraw/history&lt;/code&gt;) walk in 90 day windows back to account opening.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OKX&lt;/td&gt;
&lt;td&gt;About 90 days&lt;/td&gt;
&lt;td&gt;Full (funding bills back to Feb 2021)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;/api/v5/trade/fills-history&lt;/code&gt; serves roughly 3 months. A deeper &lt;code&gt;/api/v5/trade/fills-archive&lt;/code&gt; exists but needs a separately requested download, and returned 404 for the account shape I tested. Savings interest on &lt;code&gt;/api/v5/finance/savings/lending-history&lt;/code&gt; is a rolling 30 days.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;KuCoin&lt;/td&gt;
&lt;td&gt;Full&lt;/td&gt;
&lt;td&gt;Full&lt;/td&gt;
&lt;td&gt;Per-fill data (&lt;code&gt;/api/v1/fills&lt;/code&gt;) is only 7 days, but done orders (&lt;code&gt;/api/v1/orders&lt;/code&gt;, status done) reach account opening with a 7 day range cap per query, so trades are reconstructed from orders. &lt;code&gt;/api/v1/accounts/ledgers&lt;/code&gt; is capped at 1 year with a 24 hour query window.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gate.io&lt;/td&gt;
&lt;td&gt;Full&lt;/td&gt;
&lt;td&gt;Full&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;/spot/my_trades&lt;/code&gt; and &lt;code&gt;/wallet/deposits&lt;/code&gt; use a 30 day request window, walked back in full.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bybit&lt;/td&gt;
&lt;td&gt;About 2 years&lt;/td&gt;
&lt;td&gt;Full&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;/v5/execution/list&lt;/code&gt; retains roughly two years of executions and is queried in 7 day windows. Older fills come back empty rather than as an error.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Coinbase&lt;/td&gt;
&lt;td&gt;Full&lt;/td&gt;
&lt;td&gt;Full&lt;/td&gt;
&lt;td&gt;Advanced Trade fills (&lt;code&gt;/api/v3/brokerage/orders/historical/fills&lt;/code&gt;) are cursor paginated with no retention floor. Simple-mode buys and sells are not in that feed at all; they live on the legacy v2 &lt;code&gt;/v2/accounts/{id}/buys&lt;/code&gt; and &lt;code&gt;/sells&lt;/code&gt; endpoints, which Coinbase has deprecated but still serves, with the real fiat amount.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kraken&lt;/td&gt;
&lt;td&gt;Full&lt;/td&gt;
&lt;td&gt;Full&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;/0/private/TradesHistory&lt;/code&gt; is account-wide, and &lt;code&gt;/0/private/Ledgers&lt;/code&gt; carries the movements. Both reach the start.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bitget&lt;/td&gt;
&lt;td&gt;About 90 days&lt;/td&gt;
&lt;td&gt;Full&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;/api/v2/spot/trade/fills&lt;/code&gt; has a 90 day wall. Deposit and withdrawal records (&lt;code&gt;/api/v2/spot/wallet/deposit-records&lt;/code&gt;) walk 90 day windows back to the 2018 launch.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Upbit&lt;/td&gt;
&lt;td&gt;Full&lt;/td&gt;
&lt;td&gt;Full&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;/v1/orders/closed&lt;/code&gt; is queryable 7 days at a time; you walk back about 11 years. Markets are KRW based.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MEXC&lt;/td&gt;
&lt;td&gt;About a month (31 days)&lt;/td&gt;
&lt;td&gt;About 90 days&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;/api/v3/myTrades&lt;/code&gt; plus 7 day windows inside a hard retention limit. Older data is only available as a manual export from the website.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HTX&lt;/td&gt;
&lt;td&gt;About 120 days&lt;/td&gt;
&lt;td&gt;About a year&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;/v1/order/matchresults&lt;/code&gt; caps each query at 48 hours and returns at most 500 rows per page, so a busy window has to be split. Transfers come from &lt;code&gt;/v1/query/deposit-withdraw&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Five of the eleven have a trade wall: OKX, Bybit, Bitget, MEXC and HTX. MEXC and HTX also wall transfers. For KuCoin the honest summary is "full history", even though one of its endpoints is walled at 7 days; I document the outcome, not the endpoint.&lt;/p&gt;

&lt;p&gt;A note on sourcing. The OKX figures were verified against a real account in September 2026, and the KuCoin ones were re-checked against KuCoin's own docs in August 2026. The rest are the limits recorded in each adapter from earlier probing, and I did not re-probe all of them for this post. Re-probe before you build on any of it. Exchanges change these quietly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters at year end
&lt;/h2&gt;

&lt;p&gt;This is the part that made me write the post. Walls are measured from today, not from the start of the year. If you read the API in the last week of October 2026, the reach is roughly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;OKX and Bitget, 90 days: back to late July&lt;/li&gt;
&lt;li&gt;HTX, 120 days: back to late June&lt;/li&gt;
&lt;li&gt;MEXC, about a month: back to late September&lt;/li&gt;
&lt;li&gt;Bybit, about 2 years: back to late October 2024&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;By 31 December the same walls reach back only to about 2 October (OKX, Bitget), 2 September (HTX) and 30 November (MEXC). Anything you traded earlier in 2026 on those venues will be gone from the API by then. It is not hidden, it is deleted from the API's point of view. Bybit's two year wall means 2024 trades are the ones leaving now.&lt;/p&gt;

&lt;p&gt;The consequence is not abstract. On one real OKX account, 90 days of fills against an account funded since March 2024 left about 27 months of buys unrecorded. It surfaced as "you hold 87 LINK the ledger has never seen". A tool built on such an API will quietly compute a cost basis that is wrong forever, and the symptom does not tell you whose fault it is.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do about it now
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Export CSVs from each exchange's web UI before the wall moves.&lt;/strong&gt; Trade history and deposit/withdrawal history, the longest range each site allows. For MEXC, this is the only route to anything older than the API serves. Do the venues with the shortest walls first: MEXC, then OKX, Bitget, HTX.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do it per account and per product.&lt;/strong&gt; Spot, funding, earn and derivatives are separate ledgers. OKX derivatives bills, for instance, are 7 days live and 3 months archived.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Store the files somewhere you control.&lt;/strong&gt; An export is the only copy you will ever have of the data the API has already dropped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you build against these APIs, record the first sync date.&lt;/strong&gt; A history that starts at your first successful sync is a different claim from a history that starts at account opening, and your users will want to know which one they have.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do not let a lazy walk truncate you.&lt;/strong&gt; Never stop a backward window walk after N empty windows. Real accounts have long gaps, and you will silently lose everything older.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of this is tax advice. It is just arithmetic about which dates are still reachable.&lt;/p&gt;

&lt;p&gt;If you only want to see how an allocation would have held up, rather than rebuild old trades, the free in-browser stress test needs none of this history: &lt;a href="https://whereitgoes.money/stress-test?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=history-limits" rel="noopener noreferrer"&gt;whereitgoes.money/stress-test&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The per-exchange breakdown I maintain for users is on &lt;a href="https://whereitgoes.money/exchanges?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=history-limits" rel="noopener noreferrer"&gt;whereitgoes.money/exchanges&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I build WhereItGoes; this article was drafted with AI assistance and checked against the code.&lt;/p&gt;

</description>
      <category>crypto</category>
      <category>api</category>
      <category>fintech</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
