@pharaoh-so/mcp 0.3.22 → 0.3.23

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
@@ -28,17 +28,65 @@ export interface TokenResponse {
28
28
  provisional?: boolean;
29
29
  install_url?: string;
30
30
  }
31
+ /**
32
+ * Result of a single poll against /device/token.
33
+ *
34
+ * This type is the contract between the state-machine gateway (gateway.ts),
35
+ * the blocking interactive poller (pollForToken), and the test harness. It
36
+ * collapses HTTP details into the four lifecycle outcomes callers actually
37
+ * need to branch on, plus a network_error bucket so callers can distinguish
38
+ * "server said no" from "couldn't reach server."
39
+ */
40
+ export type PollResult = {
41
+ status: "pending";
42
+ slowDown?: boolean;
43
+ } | {
44
+ status: "authorized";
45
+ token: TokenResponse;
46
+ } | {
47
+ status: "expired";
48
+ } | {
49
+ status: "denied";
50
+ error: string;
51
+ } | {
52
+ status: "network_error";
53
+ error: string;
54
+ };
31
55
  /**
32
56
  * Request a device code from the Pharaoh server (RFC 8628 §3.1).
33
57
  * Returns the device code, user code, and verification URIs.
34
58
  */
35
59
  export declare function requestDeviceCode(baseUrl: string): Promise<DeviceCodeResponse>;
60
+ /**
61
+ * Perform one POST to /device/token. No loop, no sleep — maps HTTP responses
62
+ * into a PollResult discriminated union.
63
+ *
64
+ * Called in two shapes:
65
+ * - pollForToken() (TTY path): loops with interval+jitter until terminal state.
66
+ * - gateway.handleToolCall() (proxy path): called once per tool call, gated
67
+ * by a last_poll_at check against the server's interval.
68
+ *
69
+ * Note: slow_down is mapped to "pending" — the caller's pacing logic is what
70
+ * responds to it. pollForToken bumps its local interval; gateway relies on the
71
+ * server-specified interval it already tracks. This keeps pollOnce stateless.
72
+ */
73
+ export declare function pollOnce(baseUrl: string, deviceCode: string): Promise<PollResult>;
36
74
  /**
37
75
  * Poll for device authorization completion (RFC 8628 §3.5).
38
76
  * Blocks until the user authorizes, the code expires, or an error occurs.
39
77
  * Adds 1s jitter to the server-specified interval to avoid thundering herd.
40
78
  */
41
79
  export declare function pollForToken(baseUrl: string, deviceCode: string, interval: number): Promise<TokenResponse>;
80
+ /**
81
+ * Open a URL in the user's default browser.
82
+ * Uses platform-specific commands; fails silently if unavailable.
83
+ *
84
+ * Exported for gateway.ts — the state machine fires this once per device code
85
+ * to pre-fill the activation URL in the user's browser. Silent-on-failure is
86
+ * critical for headless environments: a missing `open`/`xdg-open` binary must
87
+ * not crash the proxy.
88
+ */
89
+ export declare function openBrowser(url: string): void;
42
90
  /**
43
91
  * Print the device activation prompt to stderr.
44
92
  * Shows the user code and verification URI in a visible box,
package/dist/auth.js CHANGED
@@ -19,48 +19,94 @@ export async function requestDeviceCode(baseUrl) {
19
19
  }
20
20
  return (await res.json());
21
21
  }
22
+ /**
23
+ * Perform one POST to /device/token. No loop, no sleep — maps HTTP responses
24
+ * into a PollResult discriminated union.
25
+ *
26
+ * Called in two shapes:
27
+ * - pollForToken() (TTY path): loops with interval+jitter until terminal state.
28
+ * - gateway.handleToolCall() (proxy path): called once per tool call, gated
29
+ * by a last_poll_at check against the server's interval.
30
+ *
31
+ * Note: slow_down is mapped to "pending" — the caller's pacing logic is what
32
+ * responds to it. pollForToken bumps its local interval; gateway relies on the
33
+ * server-specified interval it already tracks. This keeps pollOnce stateless.
34
+ */
35
+ export async function pollOnce(baseUrl, deviceCode) {
36
+ const grantType = "urn:ietf:params:oauth:grant-type:device_code";
37
+ let res;
38
+ try {
39
+ res = await fetch(`${baseUrl}/device/token`, {
40
+ method: "POST",
41
+ headers: { "Content-Type": "application/json" },
42
+ body: JSON.stringify({ device_code: deviceCode, grant_type: grantType }),
43
+ });
44
+ }
45
+ catch (err) {
46
+ return {
47
+ status: "network_error",
48
+ error: err instanceof Error ? err.message : String(err),
49
+ };
50
+ }
51
+ if (res.ok) {
52
+ const token = (await res.json());
53
+ return { status: "authorized", token };
54
+ }
55
+ const body = (await res.json().catch(() => ({ error: "unknown" })));
56
+ if (body.error === "authorization_pending") {
57
+ return { status: "pending" };
58
+ }
59
+ if (body.error === "slow_down") {
60
+ return { status: "pending", slowDown: true };
61
+ }
62
+ if (body.error === "expired_token") {
63
+ return { status: "expired" };
64
+ }
65
+ if (body.error === "access_denied") {
66
+ return { status: "denied", error: "Authorization was denied." };
67
+ }
68
+ return {
69
+ status: "denied",
70
+ error: `Device token error: ${body.error} — ${body.error_description ?? ""}`,
71
+ };
72
+ }
22
73
  /**
23
74
  * Poll for device authorization completion (RFC 8628 §3.5).
24
75
  * Blocks until the user authorizes, the code expires, or an error occurs.
25
76
  * Adds 1s jitter to the server-specified interval to avoid thundering herd.
26
77
  */
27
78
  export async function pollForToken(baseUrl, deviceCode, interval) {
28
- const grantType = "urn:ietf:params:oauth:grant-type:device_code";
79
+ let currentInterval = interval;
29
80
  while (true) {
30
81
  // Wait interval + 0–1s jitter before each poll
31
82
  const jitterMs = Math.random() * 1000;
32
- await sleep(interval * 1000 + jitterMs);
33
- const res = await fetch(`${baseUrl}/device/token`, {
34
- method: "POST",
35
- headers: { "Content-Type": "application/json" },
36
- body: JSON.stringify({ device_code: deviceCode, grant_type: grantType }),
37
- });
38
- if (res.ok) {
39
- return (await res.json());
40
- }
41
- const body = (await res.json().catch(() => ({ error: "unknown" })));
42
- if (body.error === "authorization_pending") {
43
- continue;
44
- }
45
- if (body.error === "slow_down") {
46
- // RFC 8628 §3.5: increase interval by 5 seconds
47
- interval += 5;
48
- continue;
49
- }
50
- if (body.error === "expired_token") {
83
+ await sleep(currentInterval * 1000 + jitterMs);
84
+ const result = await pollOnce(baseUrl, deviceCode);
85
+ if (result.status === "authorized")
86
+ return result.token;
87
+ if (result.status === "expired") {
51
88
  throw new Error("Device code expired. Please try again.");
52
89
  }
53
- if (body.error === "access_denied") {
54
- throw new Error("Authorization was denied.");
55
- }
56
- throw new Error(`Device token error: ${body.error} — ${body.error_description ?? ""}`);
90
+ if (result.status === "denied")
91
+ throw new Error(result.error);
92
+ if (result.status === "network_error")
93
+ throw new Error(result.error);
94
+ // result.status === "pending". RFC 8628 §3.5: only bump interval on
95
+ // slow_down (authorization_pending is the normal polling response).
96
+ if (result.slowDown)
97
+ currentInterval += 5;
57
98
  }
58
99
  }
59
100
  /**
60
101
  * Open a URL in the user's default browser.
61
102
  * Uses platform-specific commands; fails silently if unavailable.
103
+ *
104
+ * Exported for gateway.ts — the state machine fires this once per device code
105
+ * to pre-fill the activation URL in the user's browser. Silent-on-failure is
106
+ * critical for headless environments: a missing `open`/`xdg-open` binary must
107
+ * not crash the proxy.
62
108
  */
63
- function openBrowser(url) {
109
+ export function openBrowser(url) {
64
110
  if (process.platform === "darwin") {
65
111
  execFile("open", [url], () => { });
66
112
  }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Backend interface — the abstraction the ProxyServer delegates tool calls to.
3
+ *
4
+ * Two concrete implementations:
5
+ *
6
+ * 1. AuthRequiredBackend — advertises a single tool (`get_codebase_map`) that
7
+ * turns every invocation into a device-flow state step. Used while the
8
+ * proxy is waiting for the user to authorize.
9
+ *
10
+ * 2. SseProxyBackend (returned by createSseBackend) — wraps an MCP Client
11
+ * connected to the remote Pharaoh SSE server. Forwards list/call requests
12
+ * verbatim and preserves the ghost-session reconnect fix from proxy.ts.
13
+ *
14
+ * ProxyServer swaps between the two by reassigning a reference. The SDK
15
+ * resolves each tool call's handler at request-arrival time, so in-flight
16
+ * calls complete against whichever backend started them.
17
+ */
18
+ import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js";
19
+ import type { DeviceFlowGateway } from "./gateway.js";
20
+ /** The contract ProxyServer uses to delegate tool handling. */
21
+ export interface Backend {
22
+ readonly name: "auth-required" | "sse";
23
+ listTools(): Promise<Tool[]>;
24
+ callTool(name: string, args: Record<string, unknown>): Promise<CallToolResult>;
25
+ /** Optional one-time startup (e.g. connect SSE). Idempotent. */
26
+ start?(): Promise<void>;
27
+ /** Optional teardown (e.g. close SSE). Idempotent. */
28
+ stop?(): Promise<void>;
29
+ }
30
+ /**
31
+ * Minimal pre-auth catalog. Single tool — matches Pharaoh's "call this first"
32
+ * protocol (CLAUDE.md, skills), so the pre-auth surface mirrors the first
33
+ * tool most agents try. Keeping this at 1 means the swap to the real catalog
34
+ * fires a clean visible sendToolListChanged delta.
35
+ *
36
+ * If you add tools here, sync with src/mcp/tool-registry.ts in the main
37
+ * package — descriptions should match so agents get consistent behavior
38
+ * across the pre-auth / authorized transition.
39
+ */
40
+ export declare const AUTH_REQUIRED_TOOLS: Tool[];
41
+ /**
42
+ * Pre-auth backend. Every tool call triggers a device-flow state step via the
43
+ * 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.
46
+ */
47
+ export declare class AuthRequiredBackend implements Backend {
48
+ private readonly gateway;
49
+ readonly name: "auth-required";
50
+ constructor(gateway: DeviceFlowGateway);
51
+ listTools(): Promise<Tool[]>;
52
+ callTool(_name: string, _args: Record<string, unknown>): Promise<CallToolResult>;
53
+ }
54
+ /**
55
+ * Real SSE-backed backend. Owns an MCP Client + SSEClientTransport and a
56
+ * reconnect loop. Forwards listTools/callTool to the remote server.
57
+ *
58
+ * Reconnect semantics (preserved verbatim from the old startProxy):
59
+ * EventSource's built-in auto-reconnect creates ghost sessions on the
60
+ * server (new auth, new session ID per 3s reconnect). On any non-fatal
61
+ * error we explicitly close() the transport to kill EventSource, then
62
+ * let this loop create a fresh transport for the next attempt. 5 retries
63
+ * (2s, 4s, 8s, 16s, 32s) before giving up.
64
+ */
65
+ declare class SseProxyBackend implements Backend {
66
+ private readonly sseUrl;
67
+ private readonly token;
68
+ readonly name: "sse";
69
+ private client;
70
+ private transport;
71
+ private stopped;
72
+ private reconnectPromise;
73
+ constructor(sseUrl: string, token: string);
74
+ start(): Promise<void>;
75
+ /**
76
+ * Expose the long-running reconnect loop so a supervisor can await it.
77
+ * Session C uses this to bubble TokenExpiredError / TenantSuspendedError
78
+ * up to the same handler that used to wrap `startProxy`.
79
+ */
80
+ waitForExit(): Promise<void>;
81
+ private connectOnce;
82
+ /** Wait for the current transport to close, then decide what to do. */
83
+ private waitForClose;
84
+ private reconnectAfter;
85
+ private runReconnectLoop;
86
+ listTools(): Promise<Tool[]>;
87
+ callTool(name: string, args: Record<string, unknown>): Promise<CallToolResult>;
88
+ stop(): Promise<void>;
89
+ }
90
+ /**
91
+ * Public factory — ProxyServer / Session C build SSE backends via this.
92
+ * Keeps the SseProxyBackend class private so callers can't accidentally
93
+ * depend on its internal reconnect state.
94
+ */
95
+ export declare function createSseBackend(sseUrl: string, token: string): SseProxyBackend;
96
+ export {};
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Backend interface — the abstraction the ProxyServer delegates tool calls to.
3
+ *
4
+ * Two concrete implementations:
5
+ *
6
+ * 1. AuthRequiredBackend — advertises a single tool (`get_codebase_map`) that
7
+ * turns every invocation into a device-flow state step. Used while the
8
+ * proxy is waiting for the user to authorize.
9
+ *
10
+ * 2. SseProxyBackend (returned by createSseBackend) — wraps an MCP Client
11
+ * connected to the remote Pharaoh SSE server. Forwards list/call requests
12
+ * verbatim and preserves the ghost-session reconnect fix from proxy.ts.
13
+ *
14
+ * ProxyServer swaps between the two by reassigning a reference. The SDK
15
+ * resolves each tool call's handler at request-arrival time, so in-flight
16
+ * calls complete against whichever backend started them.
17
+ */
18
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
19
+ import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
20
+ import { classifySSEError, TenantSuspendedError, TokenExpiredError } from "./proxy.js";
21
+ import { getPackageVersion } from "./version.js";
22
+ /**
23
+ * Minimal pre-auth catalog. Single tool — matches Pharaoh's "call this first"
24
+ * protocol (CLAUDE.md, skills), so the pre-auth surface mirrors the first
25
+ * tool most agents try. Keeping this at 1 means the swap to the real catalog
26
+ * fires a clean visible sendToolListChanged delta.
27
+ *
28
+ * If you add tools here, sync with src/mcp/tool-registry.ts in the main
29
+ * package — descriptions should match so agents get consistent behavior
30
+ * across the pre-auth / authorized transition.
31
+ */
32
+ export const AUTH_REQUIRED_TOOLS = [
33
+ {
34
+ name: "get_codebase_map",
35
+ description: "Pharaoh — call this FIRST to understand the codebase structure. " +
36
+ "Requires authentication; calling this tool will surface the auth URL.",
37
+ inputSchema: {
38
+ type: "object",
39
+ properties: {
40
+ repo: {
41
+ type: "string",
42
+ description: "Optional repo slug (owner/name) to map.",
43
+ },
44
+ },
45
+ },
46
+ },
47
+ ];
48
+ /**
49
+ * Pre-auth backend. Every tool call triggers a device-flow state step via the
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.
53
+ */
54
+ export class AuthRequiredBackend {
55
+ gateway;
56
+ name = "auth-required";
57
+ constructor(gateway) {
58
+ this.gateway = gateway;
59
+ }
60
+ async listTools() {
61
+ return AUTH_REQUIRED_TOOLS;
62
+ }
63
+ async callTool(_name, _args) {
64
+ const response = await this.gateway.handleToolCall();
65
+ 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.
69
+ return {
70
+ content: [
71
+ {
72
+ type: "text",
73
+ text: "Pharaoh: authenticated. Call this tool again to get the codebase map.",
74
+ },
75
+ ],
76
+ isError: false,
77
+ };
78
+ }
79
+ // The gateway response shape is CallToolResult-compatible (content,
80
+ // isError, _meta) — the MCP SDK accepts it verbatim.
81
+ return response;
82
+ }
83
+ }
84
+ /** Backoff schedule for reconnect attempts (milliseconds). */
85
+ const BACKOFF_MS = [2000, 4000, 8000, 16000, 32000];
86
+ /**
87
+ * Build the SSEClientTransport with Bearer token auth. Injects the
88
+ * Authorization header into both the SSE connection (via custom fetch) and
89
+ * POST requests (via requestInit).
90
+ */
91
+ function createSSETransport(sseUrl, token) {
92
+ const authHeader = `Bearer ${token}`;
93
+ return new SSEClientTransport(new URL(sseUrl), {
94
+ eventSourceInit: {
95
+ fetch: (url, init) => {
96
+ const h = new Headers(init.headers);
97
+ h.set("Authorization", authHeader);
98
+ return fetch(url, { ...init, headers: h });
99
+ },
100
+ },
101
+ requestInit: {
102
+ headers: { Authorization: authHeader },
103
+ },
104
+ });
105
+ }
106
+ function sleep(ms) {
107
+ return new Promise((resolve) => setTimeout(resolve, ms));
108
+ }
109
+ /**
110
+ * Real SSE-backed backend. Owns an MCP Client + SSEClientTransport and a
111
+ * reconnect loop. Forwards listTools/callTool to the remote server.
112
+ *
113
+ * Reconnect semantics (preserved verbatim from the old startProxy):
114
+ * EventSource's built-in auto-reconnect creates ghost sessions on the
115
+ * server (new auth, new session ID per 3s reconnect). On any non-fatal
116
+ * error we explicitly close() the transport to kill EventSource, then
117
+ * let this loop create a fresh transport for the next attempt. 5 retries
118
+ * (2s, 4s, 8s, 16s, 32s) before giving up.
119
+ */
120
+ class SseProxyBackend {
121
+ sseUrl;
122
+ token;
123
+ name = "sse";
124
+ client = null;
125
+ transport = null;
126
+ stopped = false;
127
+ reconnectPromise = null;
128
+ constructor(sseUrl, token) {
129
+ this.sseUrl = sseUrl;
130
+ this.token = token;
131
+ }
132
+ async start() {
133
+ if (this.client)
134
+ return; // idempotent
135
+ await this.connectOnce();
136
+ // Keep the reconnect loop alive in the background. It resolves when
137
+ // the SSE stream closes cleanly; on typed errors (401/403) it throws.
138
+ this.reconnectPromise = this.runReconnectLoop();
139
+ // Surface typed errors at the top level so the CLI (or future
140
+ // supervisor) can act on them.
141
+ this.reconnectPromise.catch(() => {
142
+ /* error is retrieved via waitForExit() when needed */
143
+ });
144
+ }
145
+ /**
146
+ * Expose the long-running reconnect loop so a supervisor can await it.
147
+ * Session C uses this to bubble TokenExpiredError / TenantSuspendedError
148
+ * up to the same handler that used to wrap `startProxy`.
149
+ */
150
+ waitForExit() {
151
+ return this.reconnectPromise ?? Promise.resolve();
152
+ }
153
+ async connectOnce() {
154
+ this.transport = createSSETransport(this.sseUrl, this.token);
155
+ this.client = new Client({ name: "pharaoh-proxy", version: getPackageVersion() }, { capabilities: {} });
156
+ await this.client.connect(this.transport);
157
+ }
158
+ /** Wait for the current transport to close, then decide what to do. */
159
+ waitForClose() {
160
+ return new Promise((resolve, reject) => {
161
+ if (!this.transport) {
162
+ resolve();
163
+ return;
164
+ }
165
+ const sse = this.transport;
166
+ sse.onclose = () => resolve();
167
+ sse.onerror = (err) => {
168
+ try {
169
+ classifySSEError(err);
170
+ }
171
+ catch (typed) {
172
+ reject(typed);
173
+ return;
174
+ }
175
+ // Non-fatal — explicitly close the transport to kill
176
+ // EventSource's built-in auto-reconnect. Without this,
177
+ // EventSource reconnects every ~3s, creating ghost sessions
178
+ // on the server (100+ auth successes, zero tool calls
179
+ // completing). close() fires onclose which resolves this
180
+ // promise, handing control back to the backoff loop.
181
+ sse.close().catch(() => { });
182
+ };
183
+ });
184
+ }
185
+ async reconnectAfter(attempt) {
186
+ const delay = BACKOFF_MS[attempt - 1];
187
+ process.stderr.write(`Pharaoh: reconnecting in ${delay / 1000}s (attempt ${attempt}/${BACKOFF_MS.length})...\n`);
188
+ await sleep(delay);
189
+ await this.connectOnce();
190
+ process.stderr.write("Pharaoh: reconnected\n");
191
+ }
192
+ async runReconnectLoop() {
193
+ process.stderr.write("Pharaoh: connected\n");
194
+ let attempt = 0;
195
+ while (!this.stopped) {
196
+ try {
197
+ await this.waitForClose();
198
+ attempt++;
199
+ if (attempt > BACKOFF_MS.length) {
200
+ process.stderr.write("Pharaoh: max reconnect attempts reached — exiting\n");
201
+ throw new Error("SSE connection failed after maximum retries");
202
+ }
203
+ await this.reconnectAfter(attempt);
204
+ attempt = 0;
205
+ }
206
+ catch (err) {
207
+ if (err instanceof TokenExpiredError || err instanceof TenantSuspendedError) {
208
+ throw err;
209
+ }
210
+ attempt++;
211
+ if (attempt > BACKOFF_MS.length) {
212
+ throw err instanceof Error
213
+ ? err
214
+ : new Error("SSE connection failed after maximum retries");
215
+ }
216
+ }
217
+ }
218
+ }
219
+ async listTools() {
220
+ if (!this.client)
221
+ throw new Error("SseProxyBackend.listTools() before start()");
222
+ const res = await this.client.listTools();
223
+ return res.tools;
224
+ }
225
+ async callTool(name, args) {
226
+ if (!this.client)
227
+ throw new Error("SseProxyBackend.callTool() before start()");
228
+ const res = await this.client.callTool({ name, arguments: args });
229
+ return res;
230
+ }
231
+ async stop() {
232
+ this.stopped = true;
233
+ if (this.transport) {
234
+ await this.transport.close().catch(() => { });
235
+ this.transport = null;
236
+ }
237
+ if (this.client) {
238
+ await this.client.close().catch(() => { });
239
+ this.client = null;
240
+ }
241
+ }
242
+ }
243
+ /**
244
+ * Public factory — ProxyServer / Session C build SSE backends via this.
245
+ * Keeps the SseProxyBackend class private so callers can't accidentally
246
+ * depend on its internal reconnect state.
247
+ */
248
+ export function createSseBackend(sseUrl, token) {
249
+ return new SseProxyBackend(sseUrl, token);
250
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Pure-logic device-flow state machine for the auth-required proxy mode.
3
+ *
4
+ * When Claude Code spawns the proxy without valid credentials, we can't just
5
+ * exit — that would force the user to restart their agent every time. Instead,
6
+ * the proxy stays alive, advertises a single MCP tool, and turns every tool
7
+ * call into a device-flow lifecycle step: request a code, surface the URL,
8
+ * poll once per tool call (rate-limited), and swap to the SSE backend once
9
+ * the user authorizes.
10
+ *
11
+ * This module owns the state. It has no MCP imports and no knowledge of the
12
+ * stdio transport — those live in backend.ts and proxy-server.ts. The only
13
+ * side effects are (a) POSTing to the Pharaoh /device and /device/token
14
+ * endpoints via auth.ts, (b) opening a browser once per cycle, and (c)
15
+ * writing credentials to disk on authorization.
16
+ */
17
+ import { type Credentials } from "./credentials.js";
18
+ /**
19
+ * Structured MCP tool response returned when the proxy is waiting for auth.
20
+ *
21
+ * Shape rationale: MCP clients that inspect `_meta` can render the auth URL
22
+ * and user code directly in their UI (Claude Code does this). Clients that
23
+ * only read `content[0].text` see the same information in human-readable form.
24
+ * `isError: true` ensures Claude Code surfaces this to the user as an actionable
25
+ * error rather than silently eating the response.
26
+ */
27
+ export interface ToolAuthResponse {
28
+ content: Array<{
29
+ type: "text";
30
+ text: string;
31
+ }>;
32
+ isError: true;
33
+ _meta: {
34
+ auth_url: string;
35
+ user_code: string;
36
+ expires_in_seconds: number;
37
+ };
38
+ }
39
+ /** Public surface consumed by AuthRequiredBackend. */
40
+ export interface DeviceFlowGateway {
41
+ /**
42
+ * Called on every pre-auth tool invocation. Returns null when credentials
43
+ * just became valid (caller should swap to SSE backend); otherwise returns
44
+ * the auth-required tool response. Never throws — all error paths collapse
45
+ * into a ToolAuthResponse the agent can surface.
46
+ */
47
+ handleToolCall(): Promise<ToolAuthResponse | null>;
48
+ /** True when credentials on disk are present and not access-expired. */
49
+ hasValidCredentials(): boolean;
50
+ /** The last credentials loaded from disk; null if never loaded successfully. */
51
+ getCredentials(): Credentials | null;
52
+ }
53
+ /**
54
+ * Create a fresh state-machine instance. Each proxy spawn gets one gateway;
55
+ * its lifecycle is the lifecycle of the stdio process.
56
+ */
57
+ export declare function createGateway(opts: {
58
+ server: string;
59
+ credentialsPath?: string;
60
+ /** Test hook for deterministic expiry/interval math. Do not use in prod. */
61
+ now?: () => number;
62
+ /**
63
+ * Override the browser-open side effect. Tests MUST pass a noop — the real
64
+ * impl calls `open`/`xdg-open` on the OS, which hits a user's actual browser
65
+ * even when the URL points at an ephemeral test server that's already closed.
66
+ * Default: the platform-native open from auth.ts.
67
+ */
68
+ openBrowser?: (url: string) => void;
69
+ }): DeviceFlowGateway;
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Pure-logic device-flow state machine for the auth-required proxy mode.
3
+ *
4
+ * When Claude Code spawns the proxy without valid credentials, we can't just
5
+ * exit — that would force the user to restart their agent every time. Instead,
6
+ * the proxy stays alive, advertises a single MCP tool, and turns every tool
7
+ * call into a device-flow lifecycle step: request a code, surface the URL,
8
+ * poll once per tool call (rate-limited), and swap to the SSE backend once
9
+ * the user authorizes.
10
+ *
11
+ * This module owns the state. It has no MCP imports and no knowledge of the
12
+ * stdio transport — those live in backend.ts and proxy-server.ts. The only
13
+ * side effects are (a) POSTing to the Pharaoh /device and /device/token
14
+ * endpoints via auth.ts, (b) opening a browser once per cycle, and (c)
15
+ * writing credentials to disk on authorization.
16
+ */
17
+ import { openBrowser, pollOnce, requestDeviceCode, } from "./auth.js";
18
+ import { readCredentials, writeCredentials } from "./credentials.js";
19
+ import { resolveSseUrl, tokenToCredentials } from "./helpers.js";
20
+ function isAccessExpired(creds) {
21
+ const expiresAt = new Date(creds.expires_at).getTime();
22
+ if (Number.isNaN(expiresAt))
23
+ return true;
24
+ return Date.now() >= expiresAt;
25
+ }
26
+ function buildAuthResponse(device) {
27
+ const prefix = device.errorMessage ? `${device.errorMessage}\n\n` : "";
28
+ const text = `${prefix}Pharaoh authentication required.\n\n` +
29
+ ` 1. Visit ${device.verification_uri_complete}\n` +
30
+ ` 2. Enter code: ${device.user_code}\n` +
31
+ ` 3. Re-run this tool after authorizing.`;
32
+ return {
33
+ content: [{ type: "text", text }],
34
+ isError: true,
35
+ _meta: {
36
+ auth_url: device.verification_uri_complete,
37
+ user_code: device.user_code,
38
+ expires_in_seconds: device.expires_in,
39
+ },
40
+ };
41
+ }
42
+ /**
43
+ * Create a fresh state-machine instance. Each proxy spawn gets one gateway;
44
+ * its lifecycle is the lifecycle of the stdio process.
45
+ */
46
+ export function createGateway(opts) {
47
+ const now = opts.now ?? (() => Date.now());
48
+ const openBrowserImpl = opts.openBrowser ?? openBrowser;
49
+ let pending = null;
50
+ let lastCreds = null;
51
+ /** Start a new device-flow cycle: POST /device, open browser, cache state. */
52
+ async function startNewCycle() {
53
+ const device = await requestDeviceCode(opts.server);
54
+ // Browser-open is opportunistic. Headless environments (SSH, containers,
55
+ // CI runners with no DE) are first-class — the URL surfaces in the tool
56
+ // response regardless, so a throwing opener must not mask it.
57
+ try {
58
+ openBrowserImpl(device.verification_uri_complete);
59
+ }
60
+ catch {
61
+ // ALLOWED-CATCH: browser-open failures are non-fatal by design
62
+ }
63
+ const response = buildAuthResponse(device);
64
+ // last_poll_at is seeded with now() — the cycle start counts as a
65
+ // server round-trip, so the next tool call shouldn't immediately
66
+ // re-hit /device/token within the interval window. This is what
67
+ // makes scenario "rapid-fire calls right after auth URL appears"
68
+ // not flood the server's 20/min rate limiter.
69
+ pending = {
70
+ device_code: device.device_code,
71
+ user_code: device.user_code,
72
+ verification_uri: device.verification_uri,
73
+ verification_uri_complete: device.verification_uri_complete,
74
+ expires_at: now() + device.expires_in * 1000,
75
+ interval_ms: device.interval * 1000,
76
+ last_poll_at: now(),
77
+ response,
78
+ };
79
+ return response;
80
+ }
81
+ /**
82
+ * Persist a new session's credentials. Separated so handleToolCall stays
83
+ * simple — see how success is collapsed into `pending = null; return null`.
84
+ */
85
+ function persistAuthorizedToken(token) {
86
+ const sseUrl = resolveSseUrl(token.sse_url, opts.server);
87
+ const creds = tokenToCredentials(token, sseUrl);
88
+ writeCredentials(creds, opts.credentialsPath);
89
+ lastCreds = creds;
90
+ pending = null;
91
+ }
92
+ /** Decide whether the cached response is still valid without polling. */
93
+ function shouldSkipPoll(state) {
94
+ if (state.last_poll_at === null)
95
+ return false;
96
+ return now() - state.last_poll_at < state.interval_ms;
97
+ }
98
+ /**
99
+ * Handle the result of a single /device/token poll. Mutates state as a
100
+ * side effect and returns the outbound response (or null on authorization).
101
+ * Network errors fall through silently (no state change, retry on next call).
102
+ */
103
+ function applyPollResult(state, result) {
104
+ if (result.status === "authorized") {
105
+ persistAuthorizedToken(result.token);
106
+ return null;
107
+ }
108
+ if (result.status === "denied") {
109
+ // Clear state so the next call starts fresh. Return the error text
110
+ // inline so the agent sees why; the user will naturally retry.
111
+ pending = null;
112
+ return {
113
+ content: [{ type: "text", text: `Pharaoh auth denied: ${result.error}` }],
114
+ isError: true,
115
+ _meta: {
116
+ auth_url: state.verification_uri,
117
+ user_code: state.user_code,
118
+ expires_in_seconds: 0,
119
+ },
120
+ };
121
+ }
122
+ // pending, expired, network_error: keep cached response. Expired is
123
+ // handled by the expiry check in handleToolCall; we don't force it here
124
+ // because applyPollResult runs AFTER the expiry check, which means an
125
+ // expired server code here is a rare race (<1s window) and getting the
126
+ // same response back is fine.
127
+ return state.response;
128
+ }
129
+ async function handleToolCall() {
130
+ // No active cycle — start one.
131
+ if (pending === null) {
132
+ return startNewCycle();
133
+ }
134
+ // Client-side expiry check. If the device code has expired, the server
135
+ // will reject further polls; clear state and start a fresh cycle. This
136
+ // is the only state transition triggered purely by time.
137
+ if (now() >= pending.expires_at) {
138
+ pending = null;
139
+ return startNewCycle();
140
+ }
141
+ // Rate-limit: respect the server's interval so we don't hammer /token
142
+ // when agents rapid-fire tool calls on failure.
143
+ if (shouldSkipPoll(pending)) {
144
+ return pending.response;
145
+ }
146
+ const state = pending;
147
+ state.last_poll_at = now();
148
+ const result = await pollOnce(opts.server, state.device_code);
149
+ return applyPollResult(state, result);
150
+ }
151
+ function hasValidCredentials() {
152
+ const creds = readCredentials(opts.credentialsPath);
153
+ if (!creds)
154
+ return false;
155
+ lastCreds = creds;
156
+ return !isAccessExpired(creds);
157
+ }
158
+ function getCredentials() {
159
+ return lastCreds;
160
+ }
161
+ return { handleToolCall, hasValidCredentials, getCredentials };
162
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * ProxyServer — owns the stdio MCP Server and delegates tool calls through a
3
+ * mutable Backend reference. The whole point of this abstraction is that we
4
+ * can swap the backend under a running server without restarting stdio, which
5
+ * means Claude Code never has to reconnect.
6
+ *
7
+ * Two entry points cover the interesting startup shapes:
8
+ *
9
+ * - startInAuthRequiredMode: cold start, no (or expired-refresh) credentials.
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.
13
+ *
14
+ * - startInSseMode: happy path — valid credentials are already on disk. Starts
15
+ * the server directly with an SSE backend.
16
+ */
17
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
18
+ import { type Backend } from "./backend.js";
19
+ import type { Credentials } from "./credentials.js";
20
+ import { type DeviceFlowGateway } from "./gateway.js";
21
+ /**
22
+ * Stdio MCP server with a swappable backend. The Server instance is
23
+ * long-lived across backend swaps; only the Backend reference moves.
24
+ */
25
+ export declare class ProxyServer {
26
+ private server;
27
+ private transport;
28
+ private backend;
29
+ private started;
30
+ constructor(initialBackend: Backend);
31
+ /**
32
+ * Wire up list/call handlers. Each handler reads this.backend fresh at
33
+ * dispatch time — do NOT hoist the reference into a local, or setBackend()
34
+ * won't take effect for subsequent calls. This is the core property that
35
+ * makes mutable-backend swaps safe.
36
+ */
37
+ private registerHandlers;
38
+ /**
39
+ * Start the backend (if it has a start hook) and connect stdio. Idempotent.
40
+ * Tests inject an alternate transport via startWithTransport().
41
+ */
42
+ start(): Promise<void>;
43
+ /**
44
+ * Test hook: start with a caller-supplied transport (e.g. InMemoryTransport).
45
+ * Kept separate from start() so the stdio wiring stays implicit in prod.
46
+ */
47
+ startWithTransport(transport: StdioServerTransport): Promise<void>;
48
+ /**
49
+ * Swap the backend reference and fire a tools/list_changed notification so
50
+ * the client re-fetches. No in-flight tracking — the SDK routes each call
51
+ * to whichever backend is current at dispatch time.
52
+ */
53
+ setBackend(next: Backend): Promise<void>;
54
+ stop(): Promise<void>;
55
+ getBackend(): Backend;
56
+ }
57
+ /**
58
+ * Cold-start entry point. No credentials (or refresh-expired creds) — start
59
+ * the proxy in auth-required mode so the stdio connection stays alive while
60
+ * the user completes the device flow.
61
+ *
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.
66
+ */
67
+ export declare function startInAuthRequiredMode(server: string): Promise<{
68
+ proxy: ProxyServer;
69
+ gateway: DeviceFlowGateway;
70
+ }>;
71
+ /**
72
+ * Happy-path entry point. Credentials are fresh — start directly with the
73
+ * SSE backend. Returned proxy is running and ready for tool calls.
74
+ */
75
+ export declare function startInSseMode(creds: Credentials): Promise<ProxyServer>;
@@ -0,0 +1,143 @@
1
+ /**
2
+ * ProxyServer — owns the stdio MCP Server and delegates tool calls through a
3
+ * mutable Backend reference. The whole point of this abstraction is that we
4
+ * can swap the backend under a running server without restarting stdio, which
5
+ * means Claude Code never has to reconnect.
6
+ *
7
+ * Two entry points cover the interesting startup shapes:
8
+ *
9
+ * - startInAuthRequiredMode: cold start, no (or expired-refresh) credentials.
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.
13
+ *
14
+ * - startInSseMode: happy path — valid credentials are already on disk. Starts
15
+ * the server directly with an SSE backend.
16
+ */
17
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
18
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
19
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
20
+ import { AuthRequiredBackend, createSseBackend } from "./backend.js";
21
+ import { createGateway } from "./gateway.js";
22
+ import { getPackageVersion } from "./version.js";
23
+ /**
24
+ * Stdio MCP server with a swappable backend. The Server instance is
25
+ * long-lived across backend swaps; only the Backend reference moves.
26
+ */
27
+ export class ProxyServer {
28
+ server;
29
+ transport = null;
30
+ backend;
31
+ started = false;
32
+ constructor(initialBackend) {
33
+ this.backend = initialBackend;
34
+ this.server = new Server({ name: "pharaoh-proxy", version: getPackageVersion() }, {
35
+ // tools.listChanged is required for sendToolListChanged() not
36
+ // to throw; the SDK's assertNotificationCapability gates it.
37
+ capabilities: { tools: { listChanged: true } },
38
+ });
39
+ this.registerHandlers();
40
+ }
41
+ /**
42
+ * Wire up list/call handlers. Each handler reads this.backend fresh at
43
+ * dispatch time — do NOT hoist the reference into a local, or setBackend()
44
+ * won't take effect for subsequent calls. This is the core property that
45
+ * makes mutable-backend swaps safe.
46
+ */
47
+ registerHandlers() {
48
+ this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
49
+ tools: await this.backend.listTools(),
50
+ }));
51
+ this.server.setRequestHandler(CallToolRequestSchema, async (req) => {
52
+ const result = await this.backend.callTool(req.params.name, req.params.arguments ?? {});
53
+ return result;
54
+ });
55
+ }
56
+ /**
57
+ * Start the backend (if it has a start hook) and connect stdio. Idempotent.
58
+ * Tests inject an alternate transport via startWithTransport().
59
+ */
60
+ async start() {
61
+ if (this.started)
62
+ return;
63
+ if (this.backend.start)
64
+ await this.backend.start();
65
+ this.transport = new StdioServerTransport();
66
+ await this.server.connect(this.transport);
67
+ this.started = true;
68
+ }
69
+ /**
70
+ * Test hook: start with a caller-supplied transport (e.g. InMemoryTransport).
71
+ * Kept separate from start() so the stdio wiring stays implicit in prod.
72
+ */
73
+ async startWithTransport(transport) {
74
+ if (this.started)
75
+ return;
76
+ if (this.backend.start)
77
+ await this.backend.start();
78
+ this.transport = transport;
79
+ // biome-ignore lint/suspicious/noExplicitAny: test transport is a Transport, but the type import chain is awkward here
80
+ await this.server.connect(transport);
81
+ this.started = true;
82
+ }
83
+ /**
84
+ * Swap the backend reference and fire a tools/list_changed notification so
85
+ * the client re-fetches. No in-flight tracking — the SDK routes each call
86
+ * to whichever backend is current at dispatch time.
87
+ */
88
+ async setBackend(next) {
89
+ const prev = this.backend;
90
+ this.backend = next;
91
+ if (next.start)
92
+ await next.start();
93
+ // Only emit after backend reference is swapped — if the client races
94
+ // a list request against the notification, we want them to see the
95
+ // new catalog.
96
+ await this.server.sendToolListChanged();
97
+ // Fire-and-forget stop of the old backend. Errors here don't affect
98
+ // the running session — the swap has already happened.
99
+ if (prev !== next && prev.stop) {
100
+ prev.stop().catch(() => { });
101
+ }
102
+ }
103
+ async stop() {
104
+ if (this.backend.stop) {
105
+ await this.backend.stop().catch(() => { });
106
+ }
107
+ if (this.transport) {
108
+ await this.server.close().catch(() => { });
109
+ this.transport = null;
110
+ }
111
+ this.started = false;
112
+ }
113
+ getBackend() {
114
+ return this.backend;
115
+ }
116
+ }
117
+ /**
118
+ * Cold-start entry point. No credentials (or refresh-expired creds) — start
119
+ * the proxy in auth-required mode so the stdio connection stays alive while
120
+ * the user completes the device flow.
121
+ *
122
+ * Returns the running ProxyServer and its gateway. Session C awaits
123
+ * gateway.hasValidCredentials() on each tool call boundary, and once that
124
+ * flips true, calls proxy.setBackend(createSseBackend(...)) to swap in the
125
+ * real catalog.
126
+ */
127
+ export async function startInAuthRequiredMode(server) {
128
+ const gateway = createGateway({ server });
129
+ const backend = new AuthRequiredBackend(gateway);
130
+ const proxy = new ProxyServer(backend);
131
+ await proxy.start();
132
+ return { proxy, gateway };
133
+ }
134
+ /**
135
+ * Happy-path entry point. Credentials are fresh — start directly with the
136
+ * SSE backend. Returned proxy is running and ready for tool calls.
137
+ */
138
+ export async function startInSseMode(creds) {
139
+ const backend = createSseBackend(creds.sse_url, creds.access_token);
140
+ const proxy = new ProxyServer(backend);
141
+ await proxy.start();
142
+ return proxy;
143
+ }
package/dist/proxy.d.ts CHANGED
@@ -1,18 +1,25 @@
1
1
  /**
2
- * Stdio ↔ SSE proxy — bridges a local MCP stdio connection to a remote Pharaoh SSE server.
3
- * Uses SDK transports on both ends: StdioServerTransport (local) and SSEClientTransport (remote).
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.
4
7
  *
5
- * RECONNECTION DESIGN:
6
- * The MCP SDK's SSEClientTransport wraps an EventSource, which has built-in auto-reconnect
7
- * (every 3 seconds by default). This auto-reconnect is problematic because:
8
- * 1. Each reconnect creates a NEW server-side session (new auth, new session ID)
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):
14
+ * The MCP SDK's SSEClientTransport wraps an EventSource, which has built-in
15
+ * auto-reconnect (every 3s by default). This is problematic because:
16
+ * 1. Each reconnect creates a NEW server-side session (new auth, new ID)
9
17
  * 2. The proxy's reconnect loop doesn't know about the internal reconnect
10
18
  * 3. Tool calls get lost between the stale and new sessions
11
19
  *
12
- * Solution: On any non-fatal SSE error, we explicitly close() the transport to kill
13
- * the EventSource and its auto-reconnect timer, then let the proxy's own backoff loop
14
- * create a fresh SSEClientTransport for the next attempt. This ensures one session
15
- * at a time and clean reconnection semantics.
20
+ * Solution: on any non-fatal SSE error, explicitly close() the transport to
21
+ * kill the EventSource, then let the proxy's own backoff loop create a fresh
22
+ * transport for the next attempt. See backend.ts SseProxyBackend.waitForClose.
16
23
  */
17
24
  /** Thrown when the remote server returns 401 — token is expired or revoked. */
18
25
  export declare class TokenExpiredError extends Error {
@@ -24,21 +31,16 @@ export declare class TenantSuspendedError extends Error {
24
31
  }
25
32
  /**
26
33
  * Classify an SSE error — detect 401/403 and throw typed errors.
27
- * Exported for testability; used internally by the reconnect loop.
34
+ * Exported for testability; used internally by the reconnect loop in
35
+ * backend.ts.
28
36
  */
29
37
  export declare function classifySSEError(err: Error): void;
30
38
  /**
31
- * Start the stdio ↔ SSE proxy. Blocks until the connection is permanently closed.
32
- *
33
- * Message flow:
34
- * Claude Code → stdin → StdioServerTransport.onmessage → SSEClientTransport.send → POST /message
35
- * Pharaoh → SSE stream → SSEClientTransport.onmessage → StdioServerTransport.send → stdout
36
- *
37
- * CRITICAL ordering: SSE must be connected BEFORE stdio starts reading stdin.
38
- * Otherwise the MCP `initialize` message arrives before the remote transport is ready,
39
- * gets permanently lost ("send error — Not connected"), and the client times out.
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).
40
43
  *
41
- * On SSE disconnect, attempts reconnect with exponential backoff (5 retries, ~62s total).
42
- * Throws TokenExpiredError on 401, TenantSuspendedError on 403.
44
+ * @deprecated Session C will call ProxyServer / startInSseMode directly.
43
45
  */
44
46
  export declare function startProxy(sseUrl: string, token: string): Promise<void>;
package/dist/proxy.js CHANGED
@@ -1,21 +1,26 @@
1
1
  /**
2
- * Stdio ↔ SSE proxy — bridges a local MCP stdio connection to a remote Pharaoh SSE server.
3
- * Uses SDK transports on both ends: StdioServerTransport (local) and SSEClientTransport (remote).
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.
4
7
  *
5
- * RECONNECTION DESIGN:
6
- * The MCP SDK's SSEClientTransport wraps an EventSource, which has built-in auto-reconnect
7
- * (every 3 seconds by default). This auto-reconnect is problematic because:
8
- * 1. Each reconnect creates a NEW server-side session (new auth, new session ID)
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):
14
+ * The MCP SDK's SSEClientTransport wraps an EventSource, which has built-in
15
+ * auto-reconnect (every 3s by default). This is problematic because:
16
+ * 1. Each reconnect creates a NEW server-side session (new auth, new ID)
9
17
  * 2. The proxy's reconnect loop doesn't know about the internal reconnect
10
18
  * 3. Tool calls get lost between the stale and new sessions
11
19
  *
12
- * Solution: On any non-fatal SSE error, we explicitly close() the transport to kill
13
- * the EventSource and its auto-reconnect timer, then let the proxy's own backoff loop
14
- * create a fresh SSEClientTransport for the next attempt. This ensures one session
15
- * at a time and clean reconnection semantics.
20
+ * Solution: on any non-fatal SSE error, explicitly close() the transport to
21
+ * kill the EventSource, then let the proxy's own backoff loop create a fresh
22
+ * transport for the next attempt. See backend.ts SseProxyBackend.waitForClose.
16
23
  */
17
- import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
18
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
19
24
  /** Thrown when the remote server returns 401 — token is expired or revoked. */
20
25
  export class TokenExpiredError extends Error {
21
26
  constructor() {
@@ -30,11 +35,10 @@ export class TenantSuspendedError extends Error {
30
35
  this.name = "TenantSuspendedError";
31
36
  }
32
37
  }
33
- /** Backoff schedule for reconnect attempts (milliseconds). */
34
- const BACKOFF_MS = [2000, 4000, 8000, 16000, 32000];
35
38
  /**
36
39
  * Classify an SSE error — detect 401/403 and throw typed errors.
37
- * Exported for testability; used internally by the reconnect loop.
40
+ * Exported for testability; used internally by the reconnect loop in
41
+ * backend.ts.
38
42
  */
39
43
  export function classifySSEError(err) {
40
44
  const msg = err.message ?? "";
@@ -53,120 +57,28 @@ export function classifySSEError(err) {
53
57
  throw new TenantSuspendedError(msg || "Tenant suspended");
54
58
  }
55
59
  /**
56
- * Create an SSEClientTransport with Bearer token auth.
57
- * Injects the Authorization header into both the SSE connection (via custom fetch)
58
- * and POST requests (via requestInit).
59
- */
60
- function createSSETransport(sseUrl, token) {
61
- const authHeader = `Bearer ${token}`;
62
- return new SSEClientTransport(new URL(sseUrl), {
63
- eventSourceInit: {
64
- fetch: (url, init) => {
65
- const h = new Headers(init.headers);
66
- h.set("Authorization", authHeader);
67
- return fetch(url, { ...init, headers: h });
68
- },
69
- },
70
- requestInit: {
71
- headers: { Authorization: authHeader },
72
- },
73
- });
74
- }
75
- /**
76
- * Start the stdio ↔ SSE proxy. Blocks until the connection is permanently closed.
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).
77
64
  *
78
- * Message flow:
79
- * Claude Code → stdin → StdioServerTransport.onmessage → SSEClientTransport.send → POST /message
80
- * Pharaoh → SSE stream → SSEClientTransport.onmessage → StdioServerTransport.send → stdout
81
- *
82
- * CRITICAL ordering: SSE must be connected BEFORE stdio starts reading stdin.
83
- * Otherwise the MCP `initialize` message arrives before the remote transport is ready,
84
- * gets permanently lost ("send error — Not connected"), and the client times out.
85
- *
86
- * On SSE disconnect, attempts reconnect with exponential backoff (5 retries, ~62s total).
87
- * Throws TokenExpiredError on 401, TenantSuspendedError on 403.
65
+ * @deprecated Session C will call ProxyServer / startInSseMode directly.
88
66
  */
89
67
  export async function startProxy(sseUrl, token) {
90
- const stdio = new StdioServerTransport();
91
- let sse = createSSETransport(sseUrl, token);
92
- let reconnectAttempt = 0;
93
- let stdioStarted = false;
94
- /** Wire up bidirectional message relay between the two transports. */
95
- function bridge(sseTransport) {
96
- stdio.onmessage = (msg) => {
97
- sseTransport.send(msg).catch((err) => {
98
- process.stderr.write(`Pharaoh: send error — ${err instanceof Error ? err.message : String(err)}\n`);
99
- });
100
- };
101
- sseTransport.onmessage = (msg) => {
102
- stdio.send(msg).catch((err) => {
103
- process.stderr.write(`Pharaoh: stdout error — ${err instanceof Error ? err.message : String(err)}\n`);
104
- });
105
- };
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();
106
80
  }
107
- // Connect SSE with reconnect loop — stdio starts AFTER first successful connection
108
- await connectWithReconnect();
109
- /** Attempt SSE connection with exponential backoff on failure. */
110
- async function connectWithReconnect() {
111
- while (reconnectAttempt <= BACKOFF_MS.length) {
112
- try {
113
- if (reconnectAttempt > 0) {
114
- const delay = BACKOFF_MS[reconnectAttempt - 1];
115
- process.stderr.write(`Pharaoh: reconnecting in ${delay / 1000}s (attempt ${reconnectAttempt}/${BACKOFF_MS.length})...\n`);
116
- await sleep(delay);
117
- sse = createSSETransport(sseUrl, token);
118
- }
119
- await sse.start();
120
- reconnectAttempt = 0; // Reset on successful connection
121
- // Bridge AFTER SSE is connected — messages can now be forwarded
122
- bridge(sse);
123
- // Start stdio AFTER first SSE connection — prevents initialize race
124
- if (!stdioStarted) {
125
- process.stderr.write("Pharaoh: connected\n");
126
- await stdio.start();
127
- stdioStarted = true;
128
- }
129
- else {
130
- process.stderr.write("Pharaoh: reconnected\n");
131
- }
132
- // Wait for close — this promise resolves when SSE disconnects
133
- await new Promise((resolve, reject) => {
134
- sse.onclose = () => resolve();
135
- sse.onerror = (err) => {
136
- try {
137
- classifySSEError(err);
138
- }
139
- catch (typed) {
140
- reject(typed);
141
- return;
142
- }
143
- // Non-fatal error — explicitly close the transport to kill
144
- // EventSource's built-in auto-reconnect. Without this, EventSource
145
- // reconnects every ~3s, creating ghost sessions on the server
146
- // (100+ auth successes, zero tool calls completing).
147
- // close() fires onclose which resolves this promise, handing
148
- // control back to the proxy's backoff loop.
149
- sse.close().catch(() => { });
150
- };
151
- });
152
- // SSE closed — attempt reconnect
153
- reconnectAttempt++;
154
- }
155
- catch (err) {
156
- if (err instanceof TokenExpiredError || err instanceof TenantSuspendedError) {
157
- await stdio.close().catch(() => { });
158
- throw err;
159
- }
160
- reconnectAttempt++;
161
- if (reconnectAttempt > BACKOFF_MS.length) {
162
- process.stderr.write("Pharaoh: max reconnect attempts reached — exiting\n");
163
- await stdio.close().catch(() => { });
164
- throw new Error("SSE connection failed after maximum retries");
165
- }
166
- }
167
- }
81
+ finally {
82
+ await proxy.stop().catch(() => { });
168
83
  }
169
84
  }
170
- function sleep(ms) {
171
- return new Promise((resolve) => setTimeout(resolve, ms));
172
- }
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.22",
4
+ "version": "0.3.23",
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",