Skip to content

fix(agent): treat missing error field as success in RemoteAgent.run - #2651

Merged
khandrew1 merged 3 commits into
mcp-use:canaryfrom
BlueX888:fix/prep-remote-success-response-rejected
Oct 19, 2026
Merged

khandrew1 merged 3 commits into
mcp-use:canaryfrom
BlueX888:fix/prep-remote-success-response-rejected

Conversation

@BlueX888

@BlueX888 BlueX888 commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Pull Request Description

Where did you find this issue?

Reported in #2637, which includes a stubbed-fetch reproduction: a 200 body like {"result": "The answer"} (no error key) made run() throw Remote agent execution failed: [object Object], while the same body with an explicit error: null resolved.

Language / Project Scope

Check all that apply:

  • TypeScript
  • Python
  • Documentation only
  • CI/CD or tooling

Changes

RemoteAgent.run no longer treats a 200 response as a failure just because the body omits the error key, and rejection messages now render object error values as JSON instead of [object Object].

Implementation Details

  1. libraries/typescript/packages/agent/src/agents/remote.ts:347 — the guard result.status === "error" || result.error !== null rejected every body whose error key is absent, because undefined !== null. Changed to result.error != null, so an absent error field counts as no error while real error values and status: "error" bodies still reject. This matches the comment above the guard ("Check for actual error conditions (not just presence of error field)") and the success handling ten lines below ("result" in result → return result.result).
  2. remote.ts:348 — result.error ?? String(result) interpolated an object error value as [object Object]; JSON.stringify(result.error) keeps the payload readable.
  3. The String(result).includes("failed to initialize") check is removed. It was dead code (String({}) is "[object Object]"), so it could never match. The error check now throws the bare message and the existing catch adds the "Remote agent execution failed:" prefix once (it used to be added twice). A test pins that a success body quoting "failed to initialize" still resolves.
  4. Added tests/agents/remote_run.test.ts (7 cases, stubbed globalThis.fetch, no network) and a changeset. No dependencies added or modified.

TypeScript Checklist

Packages Modified

Check all packages that were modified:

  • docs
  • tests
  • cli
  • create-mcp-use-app
  • mcp-use (server)
  • mcp-use (client)
  • inspector

