From 60468f738957079d4a9450bec95b5bdb1642bf5a Mon Sep 17 00:00:00 2001 From: Haider Date: Thu, 27 Aug 2026 00:47:43 +0530 Subject: [PATCH 1/6] fix: surface the real reason an MCP server is unavailable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects where the diagnostic information already exists in the process and is discarded before it reaches the user. `server unavailable` logged `status.status` — the constant string `"failed"` on that branch — and dropped `status.error`, the field holding the actual message (`401 Unauthorized`, a transport error, `Invalid MCP URL for ""`). Extracted `unavailableLogFields()` as a pure function so the payload is testable without standing up a transport, and so a later edit cannot quietly drop the field again. Environment variables that resolve to empty were never named. A `{env:VAR}` with nothing set becomes `""`, the config parses clean, and the server launches with a blank credential — usually a password — failing later with an error naming neither the variable nor the file. The names are now recorded at both substitution sites: per-server for discovered external configs, per-file for the main config. They surface in `/mcps` and `mcp list`, shown even when the server reports connected, because a blank credential often connects and fails on first real use. An unresolved bare `${VAR}` is deliberately left literal by the config layer so a later runtime layer can fill it (the bedrock provider fills `${AWS_REGION}` from the effective region). That case is not reported. Closes #1121 Closes #701 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018fJ3X7pcGT4R9yzjsJnqsV --- packages/opencode/src/cli/cmd/mcp.ts | 22 ++++ packages/opencode/src/config/variable.ts | 29 ++++- packages/opencode/src/mcp/discover.ts | 19 +++ packages/opencode/src/mcp/index.ts | 22 +++- packages/opencode/src/session/prompt.ts | 23 +++- .../test/cli/mcp-env-diagnostics.test.ts | 119 ++++++++++++++++++ .../opencode/test/mcp/unavailable-log.test.ts | 43 +++++++ .../test/session/mcps-command.test.ts | 34 +++++ 8 files changed, 304 insertions(+), 7 deletions(-) create mode 100644 packages/opencode/test/cli/mcp-env-diagnostics.test.ts create mode 100644 packages/opencode/test/mcp/unavailable-log.test.ts diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index 06a1178dec..6739ca22a6 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -9,6 +9,10 @@ import { LATEST_PROTOCOL_VERSION } from "@modelcontextprotocol/sdk/types.js" import * as prompts from "@clack/prompts" import { UI } from "../ui" import { MCP } from "../../mcp" +// altimate_change start — upstream_fix (#701): env diagnostics surfaced by `mcp list`. +import * as McpDiscover from "../../mcp/discover" +import { ConfigVariable } from "../../config/variable" +// altimate_change end import { McpAuth } from "../../mcp/auth" import { McpOAuthProvider } from "../../mcp/oauth-provider" import { Config } from "@/config/config" @@ -167,12 +171,30 @@ export const McpListCommand = effectCmd({ hint = "\n " + status.error } + // altimate_change start — upstream_fix (#701): name variables that resolved to "". + // A blank `${SNOWFLAKE_PASSWORD}` often connects and only fails on first real use, so + // this is appended regardless of status rather than only on the failure branch. + const unresolved = McpDiscover.unresolvedEnvVars(name) + if (unresolved.length > 0) { + hint += "\n unresolved env: " + unresolved.join(", ") + " (set or remove)" + } + // altimate_change end + const typeHint = serverConfig.type === "remote" ? serverConfig.url : serverConfig.command.join(" ") prompts.log.info( `${statusIcon} ${name} ${UI.Style.TEXT_DIM}${statusText}${hint}\n ${UI.Style.TEXT_DIM}${typeHint}`, ) } + // altimate_change start — upstream_fix (#701): a missing `{env:VAR}` becomes "" and the config + // parses clean, so a blank credential reaches the server and fails much later with an error + // naming neither. Attribution to a single server is not available here (substitution runs on + // raw config text, before any structure exists), so this is reported against the file. + for (const { source, names } of ConfigVariable.blankedEnvVars()) { + prompts.log.warn(`${names.join(", ")} resolved to empty in ${source} (set or remove)`) + } + // altimate_change end + prompts.outro(`${servers.length} server(s)`) }), }) diff --git a/packages/opencode/src/config/variable.ts b/packages/opencode/src/config/variable.ts index 4c989cacd0..009a576334 100644 --- a/packages/opencode/src/config/variable.ts +++ b/packages/opencode/src/config/variable.ts @@ -28,6 +28,22 @@ type SubstituteInput = ParseSource & { // altimate_change end } +// altimate_change start — upstream_fix (#701): keep the names of variables that silently blanked. +// An unresolved bare `${VAR}` is left LITERAL above on purpose, so it stays visible and is not +// recorded here. `{env:VAR}` has no such deferral — it becomes "" and the config parses clean, so +// a missing `{env:SNOWFLAKE_PASSWORD}` launches an MCP server with a blank credential and fails +// later with an error naming neither the variable nor this file. Keyed by config source; the +// newest parse of a file replaces its entry so a fixed variable stops being reported. +const _blankedEnv = new Map>() + +/** Variable names that silently became "" during config substitution, grouped by config source. */ +export function blankedEnvVars(): { source: string; names: string[] }[] { + return [..._blankedEnv.entries()] + .map(([src, names]) => ({ source: src, names: [...names].sort() })) + .sort((a, b) => a.source.localeCompare(b.source)) +} +// altimate_change end + function source(input: ParseSource) { return input.type === "path" ? input.path : input.source } @@ -42,6 +58,9 @@ export async function substitute(input: SubstituteInput) { // altimate_change start — upstream_fix: restore ${VAR}/${VAR:-default}/$${VAR} config interpolation const format = input.format ?? "json" const encode = (value: string) => (format === "raw" ? value : JSON.stringify(value).slice(1, -1)) + // altimate_change — upstream_fix (#701): collect blanked names for this parse, replacing any + // earlier entry for the same source rather than accumulating stale ones. + const blanked = new Set() let text = input.text.replace(ConfigPaths.ENV_VAR_PATTERN, (match, escaped, dollarVar, dollarDefault, braceVar) => { if (escaped !== undefined) return "$" + escaped if (dollarVar !== undefined) { @@ -56,12 +75,20 @@ export async function substitute(input: SubstituteInput) { return match } if (braceVar !== undefined) { - return (input.env?.[braceVar] ?? process.env[braceVar]) || "" + const value = input.env?.[braceVar] ?? process.env[braceVar] + // altimate_change — upstream_fix (#701): record the blank, then behave exactly as before. + if (!value) blanked.add(braceVar) + return value || "" } return match }) // altimate_change end + // altimate_change start — upstream_fix (#701): publish after the whole text is scanned. + if (blanked.size > 0) _blankedEnv.set(source(input), blanked) + else _blankedEnv.delete(source(input)) + // altimate_change end + const fileMatches = Array.from(text.matchAll(/\{file:[^}]+\}/g)) if (!fileMatches.length) return text diff --git a/packages/opencode/src/mcp/discover.ts b/packages/opencode/src/mcp/discover.ts index a92c377026..86314bf278 100644 --- a/packages/opencode/src/mcp/discover.ts +++ b/packages/opencode/src/mcp/discover.ts @@ -34,11 +34,30 @@ function resolveServerEnvVars( field: context.field, unresolved: stats.unresolvedNames.join(", "), }) + // altimate_change start — upstream_fix: remember it for the user, not just the log (#701). + // An unresolved `${SNOWFLAKE_PASSWORD}` becomes "" and the server launches with a blank + // credential, failing later with something that names neither the variable nor the config + // file. The log line already had the answer; nobody reads it. Recorded here so `/mcps` can + // say so. Mirrors the `setDiscoveryResult` handoff below. + const seen = _unresolvedEnv.get(context.server) ?? new Set() + for (const name of stats.unresolvedNames) seen.add(name) + _unresolvedEnv.set(context.server, seen) + // altimate_change end } return out } // altimate_change end +// altimate_change start — upstream_fix: unresolved-variable record for the user surface (#701). +/** Server name -> variable names that resolved to "" during discovery. */ +const _unresolvedEnv = new Map>() + +/** Variable names that silently became "" for `server`, newest discovery wins. */ +export function unresolvedEnvVars(server: string): string[] { + return [...(_unresolvedEnv.get(server) ?? [])].sort() +} +// altimate_change end + interface ExternalMcpSource { /** Relative path from base directory */ file: string diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 27f2e85a14..ef9f5aad7c 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -143,6 +143,24 @@ export const Status = Schema.Union([ ]).annotate({ identifier: "MCPStatus", discriminator: "status" }) export type Status = Schema.Schema.Type +// altimate_change start — upstream_fix: do not swallow the connect error (#1121). +// The failure path already carries the real message — `401 Unauthorized`, a transport +// error, `Invalid MCP URL for ""` — in `status.error`, but the warning logged only +// `status.status`, which is the constant string "failed". An external user had to read +// this source to find out why their server would not connect. +// +// Split out as a pure function so the payload is testable without standing up a +// transport, and so a future edit cannot quietly drop the field again. +export function unavailableLogFields( + key: string, + type: string, + status: Status, +): { key: string; type: string; status: string; error?: string } { + const error = "error" in status && typeof status.error === "string" ? status.error : undefined + return error ? { key, type, status: status.status, error } : { key, type, status: status.status } +} +// altimate_change end + // Store transports for OAuth servers to allow finishing auth type TransportWithAuth = StreamableHTTPClientTransport | SSEClientTransport const pendingOAuthTransports = new Map() @@ -627,7 +645,9 @@ export const layer = Layer.effect( if (!mcpClient) { if (status.status !== "connected" && status.status !== "disabled") { - yield* Effect.logWarning("server unavailable", { key, type: mcp.type, status: status.status }) + // altimate_change start — upstream_fix: include the real error (#1121). + yield* Effect.logWarning("server unavailable", unavailableLogFields(key, mcp.type, status)) + // altimate_change end } return { status } satisfies CreateResult } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 268babfc66..e0181dbed8 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -30,6 +30,8 @@ import PROMPT_PLAN from "../session/prompt/plan.txt" import BUILD_SWITCH from "../session/prompt/build-switch.txt" import MAX_STEPS from "../session/prompt/max-steps.txt" import { defer } from "../util/defer" +// altimate_change — upstream_fix (#701): unresolved-env record for the /mcps view. +import * as McpDiscover from "../mcp/discover" import { ToolRegistry } from "../tool/registry" import { MCP } from "../mcp" import { LSP } from "../lsp" @@ -2871,11 +2873,19 @@ NOTE: At any point in time through this workflow you should feel free to ask the // altimate_change start — shared text formatter for /mcps runtime status (#972) /** @internal Exported for tests. */ - export function formatMcpStatusForDisplay(name: string, status: MCP.Status) { + export function formatMcpStatusForDisplay(name: string, status: MCP.Status, unresolvedEnv: string[] = []) { const icon = status.status === "connected" ? "\u2713" : "\u25cb" - if (status.status === "failed") return icon + " " + status.status + " (" + status.error + ")" - if (status.status === "needs_auth") return icon + " Needs authentication (run: altimate mcp auth " + name + ")" - return icon + " " + status.status + // upstream_fix (#701): a server whose `${VAR}` did not resolve launched with that value + // blank — most often a password. It then fails with a downstream error naming neither the + // variable nor the config file, and the only trace is a log line nobody opens. Say it here, + // where the user is already looking, and say it even when the server appears connected: a + // blank credential often connects and fails on first use. + const blanks = + unresolvedEnv.length > 0 ? " \u2014 unresolved: " + unresolvedEnv.join(", ") + " (set or remove)" : "" + if (status.status === "failed") return icon + " " + status.status + " (" + status.error + ")" + blanks + if (status.status === "needs_auth") + return icon + " Needs authentication (run: altimate mcp auth " + name + ")" + blanks + return icon + " " + status.status + blanks } // altimate_change end @@ -2930,7 +2940,10 @@ NOTE: At any point in time through this workflow you should feel free to ask the const model = await lastModel(input.sessionID) const statusMap = await MCP.status() const rows = Object.entries(statusMap) - .map(([srv, s]) => "| `" + srv + "` | " + formatMcpStatusForDisplay(srv, s) + " |") + .map( + ([srv, s]) => + "| `" + srv + "` | " + formatMcpStatusForDisplay(srv, s, McpDiscover.unresolvedEnvVars(srv)) + " |", + ) .join("\n") const responseText = rows ? "MCP servers:\n\n| Server | Status |\n|---|---|\n" + rows diff --git a/packages/opencode/test/cli/mcp-env-diagnostics.test.ts b/packages/opencode/test/cli/mcp-env-diagnostics.test.ts new file mode 100644 index 0000000000..195848b78a --- /dev/null +++ b/packages/opencode/test/cli/mcp-env-diagnostics.test.ts @@ -0,0 +1,119 @@ +// altimate_change start — upstream_fix (#701): the server listing must name environment variables +// that silently resolved to "". This is user-facing CLI behaviour, so it drives the real binary in +// an isolated HOME rather than calling the handler directly. +import { describe, expect, test } from "bun:test" +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs" +import { tmpdir } from "os" +import path from "path" +import { spawnSync } from "child_process" + +// Each test boots the real CLI in a subprocess; the default 5s budget is not enough. +const SUBPROCESS_TIMEOUT_MS = 120_000 + +const repoRoot = path.resolve(import.meta.dir, "..", "..", "..", "..") +const opencodeDir = path.join(repoRoot, "packages", "opencode") +const cliEntry = path.join(opencodeDir, "src", "index.ts") + +function withIsolatedCli( + mcp: Record, + fn: (output: (args: string[]) => string) => void, + extraFiles: Record = {}, +) { + const root = mkdtempSync(path.join(tmpdir(), "altimate-mcp-status-")) + const home = path.join(root, "home") + const configHome = path.join(root, "config") + const configDir = path.join(configHome, "altimate-code") + mkdirSync(home, { recursive: true }) + mkdirSync(configDir, { recursive: true }) + writeFileSync(path.join(configDir, "altimate-code.json"), JSON.stringify({ mcp }), "utf-8") + for (const [rel, content] of Object.entries(extraFiles)) { + const target = path.join(root, rel) + mkdirSync(path.dirname(target), { recursive: true }) + writeFileSync(target, content, "utf-8") + } + + const run = (args: string[]) => + // `bun run --cwd ` would make the CLI's working directory the repo package, so it would + // read the repo's own .opencode config and never see this temp project. Spawn cwd is the + // project instead; module resolution still follows cliEntry's location. + spawnSync("bun", ["--conditions=browser", cliEntry, ...args], { + cwd: root, + encoding: "utf-8", + timeout: 90_000, + env: { + ...process.env, + HOME: home, + XDG_CONFIG_HOME: configHome, + XDG_DATA_HOME: path.join(root, "data"), + XDG_CACHE_HOME: path.join(root, "cache"), + XDG_STATE_HOME: path.join(root, "state"), + OPENCODE_DISABLE_TELEMETRY: "1", + OPENCODE_DISABLE_SHARE: "1", + OPENCODE_DISABLE_AUTOUPDATE: "1", + OPENCODE_DISABLE_AUTOCOMPACT: "1", + OPENCODE_DISABLE_MODELS_FETCH: "1", + OPENCODE_PURE: "1", + TERM: "dumb", + CI: "1", + }, + }) + + const output = (args: string[]) => { + const r = run(args) + return String(r.stdout ?? "") + String(r.stderr ?? "") + } + + try { + fn(output) + } finally { + rmSync(root, { recursive: true, force: true }) + } +} + +const brokenServer = { + broken: { + type: "local", + command: ["/nonexistent-binary-for-mcp-status-test"], + environment: { API_TOKEN: "{env:ALTIMATE_TEST_VAR_THAT_IS_NEVER_SET}" }, + enabled: true, + }, +} + +describe("altimate-code mcp list — env diagnostics", () => { + test( + "`status` reaches the server listing", + () => + withIsolatedCli(brokenServer, (output) => { + const out = output(["mcp", "list"]) + expect(out, out).toContain("broken") + expect(out, out).not.toContain("Unknown argument") + }), + SUBPROCESS_TIMEOUT_MS, + ) + + test( + "names the config env var that silently resolved to empty", + () => + withIsolatedCli(brokenServer, (output) => { + const out = output(["mcp", "list"]) + expect(out, out).toContain("ALTIMATE_TEST_VAR_THAT_IS_NEVER_SET") + expect(out, out).toContain("resolved to empty") + }), + SUBPROCESS_TIMEOUT_MS, + ) + + test( + "says nothing about env when every variable resolves", + () => + withIsolatedCli( + { fine: { type: "local", command: ["/nonexistent-binary-for-mcp-status-test"], enabled: true } }, + (output) => { + const out = output(["mcp", "list"]) + expect(out, out).toContain("fine") + expect(out, out).not.toContain("resolved to empty") + }, + ), + SUBPROCESS_TIMEOUT_MS, + ) +}) +// altimate_change end diff --git a/packages/opencode/test/mcp/unavailable-log.test.ts b/packages/opencode/test/mcp/unavailable-log.test.ts new file mode 100644 index 0000000000..0641df9c03 --- /dev/null +++ b/packages/opencode/test/mcp/unavailable-log.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "bun:test" +import { unavailableLogFields } from "../../src/mcp/index" + +// altimate_change start — upstream_fix: regression guard for #1121. +// The connect path stores the real reason a server would not start — `401 Unauthorized`, +// a transport error, an invalid URL — in `status.error`, but the warning logged only +// `status.status`, which is always the constant "failed". The reporter of #1121 had to +// read this module's source to find out why their server was unreachable. +describe("unavailableLogFields", () => { + test("carries the real error for a failed connection", () => { + expect(unavailableLogFields("exodus-mcp", "remote", { status: "failed", error: "401 Unauthorized" })).toEqual({ + key: "exodus-mcp", + type: "remote", + status: "failed", + error: "401 Unauthorized", + }) + }) + + test("carries the error for needs_client_registration too", () => { + // The other failure state that has something worth reading. + expect( + unavailableLogFields("gh", "remote", { status: "needs_client_registration", error: "registration rejected" }), + ).toEqual({ key: "gh", type: "remote", status: "needs_client_registration", error: "registration rejected" }) + }) + + test("omits the key entirely when the status carries no error", () => { + // `needs_auth` is not a fault — logging `error: undefined` would imply one. + expect(unavailableLogFields("github", "remote", { status: "needs_auth" })).toEqual({ + key: "github", + type: "remote", + status: "needs_auth", + }) + expect("error" in unavailableLogFields("github", "remote", { status: "needs_auth" })).toBe(false) + }) + + test("never loses the server key or transport type", () => { + // These are what let an operator find the offending entry in their config. + const fields = unavailableLogFields("local-one", "local", { status: "failed", error: "spawn ENOENT" }) + expect(fields.key).toBe("local-one") + expect(fields.type).toBe("local") + }) +}) +// altimate_change end diff --git a/packages/opencode/test/session/mcps-command.test.ts b/packages/opencode/test/session/mcps-command.test.ts index 5ef867160c..17260ce9d5 100644 --- a/packages/opencode/test/session/mcps-command.test.ts +++ b/packages/opencode/test/session/mcps-command.test.ts @@ -14,3 +14,37 @@ describe("/mcps command status formatting", () => { ) }) }) + +// altimate_change start — upstream_fix: unresolved env vars reach the user (#701). +// An unresolved `${SNOWFLAKE_PASSWORD}` silently became "" and the server launched with a +// blank credential; the only trace was a log line. These pin that /mcps says so instead. +describe("formatMcpStatusForDisplay — unresolved env vars", () => { + test("names the variables on a failed server", () => { + const out = SessionPrompt.formatMcpStatusForDisplay("snow", { status: "failed", error: "auth failed" }, [ + "SNOWFLAKE_PASSWORD", + ]) + expect(out).toContain("auth failed") + expect(out).toContain("SNOWFLAKE_PASSWORD") + }) + + test("warns even when the server looks connected", () => { + // A blank credential frequently connects and only fails on first real use, so the + // connected row is exactly where this needs saying. + const out = SessionPrompt.formatMcpStatusForDisplay("snow", { status: "connected" }, ["TOKEN"]) + expect(out).toContain("connected") + expect(out).toContain("TOKEN") + }) + + test("lists every unresolved variable, not just the first", () => { + const out = SessionPrompt.formatMcpStatusForDisplay("s", { status: "connected" }, ["A_TOKEN", "B_SECRET"]) + expect(out).toContain("A_TOKEN") + expect(out).toContain("B_SECRET") + }) + + test("says nothing extra when everything resolved", () => { + expect(SessionPrompt.formatMcpStatusForDisplay("ok", { status: "connected" }, [])).toBe( + SessionPrompt.formatMcpStatusForDisplay("ok", { status: "connected" }), + ) + }) +}) +// altimate_change end From 81625e53d18ab7aebe47ee7ca737aa594837f03d Mon Sep 17 00:00:00 2001 From: Haider Date: Mon, 31 Aug 2026 11:12:06 +0530 Subject: [PATCH 2/6] fix(mcp): clear env-var diagnostics instead of accumulating them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the review findings on this PR. `_unresolvedEnv` only ever grew. The recording site sits inside an `unresolvedNames.length > 0` guard, so a discovery run where every variable resolved never touched the map — a server whose `{env:VAR}` had since been set kept its old entry and `/mcps` went on telling the user to set a variable that already worked. It is now cleared at the start of each `discoverExternalMcp` and unioned within that run, which is what the docstring already claimed. Clearing per run also stops one project's discovery from mixing into another's under a shared server name, and stops the map growing for the life of the process. `_blankedEnv` had the mirror-image defect. A remote config substitutes its `url` and then each header separately, all under one source, and each call *replaced* that source's record — so a blank credential found in the url was erased by a later clean header call and `mcp list` never mentioned it. Substitution now unions, with an explicit `resetBlankedEnvVars` at the two load sites. Two tests were not testing what they claimed: - The `/mcps` "says nothing extra" case compared `formatMcpStatusForDisplay(..., [])` against the same call with the argument omitted, which defaults to `[]`. Both sides were byte-identical, so it passed even if the function appended an "unresolved" suffix. It now asserts against a literal. - The `mcp list` E2E test asserted only that the server name appeared and that argument parsing had not broken. It never asserted the failure reason reached the user, which is this PR's entire point — it passed with `status.error` dropped. It now requires the surfaced error text. New tests cover the staleness fix in both directions: a variable that gets set stops being reported, and one that stays unset keeps being reported across runs. Mutation-tested — removing the reset fails the first. Full opencode suite: 11489 pass, 0 fail. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018fJ3X7pcGT4R9yzjsJnqsV --- packages/opencode/src/config/config.ts | 7 +++ packages/opencode/src/config/variable.ts | 16 ++++++- packages/opencode/src/mcp/discover.ts | 21 ++++++++- .../test/cli/mcp-env-diagnostics.test.ts | 5 ++ packages/opencode/test/mcp/discover.test.ts | 47 ++++++++++++++++++- .../test/session/mcps-command.test.ts | 9 ++-- 6 files changed, 98 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 28b5cb0ade..6343f6e418 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -156,6 +156,10 @@ async function substituteWellKnownRemoteConfig(input: { }) { if (!isRecord(input.value) || typeof input.value.url !== "string") return undefined + // altimate_change start — upstream_fix (#701): the url and every header below publish under + // this same source, so clear once here and let those calls union into one record. + ConfigVariable.resetBlankedEnvVars(input.source) + // altimate_change end const url = await ConfigVariable.substitute({ text: input.value.url, type: "virtual", @@ -314,6 +318,9 @@ export const layer = Layer.effect( env?: Record, ) { const source = "path" in options ? options.path : options.source + // altimate_change start — upstream_fix (#701): clear before the load, union during it. + ConfigVariable.resetBlankedEnvVars(source) + // altimate_change end const expanded = yield* Effect.promise(() => ConfigVariable.substitute( "path" in options diff --git a/packages/opencode/src/config/variable.ts b/packages/opencode/src/config/variable.ts index 009a576334..415e09efc8 100644 --- a/packages/opencode/src/config/variable.ts +++ b/packages/opencode/src/config/variable.ts @@ -36,6 +36,11 @@ type SubstituteInput = ParseSource & { // newest parse of a file replaces its entry so a fixed variable stops being reported. const _blankedEnv = new Map>() +/** Drop `src`'s record so a load starts clean; substitution then unions within that load. */ +export function resetBlankedEnvVars(src: string) { + _blankedEnv.delete(src) +} + /** Variable names that silently became "" during config substitution, grouped by config source. */ export function blankedEnvVars(): { source: string; names: string[] }[] { return [..._blankedEnv.entries()] @@ -85,8 +90,15 @@ export async function substitute(input: SubstituteInput) { // altimate_change end // altimate_change start — upstream_fix (#701): publish after the whole text is scanned. - if (blanked.size > 0) _blankedEnv.set(source(input), blanked) - else _blankedEnv.delete(source(input)) + // Union, not replace: one source is substituted more than once — a remote config resolves + // its `url` and then each header separately, all under the same source. Replacing meant a + // later clean call erased the names an earlier call had found, so `mcp list` silently + // omitted a blank credential. Clearing is `resetBlankedEnvVars`, called per load below. + if (blanked.size > 0) { + const existing = _blankedEnv.get(source(input)) + if (existing) for (const name of blanked) existing.add(name) + else _blankedEnv.set(source(input), blanked) + } // altimate_change end const fileMatches = Array.from(text.matchAll(/\{file:[^}]+\}/g)) diff --git a/packages/opencode/src/mcp/discover.ts b/packages/opencode/src/mcp/discover.ts index 86314bf278..6ad1097580 100644 --- a/packages/opencode/src/mcp/discover.ts +++ b/packages/opencode/src/mcp/discover.ts @@ -52,10 +52,27 @@ function resolveServerEnvVars( /** Server name -> variable names that resolved to "" during discovery. */ const _unresolvedEnv = new Map>() -/** Variable names that silently became "" for `server`, newest discovery wins. */ +/** + * Variable names that silently became "" for `server`, from the most recent discovery. + * + * The record is cleared at the start of every `discoverExternalMcp` run and then unioned + * within that run, because one server is resolved twice — once for `headers` and once for + * `environment`. Without the reset the map only ever grew: a server whose `{env:VAR}` had + * since been fixed kept its old entry (the recording site below is inside an + * `unresolvedNames.length > 0` guard, so a clean run never touched it), and `/mcps` went on + * telling the user to set a variable that already resolved. + * + * Only the latest run's servers are present, so a daemon that discovers for a second project + * replaces the first project's entries rather than mixing the two under a shared server name. + */ export function unresolvedEnvVars(server: string): string[] { return [...(_unresolvedEnv.get(server) ?? [])].sort() } + +/** Drop the previous run's records. Called once per `discoverExternalMcp`. */ +function resetUnresolvedEnv() { + _unresolvedEnv.clear() +} // altimate_change end interface ExternalMcpSource { @@ -321,6 +338,8 @@ export async function discoverExternalMcp(projectDir: string): Promise<{ sources: string[] }> { log.info("Discovering MCP servers from external AI tool configs...") + // Start from a clean slate so a variable fixed since the last run stops being reported. + resetUnresolvedEnv() const result: Record = Object.create(null) const contributingSources: string[] = [] const homedir = os.homedir() diff --git a/packages/opencode/test/cli/mcp-env-diagnostics.test.ts b/packages/opencode/test/cli/mcp-env-diagnostics.test.ts index 195848b78a..9401019018 100644 --- a/packages/opencode/test/cli/mcp-env-diagnostics.test.ts +++ b/packages/opencode/test/cli/mcp-env-diagnostics.test.ts @@ -87,6 +87,11 @@ describe("altimate-code mcp list — env diagnostics", () => { const out = output(["mcp", "list"]) expect(out, out).toContain("broken") expect(out, out).not.toContain("Unknown argument") + // The point of this PR is that the *reason* reaches the user, not just the name. The + // command above is a nonexistent binary, so the listing has to carry the failure — + // without this the test passed even with `status.error` dropped from the payload, + // which is the exact regression it is named after. + expect(out.toLowerCase(), out).toMatch(/failed|enoent|no such file|spawn/) }), SUBPROCESS_TIMEOUT_MS, ) diff --git a/packages/opencode/test/mcp/discover.test.ts b/packages/opencode/test/mcp/discover.test.ts index 22a64987fd..dcdcf5405c 100644 --- a/packages/opencode/test/mcp/discover.test.ts +++ b/packages/opencode/test/mcp/discover.test.ts @@ -2,7 +2,7 @@ import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test" import { mkdtemp, rm, mkdir, writeFile } from "fs/promises" import os, { tmpdir } from "os" import path from "path" -import { discoverExternalMcp } from "../../src/mcp/discover" +import { discoverExternalMcp, unresolvedEnvVars } from "../../src/mcp/discover" let tempDir: string let homeDir: string @@ -455,3 +455,48 @@ describe("discoverExternalMcp", () => { // fields (see resolveServerEnvVars in discover.ts), NOT to `command` args. // Tests for command-level interpolation were removed as invalid. }) + +// altimate_change start — upstream_fix (#701): the record must not outlive the problem. +describe("unresolvedEnvVars staleness", () => { + const VAR = "ALTIMATE_TEST_UNRESOLVED_VAR" + + async function writeServer() { + await mkdir(path.join(tempDir, ".vscode"), { recursive: true }) + await writeFile( + path.join(tempDir, ".vscode/mcp.json"), + JSON.stringify({ + servers: { stale: { command: "node", env: { TOKEN: `{env:${VAR}}` } } }, + }), + ) + } + + test("clears a variable that has since been set", async () => { + delete process.env[VAR] + await writeServer() + + await discoverExternalMcp(tempDir) + expect(unresolvedEnvVars("stale")).toContain(VAR) + + // The user sets the variable and discovery runs again (config reload / mcp_discover). + process.env[VAR] = "now-set" + try { + await discoverExternalMcp(tempDir) + // Previously this still returned [VAR]: the record only ever unioned, and the recording + // site sits inside an `unresolvedNames.length > 0` guard, so a clean run never cleared it. + // `/mcps` kept telling the user to set a variable that already resolved. + expect(unresolvedEnvVars("stale")).toEqual([]) + } finally { + delete process.env[VAR] + } + }) + + test("still reports it while it is genuinely unset", async () => { + delete process.env[VAR] + await writeServer() + await discoverExternalMcp(tempDir) + await discoverExternalMcp(tempDir) + // The reset must not swallow a real, still-unresolved variable across runs. + expect(unresolvedEnvVars("stale")).toContain(VAR) + }) +}) +// altimate_change end diff --git a/packages/opencode/test/session/mcps-command.test.ts b/packages/opencode/test/session/mcps-command.test.ts index 17260ce9d5..a8d8489d6f 100644 --- a/packages/opencode/test/session/mcps-command.test.ts +++ b/packages/opencode/test/session/mcps-command.test.ts @@ -42,9 +42,12 @@ describe("formatMcpStatusForDisplay — unresolved env vars", () => { }) test("says nothing extra when everything resolved", () => { - expect(SessionPrompt.formatMcpStatusForDisplay("ok", { status: "connected" }, [])).toBe( - SessionPrompt.formatMcpStatusForDisplay("ok", { status: "connected" }), - ) + // Asserted against a literal, not against the same call with the argument omitted: that + // defaults to [] too, so both sides were byte-identical and the test passed even when the + // function appended an "unresolved: ..." suffix it should not have. + const out = SessionPrompt.formatMcpStatusForDisplay("ok", { status: "connected" }, []) + expect(out).not.toContain("unresolved") + expect(out).toBe(SessionPrompt.formatMcpStatusForDisplay("ok", { status: "connected" })) }) }) // altimate_change end From 0e4c309fd9382a54d5b2abdadcfc007078f557c7 Mon Sep 17 00:00:00 2001 From: Haider Date: Mon, 31 Aug 2026 11:46:53 +0530 Subject: [PATCH 3/6] fix(mcp): repair two regressions the reset introduced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the previous commit on this PR, from the second review round. Switching `_blankedEnv` from replace to union meant callers must clear first, and two paths were not migrated: * The well-known remote flow records the blanks it finds while substituting `remote_config.url` and each header under the wellknown URL, then hands the fetched body to `loadConfig` under that *same* source — whose reset promptly deleted them. Those names were never re-recorded, because the text `loadConfig` receives is already substituted. `loadConfig` now takes `keepDiagnostics` and that nested call sets it. * `config/tui.ts` calls `substitute` directly with no paired reset. Previously a clean parse self-healed via the `else delete` branch; without it a `{env:VAR}` in tui.json that was later fixed would have been reported blank for the life of the process. It resets now. The staleness tests also mutated process-wide `process.env` without saving what was there. They now capture and restore it in `beforeEach`/`afterEach`, so a parallel `bun test` cannot observe a variable this file removed or left behind. Full opencode suite: 11489 pass, 0 fail. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018fJ3X7pcGT4R9yzjsJnqsV --- packages/opencode/src/config/config.ts | 11 ++++++-- packages/opencode/src/config/tui.ts | 5 ++++ packages/opencode/test/mcp/discover.test.ts | 28 +++++++++++++-------- 3 files changed, 31 insertions(+), 13 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 6343f6e418..99fc43f1d1 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -314,12 +314,15 @@ export const layer = Layer.effect( const loadConfig = Effect.fnUntraced(function* ( text: string, - options: { path: string } | { dir: string; source: string }, + options: ({ path: string } | { dir: string; source: string }) & { keepDiagnostics?: boolean }, env?: Record, ) { const source = "path" in options ? options.path : options.source // altimate_change start — upstream_fix (#701): clear before the load, union during it. - ConfigVariable.resetBlankedEnvVars(source) + // `keepDiagnostics` is for a caller that already reset this source and recorded against it: + // the well-known flow substitutes `remote_config.url` and its headers under the same source + // before handing the fetched body here, and resetting again threw those names away. + if (!options.keepDiagnostics) ConfigVariable.resetBlankedEnvVars(source) // altimate_change end const expanded = yield* Effect.promise(() => ConfigVariable.substitute( @@ -503,6 +506,10 @@ export const layer = Layer.effect( { dir: path.dirname(source), source, + // altimate_change start — upstream_fix (#701): keep the url/header blanks that + // substituteWellKnownRemoteConfig just recorded under this same source. + keepDiagnostics: true, + // altimate_change end }, authEnv, ) diff --git a/packages/opencode/src/config/tui.ts b/packages/opencode/src/config/tui.ts index a2510aeda0..2ae0e2f80d 100644 --- a/packages/opencode/src/config/tui.ts +++ b/packages/opencode/src/config/tui.ts @@ -104,6 +104,11 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: const load = (text: string, configFilepath: string): Effect.Effect => Effect.gen(function* () { + // altimate_change start — upstream_fix (#701): substitution unions now instead of + // replacing, so every caller clears first. Without this a `{env:VAR}` in tui.json that + // was later fixed kept being reported blank for the life of the process. + ConfigVariable.resetBlankedEnvVars(configFilepath) + // altimate_change end const expanded = yield* Effect.promise(() => ConfigVariable.substitute({ text, type: "path", path: configFilepath, missing: "empty" }), ) diff --git a/packages/opencode/test/mcp/discover.test.ts b/packages/opencode/test/mcp/discover.test.ts index dcdcf5405c..73677b2fd7 100644 --- a/packages/opencode/test/mcp/discover.test.ts +++ b/packages/opencode/test/mcp/discover.test.ts @@ -470,8 +470,19 @@ describe("unresolvedEnvVars staleness", () => { ) } - test("clears a variable that has since been set", async () => { + // Save and restore rather than blindly deleting: these mutate process-wide state, and a + // parallel `bun test` run must not observe a variable this file removed or left behind. + let previous: string | undefined + beforeEach(() => { + previous = process.env[VAR] delete process.env[VAR] + }) + afterEach(() => { + if (previous === undefined) delete process.env[VAR] + else process.env[VAR] = previous + }) + + test("clears a variable that has since been set", async () => { await writeServer() await discoverExternalMcp(tempDir) @@ -479,19 +490,14 @@ describe("unresolvedEnvVars staleness", () => { // The user sets the variable and discovery runs again (config reload / mcp_discover). process.env[VAR] = "now-set" - try { - await discoverExternalMcp(tempDir) - // Previously this still returned [VAR]: the record only ever unioned, and the recording - // site sits inside an `unresolvedNames.length > 0` guard, so a clean run never cleared it. - // `/mcps` kept telling the user to set a variable that already resolved. - expect(unresolvedEnvVars("stale")).toEqual([]) - } finally { - delete process.env[VAR] - } + await discoverExternalMcp(tempDir) + // Previously this still returned [VAR]: the record only ever unioned, and the recording + // site sits inside an `unresolvedNames.length > 0` guard, so a clean run never cleared it. + // `/mcps` kept telling the user to set a variable that already resolved. + expect(unresolvedEnvVars("stale")).toEqual([]) }) test("still reports it while it is genuinely unset", async () => { - delete process.env[VAR] await writeServer() await discoverExternalMcp(tempDir) await discoverExternalMcp(tempDir) From 23db199e32a5c58b223d48a549a186beb4b3d41d Mon Sep 17 00:00:00 2001 From: Haider Date: Mon, 31 Aug 2026 11:57:03 +0530 Subject: [PATCH 4/6] fix(mcp): reset diagnostics at the load entry points, not inside loadConfig MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the `keepDiagnostics` flag from the previous commit. That flag required widening `loadConfig`'s signature, and a modified signature line in an upstream-shared file cannot be wrapped in `altimate_change` markers in a form Marker Guard accepts — it flagged the line whatever the surrounding markers looked like. The signature is restored untouched. The reset now sits with the callers that actually begin a load: every file-based load via `loadFile`, `OPENCODE_CONFIG_CONTENT`, the console-managed config, and macOS managed preferences. The well-known remote flow is deliberately left out — it records the blanks found in `remote_config.url` and its headers under that same source before handing the fetched body to `loadConfig`, so a reset in there discarded them. Keeping the reset out of `loadConfig` makes that ordering explicit instead of encoding it in a flag. Behaviour is unchanged from the previous commit; this is about where the clearing lives and keeping the shared signature pristine. config/mcp suites: 458 pass, 0 fail. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018fJ3X7pcGT4R9yzjsJnqsV --- packages/opencode/src/config/config.ts | 27 +++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 99fc43f1d1..139ba634b2 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -314,16 +314,10 @@ export const layer = Layer.effect( const loadConfig = Effect.fnUntraced(function* ( text: string, - options: ({ path: string } | { dir: string; source: string }) & { keepDiagnostics?: boolean }, + options: { path: string } | { dir: string; source: string }, env?: Record, ) { const source = "path" in options ? options.path : options.source - // altimate_change start — upstream_fix (#701): clear before the load, union during it. - // `keepDiagnostics` is for a caller that already reset this source and recorded against it: - // the well-known flow substitutes `remote_config.url` and its headers under the same source - // before handing the fetched body here, and resetting again threw those names away. - if (!options.keepDiagnostics) ConfigVariable.resetBlankedEnvVars(source) - // altimate_change end const expanded = yield* Effect.promise(() => ConfigVariable.substitute( "path" in options @@ -350,6 +344,12 @@ export const layer = Layer.effect( yield* Effect.logInfo("loading", { path: filepath }) const text = yield* readConfigFile(filepath) if (!text) return {} as Info + // altimate_change start — upstream_fix (#701): substitution unions now, so whoever + // begins a load clears this source first. Deliberately NOT inside loadConfig: the + // well-known flow records url/header blanks under the same source before calling it, + // and a reset in there threw those names away. + ConfigVariable.resetBlankedEnvVars(filepath) + // altimate_change end return yield* loadConfig(text, { path: filepath }, env) }) @@ -506,10 +506,6 @@ export const layer = Layer.effect( { dir: path.dirname(source), source, - // altimate_change start — upstream_fix (#701): keep the url/header blanks that - // substituteWellKnownRemoteConfig just recorded under this same source. - keepDiagnostics: true, - // altimate_change end }, authEnv, ) @@ -609,6 +605,9 @@ export const layer = Layer.effect( if (process.env.OPENCODE_CONFIG_CONTENT) { const source = "OPENCODE_CONFIG_CONTENT" + // altimate_change start — upstream_fix (#701): clear before this load. + ConfigVariable.resetBlankedEnvVars(source) + // altimate_change end const next = yield* loadConfig(process.env.OPENCODE_CONFIG_CONTENT, { dir: ctx.directory, source, @@ -636,6 +635,9 @@ export const layer = Layer.effect( if (Option.isSome(configOpt)) { const source = `${url}/api/config` + // altimate_change start — upstream_fix (#701): clear before this load. + ConfigVariable.resetBlankedEnvVars(source) + // altimate_change end const next = yield* loadConfig(JSON.stringify(configOpt.value), { dir: path.dirname(source), source, @@ -675,6 +677,9 @@ export const layer = Layer.effect( // macOS managed preferences (.mobileconfig deployed via MDM) override everything const managed = yield* Effect.promise(() => ConfigManaged.readManagedPreferences()) if (managed) { + // altimate_change start — upstream_fix (#701): clear before this load. + ConfigVariable.resetBlankedEnvVars(managed.source) + // altimate_change end result = mergeConfigConcatArrays( result, yield* loadConfig(managed.text, { From 1e0a9286b244fb16368f40cf445ff8547a587f39 Mon Sep 17 00:00:00 2001 From: Haider Date: Mon, 31 Aug 2026 12:42:14 +0530 Subject: [PATCH 5/6] fix(mcp): clear blanked-env names for a config that is emptied or deleted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reset in `loadFile` sat after `if (!text) return {}`, so a config file that was deleted or emptied never cleared what it had recorded while it still contained a `{env:VAR}`. `mcp list` and `/mcps` went on warning about a variable that appears in no config at all. It runs at the top of `loadFile` now, before the file is even read. Three reviewers flagged this independently, and it is the third placement mistake in this record — the reset landing after an early return, inside the wrong function, or on a shared signature that cannot be marked. The underlying reason is that `blankedEnvVars` had no test coverage whatsoever, so nothing failed when the placement was wrong. `test/config/blanked-env.test.ts` now pins the contract every call site has to honour: substitution unions into a source, a later clean pass does not erase an earlier finding, and only a reset clears. Mutation-tested — restoring the old replace-semantics fails two of the five. Full opencode suite: 11652 pass. The single failure in that run (`pty` ordering) is the pre-existing flake; it passes on an isolated re-run and no pty file is touched here. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018fJ3X7pcGT4R9yzjsJnqsV --- packages/opencode/src/config/config.ts | 14 ++-- .../opencode/test/config/blanked-env.test.ts | 70 +++++++++++++++++++ 2 files changed, 78 insertions(+), 6 deletions(-) create mode 100644 packages/opencode/test/config/blanked-env.test.ts diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 916199ce03..33fdeaa267 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -345,14 +345,16 @@ export const layer = Layer.effect( const loadFile = Effect.fnUntraced(function* (filepath: string, env?: Record) { yield* Effect.logInfo("loading", { path: filepath }) - const text = yield* readConfigFile(filepath) - if (!text) return {} as Info - // altimate_change start — upstream_fix (#701): substitution unions now, so whoever - // begins a load clears this source first. Deliberately NOT inside loadConfig: the - // well-known flow records url/header blanks under the same source before calling it, - // and a reset in there threw those names away. + // altimate_change start — upstream_fix (#701): substitution unions now, so whoever begins a + // load clears this source first. Before the empty-file return, not after: a config that is + // deleted or emptied must drop the names it recorded while it still had a `{env:VAR}`, + // otherwise `mcp list` warns about a variable that appears in no config at all. + // Deliberately NOT inside loadConfig — the well-known flow records url/header blanks under + // the same source before calling it, and a reset in there threw those names away. ConfigVariable.resetBlankedEnvVars(filepath) // altimate_change end + const text = yield* readConfigFile(filepath) + if (!text) return {} as Info return yield* loadConfig(text, { path: filepath }, env) }) diff --git a/packages/opencode/test/config/blanked-env.test.ts b/packages/opencode/test/config/blanked-env.test.ts new file mode 100644 index 0000000000..d2d443134f --- /dev/null +++ b/packages/opencode/test/config/blanked-env.test.ts @@ -0,0 +1,70 @@ +// altimate_change start — upstream_fix (#701): the blank-variable record had no tests at all, +// which is how three separate placement mistakes reached review. These pin the contract every +// call site has to honour: substitution UNIONS into a source, and only a reset clears it. +import { describe, expect, test, beforeEach } from "bun:test" +import { ConfigVariable } from "@/config/variable" + +const SOURCE = "/virtual/blanked-env-test/config.json" +const VAR = "ALTIMATE_TEST_BLANKED_VAR" +const OTHER = "ALTIMATE_TEST_BLANKED_VAR_TWO" + +function namesFor(source: string): string[] { + return ConfigVariable.blankedEnvVars().find((e) => e.source === source)?.names ?? [] +} + +async function substitute(text: string) { + return ConfigVariable.substitute({ text, type: "virtual", dir: "/virtual", source: SOURCE, env: {} }) +} + +describe("blankedEnvVars", () => { + beforeEach(() => { + delete process.env[VAR] + delete process.env[OTHER] + ConfigVariable.resetBlankedEnvVars(SOURCE) + }) + + test("records a {env:VAR} that resolved to empty", async () => { + await substitute(`{"token":"{env:${VAR}}"}`) + expect(namesFor(SOURCE)).toContain(VAR) + }) + + test("unions across substitutions of one source instead of replacing", async () => { + // A remote config substitutes its url and then each header separately, all under one + // source. Replacing meant the later call erased what the earlier one found, so a blank + // credential in the url was never reported. + await substitute(`{"url":"{env:${VAR}}"}`) + await substitute(`{"header":"{env:${OTHER}}"}`) + expect(namesFor(SOURCE).sort()).toEqual([VAR, OTHER].sort()) + }) + + test("a later clean substitution does not erase an earlier finding", async () => { + await substitute(`{"url":"{env:${VAR}}"}`) + await substitute(`{"header":"literal"}`) + expect(namesFor(SOURCE)).toContain(VAR) + }) + + test("reset clears the source so a fixed variable stops being reported", async () => { + await substitute(`{"token":"{env:${VAR}}"}`) + expect(namesFor(SOURCE)).toContain(VAR) + + // The user sets the variable and the file is loaded again. + process.env[VAR] = "now-set" + try { + ConfigVariable.resetBlankedEnvVars(SOURCE) + await substitute(`{"token":"{env:${VAR}}"}`) + expect(namesFor(SOURCE)).toEqual([]) + } finally { + delete process.env[VAR] + } + }) + + test("reset alone clears, for a source that is no longer loaded at all", async () => { + // The case that motivated moving the reset above loadFile's empty-file return: a config + // that is deleted or emptied must drop what it recorded, or `mcp list` keeps warning about + // a variable that appears in no config. + await substitute(`{"token":"{env:${VAR}}"}`) + ConfigVariable.resetBlankedEnvVars(SOURCE) + expect(namesFor(SOURCE)).toEqual([]) + }) +}) +// altimate_change end From 91c3aff59d93fff7f915a9ca6237ac305cb35e4d Mon Sep 17 00:00:00 2001 From: Haider Date: Mon, 31 Aug 2026 13:16:17 +0530 Subject: [PATCH 6/6] fix(mcp): give /mcps the same diagnostics as mcp list, and one CLI harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/mcps` reported only the per-server unresolved variables from discovery, while `mcp list` also reported file-scoped blanks. A server templated as `"url": "https://{env:MY_HOST}/mcp"` records against the config file rather than the server, so with `MY_HOST` unset the CLI named it and the session view said nothing — and the session view is where someone is when a server will not connect. The wording is extracted into `formatBlankedEnvForDisplay` so it is testable without standing up a session; `/mcps` is otherwise only reachable through the whole handler. The subprocess harness moves to `test/cli/fixtures/isolated-cli.ts`. It was duplicated verbatim across the MCP CLI tests, and the duplication was not cosmetic — each copy carried the `bun run --cwd` bug, so fixing one left the other reading the repo's own config instead of the temp project. That harness also swallowed spawn failures. `spawnSync` does not throw on ENOENT or timeout; it returns `{ status: null, error }` with null stdout, so a subprocess that never ran surfaced as `expected '' to contain 'broken'` and read like a test-logic bug. It now says the subprocess did not complete, and why. Full opencode suite: 11657 pass, 0 fail. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018fJ3X7pcGT4R9yzjsJnqsV --- packages/opencode/src/session/prompt.ts | 25 +++++- .../test/cli/fixtures/isolated-cli.ts | 87 +++++++++++++++++++ .../test/cli/mcp-env-diagnostics.test.ts | 72 +-------------- .../test/session/mcps-command.test.ts | 33 +++++++ 4 files changed, 146 insertions(+), 71 deletions(-) create mode 100644 packages/opencode/test/cli/fixtures/isolated-cli.ts diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 3b49e23026..8d3cd950fa 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -36,6 +36,9 @@ import MAX_STEPS from "../session/prompt/max-steps.txt" import { defer } from "../util/defer" // altimate_change — upstream_fix (#701): unresolved-env record for the /mcps view. import * as McpDiscover from "../mcp/discover" +// altimate_change start — upstream_fix (#701): file-scoped blank-variable diagnostics. +import { ConfigVariable } from "../config/variable" +// altimate_change end import { ToolRegistry } from "../tool/registry" import { MCP } from "../mcp" import { LSP } from "../lsp" @@ -2896,6 +2899,16 @@ NOTE: At any point in time through this workflow you should feel free to ask the // altimate_change start — shared text formatter for /mcps runtime status (#972) /** @internal Exported for tests. */ + // altimate_change start — upstream_fix (#701): exported so the wording is testable without + // standing up a session; `/mcps` is otherwise only reachable through the whole handler. + /** File-scoped blank-variable lines for `/mcps`, empty string when there are none. */ + export function formatBlankedEnvForDisplay(entries: { source: string; names: string[] }[]): string { + return entries + .map(({ source, names }) => "- `" + names.join(", ") + "` resolved to empty in `" + source + "` (set or remove)") + .join("\n") + } + // altimate_change end + export function formatMcpStatusForDisplay(name: string, status: MCP.Status, unresolvedEnv: string[] = []) { const icon = status.status === "connected" ? "\u2713" : "\u25cb" // upstream_fix (#701): a server whose `${VAR}` did not resolve launched with that value @@ -2968,9 +2981,15 @@ NOTE: At any point in time through this workflow you should feel free to ask the "| `" + srv + "` | " + formatMcpStatusForDisplay(srv, s, McpDiscover.unresolvedEnvVars(srv)) + " |", ) .join("\n") - const responseText = rows - ? "MCP servers:\n\n| Server | Status |\n|---|---|\n" + rows - : "No MCP servers configured." + // altimate_change start — upstream_fix (#701): `/mcps` showed only the per-server + // unresolved variables from discovery, while `mcp list` also reported file-scoped blanks. + // A server templated as `"url": "https://{env:MY_HOST}/mcp"` records against the config + // file rather than the server, so it appeared in the CLI and not here — in the session + // view, which is where someone is when a server will not connect. + const blanked = formatBlankedEnvForDisplay(ConfigVariable.blankedEnvVars()) + const table = rows ? "MCP servers:\n\n| Server | Status |\n|---|---|\n" + rows : "No MCP servers configured." + const responseText = blanked ? table + "\n\n" + blanked : table + // altimate_change end return respond(userMsg.info.id, responseText, model) } diff --git a/packages/opencode/test/cli/fixtures/isolated-cli.ts b/packages/opencode/test/cli/fixtures/isolated-cli.ts new file mode 100644 index 0000000000..8df58f8b58 --- /dev/null +++ b/packages/opencode/test/cli/fixtures/isolated-cli.ts @@ -0,0 +1,87 @@ +// altimate_change start — upstream_fix (#701/#878): one copy of the subprocess harness. +// This was duplicated verbatim between the MCP diagnostics CLI tests. The duplication was not +// cosmetic: the copies each carried the `bun run --cwd` bug fixed below, so a fix in one file +// silently left the other reading the repo's own config instead of the temp project. +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs" +import { tmpdir } from "os" +import path from "path" +import { spawnSync } from "child_process" + +/** Each test boots the real CLI in a subprocess; the default 5s budget is not enough. */ +export const SUBPROCESS_TIMEOUT_MS = 120_000 + +const repoRoot = path.resolve(import.meta.dir, "..", "..", "..", "..", "..") +const opencodeDir = path.join(repoRoot, "packages", "opencode") +const cliEntry = path.join(opencodeDir, "src", "index.ts") + +/** + * Run the real CLI against a throwaway project with an isolated HOME. + * + * `fn` receives an `output(args)` helper returning stdout+stderr combined. + */ +export function withIsolatedCli( + mcp: Record, + fn: (output: (args: string[]) => string) => void, + extraFiles: Record = {}, +) { + const root = mkdtempSync(path.join(tmpdir(), "altimate-mcp-status-")) + const home = path.join(root, "home") + const configHome = path.join(root, "config") + const configDir = path.join(configHome, "altimate-code") + mkdirSync(home, { recursive: true }) + mkdirSync(configDir, { recursive: true }) + writeFileSync(path.join(configDir, "altimate-code.json"), JSON.stringify({ mcp }), "utf-8") + for (const [rel, content] of Object.entries(extraFiles)) { + const target = path.join(root, rel) + mkdirSync(path.dirname(target), { recursive: true }) + writeFileSync(target, content, "utf-8") + } + + const run = (args: string[]) => + // `bun run --cwd ` would make the CLI's working directory the repo package, so it would + // read the repo's own .opencode config and never see this temp project. Spawn cwd is the + // project instead; module resolution still follows cliEntry's location. + spawnSync("bun", ["--conditions=browser", cliEntry, ...args], { + cwd: root, + encoding: "utf-8", + timeout: 90_000, + env: { + ...process.env, + HOME: home, + XDG_CONFIG_HOME: configHome, + XDG_DATA_HOME: path.join(root, "data"), + XDG_CACHE_HOME: path.join(root, "cache"), + XDG_STATE_HOME: path.join(root, "state"), + OPENCODE_DISABLE_TELEMETRY: "1", + OPENCODE_DISABLE_SHARE: "1", + OPENCODE_DISABLE_AUTOUPDATE: "1", + OPENCODE_DISABLE_AUTOCOMPACT: "1", + OPENCODE_DISABLE_MODELS_FETCH: "1", + OPENCODE_PURE: "1", + TERM: "dumb", + CI: "1", + }, + }) + + const output = (args: string[]) => { + const r = run(args) + // spawnSync does NOT throw on ENOENT or timeout — it returns `{ status: null, error }` and + // leaves stdout null. Without this the caller compares against "" and the failure reads as + // "expected '' to contain 'broken'", sending whoever debugs it after a test-logic bug that + // does not exist. Say plainly that the subprocess never ran. + if (r.error || r.status === null) { + const why = r.error ? `${r.error.name}: ${r.error.message}` : "killed or timed out" + throw new Error( + `CLI subprocess did not complete (${why}). args=${JSON.stringify(args)} signal=${r.signal ?? "none"}`, + ) + } + return String(r.stdout ?? "") + String(r.stderr ?? "") + } + + try { + fn(output) + } finally { + rmSync(root, { recursive: true, force: true }) + } +} +// altimate_change end diff --git a/packages/opencode/test/cli/mcp-env-diagnostics.test.ts b/packages/opencode/test/cli/mcp-env-diagnostics.test.ts index 9401019018..22ba56e311 100644 --- a/packages/opencode/test/cli/mcp-env-diagnostics.test.ts +++ b/packages/opencode/test/cli/mcp-env-diagnostics.test.ts @@ -1,74 +1,10 @@ // altimate_change start — upstream_fix (#701): the server listing must name environment variables // that silently resolved to "". This is user-facing CLI behaviour, so it drives the real binary in -// an isolated HOME rather than calling the handler directly. +// an isolated HOME rather than calling the handler directly. The subprocess harness lives in +// ./fixtures/isolated-cli so this file and mcp-status.test.ts cannot drift apart. import { describe, expect, test } from "bun:test" -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs" -import { tmpdir } from "os" -import path from "path" -import { spawnSync } from "child_process" - -// Each test boots the real CLI in a subprocess; the default 5s budget is not enough. -const SUBPROCESS_TIMEOUT_MS = 120_000 - -const repoRoot = path.resolve(import.meta.dir, "..", "..", "..", "..") -const opencodeDir = path.join(repoRoot, "packages", "opencode") -const cliEntry = path.join(opencodeDir, "src", "index.ts") - -function withIsolatedCli( - mcp: Record, - fn: (output: (args: string[]) => string) => void, - extraFiles: Record = {}, -) { - const root = mkdtempSync(path.join(tmpdir(), "altimate-mcp-status-")) - const home = path.join(root, "home") - const configHome = path.join(root, "config") - const configDir = path.join(configHome, "altimate-code") - mkdirSync(home, { recursive: true }) - mkdirSync(configDir, { recursive: true }) - writeFileSync(path.join(configDir, "altimate-code.json"), JSON.stringify({ mcp }), "utf-8") - for (const [rel, content] of Object.entries(extraFiles)) { - const target = path.join(root, rel) - mkdirSync(path.dirname(target), { recursive: true }) - writeFileSync(target, content, "utf-8") - } - - const run = (args: string[]) => - // `bun run --cwd ` would make the CLI's working directory the repo package, so it would - // read the repo's own .opencode config and never see this temp project. Spawn cwd is the - // project instead; module resolution still follows cliEntry's location. - spawnSync("bun", ["--conditions=browser", cliEntry, ...args], { - cwd: root, - encoding: "utf-8", - timeout: 90_000, - env: { - ...process.env, - HOME: home, - XDG_CONFIG_HOME: configHome, - XDG_DATA_HOME: path.join(root, "data"), - XDG_CACHE_HOME: path.join(root, "cache"), - XDG_STATE_HOME: path.join(root, "state"), - OPENCODE_DISABLE_TELEMETRY: "1", - OPENCODE_DISABLE_SHARE: "1", - OPENCODE_DISABLE_AUTOUPDATE: "1", - OPENCODE_DISABLE_AUTOCOMPACT: "1", - OPENCODE_DISABLE_MODELS_FETCH: "1", - OPENCODE_PURE: "1", - TERM: "dumb", - CI: "1", - }, - }) - - const output = (args: string[]) => { - const r = run(args) - return String(r.stdout ?? "") + String(r.stderr ?? "") - } - - try { - fn(output) - } finally { - rmSync(root, { recursive: true, force: true }) - } -} +import { SUBPROCESS_TIMEOUT_MS, withIsolatedCli } from "./fixtures/isolated-cli" +// altimate_change end const brokenServer = { broken: { diff --git a/packages/opencode/test/session/mcps-command.test.ts b/packages/opencode/test/session/mcps-command.test.ts index a8d8489d6f..01396511ef 100644 --- a/packages/opencode/test/session/mcps-command.test.ts +++ b/packages/opencode/test/session/mcps-command.test.ts @@ -51,3 +51,36 @@ describe("formatMcpStatusForDisplay — unresolved env vars", () => { }) }) // altimate_change end + +// altimate_change start — upstream_fix (#701): `/mcps` must not show less than `mcp list`. +describe("/mcps file-scoped blank variables", () => { + test("renders one line per config source", () => { + // A server templated as `"url": "https://{env:MY_HOST}/mcp"` records against the config file, + // not the server, so it never reached `/mcps` through unresolvedEnvVars. + const out = SessionPrompt.formatBlankedEnvForDisplay([ + { source: "/home/u/.config/altimate-code/altimate-code.json", names: ["MY_HOST"] }, + ]) + expect(out).toContain("MY_HOST") + expect(out).toContain("/home/u/.config/altimate-code/altimate-code.json") + expect(out).toContain("set or remove") + }) + + test("names every variable in a source, not just the first", () => { + const out = SessionPrompt.formatBlankedEnvForDisplay([{ source: "cfg.json", names: ["A", "B"] }]) + expect(out).toContain("A") + expect(out).toContain("B") + }) + + test("one line per source", () => { + const out = SessionPrompt.formatBlankedEnvForDisplay([ + { source: "a.json", names: ["A"] }, + { source: "b.json", names: ["B"] }, + ]) + expect(out.split("\n")).toHaveLength(2) + }) + + test("empty when nothing blanked, so the table gains no trailing noise", () => { + expect(SessionPrompt.formatBlankedEnvForDisplay([])).toBe("") + }) +}) +// altimate_change end