<?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: Mohammad Hayat</title>
    <description>The latest articles on DEV Community by Mohammad Hayat (@hayatnextoria).</description>
    <link>https://dev.to/hayatnextoria</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%2F4237113%2F1e44c1cb-9b1e-41e2-9a18-0883a73afbb5.png</url>
      <title>DEV Community: Mohammad Hayat</title>
      <link>https://dev.to/hayatnextoria</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hayatnextoria"/>
    <language>en</language>
    <item>
      <title>Building a remote MCP server in ASP.NET Core: OAuth, client registration and the TLS bug we didn't expect</title>
      <dc:creator>Mohammad Hayat</dc:creator>
      <pubDate>Wed, 11 Nov 2026 04:12:49 +0000</pubDate>
      <link>https://dev.to/hayatnextoria/building-a-remote-mcp-server-in-aspnet-core-oauth-client-registration-and-the-tls-bug-we-didnt-5g17</link>
      <guid>https://dev.to/hayatnextoria/building-a-remote-mcp-server-in-aspnet-core-oauth-client-registration-and-the-tls-bug-we-didnt-5g17</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure: I work on PersonaXpand, a social media tool made by Nextoria in Dubai. This post is about how we built its MCP connector, which lets Claude and ChatGPT post, schedule and answer comments on a person's own accounts. The code patterns apply to any remote MCP server.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Most MCP tutorials stop at a local server that talks over stdio. That works for a developer's own laptop. It stops working the moment you want a normal person to click "Connect" inside Claude or ChatGPT and have it just work. Then you need a &lt;strong&gt;remote&lt;/strong&gt; server, and a remote server needs &lt;strong&gt;real OAuth&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;We built ours inside an existing ASP.NET Core 10 app with the official C# SDK (&lt;code&gt;ModelContextProtocol.AspNetCore&lt;/code&gt;). Here is what we learned, including three things that cost us days.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Run the transport stateless
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AddMcpServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ServerInfo&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;Implementation&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;Name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"personaxpand"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"PersonaXpand"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"1.0.0"&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="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;WithHttpTransport&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Stateless&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;WithTools&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;PersonaXpandTools&lt;/span&gt;&lt;span class="o"&gt;&amp;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Streamable HTTP can keep a session per client. Don't, unless you need server-to-client streaming. Our app runs on more than one instance behind a load balancer, and a stateful session pinned to one instance breaks the moment the next request lands somewhere else. With &lt;code&gt;Stateless = true&lt;/code&gt;, any instance can answer any call, and every request carries its own bearer token.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Your app is now an OAuth authorization server
&lt;/h2&gt;

&lt;p&gt;The assistant discovers everything from two well-known documents:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;/.well-known/oauth-protected-resource&lt;/code&gt; on the MCP server says which authorization server to use.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/.well-known/oauth-authorization-server&lt;/code&gt; lists the authorize, token, registration and revocation endpoints.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then it needs a client ID. Nobody pre-registers "Claude on someone's laptop", so the spec gives you two ways to get one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dynamic Client Registration (DCR, RFC 7591):&lt;/strong&gt; the client POSTs its name and redirect URIs and gets an ID back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Client ID Metadata Documents (CIMD):&lt;/strong&gt; the client ID &lt;em&gt;is&lt;/em&gt; an https URL, and you fetch a JSON document from it that lists the redirect URIs. No registration call at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Support both. Different assistants use different ones, and CIMD is where things are heading because the client's identity is tied to a domain it controls.&lt;/p&gt;

&lt;p&gt;The rest is standard OAuth 2.1 and worth doing properly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PKCE on every authorization code&lt;/strong&gt;, S256 only. Codes live 60 seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Short access tokens (1 hour), rotating refresh tokens (30 days).&lt;/strong&gt; Each refresh returns a new refresh token and retires the old one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refresh reuse detection with a grace window.&lt;/strong&gt; If a retired refresh token comes back, that is either theft or a client retrying after a network blip. We allow a reuse within 60 seconds of rotation (retries are real and common) and revoke the whole connection after that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Store every secret hashed.&lt;/strong&gt; Codes, access tokens and refresh tokens are looked up by hash. A database leak should not hand out working tokens.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Revocation on password change.&lt;/strong&gt; When the person changes their password, every assistant connection is cut.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  3. Open registration needs a redirect allowlist
&lt;/h2&gt;

&lt;p&gt;This is the one we got wrong at first.&lt;/p&gt;

&lt;p&gt;DCR means &lt;em&gt;anyone&lt;/em&gt; can register a client with &lt;em&gt;any&lt;/em&gt; redirect URI. An attacker can register their own site as the return address, then send a victim a perfectly real consent link on your domain. The victim sees your real login page and a real "Allow" button. If they click it, the authorization code goes to the attacker's site.&lt;/p&gt;

&lt;p&gt;PKCE doesn't save you here, because the attacker started the flow and holds the verifier.&lt;/p&gt;

