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 acc
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>();
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-resourceon the MCP server says which authorization server to use. -
/.well-known/oauth-authorization-serverlists 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());
}
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).
AddListToolsFilterandAddCallToolFilterlet 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.
Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.