Skip to content

claude.ai connector keeps asking to reconnect: four server-side causes (fixes tested) #68

Description

@farrukh-x2

Setup. Self-hosted NoteMesh in Docker (the 1.2.0 image, now 1.3.0 built from source) behind a TLS tunnel (Tailscale Funnel). It serves a claude.ai custom connector used from the web, the iOS app and cloud routines. Every day or two claude.ai marked the connector "Connection issue — reconnect". I put request logging in front of the container, and four things on the NoteMesh side stood out.

Some of the flagging is claude.ai's own. It has dropped the connector while the refresh token was still valid, with no failed request in the log. Each of the four points below, though, gives a client a real reason to drop the grant. This is an issue rather than a PR, per CONTRIBUTING. The fixes are small, and I've been running them since 2026-09-25.

1. Token verification depends on a round trip through BASE_URL

src/server/mcp/oauth.ts verifies with jwksUrl: ${env.baseUrl}/api/auth/jwks. BASE_URL is the public address, so every JWKS cache miss leaves through the tunnel or proxy and comes back in. The verifier keeps keys for five minutes, and the fetch has no timeout of its own. verify() turns any exception into null, so a hiccup on that trip answers a valid token with 401. That is the response that makes a client discard its grant.

Before the connector was flagged, I saw claude.ai refresh brand-new tokens every few seconds and then ask for a reconnect. That is consistent with valid tokens getting 401.

Fix: fetch the keys over loopback. This is a one-line change that keeps the verifier's cache.

const JWKS_URL = `http://127.0.0.1:${process.env.NITRO_PORT ?? process.env.PORT ?? 3000}/api/auth/jwks`;

2. One-hour access tokens

I have seen claude.ai contact the server hours after its access token expired, never call the token endpoint, and then flag the connector, with the refresh token still valid for weeks. Longer access tokens leave fewer renewals to go wrong. Revocation is unaffected, because oauth.ts looks the client up on every call.

oauthProvider({ /* … */ accessTokenExpiresIn: 7 * 24 * 60 * 60 })

An optional env var would keep the one-hour default for anyone who prefers it.

3. The 400 answering a 2026-07-28 probe reads like a modern error

claude.ai now opens every connection with server/discover and MCP-Protocol-Version: 2026-07-28. SDK 1.30 answers with this:

400 {"jsonrpc":"2.0","error":{"code":-32000,"message":"Bad Request: Unsupported protocol version: 2026-07-28 (supported versions: 2025-11-25, …)"},"id":null}

The 2026-07-28 Streamable HTTP spec's Backward Compatibility section tells a client to fall back to initialize only when that 400's body "is empty or is not a recognized modern JSON-RPC error". The SDK's message reads like UnsupportedProtocolVersionError. claude.ai fell back on most connections, but twice in one evening it stopped after the 400 and sent nothing else.

Fix: in serveMcp, after the body is read, answer with an empty 400. The condition is the one the SDK uses, which doesn't check the header on initialize.

const version = request.headers.get("mcp-protocol-version");
const messages: unknown[] = Array.isArray(body) ? body : [body];
if (
  version &&
  !SUPPORTED_PROTOCOL_VERSIONS.includes(version) &&
  !messages.some((m) => (m as any)?.method === "initialize")
) {
  void transport.close();
  void server.close();
  return new Response(null, { status: 400 });
}

4. The RFC 9728 path-suffixed metadata URL returns the SPA

For a resource at <base>/api/mcp, RFC 9728 puts the metadata at /.well-known/oauth-protected-resource/api/mcp. That path isn't in PROTECTED_RESOURCE in src/server/discovery.ts, so it gets the app shell with 200 text/html. That's the parse-error case the comment in that file warns about. Fix: add the path to the list.

Tested

I ran these checks against the built server with the tests/http harness:

  • server/discover at 2026-07-28 returns an empty 400.
  • initialize sent with that header returns 200.
  • tools/list at 2025-11-25 returns 200.
  • The path-suffixed metadata URL returns JSON.

endpoints.test.ts, oauth-revocation, oauth-scopes and oauth-legacy-client all pass.

After deploying, claude.ai's next connection went through cleanly:

  • Its access token expired in 7 days.
  • server/discover got an empty 400.
  • initialize and tools/list both returned 200.

Happy to answer questions or re-test a fix.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions