@yagni-app/code-staging 1.0.5-staging.1239.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.
- package/dist/cli.js +5 -0
- package/dist/extension/mcp/auth.d.ts +1 -0
- package/dist/extension/mcp/auth.js +19 -6
- package/dist/extension/mcp/callbackPage.d.ts +31 -0
- package/dist/extension/mcp/callbackPage.js +222 -0
- package/dist/extension/mcp/config.d.ts +2 -1
- package/dist/extension/mcp/config.js +45 -1
- package/dist/extension/mcp/manager.d.ts +4 -0
- package/dist/extension/mcp/manager.js +44 -4
- package/dist/extension/mcp/startup.js +1 -0
- package/dist/mcpCommand.d.ts +9 -1
- package/dist/mcpCommand.js +28 -3
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -516,6 +516,10 @@ export const HELP_TEXT = [
|
|
|
516
516
|
" yagni connect claude-code Route Claude Code through the YAGNI model proxy",
|
|
517
517
|
" (--project scopes to this repo; --off disconnects).",
|
|
518
518
|
" yagni connect codex Route Codex CLI through the YAGNI model proxy.",
|
|
519
|
+
" yagni mcp <subcommand> Manage MCP servers: add, remove, list, get,",
|
|
520
|
+
" add-from-claude, and more — run `yagni mcp` for",
|
|
521
|
+
" details. OAuth servers authenticate via the",
|
|
522
|
+
" /mcp panel in a session.",
|
|
519
523
|
" yagni token Output the active environment's API token (for helpers).",
|
|
520
524
|
" yagni use <name> Switch the active environment (sticky).",
|
|
521
525
|
" Presets: prod, local. Others need --base-url <url>.",
|
|
@@ -537,6 +541,7 @@ export const HELP_TEXT = [
|
|
|
537
541
|
" /mode plan|review|auto Hold writes for planning, or confirm each change.",
|
|
538
542
|
" /todos Show the agent's live task list.",
|
|
539
543
|
" /cost Session usage and credit headroom.",
|
|
544
|
+
" /mcp Manage MCP servers; authenticate OAuth servers.",
|
|
540
545
|
"",
|
|
541
546
|
"The active environment is sticky; `use` switches it (prod is the default).",
|
|
542
547
|
"Set YAGNI_BASE_URL to override the base URL for a single run.",
|
|
@@ -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,10 +21,12 @@
|
|
|
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";
|
|
27
28
|
import { logMcpEvent, redactSensitiveUrlParams } from "./log.js";
|
|
29
|
+
import { renderAuthErrorPage, renderAuthStateMismatchPage, renderAuthSuccessPage, } from "./callbackPage.js";
|
|
28
30
|
/** Cancellation via an AbortSignal (Ctrl-C / Esc in the /mcp panel). */
|
|
29
31
|
export class AuthenticationCancelledError extends Error {
|
|
30
32
|
constructor(message = "OAuth cancelled") {
|
|
@@ -269,6 +271,10 @@ function traceOutcomeFromError(err) {
|
|
|
269
271
|
export async function authenticate(serverName, config, deps = {}, opts = {}) {
|
|
270
272
|
const openUrl = deps.openUrl ?? defaultOpenUrl;
|
|
271
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;
|
|
272
278
|
// Bind the loopback port FIRST so redirect_uri and the listener agree.
|
|
273
279
|
const fixedPort = config.oauth?.callbackPort;
|
|
274
280
|
const port = fixedPort ?? (await findFreePort());
|
|
@@ -280,7 +286,7 @@ export async function authenticate(serverName, config, deps = {}, opts = {}) {
|
|
|
280
286
|
// command handler hostage.
|
|
281
287
|
let first;
|
|
282
288
|
try {
|
|
283
|
-
first = await sdkAuthPhase("discovery", provider, { serverUrl
|
|
289
|
+
first = await sdkAuthPhase("discovery", provider, { serverUrl }, { signal: opts.signal, timeoutMs });
|
|
284
290
|
}
|
|
285
291
|
catch (err) {
|
|
286
292
|
if (err instanceof AuthenticationCancelledError)
|
|
@@ -327,7 +333,7 @@ export async function authenticate(serverName, config, deps = {}, opts = {}) {
|
|
|
327
333
|
// Bounded like discovery: a hung token endpoint must fail, not wedge.
|
|
328
334
|
let result;
|
|
329
335
|
try {
|
|
330
|
-
result = await sdkAuthPhase("token exchange", provider, { serverUrl
|
|
336
|
+
result = await sdkAuthPhase("token exchange", provider, { serverUrl, authorizationCode: code }, { signal: opts.signal, timeoutMs });
|
|
331
337
|
}
|
|
332
338
|
catch (err) {
|
|
333
339
|
if (err instanceof AuthenticationCancelledError)
|
|
@@ -424,20 +430,23 @@ function waitForCode(port, expectedState, opts = {}) {
|
|
|
424
430
|
// The provider-controlled values are HTML-escaped: any local process
|
|
425
431
|
// can hit the loopback with a crafted ?error=<script> payload, and the
|
|
426
432
|
// page must never reflect it (same spot Claude Code sanitizes).
|
|
427
|
-
res.end(
|
|
433
|
+
res.end(renderAuthErrorPage({
|
|
434
|
+
error: escapeHtml(error),
|
|
435
|
+
errorDescription: errorDescription ? escapeHtml(errorDescription) : undefined,
|
|
436
|
+
}));
|
|
428
437
|
finish(undefined);
|
|
429
438
|
return;
|
|
430
439
|
}
|
|
431
440
|
if (state !== expectedState) {
|
|
432
441
|
onEvent?.("oauth_callback_state_mismatch", {});
|
|
433
442
|
res.writeHead(400, { "Content-Type": "text/html" });
|
|
434
|
-
res.end(
|
|
443
|
+
res.end(renderAuthStateMismatchPage());
|
|
435
444
|
fail(new Error("OAuth state mismatch - possible CSRF attack"));
|
|
436
445
|
return;
|
|
437
446
|
}
|
|
438
447
|
onEvent?.("oauth_callback_code", {});
|
|
439
448
|
res.writeHead(200, { "Content-Type": "text/html" });
|
|
440
|
-
res.end(
|
|
449
|
+
res.end(renderAuthSuccessPage());
|
|
441
450
|
finish(code);
|
|
442
451
|
});
|
|
443
452
|
server.on("error", () => {
|
|
@@ -528,7 +537,11 @@ async function defaultOpenUrl(url) {
|
|
|
528
537
|
*/
|
|
529
538
|
export async function revokeTokensOnRemove(serverName, config, deps = {}) {
|
|
530
539
|
try {
|
|
531
|
-
|
|
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 });
|
|
532
545
|
const metadata = info.authorizationServerMetadata;
|
|
533
546
|
const entry = getStoredOAuthEntry(serverName, config);
|
|
534
547
|
if (metadata && entry?.clientId) {
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The loopback OAuth callback page — the only surface a user ever sees served
|
|
3
|
+
* from the local `127.0.0.1:<port>/callback` listener. Rendered with the same
|
|
4
|
+
* Glass & Print language as the app: tinted blue field, floating glass card,
|
|
5
|
+
* one blue accent, three type voices (Instrument Sans interface / Newsreader
|
|
6
|
+
* authored / IBM Plex Mono fact).
|
|
7
|
+
*
|
|
8
|
+
* Self-contained: no external assets, fonts, or network calls — the page must
|
|
9
|
+
* render offline and never phone home. Tokens are inlined from
|
|
10
|
+
* packages/frontend/src/styles/tokens.css so this module can't drift a runtime
|
|
11
|
+
* dependency on the frontend build; token names are kept in comments so a
|
|
12
|
+
* re-derivation stays mechanical.
|
|
13
|
+
*
|
|
14
|
+
* `escapeHtml` is applied by the caller to every provider-controlled value
|
|
15
|
+
* before interpolation (a local process can hit the loopback with a crafted
|
|
16
|
+
* ?error=<script> payload — the page never reflects raw input).
|
|
17
|
+
*/
|
|
18
|
+
/** The success page shown after the provider redirects back with a code. */
|
|
19
|
+
export declare function renderAuthSuccessPage(): string;
|
|
20
|
+
/**
|
|
21
|
+
* The error page shown when the provider bounces the flow. `error` and
|
|
22
|
+
* `errorDescription` are provider-controlled and already HTML-escaped by the
|
|
23
|
+
* caller.
|
|
24
|
+
*/
|
|
25
|
+
export declare function renderAuthErrorPage(opts: {
|
|
26
|
+
error?: string;
|
|
27
|
+
errorDescription?: string;
|
|
28
|
+
}): string;
|
|
29
|
+
/** The state-mismatch page — a hard failure, shown before the flow aborts. */
|
|
30
|
+
export declare function renderAuthStateMismatchPage(): string;
|
|
31
|
+
//# sourceMappingURL=callbackPage.d.ts.map
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The loopback OAuth callback page — the only surface a user ever sees served
|
|
3
|
+
* from the local `127.0.0.1:<port>/callback` listener. Rendered with the same
|
|
4
|
+
* Glass & Print language as the app: tinted blue field, floating glass card,
|
|
5
|
+
* one blue accent, three type voices (Instrument Sans interface / Newsreader
|
|
6
|
+
* authored / IBM Plex Mono fact).
|
|
7
|
+
*
|
|
8
|
+
* Self-contained: no external assets, fonts, or network calls — the page must
|
|
9
|
+
* render offline and never phone home. Tokens are inlined from
|
|
10
|
+
* packages/frontend/src/styles/tokens.css so this module can't drift a runtime
|
|
11
|
+
* dependency on the frontend build; token names are kept in comments so a
|
|
12
|
+
* re-derivation stays mechanical.
|
|
13
|
+
*
|
|
14
|
+
* `escapeHtml` is applied by the caller to every provider-controlled value
|
|
15
|
+
* before interpolation (a local process can hit the loopback with a crafted
|
|
16
|
+
* ?error=<script> payload — the page never reflects raw input).
|
|
17
|
+
*/
|
|
18
|
+
const CSS = `
|
|
19
|
+
:root {
|
|
20
|
+
/* tokens.css — field, ink, accent, materials */
|
|
21
|
+
--field-base: #f4f6fc;
|
|
22
|
+
--field-tint-blue: #dfe8ff;
|
|
23
|
+
--field-tint-violet: #e8e4ff;
|
|
24
|
+
--field-tint-cyan: #e3f0f6;
|
|
25
|
+
--ink: #23252e;
|
|
26
|
+
--ink-strong: #181a22;
|
|
27
|
+
--text-secondary: #565b72;
|
|
28
|
+
--text-quiet: #8b90a5;
|
|
29
|
+
--text-label: #7b8098;
|
|
30
|
+
--accent: #2f56d3;
|
|
31
|
+
--accent-muted: rgba(47, 86, 211, 0.12);
|
|
32
|
+
--accent-border: rgba(47, 86, 211, 0.3);
|
|
33
|
+
--error: #b4452f;
|
|
34
|
+
--error-muted: rgba(180, 69, 47, 0.07);
|
|
35
|
+
--glass-bg: rgba(255, 255, 255, 0.55);
|
|
36
|
+
--glass-border: rgba(255, 255, 255, 0.85);
|
|
37
|
+
--glass-blur: 18px;
|
|
38
|
+
--radius-pane: 16px;
|
|
39
|
+
--shadow-staged: 0 1px 2px rgba(24, 34, 64, 0.07), 0 18px 40px -30px rgba(38, 52, 110, 0.35);
|
|
40
|
+
--shadow-staged-high: 0 1px 2px rgba(24, 34, 64, 0.08), 0 28px 60px -28px rgba(38, 52, 110, 0.42);
|
|
41
|
+
--body: "Instrument Sans Variable", "Instrument Sans", system-ui, sans-serif;
|
|
42
|
+
--prose: "Newsreader", ui-serif, Georgia, serif;
|
|
43
|
+
--mono: "IBM Plex Mono", ui-monospace, monospace;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
* { box-sizing: border-box; }
|
|
47
|
+
|
|
48
|
+
html, body { height: 100%; }
|
|
49
|
+
|
|
50
|
+
body {
|
|
51
|
+
margin: 0;
|
|
52
|
+
min-height: 100%;
|
|
53
|
+
display: grid;
|
|
54
|
+
place-items: center;
|
|
55
|
+
padding: 24px;
|
|
56
|
+
font-family: var(--body);
|
|
57
|
+
color: var(--ink);
|
|
58
|
+
background:
|
|
59
|
+
radial-gradient(1200px 700px at 8% -5%, var(--field-tint-blue) 0, rgba(223, 232, 255, 0) 60%),
|
|
60
|
+
radial-gradient(900px 600px at 95% 0%, var(--field-tint-violet) 0, rgba(232, 228, 255, 0) 55%),
|
|
61
|
+
radial-gradient(900px 700px at 60% 100%, var(--field-tint-cyan) 0, rgba(227, 240, 246, 0) 60%),
|
|
62
|
+
var(--field-base);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.card {
|
|
66
|
+
width: 100%;
|
|
67
|
+
max-width: 400px;
|
|
68
|
+
text-align: center;
|
|
69
|
+
padding: 40px 32px 32px;
|
|
70
|
+
background: var(--glass-bg);
|
|
71
|
+
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(1.15);
|
|
72
|
+
backdrop-filter: blur(var(--glass-blur)) saturate(1.15);
|
|
73
|
+
border: 1px solid var(--glass-border);
|
|
74
|
+
border-radius: var(--radius-pane);
|
|
75
|
+
box-shadow: var(--shadow-staged-high);
|
|
76
|
+
animation: rise 420ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
@keyframes rise {
|
|
80
|
+
from { opacity: 0; transform: translateY(8px); }
|
|
81
|
+
to { opacity: 1; transform: none; }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.wordmark {
|
|
85
|
+
font-family: var(--mono);
|
|
86
|
+
font-size: 10px;
|
|
87
|
+
font-weight: 600;
|
|
88
|
+
letter-spacing: 0.22em;
|
|
89
|
+
text-transform: uppercase;
|
|
90
|
+
color: var(--text-label);
|
|
91
|
+
margin: 0 0 28px;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.badge {
|
|
95
|
+
width: 52px;
|
|
96
|
+
height: 52px;
|
|
97
|
+
margin: 0 auto 20px;
|
|
98
|
+
display: grid;
|
|
99
|
+
place-items: center;
|
|
100
|
+
border-radius: 14px;
|
|
101
|
+
background: var(--accent-muted);
|
|
102
|
+
border: 1px solid var(--accent-border);
|
|
103
|
+
color: var(--accent);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.card--error .badge {
|
|
107
|
+
background: var(--error-muted);
|
|
108
|
+
border-color: rgba(180, 69, 47, 0.28);
|
|
109
|
+
color: var(--error);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
h1 {
|
|
113
|
+
font-family: var(--prose);
|
|
114
|
+
font-size: 30px;
|
|
115
|
+
font-weight: 600;
|
|
116
|
+
line-height: 1.15;
|
|
117
|
+
letter-spacing: -0.01em;
|
|
118
|
+
color: var(--ink-strong);
|
|
119
|
+
margin: 0 0 10px;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.lede {
|
|
123
|
+
font-size: 14px;
|
|
124
|
+
line-height: 1.55;
|
|
125
|
+
color: var(--text-secondary);
|
|
126
|
+
margin: 0 0 24px;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.detail {
|
|
130
|
+
font-family: var(--mono);
|
|
131
|
+
font-size: 12px;
|
|
132
|
+
line-height: 1.5;
|
|
133
|
+
color: var(--error);
|
|
134
|
+
background: var(--error-muted);
|
|
135
|
+
border: 1px solid rgba(180, 69, 47, 0.2);
|
|
136
|
+
border-radius: 8px;
|
|
137
|
+
padding: 8px 10px;
|
|
138
|
+
margin: 0 0 24px;
|
|
139
|
+
word-break: break-word;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.divider {
|
|
143
|
+
height: 1px;
|
|
144
|
+
margin: 0 0 20px;
|
|
145
|
+
background: linear-gradient(90deg, transparent, var(--accent-border), transparent);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.hint {
|
|
149
|
+
font-size: 12.5px;
|
|
150
|
+
color: var(--text-quiet);
|
|
151
|
+
margin: 0;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
@media (prefers-reduced-motion: reduce) {
|
|
155
|
+
.card { animation: none; }
|
|
156
|
+
}
|
|
157
|
+
`;
|
|
158
|
+
const CHECK_ICON = `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg>`;
|
|
159
|
+
const ERROR_ICON = `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><line x1="12" y1="8" x2="12" y2="13"/><line x1="12" y1="16.5" x2="12.01" y2="16.5"/></svg>`;
|
|
160
|
+
function shell(opts) {
|
|
161
|
+
return `<!DOCTYPE html>
|
|
162
|
+
<html lang="en">
|
|
163
|
+
<head>
|
|
164
|
+
<meta charset="utf-8">
|
|
165
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
166
|
+
<meta name="color-scheme" content="light">
|
|
167
|
+
<title>${opts.title} — YAGNI Code</title>
|
|
168
|
+
<style>${CSS}</style>
|
|
169
|
+
</head>
|
|
170
|
+
<body>
|
|
171
|
+
<main class="card${opts.error ? " card--error" : ""}">
|
|
172
|
+
<p class="wordmark">YAGNI Code</p>
|
|
173
|
+
<div class="badge">${opts.error ? ERROR_ICON : CHECK_ICON}</div>
|
|
174
|
+
<h1>${opts.heading}</h1>
|
|
175
|
+
<p class="lede">${opts.lede}</p>
|
|
176
|
+
${opts.detail ? `<p class="detail">${opts.detail}</p>` : ""}
|
|
177
|
+
<div class="divider"></div>
|
|
178
|
+
<p class="hint">${opts.hint}</p>
|
|
179
|
+
</main>
|
|
180
|
+
</body>
|
|
181
|
+
</html>`;
|
|
182
|
+
}
|
|
183
|
+
/** The success page shown after the provider redirects back with a code. */
|
|
184
|
+
export function renderAuthSuccessPage() {
|
|
185
|
+
return shell({
|
|
186
|
+
title: "Connected",
|
|
187
|
+
heading: "Connected to YAGNI Code",
|
|
188
|
+
lede: "Your MCP server is authorized and ready to use.",
|
|
189
|
+
hint: "You can close this window and return to your session.",
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* The error page shown when the provider bounces the flow. `error` and
|
|
194
|
+
* `errorDescription` are provider-controlled and already HTML-escaped by the
|
|
195
|
+
* caller.
|
|
196
|
+
*/
|
|
197
|
+
export function renderAuthErrorPage(opts) {
|
|
198
|
+
const detail = opts.error
|
|
199
|
+
? opts.errorDescription
|
|
200
|
+
? `${opts.error}: ${opts.errorDescription}`
|
|
201
|
+
: opts.error
|
|
202
|
+
: undefined;
|
|
203
|
+
return shell({
|
|
204
|
+
title: "Connection failed",
|
|
205
|
+
error: true,
|
|
206
|
+
heading: "Authentication didn't complete",
|
|
207
|
+
lede: "The provider didn't finish the sign-in flow.",
|
|
208
|
+
detail,
|
|
209
|
+
hint: "You can close this window and try again from YAGNI Code.",
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
/** The state-mismatch page — a hard failure, shown before the flow aborts. */
|
|
213
|
+
export function renderAuthStateMismatchPage() {
|
|
214
|
+
return shell({
|
|
215
|
+
title: "Connection failed",
|
|
216
|
+
error: true,
|
|
217
|
+
heading: "Authentication didn't complete",
|
|
218
|
+
lede: "The sign-in response didn't match the request, so it was rejected.",
|
|
219
|
+
hint: "You can close this window and try again from YAGNI Code.",
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
//# sourceMappingURL=callbackPage.js.map
|
|
@@ -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,
|
|
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,
|
|
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, {
|
|
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
|
}
|
package/dist/mcpCommand.d.ts
CHANGED
|
@@ -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
|
|
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: {
|
package/dist/mcpCommand.js
CHANGED
|
@@ -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.
|
|
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": "
|
|
45
|
+
"yagniSourceSha": "9328192141ab3d96cbeb324d748df1e571fe5eb2"
|
|
46
46
|
}
|