Skip to content

Say why /research web search setup fails, and log the cause - #786

Merged
lavaman131 merged 2 commits into
mainfrom
fix-699-research-web-search-custom-app
Nov 5, 2026
Merged

lavaman131 merged 2 commits into
mainfrom
fix-699-research-web-search-custom-app

Conversation

@lavaman131

Copy link
Copy Markdown
Collaborator

Summary

/research public web search failed with an opaque web-search-unavailable whenever its connection to GitHub's Copilot MCP endpoint could not be set up, and the underlying error was never logged. This change classifies and logs each setup failure, gives the research card an accurate reason, and documents what an operator can check. It does not make web search work on a self-hosted instance whose GitHub App token the endpoint refuses. Whether it refuses such tokens could not be established, so this is Refs #699, not Fixes #699.

Root cause

What is proven, with evidence:

  • The cause was swallowed. In stage() (apps/server/src/jobs/research-workspace.ts) the !web.ok branch threw a fresh Error("MCP web_search is unavailable") and dropped web.error.cause. webSearchTool() (apps/server/src/harness/web-search.ts) returned { kind: "Unavailable", cause } from both catch blocks without logging it. Every failure collapsed into one reason, one card text and one log line.
  • One failure bypasses both catch blocks. When the endpoint accepts the connection but its tool list has no web_search, requireTools() returns MissingTool and webSearchTool() returns it directly. I ran a fake MCP server through the real @ai-sdk/mcp HTTP transport with console.error temporarily added to both catch blocks. HTTP 401, HTTP 403, HTTP 500 and a JSON-RPC error while listing tools each printed from a catch block. Only the missing-tool case printed nothing, while producing the same web-search-unavailable at connecting-web that the report shows. The report says neither of its own console.error calls printed, which a 401/403 on connect would have triggered. That is consistent with the tool list lacking web_search, but it does not prove it: the reporter's instrumentation may not have been loaded.
  • The failure looked instant because of the log format. The failure line carries no duration. The only elapsedMs in the logs is on the connecting-web progress snapshot, taken when that phase begins. With a fake endpoint that takes 400 ms per request, the attempt failed after 813 ms while the only elapsedMs logged was 0. The "1 to 12 ms" in the report matches that snapshot, not the time to failure. Against the live endpoint with a fake token, setup took about 650 ms before failing.
  • The log shape from the report reproduces against the live endpoint. A fake ghu_-shaped token sent to https://api.githubcopilot.com/mcp/ (nothing else was sent anywhere) produced HTTP 401 on connect and exactly the reported web-search-unavailable line with phase: connecting-web.

What is not proven:

  • How the endpoint treats a real GitHub App user access token. The live endpoint returns the same HTTP 401 (invalid_token, body unauthorized: token authentication failed) for fake ghu_, gho_, ghp_ and github_pat_ tokens, so these probes say nothing about real tokens of either type. No real App user token was held.
  • Which of "connect refused with 401/403" or "connected, web_search not offered" the reporter actually hit. The log output added here will name it on the next failure.
  • Whether the endpoint refuses App user access tokens for web_search. GitHub's Copilot SDK documentation lists ghu_ tokens as supported for Copilot requests. The web_search tool is not in GitHub's open-source MCP server (no occurrence in github/github-mcp-server at 99d6d65), and GitHub documents no token requirement for it. The reported contrast, a classic OAuth token accepted by the endpoint's initialize request while the App token's search fails, never tested tools/list or web_search with the OAuth token.

Known limit: a server that answers HTTP 401/403 with a JSON-RPC error body reaches the client as an RPC error without an HTTP status, because @ai-sdk/mcp delivers it through the JSON-RPC response path. It is classified web-search-unavailable with its rpc: code (reproduced with a fake server). The live endpoint's observed 401 bodies are plain text, so this does not affect them.

Changes

  • apps/server/src/harness/web-search.ts: webSearchTool() classifies each setup failure at the step where it happens (connect or tools) and returns it as error.failure with a reason, a token-safe classification (for example local:connect:mcp-client:http:401, local:tools:missing-tool) and the duration. It logs one line with the step, classification, duration, credential type by prefix (ghu_ or gho_, never the token) and the client's error name and message collapsed to one line, with token-shaped text, Bearer values and the exact credential masked and clipped to 160 characters. Setup has sent no query, so unlike a call failure it can carry that text. The existing per-call classification now shares the HTTP and RPC code helper.
  • apps/server/src/jobs/research-workspace.ts: the !web.ok branch records the classification and duration in the existing webFailure and webFailureDurationMs diagnostic fields and fails with the classified reason. The diagnostic stays within the 20-key, token-like string format that executionDiagnostic validates.
  • apps/server/src/research/request-view.ts: card text for the two new reasons.
    • web-search-credential-rejected (HTTP 401 or 403 while connecting or listing tools): "GitHub refused the credential from this sign-in for public web search. The server log has the details."
    • web-search-not-offered (connected, no web_search tool): "GitHub did not offer public web search to this sign-in. The server log has the details."
    • Every other setup failure stays web-search-unavailable with its existing text. Retry behavior is unchanged.

Tests

  • web-search.test.ts: a refused credential at connect (reason, classification, credential type, redacted log); a refusal while listing tools; a connection without web_search; other failures staying web-search-unavailable and being valid diagnostic values; masking of tokens, Bearer values, the exact credential, control characters and token-shaped error names, plus clipping.
  • research-workspace.test.ts: the evidence stage fails with each of the three reasons, records the classification and duration in a diagnostic that carries no upstream text, and never opens a worker session.
  • request-view.test.ts: card text for both new reasons, including through attempts-exhausted:.
  • The first credential-rejection test failed on the unfixed code for the intended reason. Mutations that dropped the 403 case, exact-token masking, token-shape masking, clipping or the not-offered reason each failed a test.

Docs

  • docs/self-hosting.md: a prerequisite, a smoke-test step, and a Troubleshooting entry covering the three reasons, how to read the log line (including that elapsedMs on the connecting-web line is when the phase began), what is and is not known about the endpoint, what to check, and WEB_RESEARCH=off as the fallback.
  • docs/background-jobs.md: setup classification, webFailureDurationMs, and what the setup log line may carry.
  • docs/authentication.md, docs/conversation-research.md, README.md: one short note each that the token is also sent to the Copilot MCP endpoint, that acceptance for web_search is unconfirmed, and where to look.

Verification

Assistant-model: Claude Sonnet 5.5
Assistant-workflow: goal (run ca8f48c1-dfc2-4573-a32d-5037607e08ed)
Assistant-verification: bun test apps/server/src/{harness,jobs,research} passed: 547 pass, 0 fail across 43 files, including web-search, research-workspace, request-view and runner tests
Assistant-verification: bun run types passed: every workspace and e2e type-check exited 0
Assistant-verification: bun run ci passed: dprint, oxlint (0 errors, 72 existing warnings), token, design contract, design record and design checks
Assistant-verification: red-green and mutation checks passed: the new credential-rejection test failed on the unfixed code, and removing the 403 case, exact-token masking, token-shape masking, clipping, or the not-offered reason failed a test each
Assistant-verification: local fake-MCP reproduction through the real @ai-sdk/mcp transport passed: before, every setup failure logged an identical web-search-unavailable line; after, 401 and 403 are credential-rejected, a missing tool is not-offered, and the others stay unavailable with a logged duration
Assistant-verification: live endpoint probe with fake tokens passed: HTTP 401 for every token shape, so it cannot show how a real ghu_ token is treated
Assistant-verification: real ghu_ token check unavailable: no real GitHub App user access token was held, so the cause behind the report is unproven
Assistant-verification: relative link and anchor check of the changed docs passed: every link resolves
Assistant-duration: unmeasured

Not run: bun run e2e and the PostgreSQL contract (no storage, protocol or browser behavior changed), and a full-repo bun test (the three affected server directories passed).

Refs #699
Co-authored-by: Alex Lavaee lavaman131@github.com

A research request whose web search could not be set up failed with the
same opaque `web-search-unavailable` whatever the cause. In stage(), the
`!web.ok` branch threw a fresh generic error and dropped the underlying
cause. webSearchTool() never logged it. A missing `web_search` tool is
returned from a path outside both catch blocks, so a console.error added to
those blocks, as the reporter did, cannot show it. The failure line also
carries no failure time. The only `elapsedMs` in the logs belongs to the
`connecting-web` progress snapshot, which is when that phase began, so the
failure read as instant.

