Skip to content

Merge upstream DO-reset retry and make the portal connector recoverable - #3

Merged
sho-yamane merged 6 commits into
mainfrom
chore/upstream-bump-3
Aug 20, 2026
Merged

Merge upstream DO-reset retry and make the portal connector recoverable#3
sho-yamane merged 6 commits into
mainfrom
chore/upstream-bump-3

Conversation

@sho-yamane

Copy link
Copy Markdown

3 つをまとめて main に入れる。すべてデプロイ済みで本番確認済み。

1. upstream 2 コミットの取り込み

  • dd2b015 Retry pure user-DO reads once across a reset (#263) — capability 付与中に User DO のリセットと競合して Network connection lost. / user_do.reset.surfaced で落ちていた問題への対処。純読み取りの DO RPC がリセットをまたいで 1 回リトライされる
  • 4288713 Simplify MCP discovery flows (#269)

2. fix/portal-reconnect のマージ

portal コネクタの reconnect() をオーバーライドし、継承元(自身の Cloudflare Access OAuth をやり直すだけ)の代わりに portal_toggle_servers を呼んで Portal 自身の再認証ページの URL を返す。Portal → upstream の on-behalf OAuth が失効したとき(Server "Metabase" requires re-authentication)の唯一の回復経路で、Cloudflare はダッシュボードに代替手段を提供していない。

cloudflare#269portal.ts を大きく変えたため conflict したが、新構造の上で reconnectPortal の目的(URL 返却 / 取れなければレスポンスをログに出して throw / 継承実装へフォールバックしない)を維持して解消した。

3. Reconnect ボタンの常時表示(portal コネクタのみ)

Reconnect ボタンは credentialsValid が false のときだけ表示されていたが、このフラグはこの deployment 自身の Cloudflare Access OAuth の状態しか追跡しない。Portal → upstream の on-behalf 失効は workshop から観測できないため、実際に本番で「表示は正常・実態は失効・復帰手段なし」になった。MCP Server Portals コネクタのアカウントには常時 Reconnect を表示する。

確認

🤖 Generated with Claude Code

sho-yamane and others added 6 commits August 19, 2026 21:21
…per's OAuth

GatekeeperUserImpl inherited McpGatekeeperUserBase's reconnect(), which only
restarts this Worker's own OAuth with Cloudflare Access. That never touches
the portal's on-behalf authorization to an upstream server, so when it lapses
("Server ... requires re-authentication. Call the portal_toggle_servers tool
to re-authenticate.") the user has no way to recover: Cloudflare's MCP Server
Portals expose portal_toggle_servers as the only re-authentication path, with
no dashboard alternative, and nothing but this gatekeeper can call it.

Override reconnect() to call portal_toggle_servers itself and return the
re-authentication URL it opens. The response shape is undocumented beyond
"opens a URL-based server selection page", so the URL is recovered with the
same permissive prose scan portal_list_servers already uses
(parsePortalReconnectUrl), rather than trusting a guessed structured field.
When no URL can be recovered, this throws instead of falling back to the
inherited flow -- a silent fallback would reproduce exactly the bug being
fixed, since the user would reconnect and the upstream authorization would
still be untouched. The unparsed response is logged for later inspection but
kept out of the thrown message, since its shape isn't verified.

Adds a vitest.config.ts + cloudflare:workers stub for this package (mirroring
mcp-shared's) so GatekeeperUserImpl can be instantiated directly in tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Extracts the portal binding-mint validation and the configurator's server
listing out of their call sites, converts McpClient.#list from five
positional parameters to an options object, inlines handleOAuthCallback into
its only consumer, and prunes the mcp-shared exports map to the subpaths
that are actually imported.

None of it changes behaviour. The #list conversion preserves every call
site's effective arguments, including listMatchingToolIndex leaving
requireCompleteScan defaulted false, which client-pagination.test.ts pins on
both sides. handleOAuthCallback moved verbatim; it had one caller, and
http.ts already declared the OAuthCallbackAccount shape it needed, so the
inlining drops a duplicated type rather than adding one.

validatePortalScope owns the whole scope pipeline -- parse, policy check,
catalog evidence -- rather than taking an already-narrowed scope. Splitting
it the other way would have left requirePortalServerScope as a precondition
the type could not express: ToolScope & { serverId: string } proves serverId
is present, not that the portal-native tool refusal ran, so a second caller
could satisfy the signature having skipped the check that stops a grant for
portal_toggle_servers. Returning the narrowed scope is what lets the caller
keep its own narrowing, since an assertion signature is not available on an
async function.

The evidence rationale moves onto that function instead of being dropped
with the lines it annotated. Both halves are load-bearing: the first is the
only thing explaining why the listing == null branch probes for the
server-list tool rather than failing outright, and the second is the
justification for the one mode that validates a grant against an empty
catalog.

The local catalog is typed ToolIndex rather than ToolCatalog. Every branch
produces name-only evidence, and ToolCatalog advertises annotations -- the
field tools.ts classifies read-versus-approval from, and which nothing
outside tools.ts is allowed to read. It is assignable to the ToolCatalog
parameter either way.

Removing the six package-private modules from exports only bites if the
boundary is enforced, and it was not: both connectors aliased
@gadgets/mcp-shared/* to ../mcp-shared/src/*, which TypeScript consults
ahead of exports and esbuild honours too, so an import of a pruned subpath
would have type-checked and bundled regardless. The aliases are gone; the
set of subpaths imported anywhere is a subset of what remains exported, so
resolution now goes through the exports map in both tsc and wrangler.

project is declared as a function property rather than a method, since
method syntax is exempt from strictFunctionTypes' contravariance check and
this is the parameter deciding what is retained from an untrusted
tools/list response.
* Rename do-telemetry to do-retry ahead of adding retries

Pure rename plus the four references, split out so the retry commit's diff
shows the file as a move rather than a delete/create pair -- the content
change is large enough that git's default 50% similarity threshold no longer
detects it.

* Retry pure user-DO reads once across a reset

user_do.reset.surfaced volume (from cloudflare#133/cloudflare#162, which deliberately deferred
retries) showed the one call in flight at reset time is worth recovering
instead of surfacing to the browser.

do-retry.ts gains retryOnDoReset(callWithFreshStub, log): exactly one retry,
full jitter (a mass reset fails every in-flight call at once), and identity
rethrow so the workerd flags the frontend classifier reads survive. The retry
predicate is narrower than isDoResetError: durableObjectReset retries even when
flagged overloaded (the incarnation is dead, so the queue that overloaded it
died with it -- this is the shape production storage-timeout resets arrive in,
{remote, overloaded, durableObjectReset}), while bare retryable retries only if
the object isn't shedding load. The module header now spells out why those are
two independent flag axes -- retryable/overloaded come from the kj exception
type and describe the call, durableObjectReset is parsed from the tunneled
description and describes the object -- and links the DO error-handling docs,
which document the former pair but not the latter, and whose
never-retry-overloaded guidance we deliberately diverge from.

The replay-safety judgment lives at each call site as an explicit, greppable
wrapper -- no method-name allowlist. A reset can't distinguish "never applied"
from "applied, response lost", so only strictly pure reads are wrapped: the 14
read delegations in server.ts and the read sites on the fresh-stub session
getters in overseer.ts (whoami, getChatContext, listModels, plus the two
OverseerImpl-internal fresh-stub reads). The thunk re-evaluates the fresh-stub
getter, so the second attempt reaches the new incarnation for free. Reads
with hidden writes (listOutputs, listProvidedAccounts) and every write keep
today's behavior; getChatContext's doc now pins that it must stay write-free.

Successful retries log user_do.reset.recovered; correlated against
user_do.reset.surfaced (which still fires per attempt, with operation
attribution) this gives the retry win rate and the signal for wrapping more
sites later.
…/upstream-bump-3

# Conflicts:
#	packages/gatekeeper-mcp-portal/src/portal.ts
gatekeepers.tsx only showed Reconnect when state === 'expired', which is
driven by account.credentialsValid -- this gatekeeper's own OAuth with
Cloudflare Access. The MCP Server Portals connector's on-behalf
authorization to an upstream server (see reconnect() in
gatekeeper-mcp-portal/src/portal.ts) is a separate credential this
gatekeeper's credentialsValid can't observe, so it can lapse while the
account still reads as connected here, leaving the user with no way to
reach the portal's own re-authentication.

Add alwaysOfferReconnect, keyed off the mcp_portal vendor id, to both the
connector card (a plain, non-danger Reconnect button alongside the
existing connected state, rather than reusing the expired styling) and
the manage dialog's footer.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown


Thank you for your submission, we really appreciate it. Like many open-source projects, we ask that you all sign our Contributor License Agreement before we can accept your contribution. You can sign the CLA by just posting a Pull Request Comment same as the below format.


I have read the CLA Document and I hereby sign the CLA


0 out of 2 committers have signed the CLA.
@sho-yamane
@ndisidore
You can retrigger this bot by commenting recheck in this Pull Request. Posted by the CLA Assistant Lite bot.

@sho-yamane
sho-yamane merged commit 948b035 into main Aug 20, 2026
6 of 8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants