@pharaoh-so/mcp 0.3.22 → 0.3.24
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 +48 -0
- package/dist/auth.js +78 -25
- package/dist/backend.d.ts +97 -0
- package/dist/backend.js +255 -0
- package/dist/gateway.d.ts +69 -0
- package/dist/gateway.js +162 -0
- package/dist/proxy-server.d.ts +75 -0
- package/dist/proxy-server.js +148 -0
- package/dist/proxy.d.ts +24 -22
- package/dist/proxy.js +38 -126
- package/package.json +1 -1
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,101 @@ 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
|
+
// 5xx is an upstream/transport issue (CDN 503, Render cold-start 502,
|
|
56
|
+
// gateway 504 with an HTML error page). Treat as network_error so the
|
|
57
|
+
// gateway keeps the pending cycle alive and retries on the next tool call
|
|
58
|
+
// — a transient hiccup must not surface to the user as "auth denied".
|
|
59
|
+
if (res.status >= 500) {
|
|
60
|
+
return { status: "network_error", error: `HTTP ${res.status}` };
|
|
61
|
+
}
|
|
62
|
+
const body = (await res.json().catch(() => ({ error: "unknown" })));
|
|
63
|
+
if (body.error === "authorization_pending") {
|
|
64
|
+
return { status: "pending" };
|
|
65
|
+
}
|
|
66
|
+
if (body.error === "slow_down") {
|
|
67
|
+
return { status: "pending", slowDown: true };
|
|
68
|
+
}
|
|
69
|
+
if (body.error === "expired_token") {
|
|
70
|
+
return { status: "expired" };
|
|
71
|
+
}
|
|
72
|
+
if (body.error === "access_denied") {
|
|
73
|
+
return { status: "denied", error: "Authorization was denied." };
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
status: "denied",
|
|
77
|
+
error: `Device token error: ${body.error} — ${body.error_description ?? ""}`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
22
80
|
/**
|
|
23
81
|
* Poll for device authorization completion (RFC 8628 §3.5).
|
|
24
82
|
* Blocks until the user authorizes, the code expires, or an error occurs.
|
|
25
83
|
* Adds 1s jitter to the server-specified interval to avoid thundering herd.
|
|
26
84
|
*/
|
|
27
85
|
export async function pollForToken(baseUrl, deviceCode, interval) {
|
|
28
|
-
|
|
86
|
+
let currentInterval = interval;
|
|
29
87
|
while (true) {
|
|
30
88
|
// Wait interval + 0–1s jitter before each poll
|
|
31
89
|
const jitterMs = Math.random() * 1000;
|
|
32
|
-
await sleep(
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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") {
|
|
90
|
+
await sleep(currentInterval * 1000 + jitterMs);
|
|
91
|
+
const result = await pollOnce(baseUrl, deviceCode);
|
|
92
|
+
if (result.status === "authorized")
|
|
93
|
+
return result.token;
|
|
94
|
+
if (result.status === "expired") {
|
|
51
95
|
throw new Error("Device code expired. Please try again.");
|
|
52
96
|
}
|
|
53
|
-
if (
|
|
54
|
-
throw new Error(
|
|
55
|
-
|
|
56
|
-
|
|
97
|
+
if (result.status === "denied")
|
|
98
|
+
throw new Error(result.error);
|
|
99
|
+
if (result.status === "network_error")
|
|
100
|
+
throw new Error(result.error);
|
|
101
|
+
// result.status === "pending". RFC 8628 §3.5: only bump interval on
|
|
102
|
+
// slow_down (authorization_pending is the normal polling response).
|
|
103
|
+
if (result.slowDown)
|
|
104
|
+
currentInterval += 5;
|
|
57
105
|
}
|
|
58
106
|
}
|
|
59
107
|
/**
|
|
60
108
|
* Open a URL in the user's default browser.
|
|
61
109
|
* Uses platform-specific commands; fails silently if unavailable.
|
|
110
|
+
*
|
|
111
|
+
* Exported for gateway.ts — the state machine fires this once per device code
|
|
112
|
+
* to pre-fill the activation URL in the user's browser. Silent-on-failure is
|
|
113
|
+
* critical for headless environments: a missing `open`/`xdg-open` binary must
|
|
114
|
+
* not crash the proxy.
|
|
62
115
|
*/
|
|
63
|
-
function openBrowser(url) {
|
|
116
|
+
export function openBrowser(url) {
|
|
64
117
|
if (process.platform === "darwin") {
|
|
65
118
|
execFile("open", [url], () => { });
|
|
66
119
|
}
|
|
@@ -0,0 +1,97 @@
|
|
|
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
|
+
/** Backoff schedule for reconnect attempts (milliseconds). Exported for tests. */
|
|
55
|
+
export declare const BACKOFF_MS: number[];
|
|
56
|
+
/**
|
|
57
|
+
* Real SSE-backed backend. Owns an MCP Client + SSEClientTransport and a
|
|
58
|
+
* reconnect loop. Forwards listTools/callTool to the remote server.
|
|
59
|
+
*
|
|
60
|
+
* Reconnect semantics (preserved verbatim from the old startProxy):
|
|
61
|
+
* EventSource's built-in auto-reconnect creates ghost sessions on the
|
|
62
|
+
* server (new auth, new session ID per 3s reconnect). On any non-fatal
|
|
63
|
+
* error we explicitly close() the transport to kill EventSource, then
|
|
64
|
+
* let this loop create a fresh transport for the next attempt. 5 retries
|
|
65
|
+
* (2s, 4s, 8s, 16s, 32s) before giving up.
|
|
66
|
+
*/
|
|
67
|
+
export declare class SseProxyBackend implements Backend {
|
|
68
|
+
private readonly sseUrl;
|
|
69
|
+
private readonly token;
|
|
70
|
+
readonly name: "sse";
|
|
71
|
+
private client;
|
|
72
|
+
private transport;
|
|
73
|
+
private stopped;
|
|
74
|
+
private reconnectPromise;
|
|
75
|
+
constructor(sseUrl: string, token: string);
|
|
76
|
+
start(): Promise<void>;
|
|
77
|
+
/**
|
|
78
|
+
* Expose the long-running reconnect loop so a supervisor can await it.
|
|
79
|
+
* Session C uses this to bubble TokenExpiredError / TenantSuspendedError
|
|
80
|
+
* up to the same handler that used to wrap `startProxy`.
|
|
81
|
+
*/
|
|
82
|
+
waitForExit(): Promise<void>;
|
|
83
|
+
private connectOnce;
|
|
84
|
+
/** Wait for the current transport to close, then decide what to do. */
|
|
85
|
+
private waitForClose;
|
|
86
|
+
private reconnectAfter;
|
|
87
|
+
private runReconnectLoop;
|
|
88
|
+
listTools(): Promise<Tool[]>;
|
|
89
|
+
callTool(name: string, args: Record<string, unknown>): Promise<CallToolResult>;
|
|
90
|
+
stop(): Promise<void>;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Public factory — ProxyServer / Session C build SSE backends via this.
|
|
94
|
+
* Keeps the SseProxyBackend class private so callers can't accidentally
|
|
95
|
+
* depend on its internal reconnect state.
|
|
96
|
+
*/
|
|
97
|
+
export declare function createSseBackend(sseUrl: string, token: string): SseProxyBackend;
|
package/dist/backend.js
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
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). Exported for tests. */
|
|
85
|
+
export 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
|
+
export 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
|
+
// Do NOT increment attempt here — the try block already incremented
|
|
211
|
+
// it before calling reconnectAfter. A second bump would halve the
|
|
212
|
+
// effective retry budget so only ~3 of the 5 BACKOFF_MS slots
|
|
213
|
+
// would fire (skipping the 4s and 16s delays). The guard below
|
|
214
|
+
// catches the max-retries case where reconnectAfter's own throw
|
|
215
|
+
// arrives after we've already spent the budget in the try block.
|
|
216
|
+
if (attempt > BACKOFF_MS.length) {
|
|
217
|
+
throw err instanceof Error
|
|
218
|
+
? err
|
|
219
|
+
: new Error("SSE connection failed after maximum retries");
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
async listTools() {
|
|
225
|
+
if (!this.client)
|
|
226
|
+
throw new Error("SseProxyBackend.listTools() before start()");
|
|
227
|
+
const res = await this.client.listTools();
|
|
228
|
+
return res.tools;
|
|
229
|
+
}
|
|
230
|
+
async callTool(name, args) {
|
|
231
|
+
if (!this.client)
|
|
232
|
+
throw new Error("SseProxyBackend.callTool() before start()");
|
|
233
|
+
const res = await this.client.callTool({ name, arguments: args });
|
|
234
|
+
return res;
|
|
235
|
+
}
|
|
236
|
+
async stop() {
|
|
237
|
+
this.stopped = true;
|
|
238
|
+
if (this.transport) {
|
|
239
|
+
await this.transport.close().catch(() => { });
|
|
240
|
+
this.transport = null;
|
|
241
|
+
}
|
|
242
|
+
if (this.client) {
|
|
243
|
+
await this.client.close().catch(() => { });
|
|
244
|
+
this.client = null;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Public factory — ProxyServer / Session C build SSE backends via this.
|
|
250
|
+
* Keeps the SseProxyBackend class private so callers can't accidentally
|
|
251
|
+
* depend on its internal reconnect state.
|
|
252
|
+
*/
|
|
253
|
+
export function createSseBackend(sseUrl, token) {
|
|
254
|
+
return new SseProxyBackend(sseUrl, token);
|
|
255
|
+
}
|
|
@@ -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;
|
package/dist/gateway.js
ADDED
|
@@ -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,148 @@
|
|
|
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
|
+
// Start the new backend BEFORE swapping the reference. If start() rejects
|
|
91
|
+
// (e.g. SSE connect fails), `prev` stays active — otherwise the proxy
|
|
92
|
+
// would be wedged with a not-yet-started backend that throws on every
|
|
93
|
+
// list/call until the caller tried again. This ordering also closes the
|
|
94
|
+
// race window where an in-flight request could arrive between the
|
|
95
|
+
// reference swap and a slow SSE handshake.
|
|
96
|
+
if (next.start)
|
|
97
|
+
await next.start();
|
|
98
|
+
this.backend = next;
|
|
99
|
+
// Fire the notification after the swap so a client that races a list
|
|
100
|
+
// request against it sees the new catalog.
|
|
101
|
+
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.
|
|
104
|
+
if (prev !== next && prev.stop) {
|
|
105
|
+
prev.stop().catch(() => { });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
async stop() {
|
|
109
|
+
if (this.backend.stop) {
|
|
110
|
+
await this.backend.stop().catch(() => { });
|
|
111
|
+
}
|
|
112
|
+
if (this.transport) {
|
|
113
|
+
await this.server.close().catch(() => { });
|
|
114
|
+
this.transport = null;
|
|
115
|
+
}
|
|
116
|
+
this.started = false;
|
|
117
|
+
}
|
|
118
|
+
getBackend() {
|
|
119
|
+
return this.backend;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Cold-start entry point. No credentials (or refresh-expired creds) — start
|
|
124
|
+
* the proxy in auth-required mode so the stdio connection stays alive while
|
|
125
|
+
* the user completes the device flow.
|
|
126
|
+
*
|
|
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.
|
|
131
|
+
*/
|
|
132
|
+
export async function startInAuthRequiredMode(server) {
|
|
133
|
+
const gateway = createGateway({ server });
|
|
134
|
+
const backend = new AuthRequiredBackend(gateway);
|
|
135
|
+
const proxy = new ProxyServer(backend);
|
|
136
|
+
await proxy.start();
|
|
137
|
+
return { proxy, gateway };
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Happy-path entry point. Credentials are fresh — start directly with the
|
|
141
|
+
* SSE backend. Returned proxy is running and ready for tool calls.
|
|
142
|
+
*/
|
|
143
|
+
export async function startInSseMode(creds) {
|
|
144
|
+
const backend = createSseBackend(creds.sse_url, creds.access_token);
|
|
145
|
+
const proxy = new ProxyServer(backend);
|
|
146
|
+
await proxy.start();
|
|
147
|
+
return proxy;
|
|
148
|
+
}
|
package/dist/proxy.d.ts
CHANGED
|
@@ -1,18 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Stdio ↔ SSE proxy
|
|
3
|
-
*
|
|
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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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:
|
|
13
|
-
* the EventSource
|
|
14
|
-
*
|
|
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
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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
|
-
*
|
|
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
|
|
3
|
-
*
|
|
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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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:
|
|
13
|
-
* the EventSource
|
|
14
|
-
*
|
|
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
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
108
|
-
|
|
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.
|
|
4
|
+
"version": "0.3.24",
|
|
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",
|