From 356e8eaa8a23a66ef2e8f30b7dda44a08b6798fb Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 22 Aug 2026 23:39:08 -0400 Subject: [PATCH] fix(apps): read the settled sandbox URL from a ref at client construction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous round's `await` did not do what it claimed. `setupClientForServer` is synchronous and memoized, so a caller that awaited the config resumed with the `sandboxUrl` captured by the render it STARTED in — undefined, on exactly the load the wait was added for. The deep-link one-shot guard then prevented a retry, so that connection silently lost the capability despite having a sandbox. The OAuth-callback path did not wait at all. Construction now reads a ref written every render, so it sees the current value whichever entry point reached it, and the OAuth path waits like the connect path does. The wait still matters — it is what makes an absent value mean 'confirmed absent' rather than 'not known yet' — but the ref is what makes the value read be the settled one. Signed-off-by: cliffhall --- clients/web/src/App.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index 5a0a2bed..a61ad89e 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -818,6 +818,13 @@ function App() { // entry would otherwise be rendered by a factory bound to the replacement // client, i.e. read and answered through a different server. const appElicitationSessionRef = useRef(null); + // `setupClientForServer` is synchronous and memoized, so a caller that + // awaited the config would still resume with the `sandboxUrl` captured by the + // render it STARTED in — undefined, on the very load this matters for. The + // ref is written every render, so client construction reads the current value + // whichever entry point (connect, deep link, OAuth callback) reached it. + const sandboxUrlRef = useRef(undefined); + sandboxUrlRef.current = sandboxUrl; // Whether the sandbox exists is only known once `/api/config` resolves, and // the answer is baked into the client at construction (it decides whether the // nested MCP Apps `elicitation` capability is advertised). So a connect waits @@ -2518,7 +2525,7 @@ function App() { // here means "confirmed absent" rather than "not known yet" — a // connection that reaches this with no sandbox behaves like the // CLI/TUI: native elicitation queue, no claim made to the server. - ...(sandboxUrl && { + ...(sandboxUrlRef.current && { appElicitation: newAppElicitationSession().render, }), // Always advertise the roots capability (even with no configured @@ -2623,7 +2630,6 @@ function App() { onBeforeOAuthRedirect, clientConfig, newAppElicitationSession, - sandboxUrl, ], ); @@ -2743,6 +2749,9 @@ function App() { } void (async () => { + // Same reason as the connect path: whether this client may advertise + // app-rendered elicitation is fixed at construction. + await initialConfigSettledRef.current?.promise; try { await webOAuthStorage.load(); } catch (err) {