@pharaoh-so/mcp 0.3.25 → 0.3.26

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/auth.d.ts CHANGED
@@ -85,6 +85,12 @@ export declare function pollForToken(baseUrl: string, deviceCode: string, interv
85
85
  * to pre-fill the activation URL in the user's browser. Silent-on-failure is
86
86
  * critical for headless environments: a missing `open`/`xdg-open` binary must
87
87
  * not crash the proxy.
88
+ *
89
+ * Test / CI escape hatch: set PHARAOH_MCP_DISABLE_BROWSER=1 when spawning the
90
+ * proxy as a child process during E2E testing. The openBrowser dependency-
91
+ * injection path in gateway.ts is sufficient for in-process tests, but child-
92
+ * process tests bypass module mocking — the env-var short-circuit catches
93
+ * every spawn path (gateway, printActivationPrompt, direct index.ts calls).
88
94
  */
89
95
  export declare function openBrowser(url: string): void;
90
96
  /**
package/dist/auth.js CHANGED
@@ -112,8 +112,16 @@ export async function pollForToken(baseUrl, deviceCode, interval) {
112
112
  * to pre-fill the activation URL in the user's browser. Silent-on-failure is
113
113
  * critical for headless environments: a missing `open`/`xdg-open` binary must
114
114
  * not crash the proxy.
115
+ *
116
+ * Test / CI escape hatch: set PHARAOH_MCP_DISABLE_BROWSER=1 when spawning the
117
+ * proxy as a child process during E2E testing. The openBrowser dependency-
118
+ * injection path in gateway.ts is sufficient for in-process tests, but child-
119
+ * process tests bypass module mocking — the env-var short-circuit catches
120
+ * every spawn path (gateway, printActivationPrompt, direct index.ts calls).
115
121
  */
116
122
  export function openBrowser(url) {
123
+ if (process.env.PHARAOH_MCP_DISABLE_BROWSER === "1")
124
+ return;
117
125
  if (process.platform === "darwin") {
118
126
  execFile("open", [url], () => { });
119
127
  }
package/dist/backend.d.ts CHANGED
@@ -26,6 +26,12 @@ export interface Backend {
26
26
  start?(): Promise<void>;
27
27
  /** Optional teardown (e.g. close SSE). Idempotent. */
28
28
  stop?(): Promise<void>;
29
+ /**
30
+ * Optional long-running promise that completes on clean exit or rejects with
31
+ * a typed error (TokenExpiredError, TenantSuspendedError) the main loop can
32
+ * act on. Backends without a supervisor loop (AuthRequiredBackend) omit this.
33
+ */
34
+ waitForExit?(): Promise<void>;
29
35
  }
30
36
  /**
31
37
  * Minimal pre-auth catalog. Single tool — matches Pharaoh's "call this first"
@@ -41,13 +47,16 @@ export declare const AUTH_REQUIRED_TOOLS: Tool[];
41
47
  /**
42
48
  * Pre-auth backend. Every tool call triggers a device-flow state step via the
43
49
  * gateway; returns a structured auth-required response. Once the gateway
44
- * reports the user has authorized (handleToolCall returns null), the caller
45
- * (ProxyServer) swaps to SseProxyBackend — this backend does NOT swap itself.
50
+ * reports the user has authorized (handleToolCall returns null), this backend
51
+ * invokes the onAuthorized callback so the caller (index.ts main loop) can
52
+ * swap the ProxyServer over to an SseProxyBackend. The backend itself does NOT
53
+ * swap — callbacks keep the ownership of the proxy reference in one place.
46
54
  */
47
55
  export declare class AuthRequiredBackend implements Backend {
48
56
  private readonly gateway;
57
+ private readonly onAuthorized?;
49
58
  readonly name: "auth-required";
50
- constructor(gateway: DeviceFlowGateway);
59
+ constructor(gateway: DeviceFlowGateway, onAuthorized?: (() => Promise<void>) | undefined);
51
60
  listTools(): Promise<Tool[]>;
52
61
  callTool(_name: string, _args: Record<string, unknown>): Promise<CallToolResult>;
53
62
  }
package/dist/backend.js CHANGED
@@ -48,14 +48,18 @@ export const AUTH_REQUIRED_TOOLS = [
48
48
  /**
49
49
  * Pre-auth backend. Every tool call triggers a device-flow state step via the
50
50
  * gateway; returns a structured auth-required response. Once the gateway
51
- * reports the user has authorized (handleToolCall returns null), the caller
52
- * (ProxyServer) swaps to SseProxyBackend — this backend does NOT swap itself.
51
+ * reports the user has authorized (handleToolCall returns null), this backend
52
+ * invokes the onAuthorized callback so the caller (index.ts main loop) can
53
+ * swap the ProxyServer over to an SseProxyBackend. The backend itself does NOT
54
+ * swap — callbacks keep the ownership of the proxy reference in one place.
53
55
  */
54
56
  export class AuthRequiredBackend {
55
57
  gateway;
58
+ onAuthorized;
56
59
  name = "auth-required";
57
- constructor(gateway) {
60
+ constructor(gateway, onAuthorized) {
58
61
  this.gateway = gateway;
62
+ this.onAuthorized = onAuthorized;
59
63
  }
60
64
  async listTools() {
61
65
  return AUTH_REQUIRED_TOOLS;
@@ -63,9 +67,41 @@ export class AuthRequiredBackend {
63
67
  async callTool(_name, _args) {
64
68
  const response = await this.gateway.handleToolCall();
65
69
  if (response === null) {
66
- // Credentials just became valid. Return a success result so the
67
- // agent's current tool call doesn't error out. ProxyServer checks
68
- // gateway.hasValidCredentials() after each call and swaps backends.
70
+ // Credentials just became valid. Fire the authorization callback
71
+ // BEFORE returning success so the caller's backend swap is in place
72
+ // by the time the next tool call arrives.
73
+ if (this.onAuthorized) {
74
+ try {
75
+ await this.onAuthorized();
76
+ }
77
+ catch (err) {
78
+ // TenantSuspendedError from the post-auth SSE handshake means
79
+ // the tenant was suspended between authorize and swap. Rethrow
80
+ // so the supervisor exits cleanly instead of looping in
81
+ // auth-required mode forever.
82
+ if (err instanceof TenantSuspendedError)
83
+ throw err;
84
+ // Any other swap failure (SSE start rejected, gateway returned
85
+ // no creds, network blip) must surface as an error to the
86
+ // agent. Returning success would leave the proxy in a zombie
87
+ // state — this backend still active, onAuthorized already
88
+ // fired, every subsequent tool call burning a fresh device
89
+ // flow cycle. isError:true lets the agent see the failure and
90
+ // retry the tool call, which will re-enter the gateway's post-
91
+ // auth branch and attempt the swap again.
92
+ const msg = err instanceof Error ? err.message : String(err);
93
+ process.stderr.write(`Pharaoh: onAuthorized callback failed — ${msg}\n`);
94
+ return {
95
+ content: [
96
+ {
97
+ type: "text",
98
+ text: `Pharaoh: authenticated but backend swap failed — ${msg}. Retry the tool call.`,
99
+ },
100
+ ],
101
+ isError: true,
102
+ };
103
+ }
104
+ }
69
105
  return {
70
106
  content: [
71
107
  {
@@ -207,6 +243,16 @@ export class SseProxyBackend {
207
243
  if (err instanceof TokenExpiredError || err instanceof TenantSuspendedError) {
208
244
  throw err;
209
245
  }
246
+ // connectOnce() inside reconnectAfter() can throw an SseError with
247
+ // code === 401 / 403 when the stored token is rejected on
248
+ // reconnect (the common mid-session token-expiry shape). Run it
249
+ // through classifySSEError — that helper throws typed errors,
250
+ // which propagate out of the current catch via the enclosing
251
+ // catch's implicit re-throw. The session supervisor sees the
252
+ // typed error and can swap backends instead of treating it as a
253
+ // transient retry.
254
+ if (err instanceof Error)
255
+ classifySSEError(err);
210
256
  // Do NOT increment attempt here — the try block already incremented
211
257
  // it before calling reconnectAfter. A second bump would halve the
212
258
  // effective retry budget so only ~3 of the 5 BACKOFF_MS slots
package/dist/index.js CHANGED
@@ -8,14 +8,19 @@
8
8
  *
9
9
  * Two modes determined by whether stdin is a TTY:
10
10
  * - Interactive (TTY): authenticate, print setup instructions, exit.
11
- * - Proxy (pipe): require pre-existing credentials, bridge stdio ↔ SSE.
11
+ * - Proxy (pipe): bridge stdio ↔ SSE. No valid creds? Run in auth-required
12
+ * mode until device flow completes, then swap to SSE in
13
+ * place — the stdio session never disconnects.
12
14
  */
13
15
  import { pollForToken, printActivationPrompt, printAuthSuccess, requestDeviceCode, } from "./auth.js";
14
- import { deleteCredentials, writeCredentials } from "./credentials.js";
16
+ import { AuthRequiredBackend, createSseBackend } from "./backend.js";
17
+ import { deleteCredentials, readCredentials, writeCredentials } from "./credentials.js";
18
+ import { createGateway } from "./gateway.js";
15
19
  import { formatTtl, parseArgs, printLines, printUsage, resolveSseUrl, tokenToCredentials, } from "./helpers.js";
16
20
  import { runInstallSkills } from "./install-skills.js";
17
- import { exitForReauth, handleRefreshFailure, tryMidSessionRefresh } from "./lifecycle.js";
18
- import { startProxy, TenantSuspendedError, TokenExpiredError } from "./proxy.js";
21
+ import { handleRefreshFailure, tryMidSessionRefresh } from "./lifecycle.js";
22
+ import { TenantSuspendedError, TokenExpiredError } from "./proxy.js";
23
+ import { startInAuthRequiredMode, startInSseMode } from "./proxy-server.js";
19
24
  import { loadAndRefresh } from "./refresh.js";
20
25
  const BANNER = [
21
26
  "",
@@ -87,70 +92,177 @@ async function main() {
87
92
  process.exit(0);
88
93
  }
89
94
  // ── Proxy mode (Claude Code spawned us as a stdio MCP server) ──
90
- // Load credentials; refresh silently if the access token is expired but the
91
- // refresh token is still valid. Anything else → exit with guidance.
95
+ // Single flow: load creds → pick starting backend → wait for lifetime
96
+ // events → swap backends in place. Session C design note: we never exit
97
+ // on recoverable conditions (missing creds, expired-refresh, mid-session
98
+ // 401). The stdio session stays alive for the whole life of the agent;
99
+ // only non-recoverable failures (tenant suspended, transient refresh error
100
+ // on cold start) terminate the process.
92
101
  const outcome = await loadAndRefresh(server);
93
- if (outcome === null) {
94
- // No credentials file at all — first-time run or user logged out.
95
- exitForReauth("no credentials — run authentication first");
96
- }
97
- if (!outcome.ok) {
98
- handleRefreshFailure(outcome.action, outcome.message);
99
- return; // unreachable — handleRefreshFailure calls process.exit
100
- }
101
- let creds = outcome.creds;
102
- // Valid credentials — ensure skills are installed before starting proxy
103
- // Silently installs/updates on first run or package update; fast no-op if current.
102
+ // Install skills opportunistically — must not block startup. Failures
103
+ // here include network hiccups (npm registry blips) and transient fs
104
+ // issues, neither of which should prevent the proxy serving MCP traffic.
105
+ // Log the message so "silent" is still distinguishable from "never ran."
104
106
  try {
105
107
  runInstallSkills();
106
108
  }
107
- catch {
109
+ catch (err) {
108
110
  // ALLOWED-CATCH: skill install failure must not block MCP proxy startup
111
+ const msg = err instanceof Error ? err.message : String(err);
112
+ process.stderr.write(`Pharaoh: skill install skipped — ${msg}\n`);
109
113
  }
110
- printLines(`Pharaoh: token valid for ${formatTtl(creds.expires_at)} — connecting`);
111
- try {
112
- await startProxy(creds.sse_url, creds.access_token);
114
+ // Needs auth: either no creds file OR refresh token is terminally rejected.
115
+ // Both cases: start in auth-required mode, wait for the user to complete
116
+ // device flow, then swap to SSE via the onAuthorized callback.
117
+ const needsAuth = outcome === null || (!outcome.ok && outcome.action === "reauth");
118
+ if (needsAuth) {
119
+ // If we reached here via a rejected refresh, delete the stale creds
120
+ // file so the gateway's internal readCredentials() doesn't pick up the
121
+ // just-invalidated refresh token on restart.
122
+ if (outcome && !outcome.ok)
123
+ deleteCredentials();
124
+ // The callback closure captures `proxy` via the destructured `const`
125
+ // binding declared below. The closure is only invoked once authorization
126
+ // completes (after the user's device-flow round trip), long after the
127
+ // assignment — so TS's strict-binding checks accept the reference.
128
+ const { proxy } = await startInAuthRequiredMode(server, {
129
+ onAuthorized: async (creds) => {
130
+ printLines("Pharaoh: authenticated — swapping to SSE backend.");
131
+ await proxy.setBackend(createSseBackend(creds.sse_url, creds.access_token));
132
+ },
133
+ });
134
+ printLines("Pharaoh: awaiting authentication via Claude Code...");
135
+ await waitForProxyLifetime(proxy, server);
136
+ return;
113
137
  }
114
- catch (err) {
115
- if (err instanceof TokenExpiredError) {
116
- // Mid-session token expired — attempt one silent refresh + retry
117
- // before giving up. This is what makes the proxy self-heal across
118
- // long sessions rather than dying at arbitrary moments.
119
- const retryCreds = await tryMidSessionRefresh(creds, server);
120
- if (retryCreds) {
121
- creds = retryCreds;
122
- printLines("Pharaoh: token refreshed mid-session — reconnecting");
123
- try {
124
- await startProxy(creds.sse_url, creds.access_token);
125
- return;
138
+ // Non-recoverable refresh failure (transient / preserve). Don't delete
139
+ // creds; the next spawn will retry. Match the pre-Session-C behavior for
140
+ // these codepaths exactly — if we can't refresh on cold start, we can't
141
+ // serve any tool calls, so bailing is honest.
142
+ if (!outcome.ok) {
143
+ handleRefreshFailure(outcome.action, outcome.message);
144
+ return; // unreachable — handleRefreshFailure calls process.exit
145
+ }
146
+ // Happy path: valid credentials. Start SSE backend, wait for lifetime,
147
+ // handle any mid-session failures by swapping backends in place.
148
+ const creds = outcome.creds;
149
+ printLines(`Pharaoh: token valid for ${formatTtl(creds.expires_at)} — connecting`);
150
+ const proxy = await startInSseMode(creds);
151
+ await waitForProxyLifetime(proxy, server);
152
+ }
153
+ /** Circuit breaker: abort after this many TokenExpiredError recoveries in the window. */
154
+ const RECOVERY_WINDOW_MS = 60_000;
155
+ const MAX_RECOVERIES_PER_WINDOW = 3;
156
+ /**
157
+ * Block on proxy.waitForLifetime() and reconcile mid-session failures by
158
+ * swapping the backend in place. Loops so successive swaps (e.g. SSE → auth
159
+ * → SSE) all stay under the same stdio connection.
160
+ *
161
+ * Terminates (returns normally) only when:
162
+ * - stdio closes (Claude Code disconnects), or
163
+ * - a non-recoverable branch explicitly process.exit()s.
164
+ *
165
+ * Circuit breaker: a buggy or hostile upstream can force a SSE↔auth churn by
166
+ * issuing a fresh 401 immediately after each successful reauth. Cap the
167
+ * recovery rate so the proxy exits with a diagnostic instead of pinning the
168
+ * user in a loop that re-prompts device flow every few seconds.
169
+ */
170
+ async function waitForProxyLifetime(proxy, server) {
171
+ const recoveryTimestamps = [];
172
+ while (true) {
173
+ try {
174
+ await proxy.waitForLifetime();
175
+ return; // stdio closed cleanly — end of session
176
+ }
177
+ catch (err) {
178
+ if (err instanceof TokenExpiredError) {
179
+ const now = Date.now();
180
+ // Drop timestamps older than the window.
181
+ while (recoveryTimestamps.length && now - recoveryTimestamps[0] > RECOVERY_WINDOW_MS) {
182
+ recoveryTimestamps.shift();
126
183
  }
127
- catch (retryErr) {
128
- if (retryErr instanceof TokenExpiredError) {
129
- // Second 401 after a successful refresh — the fresh tokens
130
- // are already rejected, so the session is genuinely dead.
131
- // Delete creds before exit so the next spawn shows the
132
- // re-auth prompt instead of looping on the just-rejected
133
- // access token (which `isExpired` would still say is fresh).
134
- deleteCredentials();
135
- exitForReauth("token rejected after refresh");
136
- }
137
- if (retryErr instanceof TenantSuspendedError) {
138
- printLines(`Pharaoh: ${retryErr.message}`);
139
- process.exit(1);
140
- }
141
- throw retryErr;
184
+ recoveryTimestamps.push(now);
185
+ if (recoveryTimestamps.length > MAX_RECOVERIES_PER_WINDOW) {
186
+ printLines(`Pharaoh: ${recoveryTimestamps.length} token-expired recoveries in ${RECOVERY_WINDOW_MS / 1000}s — aborting to avoid an auth loop. Restart to retry.`);
187
+ process.exit(1);
142
188
  }
189
+ const recovered = await recoverFromTokenExpired(proxy, server);
190
+ if (recovered)
191
+ continue; // swapped to a new backend; keep waiting
192
+ return; // recovery exited / handed off to auth-required loop
143
193
  }
144
- // Refresh failed — exit message already printed by tryMidSessionRefresh
145
- process.exit(1);
146
- }
147
- if (err instanceof TenantSuspendedError) {
148
- printLines(`Pharaoh: ${err.message}`);
149
- process.exit(1);
194
+ if (err instanceof TenantSuspendedError) {
195
+ printLines(`Pharaoh: ${err.message}`);
196
+ process.exit(1);
197
+ }
198
+ throw err;
150
199
  }
151
- throw err;
152
200
  }
153
201
  }
202
+ /**
203
+ * Handle a mid-session 401 without tearing down the stdio session:
204
+ * 1. Attempt a silent refresh. If it works, swap to a fresh SSE backend.
205
+ * 2. If the refresh says reauth, swap to AuthRequiredBackend — the next
206
+ * tool call surfaces the auth URL. The onAuthorized callback swaps us
207
+ * back to SSE once the user completes device flow.
208
+ * 3. If the refresh is transient/preserve, the session can't recover here
209
+ * — exit with the classified message.
210
+ *
211
+ * Returns `true` when a backend swap happened and the caller should keep
212
+ * waiting on waitForLifetime(); returns `false` when the caller's loop should
213
+ * exit (because this function either handed off to an async auth-required
214
+ * swap-back or process.exit()'d for a non-recoverable case).
215
+ */
216
+ async function recoverFromTokenExpired(proxy, server) {
217
+ // Re-read creds from disk — the SSE backend doesn't expose its token, and
218
+ // a prior refresh may have rotated what's on disk. Silent-refresh the
219
+ // token regardless of the on-disk expiry (the 401 is authoritative: the
220
+ // server rejected us, so force a round trip).
221
+ const diskCreds = readCredentials();
222
+ if (!diskCreds) {
223
+ // No creds on disk — shouldn't happen mid-session, but if it does,
224
+ // fall through to auth-required mode to get new ones.
225
+ await swapToAuthRequired(proxy, server);
226
+ return true;
227
+ }
228
+ const result = await tryMidSessionRefresh(diskCreds, server);
229
+ if (result.ok) {
230
+ printLines("Pharaoh: token refreshed mid-session — swapping SSE backend");
231
+ await proxy.setBackend(createSseBackend(result.creds.sse_url, result.creds.access_token));
232
+ return true;
233
+ }
234
+ // Branch on the explicit classification returned by tryMidSessionRefresh
235
+ // rather than inferring from disk state. `reauth` means the refresh token
236
+ // is terminally rejected — swap to auth-required so the user can re-
237
+ // authenticate without the session tearing down. `preserve`/`transient`
238
+ // mean the server's temporarily unable to refresh us; exit with the
239
+ // message so the user knows their credentials are intact.
240
+ if (result.action === "reauth") {
241
+ await swapToAuthRequired(proxy, server);
242
+ return true;
243
+ }
244
+ printLines(`Pharaoh: mid-session refresh failed (${result.action}) — credentials preserved; restart to retry.`);
245
+ process.exit(1);
246
+ }
247
+ /**
248
+ * Swap the live ProxyServer over to an AuthRequiredBackend, wiring an
249
+ * onAuthorized callback that swaps back to SSE. Used on both mid-session
250
+ * reauth and first-missing-creds recovery.
251
+ */
252
+ async function swapToAuthRequired(proxy, server) {
253
+ const gateway = createGateway({ server });
254
+ const authBackend = new AuthRequiredBackend(gateway, async () => {
255
+ const fresh = gateway.getCredentials();
256
+ if (!fresh) {
257
+ process.stderr.write("Pharaoh: swap-back aborted — gateway returned no creds\n");
258
+ return;
259
+ }
260
+ printLines("Pharaoh: re-authenticated — swapping back to SSE backend.");
261
+ await proxy.setBackend(createSseBackend(fresh.sse_url, fresh.access_token));
262
+ });
263
+ printLines("Pharaoh: re-authentication required — next tool call will surface the URL.");
264
+ await proxy.setBackend(authBackend);
265
+ }
154
266
  main().catch((err) => {
155
267
  // Default: print only error name/code to avoid leaking tokens, internal URLs,
156
268
  // or stack fragments that persist in CI logs. Full message behind PHARAOH_DEBUG.
@@ -7,12 +7,22 @@
7
7
  */
8
8
  import { type Credentials } from "./credentials.js";
9
9
  import { type RefreshFailureAction } from "./refresh.js";
10
+ /** Discriminated result from tryMidSessionRefresh. */
11
+ export type MidSessionRefreshResult = {
12
+ ok: true;
13
+ creds: Credentials;
14
+ } | {
15
+ ok: false;
16
+ action: RefreshFailureAction;
17
+ message: string;
18
+ };
10
19
  /**
11
20
  * Attempt a silent refresh triggered by a mid-session TokenExpiredError.
12
- * Returns new credentials on success, null on any failure (messages already
13
- * printed). Caller decides whether to retry startProxy.
21
+ * Returns new credentials on success, or a tagged failure carrying the
22
+ * RefreshFailureAction so the caller can branch on the real classification
23
+ * instead of re-inferring from disk state.
14
24
  */
15
- export declare function tryMidSessionRefresh(creds: Credentials, server: string): Promise<Credentials | null>;
25
+ export declare function tryMidSessionRefresh(creds: Credentials, server: string): Promise<MidSessionRefreshResult>;
16
26
  /**
17
27
  * Handle a failed `loadAndRefresh` at startup. Exits the process with the
18
28
  * appropriate message and creds-file state per the classified action.
package/dist/lifecycle.js CHANGED
@@ -10,14 +10,15 @@ import { NPX_COMMAND, printLines } from "./helpers.js";
10
10
  import { refreshIfNeeded } from "./refresh.js";
11
11
  /**
12
12
  * Attempt a silent refresh triggered by a mid-session TokenExpiredError.
13
- * Returns new credentials on success, null on any failure (messages already
14
- * printed). Caller decides whether to retry startProxy.
13
+ * Returns new credentials on success, or a tagged failure carrying the
14
+ * RefreshFailureAction so the caller can branch on the real classification
15
+ * instead of re-inferring from disk state.
15
16
  */
16
17
  export async function tryMidSessionRefresh(creds, server) {
17
18
  const outcome = await refreshIfNeeded(creds, server);
18
19
  if (outcome.ok)
19
- return outcome.creds;
20
- // Log the failure reason before caller's exit path runs.
20
+ return { ok: true, creds: outcome.creds };
21
+ // Log the failure reason before the caller decides what to do.
21
22
  if (outcome.action === "reauth") {
22
23
  printLines("Pharaoh: refresh rejected — re-authentication required.", ` ${NPX_COMMAND}`, "");
23
24
  deleteCredentials();
@@ -27,7 +28,7 @@ export async function tryMidSessionRefresh(creds, server) {
27
28
  // the user knows their credentials file is intact (matches startup path).
28
29
  printLines(`Pharaoh: refresh failed (${outcome.action}) — ${outcome.message}`, "Credentials preserved; next startup will retry.", "");
29
30
  }
30
- return null;
31
+ return { ok: false, action: outcome.action, message: outcome.message };
31
32
  }
32
33
  /**
33
34
  * Handle a failed `loadAndRefresh` at startup. Exits the process with the
@@ -8,11 +8,18 @@
8
8
  *
9
9
  * - startInAuthRequiredMode: cold start, no (or expired-refresh) credentials.
10
10
  * Spins up a gateway, wraps it in AuthRequiredBackend, starts the server.
11
- * Session C then polls hasValidCredentials() and calls setBackend() to swap
12
- * to the SSE backend once the user finishes the device flow.
11
+ * The caller supplies an `onAuthorized` callback that fires from inside the
12
+ * backend the moment device flow completes — the callback swaps ProxyServer
13
+ * over to an SseProxyBackend in place.
13
14
  *
14
15
  * - startInSseMode: happy path — valid credentials are already on disk. Starts
15
16
  * the server directly with an SSE backend.
17
+ *
18
+ * Lifecycle:
19
+ * - `waitForLifetime()` is the single awaitable the main loop blocks on. It
20
+ * resolves when Claude Code disconnects (stdio transport closed) and rejects
21
+ * with typed errors when the active backend hits a terminal failure. Backend
22
+ * swaps happen internally via callbacks; the main loop does NOT re-await.
16
23
  */
17
24
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
18
25
  import { type Backend } from "./backend.js";
@@ -27,6 +34,8 @@ export declare class ProxyServer {
27
34
  private transport;
28
35
  private backend;
29
36
  private started;
37
+ private transportClosed;
38
+ private transportClosedResolve;
30
39
  constructor(initialBackend: Backend);
31
40
  /**
32
41
  * Wire up list/call handlers. Each handler reads this.backend fresh at
@@ -45,6 +54,26 @@ export declare class ProxyServer {
45
54
  * Kept separate from start() so the stdio wiring stays implicit in prod.
46
55
  */
47
56
  startWithTransport(transport: StdioServerTransport): Promise<void>;
57
+ /**
58
+ * Block until either (a) the stdio transport closes cleanly (Claude Code
59
+ * disconnected) or (b) the current backend's supervisor promise rejects
60
+ * with a terminal error. Returns when the transport closes. Throws when
61
+ * a backend's waitForExit rejects.
62
+ *
63
+ * Swap-aware: if setBackend() is called while waitForLifetime is blocked,
64
+ * the old backend's exit promise is abandoned and the new backend's is
65
+ * awaited. This matters for "SSE → auth-required → SSE2" flows where we
66
+ * want errors from SSE2 to bubble up, not the already-dead SSE1.
67
+ */
68
+ waitForLifetime(): Promise<void>;
69
+ /**
70
+ * Used by setBackend to wake waitForLifetime so it can re-sample. Default
71
+ * no-op so a swap fired before waitForLifetime's first iteration (e.g. an
72
+ * onAuthorized that completes between proxy.start() and the first
73
+ * waitForLifetime call) doesn't crash. The missed wake is harmless — the
74
+ * next waitForLifetime iteration samples this.backend directly.
75
+ */
76
+ private backendSwappedSignal;
48
77
  /**
49
78
  * Swap the backend reference and fire a tools/list_changed notification so
50
79
  * the client re-fetches. No in-flight tracking — the SDK routes each call
@@ -59,12 +88,15 @@ export declare class ProxyServer {
59
88
  * the proxy in auth-required mode so the stdio connection stays alive while
60
89
  * the user completes the device flow.
61
90
  *
62
- * Returns the running ProxyServer and its gateway. Session C awaits
63
- * gateway.hasValidCredentials() on each tool call boundary, and once that
64
- * flips true, calls proxy.setBackend(createSseBackend(...)) to swap in the
65
- * real catalog.
91
+ * The caller supplies `opts.onAuthorized`, which fires from inside the
92
+ * AuthRequiredBackend the moment device flow completes. Typical implementation:
93
+ * swap the ProxyServer over to an SseProxyBackend so the next tool call goes
94
+ * to the real server. This keeps the proxy reference under the caller's
95
+ * control — the backend never imports ProxyServer.
66
96
  */
67
- export declare function startInAuthRequiredMode(server: string): Promise<{
97
+ export declare function startInAuthRequiredMode(server: string, opts: {
98
+ onAuthorized: (creds: Credentials) => Promise<void>;
99
+ }): Promise<{
68
100
  proxy: ProxyServer;
69
101
  gateway: DeviceFlowGateway;
70
102
  }>;
@@ -8,11 +8,18 @@
8
8
  *
9
9
  * - startInAuthRequiredMode: cold start, no (or expired-refresh) credentials.
10
10
  * Spins up a gateway, wraps it in AuthRequiredBackend, starts the server.
11
- * Session C then polls hasValidCredentials() and calls setBackend() to swap
12
- * to the SSE backend once the user finishes the device flow.
11
+ * The caller supplies an `onAuthorized` callback that fires from inside the
12
+ * backend the moment device flow completes — the callback swaps ProxyServer
13
+ * over to an SseProxyBackend in place.
13
14
  *
14
15
  * - startInSseMode: happy path — valid credentials are already on disk. Starts
15
16
  * the server directly with an SSE backend.
17
+ *
18
+ * Lifecycle:
19
+ * - `waitForLifetime()` is the single awaitable the main loop blocks on. It
20
+ * resolves when Claude Code disconnects (stdio transport closed) and rejects
21
+ * with typed errors when the active backend hits a terminal failure. Backend
22
+ * swaps happen internally via callbacks; the main loop does NOT re-await.
16
23
  */
17
24
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
18
25
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -29,6 +36,8 @@ export class ProxyServer {
29
36
  transport = null;
30
37
  backend;
31
38
  started = false;
39
+ transportClosed;
40
+ transportClosedResolve;
32
41
  constructor(initialBackend) {
33
42
  this.backend = initialBackend;
34
43
  this.server = new Server({ name: "pharaoh-proxy", version: getPackageVersion() }, {
@@ -36,6 +45,9 @@ export class ProxyServer {
36
45
  // to throw; the SDK's assertNotificationCapability gates it.
37
46
  capabilities: { tools: { listChanged: true } },
38
47
  });
48
+ this.transportClosed = new Promise((resolve) => {
49
+ this.transportClosedResolve = resolve;
50
+ });
39
51
  this.registerHandlers();
40
52
  }
41
53
  /**
@@ -64,6 +76,11 @@ export class ProxyServer {
64
76
  await this.backend.start();
65
77
  this.transport = new StdioServerTransport();
66
78
  await this.server.connect(this.transport);
79
+ // Server.connect() wraps transport.onclose; use the Protocol-level hook
80
+ // instead so we don't clobber the SDK's chain. Fires when the transport
81
+ // closes cleanly (client disconnect) — not on error paths, which surface
82
+ // through backend.waitForExit() and the server's own onerror plumbing.
83
+ this.server.onclose = () => this.transportClosedResolve();
67
84
  this.started = true;
68
85
  }
69
86
  /**
@@ -78,8 +95,44 @@ export class ProxyServer {
78
95
  this.transport = transport;
79
96
  // biome-ignore lint/suspicious/noExplicitAny: test transport is a Transport, but the type import chain is awkward here
80
97
  await this.server.connect(transport);
98
+ this.server.onclose = () => this.transportClosedResolve();
81
99
  this.started = true;
82
100
  }
101
+ /**
102
+ * Block until either (a) the stdio transport closes cleanly (Claude Code
103
+ * disconnected) or (b) the current backend's supervisor promise rejects
104
+ * with a terminal error. Returns when the transport closes. Throws when
105
+ * a backend's waitForExit rejects.
106
+ *
107
+ * Swap-aware: if setBackend() is called while waitForLifetime is blocked,
108
+ * the old backend's exit promise is abandoned and the new backend's is
109
+ * awaited. This matters for "SSE → auth-required → SSE2" flows where we
110
+ * want errors from SSE2 to bubble up, not the already-dead SSE1.
111
+ */
112
+ async waitForLifetime() {
113
+ while (true) {
114
+ const swapSignal = new Promise((resolve) => {
115
+ this.backendSwappedSignal = () => resolve("swap");
116
+ });
117
+ const backendExit = this.backend.waitForExit?.() ?? new Promise(() => { });
118
+ const outcome = await Promise.race([
119
+ this.transportClosed.then(() => "closed"),
120
+ backendExit.then(() => "exit"),
121
+ swapSignal,
122
+ ]);
123
+ if (outcome === "swap")
124
+ continue; // re-sample waitForExit against the new backend
125
+ return; // "closed" or "exit" (the backend's waitForExit resolved cleanly)
126
+ }
127
+ }
128
+ /**
129
+ * Used by setBackend to wake waitForLifetime so it can re-sample. Default
130
+ * no-op so a swap fired before waitForLifetime's first iteration (e.g. an
131
+ * onAuthorized that completes between proxy.start() and the first
132
+ * waitForLifetime call) doesn't crash. The missed wake is harmless — the
133
+ * next waitForLifetime iteration samples this.backend directly.
134
+ */
135
+ backendSwappedSignal = () => { };
83
136
  /**
84
137
  * Swap the backend reference and fire a tools/list_changed notification so
85
138
  * the client re-fetches. No in-flight tracking — the SDK routes each call
@@ -99,15 +152,26 @@ export class ProxyServer {
99
152
  // Fire the notification after the swap so a client that races a list
100
153
  // request against it sees the new catalog.
101
154
  await this.server.sendToolListChanged();
102
- // Fire-and-forget stop of the old backend. Errors here don't affect
103
- // the running session — the swap has already happened.
155
+ // Wake any in-flight waitForLifetime so it re-samples the new backend's
156
+ // waitForExit. Without this, SSE2 errors after a swap-back would be lost.
157
+ this.backendSwappedSignal();
158
+ // Fire-and-forget stop of the old backend. The swap has already happened
159
+ // so stop failures can't break the running session — but log them so
160
+ // resource leaks (file descriptors, keepalive timers, EventSource auto-
161
+ // reconnect surviving a failed close) are visible.
104
162
  if (prev !== next && prev.stop) {
105
- prev.stop().catch(() => { });
163
+ prev.stop().catch((err) => {
164
+ const msg = err instanceof Error ? err.message : String(err);
165
+ process.stderr.write(`Pharaoh: previous backend stop() failed — ${msg}\n`);
166
+ });
106
167
  }
107
168
  }
108
169
  async stop() {
109
170
  if (this.backend.stop) {
110
- await this.backend.stop().catch(() => { });
171
+ await this.backend.stop().catch((err) => {
172
+ const msg = err instanceof Error ? err.message : String(err);
173
+ process.stderr.write(`Pharaoh: backend stop() failed during shutdown — ${msg}\n`);
174
+ });
111
175
  }
112
176
  if (this.transport) {
113
177
  await this.server.close().catch(() => { });
@@ -124,14 +188,28 @@ export class ProxyServer {
124
188
  * the proxy in auth-required mode so the stdio connection stays alive while
125
189
  * the user completes the device flow.
126
190
  *
127
- * Returns the running ProxyServer and its gateway. Session C awaits
128
- * gateway.hasValidCredentials() on each tool call boundary, and once that
129
- * flips true, calls proxy.setBackend(createSseBackend(...)) to swap in the
130
- * real catalog.
191
+ * The caller supplies `opts.onAuthorized`, which fires from inside the
192
+ * AuthRequiredBackend the moment device flow completes. Typical implementation:
193
+ * swap the ProxyServer over to an SseProxyBackend so the next tool call goes
194
+ * to the real server. This keeps the proxy reference under the caller's
195
+ * control — the backend never imports ProxyServer.
131
196
  */
132
- export async function startInAuthRequiredMode(server) {
197
+ export async function startInAuthRequiredMode(server, opts) {
133
198
  const gateway = createGateway({ server });
134
- const backend = new AuthRequiredBackend(gateway);
199
+ const backend = new AuthRequiredBackend(gateway, async () => {
200
+ // Pull fresh creds from the gateway (which just wrote them to disk) so
201
+ // the callback doesn't race a separate disk read. The gateway is the
202
+ // authoritative post-auth source.
203
+ const creds = gateway.getCredentials();
204
+ if (!creds) {
205
+ // Should never happen — gateway.handleToolCall() only returns null
206
+ // after persistAuthorizedToken sets lastCreds. Keep the guard so a
207
+ // future refactor doesn't silently ship a swap-to-nothing bug.
208
+ process.stderr.write("Pharaoh: authorized but no credentials available — skipping swap\n");
209
+ return;
210
+ }
211
+ await opts.onAuthorized(creds);
212
+ });
135
213
  const proxy = new ProxyServer(backend);
136
214
  await proxy.start();
137
215
  return { proxy, gateway };
package/dist/proxy.d.ts CHANGED
@@ -1,16 +1,10 @@
1
1
  /**
2
- * Stdio ↔ SSE proxy entry point. Legacy export surface kept for the
3
- * in-flight handoff between Sessions B and C: index.ts (Session C's scope)
4
- * still imports `startProxy`, `TokenExpiredError`, and `TenantSuspendedError`
5
- * from here. Session C will rewrite index.ts to use ProxyServer/Backend
6
- * directly and `startProxy` will disappear at that point.
2
+ * Typed SSE errors + classifier. The stdio ↔ SSE wiring itself lives in
3
+ * backend.ts (SseProxyBackend); this module exports only the error types and
4
+ * the classifier so both the backend and tests can produce/assert on the
5
+ * same typed errors.
7
6
  *
8
- * Typed error classes and SSE classification live here because both the
9
- * legacy startProxy wrapper and the new SseProxyBackend (in backend.ts) need
10
- * to produce the same typed errors so downstream consumers don't care which
11
- * path surfaced them.
12
- *
13
- * RECONNECTION DESIGN (inherited by SseProxyBackend):
7
+ * RECONNECTION DESIGN (implemented in backend.ts SseProxyBackend):
14
8
  * The MCP SDK's SSEClientTransport wraps an EventSource, which has built-in
15
9
  * auto-reconnect (every 3s by default). This is problematic because:
16
10
  * 1. Each reconnect creates a NEW server-side session (new auth, new ID)
@@ -33,14 +27,13 @@ export declare class TenantSuspendedError extends Error {
33
27
  * Classify an SSE error — detect 401/403 and throw typed errors.
34
28
  * Exported for testability; used internally by the reconnect loop in
35
29
  * backend.ts.
36
- */
37
- export declare function classifySSEError(err: Error): void;
38
- /**
39
- * Legacy thin wrapper that starts the new ProxyServer in SSE mode and blocks
40
- * until the underlying SSE backend exits. Preserved so index.ts (Session C)
41
- * keeps compiling until the wiring PR; the return/throw contract matches the
42
- * original (resolves on clean exit, throws TokenExpiredError / TenantSuspendedError).
43
30
  *
44
- * @deprecated Session C will call ProxyServer / startInSseMode directly.
31
+ * The fallback uses status-prefix regex patterns rather than a bare
32
+ * `msg.includes("401")`. Session C calls this classifier on every reconnect
33
+ * error (not just SSE transport onerror events), so a substring match would
34
+ * misclassify any unrelated error text containing "401" — e.g. a port number,
35
+ * a timestamp suffix, or HTML body text echoed from a proxy. The regex
36
+ * requires an HTTP-shaped context so only actual status codes trigger a reauth
37
+ * loop.
45
38
  */
46
- export declare function startProxy(sseUrl: string, token: string): Promise<void>;
39
+ export declare function classifySSEError(err: Error): void;
package/dist/proxy.js CHANGED
@@ -1,16 +1,10 @@
1
1
  /**
2
- * Stdio ↔ SSE proxy entry point. Legacy export surface kept for the
3
- * in-flight handoff between Sessions B and C: index.ts (Session C's scope)
4
- * still imports `startProxy`, `TokenExpiredError`, and `TenantSuspendedError`
5
- * from here. Session C will rewrite index.ts to use ProxyServer/Backend
6
- * directly and `startProxy` will disappear at that point.
2
+ * Typed SSE errors + classifier. The stdio ↔ SSE wiring itself lives in
3
+ * backend.ts (SseProxyBackend); this module exports only the error types and
4
+ * the classifier so both the backend and tests can produce/assert on the
5
+ * same typed errors.
7
6
  *
8
- * Typed error classes and SSE classification live here because both the
9
- * legacy startProxy wrapper and the new SseProxyBackend (in backend.ts) need
10
- * to produce the same typed errors so downstream consumers don't care which
11
- * path surfaced them.
12
- *
13
- * RECONNECTION DESIGN (inherited by SseProxyBackend):
7
+ * RECONNECTION DESIGN (implemented in backend.ts SseProxyBackend):
14
8
  * The MCP SDK's SSEClientTransport wraps an EventSource, which has built-in
15
9
  * auto-reconnect (every 3s by default). This is problematic because:
16
10
  * 1. Each reconnect creates a NEW server-side session (new auth, new ID)
@@ -39,10 +33,19 @@ export class TenantSuspendedError extends Error {
39
33
  * Classify an SSE error — detect 401/403 and throw typed errors.
40
34
  * Exported for testability; used internally by the reconnect loop in
41
35
  * backend.ts.
36
+ *
37
+ * The fallback uses status-prefix regex patterns rather than a bare
38
+ * `msg.includes("401")`. Session C calls this classifier on every reconnect
39
+ * error (not just SSE transport onerror events), so a substring match would
40
+ * misclassify any unrelated error text containing "401" — e.g. a port number,
41
+ * a timestamp suffix, or HTML body text echoed from a proxy. The regex
42
+ * requires an HTTP-shaped context so only actual status codes trigger a reauth
43
+ * loop.
42
44
  */
43
45
  export function classifySSEError(err) {
44
46
  const msg = err.message ?? "";
45
- // SseError from SDK includes the HTTP status code
47
+ // SseError from SDK includes the HTTP status code on `.code` — the
48
+ // authoritative signal.
46
49
  if ("code" in err) {
47
50
  const code = err.code;
48
51
  if (code === 401)
@@ -50,35 +53,15 @@ export function classifySSEError(err) {
50
53
  if (code === 403)
51
54
  throw new TenantSuspendedError(msg || "Tenant suspended");
52
55
  }
53
- // Fallback: check message for status codes
54
- if (msg.includes("401"))
55
- throw new TokenExpiredError();
56
- if (msg.includes("403"))
57
- throw new TenantSuspendedError(msg || "Tenant suspended");
58
- }
59
- /**
60
- * Legacy thin wrapper that starts the new ProxyServer in SSE mode and blocks
61
- * until the underlying SSE backend exits. Preserved so index.ts (Session C)
62
- * keeps compiling until the wiring PR; the return/throw contract matches the
63
- * original (resolves on clean exit, throws TokenExpiredError / TenantSuspendedError).
64
- *
65
- * @deprecated Session C will call ProxyServer / startInSseMode directly.
66
- */
67
- export async function startProxy(sseUrl, token) {
68
- // Lazy imports to keep module load order clean — backend.ts imports from
69
- // proxy.ts for the typed errors and classifier above, so a top-level
70
- // import would create a cycle.
71
- const { createSseBackend } = await import("./backend.js");
72
- const { ProxyServer } = await import("./proxy-server.js");
73
- const backend = createSseBackend(sseUrl, token);
74
- const proxy = new ProxyServer(backend);
75
- await proxy.start();
76
- // Block on the SSE reconnect loop — matches the original semantics of
77
- // startProxy, which resolved only when the connection terminated.
78
- try {
79
- await backend.waitForExit();
80
- }
81
- finally {
82
- await proxy.stop().catch(() => { });
56
+ // Fallback regex for errors that don't expose `.code`. Matches common HTTP
57
+ // status renderings: "HTTP 401", "HTTP/1.1 401", "401 Unauthorized",
58
+ // "status 401", "status: 401" — but NOT bare "401" anywhere in the string.
59
+ const m = msg.match(/\b(?:HTTP(?:\/[\d.]+)?|status:?)\s+(\d{3})\b|\b(\d{3})\s+(?:Unauthorized|Forbidden)\b/i);
60
+ if (m) {
61
+ const code = Number(m[1] ?? m[2]);
62
+ if (code === 401)
63
+ throw new TokenExpiredError();
64
+ if (code === 403)
65
+ throw new TenantSuspendedError(msg || "Tenant suspended");
83
66
  }
84
67
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pharaoh-so/mcp",
3
3
  "mcpName": "so.pharaoh/pharaoh",
4
- "version": "0.3.25",
4
+ "version": "0.3.26",
5
5
  "description": "MCP proxy for Pharaoh — maps codebases into queryable knowledge graphs for AI agents. Enables Claude Code in headless environments (VPS, SSH, CI) via device flow auth.",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",