fix(loader): name the MCP tool a load failure belongs to #11

Merged
thklein merged 2 commits from feat/mcp-load-error-tool-name into main 2026-09-15 05:57:32 +00:00
Owner

The problem

An MCP load failure cannot say which tool it belongs to.

mcp.(*Local).Load reports the server key, and the one-server-per-tool
configuration bpai generates always keys it "default". So a program carrying
several MCP tools produces the same sentence whichever one failed:

error loading MCP tools: failed to load MCP session for server default: ...

processMCP has t.Name in hand at the wrap site and drops it.

Downstream, that forces a caller to treat every candidate as the culprit. In
bpai, markMCPOAuthWallHit marks every unconnected oauth-mode MCP tool on the
agent when a load 401s, because it genuinely cannot narrow. An agent carrying a
stale-API-key MCP server alongside an unrelated unconnected oauth-mode server
marks the oauth one every turn; the user connects and the run still fails.

The change

Append the tool name to the existing wrap:

error loading MCP tools for https-crm-basepeak-dev-mcp: failed to load MCP session for server default: ...

Appended, not interpolated, so error loading MCP tools is still a prefix.
Consumers match that text rather than an error type (the failure crosses the
sdkserver boundary as a string), and both shapes have to keep matching across a
pin bump in either direction so a rollback does not silently stop recognising
it. bpai's matcher uses strings.Contains, and a test case for the new shape
lands alongside the pin bump there.

An unnamed tool (inline MCP, no Name) keeps the plain wrap rather than
producing ... for : ....

Tests

pkg/loader/mcp_error_test.go, driving processMCP against a failing loader:

  • the failing tool is identifiable, the error loading MCP tools prefix
    survives, and the cause stays unwrappable (errors.Is)
  • an unnamed tool keeps the plain wrap
  • a non-MCP program passes through untouched and never calls the loader

Plus one end-to-end case through ProgramFromSource, which closes the link the
others assume rather than prove: that the name a caller puts on a ToolDef
survives the SDK's "#!GPTSCRIPT" + JSON serialization and the parse back into
types.Tool. Dropped anywhere on that path, the wrap would still compile and
still read plausibly while naming nothing, and a caller narrowing on the name
would silently never match. Mutation-checked: inverting the name guard fails all
three.

go test ./pkg/loader/ is clean. pkg/loader/github fails on unmodified main
in this environment too (it fetches from raw.githubusercontent.com); unrelated.

Follow-up in bpai

Bumping the pin and narrowing markMCPOAuthWallHit to the named tool is a
separate PR there.

## The problem An MCP load failure cannot say which tool it belongs to. `mcp.(*Local).Load` reports the server **key**, and the one-server-per-tool configuration bpai generates always keys it `"default"`. So a program carrying several MCP tools produces the same sentence whichever one failed: ``` error loading MCP tools: failed to load MCP session for server default: ... ``` `processMCP` has `t.Name` in hand at the wrap site and drops it. Downstream, that forces a caller to treat every candidate as the culprit. In bpai, `markMCPOAuthWallHit` marks *every* unconnected oauth-mode MCP tool on the agent when a load 401s, because it genuinely cannot narrow. An agent carrying a stale-API-key MCP server alongside an unrelated unconnected oauth-mode server marks the oauth one every turn; the user connects and the run still fails. ## The change Append the tool name to the existing wrap: ``` error loading MCP tools for https-crm-basepeak-dev-mcp: failed to load MCP session for server default: ... ``` **Appended, not interpolated**, so `error loading MCP tools` is still a prefix. Consumers match that text rather than an error type (the failure crosses the sdkserver boundary as a string), and both shapes have to keep matching across a pin bump in either direction so a rollback does not silently stop recognising it. bpai's matcher uses `strings.Contains`, and a test case for the new shape lands alongside the pin bump there. An unnamed tool (inline MCP, no `Name`) keeps the plain wrap rather than producing `... for : ...`. ## Tests `pkg/loader/mcp_error_test.go`, driving `processMCP` against a failing loader: - the failing tool is identifiable, the `error loading MCP tools` prefix survives, and the cause stays unwrappable (`errors.Is`) - an unnamed tool keeps the plain wrap - a non-MCP program passes through untouched and never calls the loader Plus one end-to-end case through `ProgramFromSource`, which closes the link the others assume rather than prove: that the name a caller puts on a `ToolDef` survives the SDK's `"#!GPTSCRIPT" + JSON` serialization and the parse back into `types.Tool`. Dropped anywhere on that path, the wrap would still compile and still read plausibly while naming nothing, and a caller narrowing on the name would silently never match. Mutation-checked: inverting the name guard fails all three. `go test ./pkg/loader/` is clean. `pkg/loader/github` fails on unmodified `main` in this environment too (it fetches from raw.githubusercontent.com); unrelated. ## Follow-up in bpai Bumping the pin and narrowing `markMCPOAuthWallHit` to the named tool is a separate PR there.
fix(loader): name the MCP tool a load failure belongs to
Some checks failed
Request reviewers for Dependabot PRs / request_reviewers (pull_request_target) Has been skipped
smoke / check-label (pull_request_target) Failing after 2s
smoke / gpt-4o-2024-08-06 (pull_request_target) Has been skipped
smoke / gpt-4o-mini-2024-07-18 (pull_request_target) Has been skipped
smoke / claude-3-7-sonnet-20250219 (pull_request_target) Has been skipped
Validate docs build / validate-docs (pull_request) Failing after 27s
integration / integration (ubuntu-22.04) (pull_request) Failing after 29s
test / test (ubuntu-22.04) (pull_request) Failing after 29s
integration / integration (windows-latest) (pull_request) Has been cancelled
test / test (windows-latest) (pull_request) Has been cancelled
90d778a158
mcp.(*Local).Load reports the server KEY, and bpai's one-server-per-tool
configuration always keys it "default", so a program carrying several MCP tools
produced the same sentence whichever one failed. A caller could not tell a stale
API key on one server from an unauthenticated second server, and had to treat
every candidate as the culprit.

