DEV Community

Mohammad Hayat
Mohammad Hayat

Posted on

Building a remote MCP server in ASP.NET Core: OAuth, client registration and the TLS bug we didn't expect

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.

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 remote server, and a remote server needs real OAuth.

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

1. Run the transport stateless

services.AddMcpServer(o =>
    {
        o.ServerInfo = new Implementation { Name = "personaxpand", Title = "PersonaXpand", Version = "1.0.0" };
    })
    .WithHttpTransport(o => o.Stateless = true)
    .WithTools<PersonaXpandTools>();
Enter fullscreen mode Exit fullscreen mode

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 Stateless = true, any instance can answer any call, and every request carries its own bearer token.

2. Your app is now an OAuth authorization server

The assistant discovers everything from two well-known documents:

  • /.well-known/oauth-protected-resource on the MCP server says which authorization server to use.
  • /.well-known/oauth-authorization-server lists the authorize, token, registration and revocation endpoints.

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

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

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.

The rest is standard OAuth 2.1 and worth doing properly:

  • PKCE on every authorization code, S256 only. Codes live 60 seconds.
  • Short access tokens (1 hour), rotating refresh tokens (30 days). Each refresh returns a new refresh token and retires the old one.
  • Refresh reuse detection with a grace window. 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.
  • Store every secret hashed. Codes, access tokens and refresh tokens are looked up by hash. A database leak should not hand out working tokens.
  • Revocation on password change. When the person changes their password, every assistant connection is cut.

3. Open registration needs a redirect allowlist

This is the one we got wrong at first.

DCR means anyone can register a client with any 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.

PKCE doesn't save you here, because the attacker started the flow and holds the verifier.

Our fix:

public static readonly string[] DefaultRedirectHosts =
    { "claude.ai", "claude.com", "chatgpt.com", "openai.com", "vscode.dev" };

public bool IsRedirectHostAllowed(string? redirectUri)
{
    if (!Uri.TryCreate(redirectUri, UriKind.Absolute, out var uri)) return false;
    if (IsLoopback(uri)) return true;            // a code sent here only reaches the person's own machine
    if (!_config.GetValue("Mcp:RedirectAllowlist", true)) return true;   // kill switch
    var extra = _config.GetSection("Mcp:ExtraRedirectHosts").Get<string[]>() ?? [];
    return HostIs(uri.Host, DefaultRedirectHosts.Concat(extra).ToArray());
}
Enter fullscreen mode Exit fullscreen mode

Three details matter:

  • Loopback always passes. Many desktop clients redirect to http://127.0.0.1:<random port>. 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.
  • New hosts come from config, not code. When a new assistant appears, you add its host in app settings without a deploy.
  • Keep a kill switch. If a real assistant gets turned away at 2 a.m., you can switch the list off while you work out its host.

The error message matters too. Ours tells the developer exactly which host was refused and who to email, instead of a bare invalid_redirect_uri.

4. The consent screen is a security boundary

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.

5. The TLS bug: your server may not be able to fetch the client's document

This one took the longest, because nothing in our code was wrong.

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.

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

What we learned:

  • Log the exception type on every outbound metadata fetch. "Could not read client metadata" hides a TLS failure behind what looks like a bad client ID.
  • Test CIMD from the same OS and host as production. Your laptop's TLS stack is not your server's.
  • Tell the other side. 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.

6. Smaller things that saved us

  • Filter tools per client. Some tools only make sense in one assistant (we hide image generation from Claude to follow its directory rules). AddListToolsFilter and AddCallToolFilter let one server present different tool lists by client.
  • Serialize destructive calls per account. 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.
  • Watch your clock. 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.
  • Publish to the official MCP Registry. Directories and clients increasingly read from it. Ownership is proven with a DNS TXT record on your domain, so keep that record.

Try it or read the docs

The server is live at https://personaxpand.com/mcp. The listing files and docs are on GitHub, and setup steps are on our MCP page.

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

Top comments (1)

Collapse
 
mhk_sameera profile image
mhk sameera •

The redirect allowlist section is the part most MCP tutorials skip. Good point that PKCE doesn't help when the attacker started the flow. The SChannel/X25519 story is also a solid reminder to test from the production host, not your laptop. I built a Node MCP server behind Nginx for a client, and what bit me was proxy timeouts on long database queries (I had to raise them to 300s). Did you run into timeouts with long-running tools on App Service, or does stateless mode keep calls short enough? Nice to see another UAE team writing about MCP.