diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index 06a1178de..6739ca22a 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/config.ts b/packages/opencode/src/config/config.ts index 6e59e5590..33fdeaa26 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -159,6 +159,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", @@ -341,6 +345,14 @@ export const layer = Layer.effect( const loadFile = Effect.fnUntraced(function* (filepath: string, env?: Record) { yield* Effect.logInfo("loading", { path: filepath }) + // 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) @@ -598,6 +610,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, @@ -625,6 +640,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, @@ -671,6 +689,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 // altimate_change start — note a managed datamate key before merging const managedPrefs = yield* loadConfig(managed.text, { dir: path.dirname(managed.source), diff --git a/packages/opencode/src/config/tui.ts b/packages/opencode/src/config/tui.ts index a2510aeda..2ae0e2f80 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/src/config/variable.ts b/packages/opencode/src/config/variable.ts index 4c989cacd..415e09efc 100644 --- a/packages/opencode/src/config/variable.ts +++ b/packages/opencode/src/config/variable.ts @@ -28,6 +28,27 @@ 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>() + +/** 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()] + .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 +63,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 +80,27 @@ 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. + // 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)) if (!fileMatches.length) return text diff --git a/packages/opencode/src/mcp/discover.ts b/packages/opencode/src/mcp/discover.ts index 7d4090fc2..522c7f157 100644 --- a/packages/opencode/src/mcp/discover.ts +++ b/packages/opencode/src/mcp/discover.ts @@ -34,11 +34,47 @@ 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`, 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 { /** Relative path from base directory */ file: string @@ -302,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/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 27720e012..1d2dc2102 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 5f6d320cf..8d3cd950f 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -34,6 +34,11 @@ 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" +// 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" @@ -2894,11 +2899,29 @@ 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) { + // 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" - 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 @@ -2953,11 +2976,20 @@ 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 - : "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 000000000..8df58f8b5 --- /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 new file mode 100644 index 000000000..22ba56e31 --- /dev/null +++ b/packages/opencode/test/cli/mcp-env-diagnostics.test.ts @@ -0,0 +1,60 @@ +// 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. 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 { SUBPROCESS_TIMEOUT_MS, withIsolatedCli } from "./fixtures/isolated-cli" +// altimate_change end + +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") + // 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, + ) + + 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/config/blanked-env.test.ts b/packages/opencode/test/config/blanked-env.test.ts new file mode 100644 index 000000000..d2d443134 --- /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 diff --git a/packages/opencode/test/mcp/discover.test.ts b/packages/opencode/test/mcp/discover.test.ts index 2907b5d97..ad4e243db 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 @@ -482,3 +482,54 @@ describe("discoverExternalMcp", () => { }) // altimate_change end }) + +// 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}}` } } }, + }), + ) + } + + // 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) + expect(unresolvedEnvVars("stale")).toContain(VAR) + + // The user sets the variable and discovery runs again (config reload / mcp_discover). + process.env[VAR] = "now-set" + 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 () => { + 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/mcp/unavailable-log.test.ts b/packages/opencode/test/mcp/unavailable-log.test.ts new file mode 100644 index 000000000..0641df9c0 --- /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 5ef867160..01396511e 100644 --- a/packages/opencode/test/session/mcps-command.test.ts +++ b/packages/opencode/test/session/mcps-command.test.ts @@ -14,3 +14,73 @@ 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", () => { + // 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 + +// 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