Append the tool name to the existing wrap rather than interpolating it, so
"error loading MCP tools" stays a prefix. Consumers match that text (it does not
survive the sdkserver boundary as a typed error), and both shapes have to keep
matching across a pin bump in either direction.
Author
Owner

CI note: test (ubuntu-22.04) and validate-docs fail on this PR for a reason that predates it. make tidy cannot resolve basepeak-ai/broadcaster, a private module, because the runner has no credentials:

fatal: could not read Username for 'https://git.basepeak.dev': terminal prompts disabled
make: *** [Makefile:12: tidy] Error 1

Both jobs fail identically on every recent commit in this repo, merged ones included (runs #197/#198 on 7738a4c, #192/#193 on the PR before it). check-label fails on every pull_request event for the same structural reason it passed on push.

Locally, go test ./pkg/loader/ is clean. pkg/loader/github fails on unmodified main here too — it fetches from raw.githubusercontent.com.

CI note: `test (ubuntu-22.04)` and `validate-docs` fail on this PR for a reason that predates it. `make tidy` cannot resolve `basepeak-ai/broadcaster`, a private module, because the runner has no credentials: ``` fatal: could not read Username for 'https://git.basepeak.dev': terminal prompts disabled make: *** [Makefile:12: tidy] Error 1 ``` Both jobs fail identically on every recent commit in this repo, merged ones included (runs #197/#198 on `7738a4c`, #192/#193 on the PR before it). `check-label` fails on every `pull_request` event for the same structural reason it passed on push. Locally, `go test ./pkg/loader/` is clean. `pkg/loader/github` fails on unmodified `main` here too — it fetches from raw.githubusercontent.com.
test(loader): pin that the ToolDef name reaches processMCP through the SDK
Some checks failed
smoke / check-label (pull_request_target) Failing after 11s
smoke / gpt-4o-2024-08-06 (pull_request_target) Has been skipped
smoke / gpt-4o-mini-2024-07-18 (pull_request_target) Has been skipped
smoke / claude-3-7-sonnet-20250219 (pull_request_target) Has been skipped
Validate docs build / validate-docs (pull_request) Failing after 27s
test / test (ubuntu-22.04) (pull_request) Failing after 28s
integration / integration (ubuntu-22.04) (pull_request) Failing after 30s
test / test (windows-latest) (pull_request) Has been cancelled
integration / integration (windows-latest) (pull_request) Has been cancelled
03425e9642
The unit test above assumed it rather than proved it. bpai builds the def as
mcp.ToolDef(toolRef.Name, cfg) and the SDK serializes it as "#!GPTSCRIPT" +
JSON before the loader parses it back. If Name were dropped anywhere on that
path the wrap would still compile and still read plausibly while naming nothing,
and a caller narrowing on the name would silently never match.
thklein deleted branch feat/mcp-load-error-tool-name 2026-09-15 05:57:32 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
basepeak-ai/gptscript!11
No description provided.