(@mcp-use/agent source and tests — that package is not in the template's list.)

Pre-commit Checklist

  • Ran pnpm lint — clean, so pnpm lint:fix changes nothing
  • Ran pnpm format:check — clean, so pnpm format changes nothing
  • Ran pnpm build and build succeeds without errors
  • Added a changeset (.changeset/smart-lions-rest.md, "@mcp-use/agent": patch)
  • Added or updated tests if needed
  • Updated documentation in docs/ folder if needed — not needed (internal error handling, no API change)

Python Checklist

Not applicable.

Example Usage (Before)

// Remote API responds 200 with {"result": "The answer"} (no "error" key)
await agent.run({ prompt: "hi" });
// throws: Remote agent execution failed: Remote agent execution failed: [object Object]

Example Usage (After)

await agent.run({ prompt: "hi" });
// resolves: "The answer"

// real errors still reject, now readably — body {"result": "x", "error": {"message": "boom"}}
// throws: Remote agent execution failed: {"message":"boom"}

Documentation Updates

None — internal error-handling fix with no API change.

Testing

  • New tests/agents/remote_run.test.ts, 7 cases stubbing globalThis.fetch (no network): absent error field resolves; status: "success" resolves; explicit error: null resolves; string error rejects; status: "error" rejects; object error renders as JSON in the message; a success body whose result text mentions "failed to initialize" still resolves.
  • On unpatched main the file fails 4 of 7 cases (the three success-shape cases plus the object-error message); with this change 7/7 pass.
  • pnpm --filter @mcp-use/agent test:unit — 20 files, 155 passed, 1 skipped.
  • pnpm format:check, pnpm lint, pnpm build — all clean.
  • pnpm knip reports findings on main already (create-mcp-use-app / server / inspector); verified identical with this change stashed, and none reference packages/agent.

Backwards Compatibility

The behavior change is the fix itself: 200 bodies without an error key now succeed instead of throwing. Bodies with a real error value or status: "error" still throw; the thrown message for object error values changes from [object Object] to the JSON payload.

Related Issues

Closes #2637

Closes #2851 (the unreachable "failed to initialize" check is removed).


Summary by cubic

Fixes RemoteAgent.run rejecting successful 200 responses whose body omits the error field, since undefined !== null flagged every such body as a failure. An absent error field now counts as no error; real error values and status: "error" bodies still reject, object error values render as JSON instead of [object Object], string errors stay unquoted, and status-only error bodies fall back to the serialized response. Also removes the dead "failed to initialize" check, which could never match, and builds the error message in one expression so the "Remote agent execution failed:" prefix is added once.

  • Adds regression tests covering success shapes (no error, error: null, status: "success"), error shapes, JSON and plain-string error rendering, and status-only error bodies.

Written for commit f4b0ddc. Summary will update on new commits.

View guided diff

A 200 response body like {"result": "..."} has no error key, so
result.error is undefined and the result.error !== null check flagged
every such success response as a failure; only bodies carrying an
explicit error: null passed. Compare with != null so an absent error
field counts as no error, while real error values and status: "error"
bodies still reject. Also stringify the error value so object errors
render as JSON instead of [object Object].

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 3 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

JSON.stringify(result.error) wrapped primitive string errors in quotes
and produced undefined for status-only error responses. Render primitive
error values with String(), keep JSON.stringify for object errors, and
fall back to serializing the whole response body when no error payload
exists.
Build the message in one expression and let the catch add the
"Remote agent execution failed:" prefix once, instead of twice. Remove
the "failed to initialize" check: String() of a response object is
always "[object Object]", so it could never match (mcp-use#2851).

Claude-Session: https://claude.ai/code/session_01MEJoDUZb843zNMtq3jGMXu
@pkg-pr-new

pkg-pr-new Bot commented Oct 19, 2026

Copy link
Copy Markdown

Open in StackBlitz

@mcp-use/agent

npm i https://pkg.pr.new/@mcp-use/agent@2651

@mcp-use/cli

npm i https://pkg.pr.new/@mcp-use/cli@2651

@mcp-use/client

npm i https://pkg.pr.new/@mcp-use/client@2651

create-mcp-use-app

npm i https://pkg.pr.new/create-mcp-use-app@2651

@mcp-use/inspector

npm i https://pkg.pr.new/@mcp-use/inspector@2651

mcp-use

npm i https://pkg.pr.new/mcp-use@2651

@mcp-use/tunnel

npm i https://pkg.pr.new/@mcp-use/tunnel@2651

commit: f4b0ddc

@khandrew1
khandrew1 changed the base branch from main to canary October 19, 2026 17:22
@khandrew1
khandrew1 merged commit 2d7c986 into mcp-use:canary Oct 19, 2026
40 checks passed
khandrew1 added a commit that referenced this pull request Oct 19, 2026
* fix(cli): quote client SDK install args on Windows (#2853)

* fix(cli): quote client SDK install args on Windows

On Windows spawnAndWait ran npm/pnpm through cmd.exe with an args array.
cmd.exe strips ^ from @mcp-use/client@^2.0.0-alpha.0, so the package
manager asked for the unpublished 2.0.0-alpha.0 and mcp-use client /
mcp-use screenshot auto-install always failed. Quote each argument and
pass one command string, matching create-mcp-use-app's runPackageManager
(#2696); this also drops the DEP0190 warning.

* chore(cli): add changeset for Windows client install fix

* test(cli): keep only the argument-quoting cases in the spawn test

* test(cli): clarify that only Windows parses the spawn arguments through a shell

* fix(cli): read the client peer range from @mcp-use/cli

readClientPeerRange() still looked for a package.json named mcp-use, so
after the move into @mcp-use/cli it never found the declared peer range
and every auto-install fell back to ^2.0.0-alpha.0. Check for
@mcp-use/cli, test that the declared range is returned, and use ^2.0.1
as the fallback.

* chore: enter canary prerelease mode

* chore(typescript): version packages (canary)

* fix(inspector): surface streamed chat failures (#2877)

* chore(typescript): version packages (canary)

* fix(agent): emit OpenAI Responses tool-call args events keyed by item_id (#2551)

* fix(agent): emit OpenAI Responses tool-call args by item_id

`response.function_call_arguments.delta`/`.done` carry `item_id`, not
`call_id`, so the callBuffers lookup always fell back to "" and never
matched the buffer registered by `response.output_item.added`. Neither
`tool-call-args-delta` nor `tool-call-ready` was emitted on the Responses
path, so `streamNativeAgentSteps` saw no ready calls and
`MCPAgent.stream()`/`streamEvents()` reported no AgentStep for tool calls
made through OpenAIResponsesDriver.

* fix(agent): keep the call_id lookup for argument events

Index the buffer under both the output item id and the protocol call id, so
a producer that identifies `response.function_call_arguments.{delta,done}`
by `call_id` keeps matching. Spec-conformant producers are unaffected: the
item id is tried first and the fallback only runs when it misses.

Adds a regression test that feeds the same SSE sequence with the argument
events correlated by `call_id`; it fails on the previous commit and passes
here, while the existing item_id test keeps passing.

* fix(agent): key the tool-call buffers by identifier namespace

Storing every buffer under both the item id and the call_id in one flat Map
let one call's item id collide with another call's call_id: the second call's
output_item.added overwrote the key the first call was stored under, so the
first call's argument events were emitted against the second call's tool name
and call id.

The two identifiers come from the producer and share no namespace, so they are
prefixed with item: and call: and can no longer overlap.

The call_id-only producer test is also parameterized over a producer that
omits the output item's id, and a two-call case covers the collision.

* fix(agent): correlate Responses argument events by item_id only

The Responses API sends function_call_arguments delta/done with item_id,
and this provider only serves the official OpenAI API, so the call_id
fallback key had no producer. Drop it and its tests.

Claude-Session: https://claude.ai/code/session_01MEJoDUZb843zNMtq3jGMXu

---------

Co-authored-by: Andrew Khadder <[email protected]>

* test(inspector): expect tool drawer args now that Responses emits tool-call-ready

#2551 made the OpenAI Responses provider emit tool-call-ready, so chat
tool-invocation parts carry their arguments again and the drawer test
passes. Closes #2769.

Claude-Session: https://claude.ai/code/session_01MEJoDUZb843zNMtq3jGMXu

* chore(typescript): version packages (canary)

* fix(agent): return only the final turn's text from MCPAgent.stream() (#2723)

* fix(agent): return only the final turn's text from MCPAgent.stream()

streamNativeAgentSteps appended every text-delta to the return value, so text the model wrote before a tool call was glued onto the final answer and saved to memory. Reset it after each tool result, matching runNativeAgent, which keeps only the turn without tool calls.

* test(agent): cover maxSteps ending the run after tool results

Both stream() and run() return an empty string in that case. Also reset the collected text only for tool results that match a reported tool call, so ignored events have no effect.

* fix(agent): drop a tool-call turn's text when the stream is aborted before the tool runs

Decide per turn instead of on tool results: a turn that requests tools is intermediate, so an abort between the tool call and its dispatch returns an empty answer, as runNativeAgent does.

* fix(agent): reset stream() turn text on every tool result

The reset ran only after a pending step was found, and steps exist only
after tool-call-ready, so on the OpenAI Responses provider (which never
emitted tool-call-ready) the preamble text was still glued onto the
answer. Reset on every tool-result and mark a tool-calling turn on
tool-call-start, which every provider emits. Adds a regression test with
real Responses event shapes.

Claude-Session: https://claude.ai/code/session_01MEJoDUZb843zNMtq3jGMXu

---------

Co-authored-by: Andrew Khadder <[email protected]>

* chore(typescript): version packages (canary)

* fix(client): close OAuth loopback connections on teardown (#2766)

* chore(typescript): version packages (canary)

* fix(agent): treat missing error field as success in RemoteAgent.run (#2651)

* fix(agent): treat missing error field as success in RemoteAgent.run

A 200 response body like {"result": "..."} has no error key, so
result.error is undefined and the result.error !== null check flagged
every such success response as a failure; only bodies carrying an
explicit error: null passed. Compare with != null so an absent error
field counts as no error, while real error values and status: "error"
bodies still reject. Also stringify the error value so object errors
render as JSON instead of [object Object].

* fix(agent): keep string error messages unquoted in remote error path

JSON.stringify(result.error) wrapped primitive string errors in quotes
and produced undefined for status-only error responses. Render primitive
error values with String(), keep JSON.stringify for object errors, and
fall back to serializing the whole response body when no error payload
exists.

* fix(agent): simplify RemoteAgent error message and drop dead init check

Build the message in one expression and let the catch add the
"Remote agent execution failed:" prefix once, instead of twice. Remove
the "failed to initialize" check: String() of a response object is
always "[object Object]", so it could never match (#2851).

Claude-Session: https://claude.ai/code/session_01MEJoDUZb843zNMtq3jGMXu

---------

Co-authored-by: Andrew Khadder <[email protected]>

* chore(typescript): version packages (canary)

* fix(cli): join inspector paths through one basePath rule (#2555)

* fix(cli): join inspector paths through one basePath rule

views/document.ts special-cased basePath "/" while inspector-route.ts and
dev-api.ts interpolated directly, so the root produced "//inspector". That
made `mcp-use start --with-inspector` advertise /inspector while the mount
guard matched //inspector, and left the three dev-API routes unreachable.

The rule moves to src/base-path.ts and every consumer joins through it. The
two URL sites in dev.ts move with the predicate: dev was self-consistent in
the doubled form, so changing only the guard would have broken it.

* fix(cli): return basePath for an empty child and correct the inspector docs

Addresses review on #2555.

pathUnderBase("/mcp", "") returned "/mcp/", contradicting the documented
"never containing an empty segment". An empty child now yields basePath.

The Inspector integration guide told readers to set basePath: '' to serve
the Inspector at /inspector, but assertServerConfig rejects an empty string
because basePath must start with "/". The working value is '/', which is
exactly the case this PR repairs. Documented that, and replaced the naive
`${basePath}/inspector` in the options table and the v2 CLI reference with
the normalized rule.

* docs(inspector): scope the basePath root note to server config

My previous commit claimed an empty basePath is rejected, which is wrong in
the mountInspector section it sits in: registerInspectorShell defaults
basePath to "" and builds "/inspector" from it, so "" is the inspector's own
root value. Only MCPServer config rejects it, via assertServerConfig.

Restores the original `''` guidance for mountInspector and adds the
MCPServer caveat separately.

* docs: drop unrelated doc edits from this fix

Claude-Session: https://claude.ai/code/session_01MEJoDUZb843zNMtq3jGMXu

---------

Co-authored-by: Aishwary Dongre <[email protected]>
Co-authored-by: Andrew Khadder <[email protected]>

* fix(client): pin OAuth token expiry when tokens are saved (#2879)

getOAuthTokenExpiry fell back to Date.now() + expires_in for opaque
tokens on every read, so useMcp().authTokens.expires_at moved later on
each reconnect. Save the absolute expiry with the tokens and prefer it
over expires_in. JWT exp still wins.

Supersedes #2674.


Claude-Session: https://claude.ai/code/session_01MEJoDUZb843zNMtq3jGMXu

Co-authored-by: Arpitm544 <[email protected]>

* chore(typescript): version packages (canary)

* feat: customize MCP landing page from landing.tsx (#2648)

Claude-Session: https://claude.ai/code/session_018JTsXB5q2SkN8rNAfS7vn4

* chore(typescript): version packages (canary)

* fix: landing page dark mode, page-owned Tailwind, and dev styles before hydration (#2880)

* feat(server): follow the OS color scheme on landing pages; link dev page CSS

The managed Tailwind `dark:` variant keys off `data-theme`, which nothing set
on a landing page outside a host. Mirror `prefers-color-scheme` into it from
an inline head script before the first paint, until the page sets its own
value. Dev loads can also pass the stylesheets the page imports, linked in
the head and marked for the dev entry to remove once its imports insert them.

Claude-Session: https://claude.ai/code/session_018JTsXB5q2SkN8rNAfS7vn4

* fix(cli): let landing pages own Tailwind; style dev markup before hydration

A landing stylesheet with its own `@import "tailwindcss"` now replaces the
managed copy instead of compiling beside it, so the page's `dark` variant is
the only one. The landing page gets its own managed stylesheet id, separate
from the views' copy. In dev, the head links the stylesheets in the page's
module graph, and only those extra URLs pass the dev server's Vite route.

Claude-Session: https://claude.ai/code/session_018JTsXB5q2SkN8rNAfS7vn4

* docs(server): document landing dark mode and page-owned Tailwind

Claude-Session: https://claude.ai/code/session_018JTsXB5q2SkN8rNAfS7vn4

* chore(typescript): version packages (canary)

* fix(inspector): start connection timeout fields blank (#2881)

The Connect form pre-filled 10000 / 60000. Since #2656 saves those
values and tool calls honor them, every server added from the dashboard
capped tool calls at 10s per request and 60s overall. Blank means the
10-minute default and no overall limit, as the help text says.

Claude-Session: https://claude.ai/code/session_01MEJoDUZb843zNMtq3jGMXu

* chore(typescript): version packages (canary)

* test(inspector): wait for the settings form before editing the URL (#2882)

The dashboard Connect form renders the same connection-form-url-input
and stays mounted until the view transition to Connection Settings
finishes, so waiting for visibility let fills land in the dashboard
form. Save then kept the old URL and the toast still fired. Wait for
the connection's URL instead, as the other settings tests already do.

Claude-Session: https://claude.ai/code/session_01MEJoDUZb843zNMtq3jGMXu

* fix(inspector): JSON-only OAuth metadata responses; per-caller proxy limits (#2855)

* fix(inspector): JSON-only OAuth metadata responses; per-caller proxy limits

The OAuth BFF relayed /oauth/metadata bodies with the upstream Content-Type,
including on error responses, and accepts top-level navigations (no Origin).
A link to /inspector/api/oauth/metadata?serverUrl=<attacker> therefore
rendered attacker HTML on the host's origin (manufact.com for Manufact Cloud,
inspector.manufact.com for the hosted Inspector). Metadata responses are now
always application/json with nosniff; /oauth/proxy already answered with a
JSON envelope.

The proxy and OAuth BFF consumed one limiter key for every request (the
stringified Hono request), so one busy caller exhausted the 120/min budget
for everyone on the process. Each caller (client IP) now gets that budget,
under a process-wide ceiling of 1,200/min for rotating or spoofed keys. The
two copies of the limiter middleware became one helper.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(inspector): key proxy budgets on the proxy-added X-Forwarded-For entry

X-Real-IP and the leftmost X-Forwarded-For entry can be set by the client,
so each spoofed value got a fresh budget and only the process ceiling held.
The rightmost entry is the one the platform proxy adds (Vercel overwrites the
header, Railway appends), so a client behind it cannot choose its budget.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(inspector): skip the per-caller budget without X-Forwarded-For

A local Inspector or mcp-use dev has no platform proxy adding
X-Forwarded-For, so every request fell into one "unknown" bucket of
120/min, the same shared budget as before. The e2e suite drains it and
gets 429s. Without the header callers can't be told apart, so apply
only the process-wide ceiling.

Claude-Session: https://claude.ai/code/session_01MEJoDUZb843zNMtq3jGMXu

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Andrew Khadder <[email protected]>

* chore(typescript): version packages (canary)

* docs: changelog for mcp-use v2.11.0 and inspector v20.3.22

Claude-Session: https://claude.ai/code/session_01MEJoDUZb843zNMtq3jGMXu

---------

Co-authored-by: Vansh Bhardwaj <[email protected]>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: 哈基米 <[email protected]>
Co-authored-by: Freetrip <[email protected]>
Co-authored-by: aishwary-dongre <[email protected]>
Co-authored-by: Aishwary Dongre <[email protected]>
Co-authored-by: Arpitm544 <[email protected]>
Co-authored-by: Enrico Toniato <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants