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.
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.tsverifies withjwksUrl: ${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 intonull, 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.
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.tslooks the client up on every call.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/discoverandMCP-Protocol-Version: 2026-07-28. SDK 1.30 answers with this:The 2026-07-28 Streamable HTTP spec's Backward Compatibility section tells a client to fall back to
initializeonly when that 400's body "is empty or is not a recognized modern JSON-RPC error". The SDK's message reads likeUnsupportedProtocolVersionError. 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 oninitialize.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 inPROTECTED_RESOURCEinsrc/server/discovery.ts, so it gets the app shell with200 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/httpharness:server/discoverat 2026-07-28 returns an empty 400.initializesent with that header returns 200.tools/listat 2025-11-25 returns 200.endpoints.test.ts,oauth-revocation,oauth-scopesandoauth-legacy-clientall pass.After deploying, claude.ai's next connection went through cleanly:
server/discovergot an empty 400.initializeandtools/listboth returned 200.Happy to answer questions or re-test a fix.