Repository navigation
fix(mcp): re-authorization and connection failures for the MCP client - #2782
Merged
Merged
Conversation
🦋 Changeset detectedLatest commit: c75bc9d The changes in this PR will be included in the next version bump. This PR includes changesets to release 3 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
This was referenced Oct 30, 2026
agents
@cloudflare/ai-chat
@cloudflare/codemode
hono-agents
@cloudflare/shell
@cloudflare/think
@cloudflare/voice
@cloudflare/worker-bundler
commit: |
Contributor
🔴 agents import sizes: 1 entry point over threshold
Changed exports (41)
How this worksEach runtime export is bundled on its own, minified, and gzipped. Changes smaller than 100 B, or smaller than 1% and 1 KiB, are ignored. Growth over 10% or 5 KiB is marked 🔴. This report is informational and does not fail CI. The workflow artifact contains every measurement. Compared |
* gardener: give pi-upgrade room to finish a minor upgrade * gardener: keep pi-upgrade on its 9, 12 and 15 schedule * gardener: run pi-upgrade at 9 and 15 so runs can't overlap; say piped checks keep their exit status
* chore(agents): bump Pi to 1.5.0
* feat(agents): replace session.events() with session.watch()
Pi removed watchEvents() and its event types: a client renders the
conversation view's state instead of assembling it from events.
PiSession.watch(window?) passes Pi's conversation watch through, and
PiSession.entries() pages the transcript, the loader Pi's
ConversationTranscript takes.
The Pi and browser tool examples send the view, then Pi's operations,
and project the view with projectView() instead of folding events. The
channels example derives its session events by comparing each view with
the last.
* docs(agents): say how much streamed output a Pi crash can lose
Pi's next minor stores partial answers and running tool output within
settings.progress.persistWithinMs (default 1 s) instead of on every
partial commit.
* chore(agents): changeset for Pi 1.5
* feat(agents): drop retry() and parked operations
Pi no longer parks a task its restart limit cut off: a tool call ends
with an error result, a model request ends its run unanswered
(restart_limit), and an interrupted model request counts as a failed
attempt under settings.retry. Harness.retryTask() and the restart_limit
blocked reason are gone, so PiHarness.retry(), PiSession.retry() and the
`parked` pending status go too.
* docs(agents): use Pi's tagged reports and watch ends
Pi's onReport takes one Report tagged by kind, renames session_failed to
storage_failed, and tags a watch's end by kind. The recovery docs and
the examples' onReport callbacks follow.
* fix(agents): build Pi tool schemas with pi-schema
pi-ai no longer re-exports TypeBox (Type, Static, TSchema), and Pi
validates and types tool arguments with @earendil-works/pi-schema. Build
the browser, web_fetch, web_search and skill tool schemas with
pi-schema's builders (open objects, so the JSON matches what TypeBox
produced) and string enums with pi-ai's StringEnum, which gives
{ type: "string", enum } instead of an anyOf of consts. pi-schema joins
the optional Pi peers; the docs and examples use it for their tools.
* docs(agents): warn against rolling back below Pi 1.5
* feat(agents): refuse input on an idle Pi session with whenIdle
* docs(agents): finish moving the Pi docs to watch() and ended tasks
* fix(examples): show only the active context after a Pi reset
* fix(examples): settle Pi turns that finish between two watch values
* fix(examples): don't wait on a Pi turn submitted during the settle check
* feat(agents): continue a Pi session without a new message * feat(agents): cap a Pi session's tool rounds with defaults.maxTurns * chore(agents): changeset for Pi session controls
#2798) * fix(agents): parse streamed chat-completions tool arguments once Use pi-ai's ToolCallArgumentsStream for the chat-completions wire's streamed tool arguments, kept off the content block, and its one full parse at the end, which reports malformed arguments as argumentsError. It replaces our own incremental parser (PartialArguments, #2740), now that pi-ai ships one. Drop the responses wire's cleanup of scratch fields pi no longer puts on blocks. * test(agents): stream tool arguments with a __proto__ member through pi pi-durable 1.4.0 merged a __proto__ member that a partial commit's tool arguments gained into Object.prototype. Drive a harness with the agents chat-completions wire over a binding that streams such arguments, and check the isolate stays clean. Fails on 1.4.0, passes on Pi main. * chore(agents): changeset for streamed tool arguments * test(agents): assert the __proto__ test streams a partial that merges it
…#2801) pi's code mode hands a script a schema-less tool's text only, so tools.browser() resolved to a JSON string and a screenshot was dropped: image(shot.result) threw. browserTool now declares a structuredOutputSchema and returns the model projection of the run as structuredOutput, with a screenshot as an image block in result. The model's own output is unchanged. The pi harness test fixture installs code mode with the inline QuickJS sandbox (pi-codemode as a dev dependency), and a test runs a script that screenshots, reads a value, and hits an error through tools.browser.
* refactor(browser): move input validation and the default scope into their own modules
session.ts is about to import browser-functions.ts, which imported
validate() from session.ts. Moving validate() to validate.ts, and
DEFAULT_BROWSER_SCOPE to scope.ts, keeps the browser modules free of
import cycles. No behavior change.
* feat(browser): give BrowserSession the contract a Workspace module needs
- Browser.functionDocs() returns { functions, rules }: the browser.*
docs for this browser's internal hosts, and the shared rules. The
screenshot rule now says the call throws, not that the run fails.
- BrowserSession.functions is the browser.* table bound to the session,
which browserTool now runs. Every entry, the hosts functions too,
checks its input, then rejects once the session is closed and waits
for the lock when the session locks.
- The report close() returns gains notices: the sentences the model
reads, moved from browserTool so every caller writes the same ones.
- browser.session({ scope, lock: true }) holds the scope's lock, per
BrowserSource, from its first call (or lock({ signal })) until
close(), which releases it after the active tab is saved. Other
locking sessions in the scope wait in order rather than fail. Each
lock() call's signal bounds only that call's wait; the session
leaves the queue once no call is still waiting, or when it closes,
and an already-aborted signal leaves no queue behind.
waitedForLockMs says how long it waited. Sessions without lock
behave as before.
* feat(browser): export conversationScope() for pi's per-conversation scope
pi's browserTool already scoped each conversation as
conversation:<id>. Export the helper so app code (a Workspace module's
scope function, say) lands in the same tab, and use it in pi.ts. It
takes a string (pi.ts passes String(conversationId)), and throws on an
empty id instead of returning conversation:undefined.
* refactor(browser): build browserTool on BrowserSession's contract
Each run opens a session with lock: true and calls lock() before the
sandbox starts, so runs in one browser and scope still go one at a
time and the wait still doesn't count toward the timeout. It joins
the report's notices, so the description and notices read as before.
The tool's own queue and notice text are gone, and it now also waits
for any other locking session in its scope.
* docs(browser): document the session contract and add a changeset
* fix(browser): stop browserTool when its call is aborted
browserTool ignored the harness's abort signal, so a stopped chat left
its call waiting for the scope or running until the 60 s timeout, holding
the scope's lock. The pi, AI SDK and TanStack AI adapters now pass their
call's signal to the core. A call still waiting for the lock gives up
through session.lock({ signal }); a running one ends with an error result
the way the timeout does, and its session closes and frees the scope.
* chore(browser): release the session contract as a patch
* Version Packages * docs(changelog): add 0.33.0 highlights and upgrade guide * docs(changelog): correct the 0.33.0 upgrade notes pi-schema's obj() rejects unlisted properties, unlike Type.Object; the watch view keeps entries before resets and compactions, and an idle pi.live keeps runFailure; defaults.maxTurns applies to new sessions; describe the browserTool code mode change and add #2795. --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Ben Reitz <[email protected]>
…ects (#2807) * feat(web): give pi code mode scripts web_search and web_fetch results as objects pi's code mode hands a script a schema-less tool's text, so tools.web_search() resolved to the numbered list the model reads and tools.web_fetch() to a header line plus tagged content: a script had to regex out result URLs and the next offset. Both pi tools now declare a structuredOutputSchema and return structuredOutput: - web_search: { query, items: [{ title, url, description?, lastModifiedDate? }] }, descriptions trimmed as the model's view trims them. - web_fetch: { url, status, contentType, title?, content, offset, nextOffset, totalChars }, with content still in the untrusted_web_content tags. The model's output is unchanged; pi never sends structuredOutput to the model. Failures stay error results without structuredOutput, which pi turns into a rejection carrying the model's text. A new pi harness fixture runs both tools over fake sources with code mode's inline sandbox: a direct search, and a script that searches, fetches the results in parallel, pages with nextOffset and catches a failed fetch. * fix(web): name the pi structured output helpers after structuredOutput, keep error imports small - webSearchResultsForPrograms -> webSearchStructuredOutput and webFetchPageForPrograms -> webFetchStructuredOutput (types to match), using pi's own name for the field. - Build the output schemas inside the tool factories, as the input schemas are, so importing only WebSearchError or WebFetchError no longer pulls them in (back to main's size). - Docs: TypeScript fence for the search-then-fetch script; changeset links developers.cloudflare.com/agents/tools/web-fetch/. * chore: drop the changeset link, and only ask for changeset links that add something The pi web tools changeset's link pointed at a page without the new section. AGENTS.md now asks for a docs link when the docs tell the reader more than the changeset line does, and to skip it otherwise.
#2774) * gardener: close issues merged work already resolved; upgrade to 0.1.22 * gardener: resolved-check reads every page of a pull request's changed files --------- Co-authored-by: Ben Reitz <[email protected]> Co-authored-by: Ben <[email protected]>
…ens (#2815) * feat(examples): page the Pi example's transcript, re-watch after pi reopens, show compaction as a notice The client merges every watch value into pi's ConversationTranscript, loads earlier pages and missed entries over an entries request, and resumes a reconnect after its newest entry; the server watches with pi's default window. A watch that ends because pi closed is re-watched from where it stopped. pi.compaction entries project as a summary notice. * fix(examples): keep the Channels example compiling against the Pi example's transcript Keeps projectEntries in the Pi examples' transcript.ts for the channels example, which shows a compaction summary as a system message and skips gaps, which entries never produce. * fix(examples): forget the kept scroll offset once a page load ends * docs(examples): say the Pi example compacts with pi's defaults * fix(examples): follow new Pi messages only while the reader is at the bottom * fix(examples): close the socket when a client's watch cannot open pi * fix(examples): keep a compaction summary's headings small
…reopen fixes (#2816) * feat(examples): page the browser example's transcript, re-watch after pi reopens, show compaction as a notice Brings the Pi example's socket protocol, transcript mirror, and projection to the browser tool example, with Load earlier, Load missing, and summary notices in its feed. * fix(examples): keep the browser example's transcript.ts in step with the Pi example's Keeps projectEntries, as in the Pi example, so the shared files stay identical. * fix(examples): forget the kept scroll offset once a page load ends * fix(examples): close the socket when a client's watch cannot open pi * fix(examples): keep the browser example's scroll position when earlier messages load
* fix(ai-chat): stop a cancelled queued chat request from running A cancel sent while a request waited in the queue found no abort controller and was dropped, so the request ran anyway once it reached the front. Create the controller when the request is accepted, and skip onChatMessage if it was cancelled before its turn started. Fixes #2701 * fix(ai-chat): keep a cancelled queued request from replacing the stored request context The abort check ran after _setRequestContext, so a request cancelled while queued still overwrote the body and client tools that tool continuations and continueLastTurn read. Check before writing them.
Fixes #2658. Re-authorization: a request that needs the user again (scope step-up, or a 401 whose refresh fails) throws MCPAuthorizationRequiredError with a link and moves the server to authenticating. OAuth flows take turns per connection, re-authorization stops after three rounds for the same scopes (MCP spec step-up limit), a declined step-up keeps the session on its current grant, and a request made before the first authorization gets the pending link. Connection failures: each connect makes the server's quick attempts (connect plus discovery), then records the failure. A failed server stays failed across wakes until reconnectMcpServer(id); refused authorization skips the attempts. addMcpServer() for a registered server reports its state as is; a waiting server keeps its link until reconnected. The manager is split into a facade and controllers (servers, authorization, status), with per-server lanes for connection work. Integration tests run an Agent against an OAuth MCP server served through msw.
…; stale-session recovery keeps its link; a declined step-up survives a wake - Work for a server added again waits for the removed server's running work, so a late code exchange cannot delete the new flow's discovery state, and it clears only its own tokens. - Recovering a forgotten session records what discovery found, including a link when only the catalog needs OAuth. - A declined step-up on a server that still holds tokens reconnects on that grant when the session it ran on is gone.
…er a wake resumes only a step-up The flag is stored with the link (pending_step_up), so a fresh sign-in that the user declines is still recorded, whatever tokens are stored. The resume starts from the callback handling itself, so standalone callers of handleCallbackRequest() get it too.
…jected 401 for what it is Fixes #2822. Fixes #2824. The connection applies transport.headers when it builds an HTTP transport (requestInit for every request, eventSourceInit.fetch for the SSE stream), so registerServer() and restored connections send them, not only addMcpServer(). A 401 from a server with no OAuth provider now says the server rejected the request and to check the credentials.
…s a restart, and merge the SSE stream's own headers whatever their shape
This was referenced Nov 5, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #2658, #2822 and #2824. When an MCP server needs the user to authorize again partway through a session, the agent now says so. The request throws
MCPAuthorizationRequiredErrorwith a link, the server moves toauthenticating, and clients see the link throughgetMcpServers(). Connection failures are recorded and stay put, the same as in Claude Code. A connect makes a few quick attempts, then the server isfailed, across wakes too, until someone calls the newreconnectMcpServer(id).Before this, a step-up (
403 insufficient_scope) or a401whose refresh failed surfaced as a plainUnauthorizedError. The SDK had built a new authorization link that nobody saw, and its callback was ignored because the server was stillready. Failures weren't recorded, so every wake tried a failed server again, andaddMcpServer()sometimes replaced a pending link without being asked.Re-authorization:
401s share one refresh or one link.ready, reconnecting on the grant it holds if the agent has woken since.MCPAuthorizationRequiredErrorwith the pending link.401from a server with no OAuth provider now says the server rejected the request: check the credentials, or passcallbackHostif the server uses OAuth. It used to say "OAuth configuration incomplete: missing authUrl" (A 401 from a server without an OAuth provider reports "OAuth configuration incomplete: missing authUrl" #2824).transport.headersis applied when the HTTP transport is built, soregisterServer()on a standalone manager and restored connections send it. Before, onlyaddMcpServer()did, because it converted the headers itself (MCPClientManager.registerServer() persists transport.headers but never sends them #2822).Failures:
retryoptions (3 attempts by default).403, OAuth errors such asinvalid_target, a revoked static key, rejected registration) skips the quick attempts and is recorded as refused. A denied callback is recorded too, so a wake doesn't start a flow the user just declined.addMcpServer()for a registered server reports its state as is:ready, the pending link, or the recorded failure. A waiting server keeps its link until the user completes it or reconnects. Whether an old link still works is up to the authorization server.Internally,
MCPClientManageris now a thin facade over controllers for servers, authorization and shared status. Connection work runs in per-server lanes, so connect, discovery, callbacks and restore never interleave on one server. A server removed and added again under the same id waits for the old server's running work, so a late code exchange can't overwrite the new flow.cf_agents_mcp_serversgains three nullable columns, added the first time they're used:failure,authorization_roundsandpending_step_up. The base table definition doesn't change. The public API only gainsreconnectMcpServer()andMCPAuthorizationRequiredError.The integration tests run an
Agentwith its default transport against an OAuth-protected MCP server served through msw. Callbacks go through the worker, and the object restarts between steps. They cover the happy path, step-ups and the round limit, outages, refusals, removal and re-add races, and users who click old links, reopen callbacks, decline, or press Reconnect at odd times.pnpm run buildandpnpm run checkpass, and so do the MCP tests (717). This replaces stack #2660, #2661, #2662, #2663, #2751, #2758, #2759, #2760, #2761 and #2778. The example apps are in the PR stacked on this one.