My Claude connector said "Couldn't connect to the server." Right after that, /healthz on that same server answered 200 in under a second.
The server is a small MCP server in .NET 10 that lets one Claude connector reach several Notion workspaces, say a work one and a personal one. MCP is the protocol Claude uses to call tools on a remote server. Mine runs on Azure Container Apps for a single user, me, with one password and my own Notion tokens. The code is at https://github.com/guilhermeappel/multi-workspace-mcp.
This post is about why both were true and how long Claude actually waits before it gives up. The short version: under 5 seconds when you add a connector, about 10 after that, and 240 for a tool call. Further down there's a different slow thing, one a code review turned up.
The replica that wasn't there
The logs had the first half of the answer. The app scales to zero, so after a quiet stretch there's no replica at all. A request, most likely the connector's, woke it up: replica scheduled at 12:50:37, container running at 12:51:00. At least 23 seconds before the app could answer anything, and pulling the image was only 2.84 of them. By the time I opened /healthz by hand the replica was warm, and everything looked fine.
What I didn't know was whether 23 seconds was too long. Anthropic's troubleshooting page has no entry for that exact message. The closest one, "Couldn't reach the MCP server", goes through DNS, firewalls, redirects and OAuth discovery. A server that is still starting isn't on the list.
Part of the answer was on a page I hadn't read. The authentication docs say Claude waits up to 10 seconds for the OAuth discovery, registration and token endpoints, and 30 for a refresh. My server does its own OAuth, but my logs don't say which request woke the replica. It could have been one of those, or the MCP handshake itself, which the docs don't give a number for.
Measuring the rest
So I measured the handshake myself, and while I was at it, the flow for adding a new connector. I wrote a tiny MCP server with no auth that delays its answer by whatever I set and logs, to the millisecond, when a request arrives and when the client hangs up. I put it behind a temporary tunnel and added it to claude.ai.
Adding it as a new connector was the strictest. claude.ai gave up after 4.5 seconds when the delay was 75, and after 4.7 when the delay was only 8. That second run ended with "Couldn't check the server."
Pressing Connect on the connector once it existed was more patient. A 3 second delay was fine. An 8 second one connected too, although for a moment the UI said there were no tools. A 40 second delay was abandoned after 9.97 seconds, with "Couldn't reach Test Probe" (the test connector's name).
Then, in a Connect where that first check passed, I made tools/list take 75 seconds. Every Connect I logged sends initialize and tools/list twice, about a second apart. This time the second round came 10.5 seconds after the first tools/list, and Claude never hung up on the first one: it stayed open until my server answered it, 75 seconds in. So a slow request keeps running on the server after Claude has moved on.
Tool calls are a different story. Anthropic documents 240 seconds for those. It's the handshake that's short. Unless the request that woke my replica was a refresh, which gets 30, it had about 10 seconds, and the replica needed at least 23.
That's a handful of runs from one account on one day, on the web only, through a tunnel that adds a little latency of its own, and the test server had no OAuth, so it isn't a copy of my real setup. Anthropic can change any of it. But the documented 10 seconds and what I measured point the same way.
What I changed
Connecting again once the replica is up works. Microsoft's troubleshooting page for MCP servers on Container Apps describes the same problem, 10 to 30 seconds for a first request after inactivity, and suggests --min-replicas 1. That's what I did.
It's not free. A 0.25 vCPU replica running all month is about 650,000 vCPU-seconds, and the free grant was 180,000 when I checked. An idle replica is billed at a lower rate, so look at the current pricing for your region.
If you want to keep scale to zero without a failed first connect, your startup has to finish well under 10 seconds, and under 5 if it happens while someone is adding the connector. I measured my 23 seconds once, on one deployment in eastus. When I later deployed a new version, the app was listening 0.3 seconds after its container started, out of about 22 seconds from scheduling the replica, so almost all of the wait happens before my code runs.
Scale to zero was workable at all because the server keeps no state that has to survive a restart. Client IDs, authorization codes and tokens are JSON signed with HMAC-SHA256, so a fresh replica accepts tokens an older one issued. The two things that do live in memory, the login throttle and the ledger that makes authorization codes single-use, are why the app has to stay at one replica.
The other slow thing: a regex
Before announcing the project anywhere I had the code reviewed, and one finding stood out: the code that turns Markdown into Notion text had a regex that gets slow in a very specific way.
Give it a long line of **a **a **a and it never finds a closing **, so for every opening one it scans to the end of the line. Double the text and the time quadruples. None of my tests sent a long line through that regex, so none of them noticed.
I measured it on the old code in a Release build, which is what the container runs. 200,000 characters took 7 seconds, and a million took almost three minutes. (My first number, four minutes for 200,000, came from a Debug build and was wrong by a lot.) The request limit is 2 MiB, so in theory a request near it would take about thirteen minutes, though that's an extrapolation and no model is likely to write that much in one call. The real problem is smaller and more boring: an 80,000-character line took 1.1 seconds, and the server runs on one small replica, so a few slow requests can slow down everything else. It needs a valid token, but a page the model reads could try to get it to send text like that.
The fix has a few parts. Every match gets 100 milliseconds. The first match that times out ends the formatting for the rest of that conversion: that paragraph and everything after it stay plain, so you lose bold and links. A whole conversion gets about one second. And a call can't send more than 100,000 characters of Markdown, or it's rejected with an error. That 80,000-character line, which fits under the cap, now takes about a tenth of a second.
The per-match limit alone wasn't enough, which surprised me. My first version only had a 500 millisecond limit per match, and three paragraphs of 33,000 characters, each running up to that limit, added up to almost a second and a half. The lower limit and stopping at the first timeout brought that case down to a tenth of a second. The one-second budget is for the case where every paragraph stays just under the limit.
RegexOptions.NonBacktracking would run in linear time, but Microsoft's documentation says that mode doesn't allow lookarounds, and these patterns use them. There's also an input where the new code is slower than the old one: 11 paragraphs of 9,000 characters take 0.43 seconds now against 0.16, probably because checking for timeouts isn't free.
If you run a .NET regex over text you don't control, give it a timeout, and write at least one test with a very long line. I didn't have one.
Two smaller notes
A green build lied to me once too. Build & test passed while the job that builds the Docker image failed with the analyzer errors CA1859 and CA1873. Those two rules are switched off in .editorconfig, and the Dockerfile never copied .editorconfig into the build stage, so inside Docker they were back on. The image job only ran on pushes to main, so no pull request could have caught it. It runs on pull requests now.
And since someone will ask: the server doesn't use the official C# MCP SDK. The SDK isn't an OAuth server, so most of the protocol code here would be written by hand anyway, and with only four MCP methods to support, the server ended up with no NuGet packages at all. ADR 0002 has the trade-offs. There are also other servers that put several Notion workspaces behind one connector. Mine keeps no database: the Notion tokens are settings, and everything it issues is signed.
What I still don't know
Microsoft's ingress docs say the Container Apps ingress appends the client address to X-Forwarded-For and that only the rightmost one comes from Azure, which is the only one the login throttle reads. I haven't confirmed that on my own deployment. My regex timings come from a desktop and not from a 0.25 vCPU container, so the old code would be slower there. The new limits are wall-clock time, so they should hold, but on a throttled container a normal paragraph could in theory hit the 100 ms limit, and the rest of that text would lose its formatting without a warning.
And a page the model reads can still try to steer it. The strongest defence is a Notion integration without write capability. As a second layer I added a setting, Notion:ReadOnlyWorkspaces, that makes the server refuse any tool that changes something in the workspaces you list.
If you find something I got wrong, the repo has a private way to report it.
This post is AI-assisted. I built the project with an AI coding assistant, and I read all of the code and went through it part by part, approving or arguing against the design decisions. The assistant also helped with the reviews and the measurements, and wrote a first draft of this post. I chose the topic, read this text and suggested changes to it. The numbers come from my own logs, tests and runs, or from the documentation I link, and where something is only my reading of it, I say so.
Top comments (0)