@yagni-app/code-staging 1.0.5-staging.1240.1 → 1.0.5-staging.1241.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -91,6 +91,7 @@ export declare class YagniAuthProvider implements OAuthClientProvider {
91
91
  export declare function authenticate(serverName: string, config: McpHttpServerConfig, deps?: AuthDeps, opts?: {
92
92
  signal?: AbortSignal;
93
93
  timeoutMs?: number;
94
+ serverUrl?: string;
94
95
  }): Promise<OAuthResult>;
95
96
  /** Find a free loopback port (OS-assigned). */
96
97
  export declare function findFreePort(): Promise<number>;
@@ -21,6 +21,7 @@
21
21
  import { createServer } from "node:http";
22
22
  import { randomBytes } from "node:crypto";
23
23
  import { auth as sdkAuth, discoverOAuthServerInfo } from "@modelcontextprotocol/sdk/client/auth.js";
24
+ import { expandServerEnv } from "./config.js";
24
25
  import { runningUnderTest } from "../crashReport.js";
25
26
  import { getStoredOAuthEntry, updateStoredOAuthEntry } from "./authStore.js";
26
27
  import { deleteStoredOAuthEntry as clearStoredOAuthEntry } from "./authStore.js";
@@ -270,6 +271,10 @@ function traceOutcomeFromError(err) {
270
271
  export async function authenticate(serverName, config, deps = {}, opts = {}) {
271
272
  const openUrl = deps.openUrl ?? defaultOpenUrl;
272
273
  const timeoutMs = opts.timeoutMs ?? OAuthFlowTimeoutMs;
274
+ // Discovery and the token exchange go to the ${VAR}-expanded URL when the
275
+ // caller provides one; the provider below keeps the RAW config so the
276
+ // auth-store key (sha256 of type+url+headers) is stable across env changes.
277
+ const serverUrl = opts.serverUrl ?? config.url;
273
278
  // Bind the loopback port FIRST so redirect_uri and the listener agree.
274
279
  const fixedPort = config.oauth?.callbackPort;
275
280
  const port = fixedPort ?? (await findFreePort());
@@ -281,7 +286,7 @@ export async function authenticate(serverName, config, deps = {}, opts = {}) {
281
286
  // command handler hostage.
282
287
  let first;
283
288
  try {
284
- first = await sdkAuthPhase("discovery", provider, { serverUrl: config.url }, { signal: opts.signal, timeoutMs });
289
+ first = await sdkAuthPhase("discovery", provider, { serverUrl }, { signal: opts.signal, timeoutMs });
285
290
  }
286
291
  catch (err) {
287
292
  if (err instanceof AuthenticationCancelledError)
@@ -328,7 +333,7 @@ export async function authenticate(serverName, config, deps = {}, opts = {}) {
328
333
  // Bounded like discovery: a hung token endpoint must fail, not wedge.
329
334
  let result;
330
335
  try {
331
- result = await sdkAuthPhase("token exchange", provider, { serverUrl: config.url, authorizationCode: code }, { signal: opts.signal, timeoutMs });
336
+ result = await sdkAuthPhase("token exchange", provider, { serverUrl, authorizationCode: code }, { signal: opts.signal, timeoutMs });
332
337
  }
333
338
  catch (err) {
334
339
  if (err instanceof AuthenticationCancelledError)
@@ -532,7 +537,11 @@ async function defaultOpenUrl(url) {
532
537
  */
533
538
  export async function revokeTokensOnRemove(serverName, config, deps = {}) {
534
539
  try {
535
- const info = await discoverOAuthServerInfo(config.url, { fetchFn: deps.fetch });
540
+ // Discovery needs the resolvable (${VAR}-expanded) URL; store operations
541
+ // below key off the raw config. Best-effort: an unresolvable var leaves
542
+ // the raw URL, discovery fails, and the local entry is still cleared.
543
+ const discoveryUrl = expandServerEnv(config).config.url;
544
+ const info = await discoverOAuthServerInfo(discoveryUrl, { fetchFn: deps.fetch });
536
545
  const metadata = info.authorizationServerMetadata;
537
546
  const entry = getStoredOAuthEntry(serverName, config);
538
547
  if (metadata && entry?.clientId) {
@@ -58,7 +58,8 @@ export declare function expandEnvVarsInString(value: string, env?: NodeJS.Proces
58
58
  expanded: string;
59
59
  missingVars: string[];
60
60
  };
61
- /** Where env expansion applies within one server config (command, args, env values). */
61
+ /** Where env expansion applies within one server config (stdio: command,
62
+ * args, env values; http/sse: url and header values). */
62
63
  export declare function expandServerEnv(config: McpServerConfig, env?: NodeJS.ProcessEnv): {
63
64
  config: McpServerConfig;
64
65
  missingVars: string[];
@@ -38,7 +38,8 @@ export function expandEnvVarsInString(value, env = process.env) {
38
38
  });
39
39
  return { expanded, missingVars };
40
40
  }
41
- /** Where env expansion applies within one server config (command, args, env values). */
41
+ /** Where env expansion applies within one server config (stdio: command,
42
+ * args, env values; http/sse: url and header values). */
42
43
  export function expandServerEnv(config, env = process.env) {
43
44
  const missingVars = [];
44
45
  if (config.type === "stdio" || config.type === undefined) {
@@ -60,6 +61,28 @@ export function expandServerEnv(config, env = process.env) {
60
61
  missingVars: [...new Set(missingVars)],
61
62
  };
62
63
  }
64
+ // http/sse: url + header values. Used as a load-time gate (missing vars
65
+ // → server skipped with a clear error) and at connect time to build the
66
+ // actual transport. The stored config keeps the raw ${VAR} references —
67
+ // display and the OAuth auth-store key (sha256 of type+url+headers) must
68
+ // see the raw form, so env changes never orphan stored tokens.
69
+ if (config.type === "http" || config.type === "sse") {
70
+ const url = expandEnvVarsInString(config.url, env);
71
+ missingVars.push(...url.missingVars);
72
+ const headerEntries = Object.entries(config.headers ?? {}).map(([k, v]) => {
73
+ const r = expandEnvVarsInString(v, env);
74
+ missingVars.push(...r.missingVars);
75
+ return [k, r.expanded];
76
+ });
77
+ return {
78
+ config: {
79
+ ...config,
80
+ url: url.expanded,
81
+ headers: headerEntries.length > 0 ? Object.fromEntries(headerEntries) : config.headers,
82
+ },
83
+ missingVars: [...new Set(missingVars)],
84
+ };
85
+ }
63
86
  return { config, missingVars };
64
87
  }
65
88
  /** Test seam: point the state home at a tmpdir, mirroring errorSink's pattern. */
@@ -223,6 +246,18 @@ export function loadMcpServers(cwd, env = process.env) {
223
246
  for (const [name, value] of Object.entries(userServers)) {
224
247
  const validation = validateServerConfig(value);
225
248
  if (validation.ok) {
249
+ // Gate: a ${VAR} reference with no matching env var (and no default)
250
+ // skips the server with a clear error, rather than sending a literal
251
+ // "${VAR}" header on the wire. The pushed config stays raw.
252
+ const expanded = expandServerEnv(value, env);
253
+ if (expanded.missingVars.length > 0) {
254
+ errors.push({
255
+ sourcePath: mcpConfigPath(),
256
+ serverName: name,
257
+ message: `missing environment variable(s): ${expanded.missingVars.join(", ")} — server skipped`,
258
+ });
259
+ continue;
260
+ }
226
261
  byScope.user.push({ name, config: value, scope: "user", sourcePath: mcpConfigPath() });
227
262
  }
228
263
  else {
@@ -249,6 +284,15 @@ export function loadMcpServers(cwd, env = process.env) {
249
284
  for (const [name, value] of Object.entries(localServers)) {
250
285
  const validation = validateServerConfig(value);
251
286
  if (validation.ok) {
287
+ const expanded = expandServerEnv(value, env);
288
+ if (expanded.missingVars.length > 0) {
289
+ errors.push({
290
+ sourcePath: mcpConfigPath(),
291
+ serverName: name,
292
+ message: `missing environment variable(s): ${expanded.missingVars.join(", ")} — server skipped`,
293
+ });
294
+ continue;
295
+ }
252
296
  byScope.local.push({ name, config: value, scope: "local", sourcePath: mcpConfigPath() });
253
297
  }
254
298
  else {
@@ -36,6 +36,8 @@ export interface ManagerEvents {
36
36
  export interface ManagerOpts {
37
37
  connectTimeoutMs?: number;
38
38
  events?: ManagerEvents;
39
+ /** Environment for ${VAR} expansion at connect time (default: process.env). */
40
+ env?: NodeJS.ProcessEnv;
39
41
  }
40
42
  export declare function connectTimeoutFromEnv(env: NodeJS.ProcessEnv, fallback?: number): number;
41
43
  export declare function toolTimeoutFromEnv(env: NodeJS.ProcessEnv): number | undefined;
@@ -43,6 +45,7 @@ export declare class McpManager {
43
45
  private servers;
44
46
  private events;
45
47
  private connectTimeoutMs;
48
+ private env;
46
49
  private closed;
47
50
  constructor(opts?: ManagerOpts);
48
51
  list(): ManagedServer[];
@@ -87,6 +90,7 @@ export interface McpHealthResult {
87
90
  */
88
91
  export declare function probeServer(name: string, config: McpStdioServerConfig | McpHttpServerConfig, opts?: {
89
92
  connectTimeoutMs?: number;
93
+ env?: NodeJS.ProcessEnv;
90
94
  }): Promise<McpHealthResult>;
91
95
  /** 401/403 → needs_auth; everything else → failed. */
92
96
  export declare function classifyFailure(message: string): "needs_auth" | "failed";
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
14
14
  import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
15
+ import { expandServerEnv } from "./config.js";
15
16
  import { DEFAULT_CONNECT_TIMEOUT_MS, transportFor } from "./transports.js";
16
17
  import { authenticate, authProviderForServer, instrumentOAuthFetch, AuthenticationCancelledError } from "./auth.js";
17
18
  export function connectTimeoutFromEnv(env, fallback = DEFAULT_CONNECT_TIMEOUT_MS) {
@@ -32,10 +33,12 @@ export class McpManager {
32
33
  servers = new Map();
33
34
  events;
34
35
  connectTimeoutMs;
36
+ env;
35
37
  closed = false;
36
38
  constructor(opts = {}) {
37
39
  this.events = opts.events ?? {};
38
40
  this.connectTimeoutMs = opts.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
41
+ this.env = opts.env ?? process.env;
39
42
  }
40
43
  list() {
41
44
  return [...this.servers.values()];
@@ -61,18 +64,33 @@ export class McpManager {
61
64
  if (server.status === "connected")
62
65
  return server;
63
66
  this.setStatus(server, "connecting");
67
+ // Expand ${VAR} at connect time only: the stored config (panel display,
68
+ // OAuth auth-store keys) stays raw; the wire gets the resolved values.
69
+ // A missing var fails fast with a clear error instead of sending a
70
+ // literal "${VAR}" header (which the SDK retries until timeout).
71
+ const expanded = expandServerEnv(server.config, this.env);
72
+ if (expanded.missingVars.length > 0) {
73
+ server.error = `missing environment variable(s): ${expanded.missingVars.join(", ")} — set ${expanded.missingVars.join(", ")} and reconnect via /mcp`;
74
+ this.setStatus(server, "failed");
75
+ return server;
76
+ }
64
77
  const oauth = isOAuthServer(server.config);
65
78
  const authProvider = oauth ? authProviderForServer(name, server.config) : undefined;
66
79
  const fetchImpl = oauth ? instrumentOAuthFetch(name, fetch) : undefined;
67
- const transport = transportFor(server.config, authProvider, fetchImpl);
68
80
  const client = new Client({ name: "yagni-code", version: "1.0" });
81
+ let transport;
69
82
  try {
83
+ // Inside the try: an expanded URL that is not a valid URL throws from
84
+ // `new URL()` in transportFor and must land in `failed`, not escape the
85
+ // manager. Errors are sanitized before display — an expanded value that
86
+ // an SDK error echoes back must never reach the panel verbatim.
87
+ transport = transportFor(expanded.config, authProvider, fetchImpl);
70
88
  await withTimeout(client.connect(transport), this.connectTimeoutMs, `connect timed out after ${this.connectTimeoutMs}ms`);
71
89
  }
72
90
  catch (err) {
73
91
  await safeClose(client);
74
92
  const message = err instanceof Error ? err.message : String(err);
75
- server.error = message;
93
+ server.error = sanitizeError(message);
76
94
  this.setStatus(server, err instanceof UnauthorizedError ? "needs_auth" : classifyFailure(message));
77
95
  return server;
78
96
  }
@@ -130,8 +148,21 @@ export class McpManager {
130
148
  this.setStatus(server, "failed");
131
149
  return server;
132
150
  }
151
+ // OAuth discovery + token exchange must hit the ${VAR}-expanded URL (the
152
+ // literal reference is not a resolvable endpoint); the raw config is still
153
+ // what authenticate() keys the auth store with. Missing vars fail here the
154
+ // same way connect() does — names only.
155
+ const expanded = expandServerEnv(server.config, this.env);
156
+ if (expanded.missingVars.length > 0) {
157
+ server.error = `missing environment variable(s): ${expanded.missingVars.join(", ")} — set ${expanded.missingVars.join(", ")} and reconnect via /mcp`;
158
+ this.setStatus(server, "failed");
159
+ return server;
160
+ }
133
161
  try {
134
- await authenticate(name, server.config, authDeps, { signal });
162
+ await authenticate(name, server.config, authDeps, {
163
+ signal,
164
+ serverUrl: expanded.config.url,
165
+ });
135
166
  }
136
167
  catch (err) {
137
168
  if (err instanceof AuthenticationCancelledError) {
@@ -176,12 +207,21 @@ export class McpManager {
176
207
  */
177
208
  export async function probeServer(name, config, opts = {}) {
178
209
  const timeoutMs = opts.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
210
+ const expanded = expandServerEnv(config, opts.env ?? process.env);
211
+ if (expanded.missingVars.length > 0) {
212
+ return {
213
+ status: "failed",
214
+ error: `missing environment variable(s): ${expanded.missingVars.join(", ")} — set ${expanded.missingVars.join(", ")} and try again`,
215
+ };
216
+ }
179
217
  const oauth = isOAuthServer(config);
180
218
  const authProvider = oauth ? authProviderForServer(name, config) : undefined;
181
219
  const fetchImpl = oauth ? instrumentOAuthFetch(name, fetch) : undefined;
182
- const transport = transportFor(config, authProvider, fetchImpl);
183
220
  const client = new Client({ name: "yagni-code", version: "1.0" });
184
221
  try {
222
+ // transportFor inside the try: `new URL()` on a bad expanded URL must
223
+ // report `failed`, not throw out of the probe.
224
+ const transport = transportFor(expanded.config, authProvider, fetchImpl);
185
225
  await withTimeout(client.connect(transport), timeoutMs, `connect timed out after ${timeoutMs}ms`);
186
226
  return { status: "connected" };
187
227
  }
@@ -34,6 +34,7 @@ export async function startMcp(pi, opts) {
34
34
  const outcome = {
35
35
  manager: new McpManager({
36
36
  events: { onLog: appendMcpLogLine },
37
+ env,
37
38
  }),
38
39
  connectedServers: [],
39
40
  failedServers: [],
@@ -23,7 +23,9 @@ export interface McpDeps {
23
23
  /** Test seam: the extension module to import (defaults to the built copy). */
24
24
  loadMcpModule?: () => Promise<McpCliModule>;
25
25
  /** Test seam: health probe override (defaults to mod.probeServer). */
26
- probeServer?: (name: string, config: unknown) => Promise<{
26
+ probeServer?: (name: string, config: unknown, opts?: {
27
+ env?: NodeJS.ProcessEnv;
28
+ }) => Promise<{
27
29
  status: "connected" | "needs_auth" | "failed";
28
30
  error?: string;
29
31
  }>;
@@ -60,10 +62,16 @@ export interface McpCliModule {
60
62
  } | undefined;
61
63
  probeServer(name: string, config: unknown, opts?: {
62
64
  connectTimeoutMs?: number;
65
+ env?: NodeJS.ProcessEnv;
63
66
  }): Promise<{
64
67
  status: "connected" | "needs_auth" | "failed";
65
68
  error?: string;
66
69
  }>;
70
+ /** ${VAR} expansion over a full server config (url, headers, command, args, env values). */
71
+ expandServerEnv?(config: unknown, env?: NodeJS.ProcessEnv): {
72
+ config: unknown;
73
+ missingVars: string[];
74
+ };
67
75
  }
68
76
  export interface McpLoadResult {
69
77
  servers: {
@@ -598,7 +598,7 @@ async function healthLine(mod, server, io) {
598
598
  if (decision === "undecided")
599
599
  return ` Status: ! not yet approved — run /mcp to approve it\n`;
600
600
  }
601
- const result = await (io.probeServer ?? mod.probeServer)(server.name, server.config);
601
+ const result = await (io.probeServer ?? mod.probeServer)(server.name, server.config, { env: io.env });
602
602
  if (result.status === "connected")
603
603
  return ` Status: ✓ connected\n`;
604
604
  if (result.status === "needs_auth")
@@ -642,7 +642,7 @@ async function listHealthLine(mod, server, io) {
642
642
  if (decision === "undecided")
643
643
  return "! not approved";
644
644
  }
645
- const result = await (io.probeServer ?? mod.probeServer)(server.name, server.config);
645
+ const result = await (io.probeServer ?? mod.probeServer)(server.name, server.config, { env: io.env });
646
646
  if (result.status === "connected")
647
647
  return "✓ connected";
648
648
  if (result.status === "needs_auth")
@@ -685,6 +685,29 @@ function mcpAddFromClaude(mod, parsed, io) {
685
685
  }
686
686
  let imported = 0;
687
687
  const skipped = [];
688
+ const warned = [];
689
+ /**
690
+ * An imported config may reference ${VAR} env vars that Claude Code expanded
691
+ * in its own shell. We import verbatim (the raw reference is the correct
692
+ * stored form) but warn when a var is not set in THIS shell, with the export
693
+ * line to fix it — the var may legitimately be set only in the shell that
694
+ * runs sessions. The canonical expandServerEnv walks every expandable field
695
+ * (url, headers, stdio command/args/env), so the warning can never diverge
696
+ * from what the session will actually fail on.
697
+ */
698
+ const warnUnsetEnvVars = (name, config) => {
699
+ if (typeof config !== "object" || config === null)
700
+ return;
701
+ if (typeof mod.expandServerEnv !== "function")
702
+ return;
703
+ const { missingVars } = mod.expandServerEnv(config, process.env);
704
+ if (missingVars.length > 0) {
705
+ const names = missingVars.join(", ");
706
+ io.stderr(`Note: ${name} references ${names} — not currently set in this shell. ` +
707
+ `It will expand at connect time; export it (e.g. export ${missingVars[0]}=... in your shell profile) before your next session, or the server will report a missing-variable error.\n`);
708
+ warned.push(name);
709
+ }
710
+ };
688
711
  // Claude Code's user scope maps to yagni user scope (unless -s local is
689
712
  // explicit); its per-project entries map to yagni local scope for this cwd.
690
713
  const claudeUserTarget = parsed.scopeExplicit && parsed.scope === "local" ? "local" : "user";
@@ -695,6 +718,7 @@ function mcpAddFromClaude(mod, parsed, io) {
695
718
  }
696
719
  try {
697
720
  writeServerToScope(mod, name, config, claudeUserTarget, io.cwd);
721
+ warnUnsetEnvVars(name, config);
698
722
  io.stdout(`Imported ${name} (Claude Code user scope → ${claudeUserTarget})\n`);
699
723
  imported++;
700
724
  }
@@ -710,6 +734,7 @@ function mcpAddFromClaude(mod, parsed, io) {
710
734
  }
711
735
  try {
712
736
  writeServerToScope(mod, name, config, "local", io.cwd);
737
+ warnUnsetEnvVars(name, config);
713
738
  io.stdout(`Imported ${name} (this project, from Claude Code → local)\n`);
714
739
  imported++;
715
740
  }
@@ -720,7 +745,7 @@ function mcpAddFromClaude(mod, parsed, io) {
720
745
  }
721
746
  if (skipped.length > 0)
722
747
  io.stderr(`Skipped (unsupported config shape): ${skipped.join(", ")}\n`);
723
- io.stdout(`Imported ${imported} server(s) from Claude Code.\n`);
748
+ io.stdout(`Imported ${imported} server(s) from Claude Code${warned.length > 0 ? ` (${warned.length} with env-var notes)` : ""}.\n`);
724
749
  return imported > 0 ? 0 : 1;
725
750
  }
726
751
  function isImportable(config) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code-staging",
3
- "version": "1.0.5-staging.1240.1",
3
+ "version": "1.0.5-staging.1241.1",
4
4
  "description": "YAGNI Code: a terminal coding agent that already knows your company. One YAGNI login routes the model and grounds the agent in your team's context.",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "author": "YAGNI, Inc. <jack@yagni.app> (https://yagni.app)",
@@ -42,5 +42,5 @@
42
42
  "turndown": "^7.2.4",
43
43
  "typebox": "^1.3.15"
44
44
  },
45
- "yagniSourceSha": "24a0ebcde7f7df67fd0fcd230caa85c0c897742b"
45
+ "yagniSourceSha": "9328192141ab3d96cbeb324d748df1e571fe5eb2"
46
46
  }