&lt;p&gt;Our fix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="nc"&gt;DefaultRedirectHosts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s2"&gt;"claude.ai"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"claude.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"chatgpt.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"openai.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"vscode.dev"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="nc"&gt;IsRedirectHostAllowed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;redirectUri&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="o"&gt;!&lt;/span&gt;&lt;span class="nc"&gt;Uri&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;TryCreate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;redirectUri&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;UriKind&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Absolute&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;out&lt;/span&gt; &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;uri&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="kc"&gt;false&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="nc"&gt;IsLoopback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uri&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="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;            &lt;span class="c1"&gt;// a code sent here only reaches the person's own machine&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="n"&gt;_config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;GetValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Mcp:RedirectAllowlist"&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="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// kill switch&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;extra&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;GetSection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Mcp:ExtraRedirectHosts"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Get&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&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="p"&gt;)&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="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;HostIs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;DefaultRedirectHosts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Concat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;extra&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ToArray&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Loopback always passes.&lt;/strong&gt; Many desktop clients redirect to &lt;code&gt;http://127.0.0.1:&amp;lt;random port&amp;gt;&lt;/code&gt;. A code sent there can only reach the machine of the person who clicked Allow. Per RFC 8252, match loopback redirects without the port, because the port changes on every run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;New hosts come from config, not code.&lt;/strong&gt; When a new assistant appears, you add its host in app settings without a deploy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep a kill switch.&lt;/strong&gt; If a real assistant gets turned away at 2 a.m., you can switch the list off while you work out its host.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The error message matters too. Ours tells the developer exactly which host was refused and who to email, instead of a bare &lt;code&gt;invalid_redirect_uri&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The consent screen is a security boundary
&lt;/h2&gt;

&lt;p&gt;Say plainly what the assistant will be able to do. Ours separates "read" (see posts, analytics, inbox) from "act" (publish, schedule, send replies), and anything that would send a message on someone's account still asks the person first inside the chat. Assistants are good at following instructions, including ones hidden in a comment someone left on your post. Treat everything that comes from outside the person's own account as data, never as instructions, and say so in your tool descriptions.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The TLS bug: your server may not be able to fetch the client's document
&lt;/h2&gt;

&lt;p&gt;This one took the longest, because nothing in our code was wrong.&lt;/p&gt;

&lt;p&gt;CIMD means your server makes an outbound HTTPS request to the client's domain to fetch its metadata. One MCP directory's CIMD sign-in kept failing with "This app is not registered". Locally it worked. In production it didn't.&lt;/p&gt;

&lt;p&gt;The production logs showed a &lt;strong&gt;TLS handshake failure&lt;/strong&gt;. We run on Azure App Service on Windows, where outbound TLS goes through SChannel. The other side only accepted &lt;strong&gt;TLS 1.3 with the X25519 key exchange&lt;/strong&gt;, and refused TLS 1.2 and the P-256/P-384 curves. Our Windows host and their server simply had no handshake in common.&lt;/p&gt;

&lt;p&gt;What we learned:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Log the exception type on every outbound metadata fetch.&lt;/strong&gt; "Could not read client metadata" hides a TLS failure behind what looks like a bad client ID.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test CIMD from the same OS and host as production.&lt;/strong&gt; Your laptop's TLS stack is not your server's.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tell the other side.&lt;/strong&gt; We emailed them the handshake details. They added TLS 1.2 with P-256 the next day, which fixed it for every Windows-hosted server, not only ours.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  6. Smaller things that saved us
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Filter tools per client.&lt;/strong&gt; Some tools only make sense in one assistant (we hide image generation from Claude to follow its directory rules). &lt;code&gt;AddListToolsFilter&lt;/code&gt; and &lt;code&gt;AddCallToolFilter&lt;/code&gt; let one server present different tool lists by client.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Serialize destructive calls per account.&lt;/strong&gt; An assistant may fire 20 deletes in parallel. If you have a "confirm before many deletes" rule that counts recent deletes, all 20 see a count of zero. A per-account distributed lock around destructive tools fixes it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch your clock.&lt;/strong&gt; OAuth and signed-request flows fail with confusing "invalid grant" errors when a machine's clock is off. We found a dev box four hours off UTC this way.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Publish to the official MCP Registry.&lt;/strong&gt; Directories and clients increasingly read from it. Ownership is proven with a DNS TXT record on your domain, so keep that record.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it or read the docs
&lt;/h2&gt;

&lt;p&gt;The server is live at &lt;code&gt;https://personaxpand.com/mcp&lt;/code&gt;. The listing files and docs are on &lt;a href="https://github.com/Nextoria-Information-Technology-LLC/personaxpand-mcp" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;, and setup steps are on our &lt;a href="https://personaxpand.com/mcp" rel="noopener noreferrer"&gt;MCP page&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you are building a remote MCP server in .NET and hit one of these, ask in the comments. Happy to share more detail.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>dotnet</category>
      <category>oauth</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