A local fake MCP server run through the real @ai-sdk/mcp HTTP transport
shows this. HTTP 401, HTTP 403, HTTP 500, a JSON-RPC error while listing
tools, and a tool list without `web_search` all produced the same
`web-search-unavailable` line and card. Only the missing-tool case bypasses
both catch blocks. With a 400 ms endpoint the attempt failed after 813 ms
while the only `elapsedMs` logged was 0. Against the live endpoint, a fake
`ghu_`-shaped token produced the same line the reporter posted (HTTP 401 on
connect). The live endpoint answers HTTP 401 for fake `ghu_`, `gho_`, `ghp_`
and `github_pat_` tokens alike, so these probes cannot show how it treats a
real App user access token.

webSearchTool() now classifies a setup failure as it happens and logs it
once: the step (connect or tools), a token-safe classification with any
HTTP status and JSON-RPC code, how long setup ran, the credential type by
prefix (`ghu_` or `gho_`, never the token), and the client's error name and
message collapsed to one line, with token-shaped text, `Bearer` values and
the exact credential masked and clipped. The research stage records the
classification and duration in the job diagnostic. An HTTP 401 or 403 now
fails as `web-search-credential-rejected`, a connection without
`web_search` as `web-search-not-offered`, and every other failure stays
`web-search-unavailable`. The research card says which one happened and
points at the server log. Self-hosting, background-jobs, authentication,
conversation-research and the README now say what the endpoint is called
with, how to read the log line, what an operator can check, and that
whether it accepts a custom GitHub App's user access tokens for
`web_search` is unconfirmed.

This does not make web search work on such an instance. Whether GitHub's
Copilot MCP endpoint refuses or withholds `web_search` for GitHub App user
access tokens could not be established without one, so the cause behind the
report stays unproven.

Refs #699

Assistant-model: Claude Sonnet 5.5
Assistant-workflow: goal (run ca8f48c1-dfc2-4573-a32d-5037607e08ed)
Assistant-verification: bun test apps/server/src/{harness,jobs,research} passed: 547 pass, 0 fail across 43 files, including web-search, research-workspace, request-view and runner tests
Assistant-verification: bun run types passed: every workspace and e2e type-check exited 0
Assistant-verification: bun run ci passed: dprint, oxlint (0 errors, 72 existing warnings), token, design contract, design record and design checks
Assistant-verification: red-green and mutation checks passed: the new credential-rejection test failed on the unfixed code, and removing the 403 case, exact-token masking, token-shape masking, clipping, or the not-offered reason failed a test each
Assistant-verification: local fake-MCP reproduction through the real @ai-sdk/mcp transport passed: before, every setup failure logged an identical web-search-unavailable line; after, 401 and 403 are credential-rejected, a missing tool is not-offered, and the others stay unavailable with a logged duration
Assistant-verification: live endpoint probe with fake tokens passed: HTTP 401 for every token shape, so it cannot show how a real ghu_ token is treated
Assistant-verification: real ghu_ token check unavailable: no real GitHub App user access token was held, so the cause behind the report is unproven
Assistant-verification: relative link and anchor check of the changed docs passed: every link resolves
Assistant-duration: unmeasured
Co-authored-by: Alex Lavaee <lavaman131@github.com>
…every setup failure at it

A transport that rejected with a string, such as a TLS handshake failure,
logged only "a value that is not an Error was thrown", so the reason the
setup failed was lost. The string is now logged, masked and clipped like an
error's message. Any other non-Error value names its type.

Only the credential-rejected and not-offered cards said the server log has
the details. The unavailable card, which still covers an HTTP 500 or a 401 or
403 delivered as a JSON-RPC error, now says so too. The credential-rejected
card names the token Chopin signs in with, a GitHub App user access token.

Refs #699

Assistant-model: Claude Opus 5.5
Assistant-workflow: inline
Assistant-verification: bun test passed: apps/server src/harness src/jobs src/research, 547 pass, 0 fail
Assistant-verification: bun test failed without the logging change as intended: web-search.test.ts, the thrown-string test fails (21 pass, 1 fail)
Assistant-verification: bun run types and bun run ci passed
Co-authored-by: Alex Lavaee <lavaman131@github.com>
@lavaman131
lavaman131 force-pushed the fix-699-research-web-search-custom-app branch from 6270a2e to 188ee17 Compare November 5, 2026 00:49
@lavaman131
lavaman131 merged commit 552243c into main Nov 5, 2026
24 checks passed
@lavaman131
lavaman131 deleted the fix-699-research-web-search-custom-app branch November 5, 2026 00:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant