@pharaoh-so/mcp 0.3.21 → 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/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
- }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Silent access-token renewal via OAuth2 refresh grant.
3
+ *
4
+ * Called at proxy startup (when access is expired but refresh is valid) and on
5
+ * mid-session 401 (from proxy.ts TokenExpiredError handler). Success writes new
6
+ * credentials to disk. Failure returns a classified action so the caller can
7
+ * decide whether to delete credentials or preserve them for a later retry.
8
+ */
9
+ import type { Credentials } from "./credentials.js";
10
+ /** Outcome of a refresh attempt. */
11
+ export type RefreshOutcome = {
12
+ ok: true;
13
+ creds: Credentials;
14
+ } | {
15
+ ok: false;
16
+ action: RefreshFailureAction;
17
+ message: string;
18
+ };
19
+ /**
20
+ * How the caller should handle a failed refresh.
21
+ *
22
+ * - `reauth`: server says the refresh token is dead (revoked, expired past
23
+ * grace, user removed from org). Delete credentials and prompt
24
+ * for re-auth on next start.
25
+ * - `preserve`: transient server/network failure (503, fetch error). Keep the
26
+ * credentials file intact — the next startup may succeed.
27
+ * - `transient`: unexpected response shape we can't confidently classify. Keep
28
+ * credentials, exit with a diagnostic message. If this branch
29
+ * fires often in production, tighten classification.
30
+ */
31
+ export type RefreshFailureAction = "reauth" | "preserve" | "transient";
32
+ /**
33
+ * Attempt to renew the access token using the stored refresh token.
34
+ *
35
+ * On success: writes new credentials to disk and returns them.
36
+ * On failure: returns a classified action — caller owns the creds lifecycle.
37
+ *
38
+ * @param creds current (possibly-expired-access) credentials
39
+ * @param server Pharaoh server base URL (e.g. "https://mcp.pharaoh.so")
40
+ * @param path optional custom credentials path (for tests)
41
+ */
42
+ export declare function refreshIfNeeded(creds: Credentials, server: string, path?: string): Promise<RefreshOutcome>;
43
+ /**
44
+ * Read credentials from disk and refresh if the access token is expired.
45
+ *
46
+ * Returns:
47
+ * - `null` if no credentials exist (caller should show re-auth instructions)
48
+ * - `RefreshOutcome` with `ok: true` if creds are fresh or refresh succeeded
49
+ * - `RefreshOutcome` with `ok: false` if refresh failed (check `.action`)
50
+ *
51
+ * Intended use: called at proxy startup to replace the current "die if expired"
52
+ * branch in index.ts.
53
+ */
54
+ export declare function loadAndRefresh(server: string, path?: string): Promise<RefreshOutcome | null>;
@@ -0,0 +1,157 @@
1
+ import { isRefreshExpired, readCredentials, writeCredentials } from "./credentials.js";
2
+ /** Classify a non-200 response from the /token refresh endpoint. */
3
+ function classifyRefreshError(status, body) {
4
+ // 503 or network — the server or GitHub API had a blip. Keep the refresh
5
+ // token; the next proxy spawn will retry. Blowing away good creds on a
6
+ // transient failure is exactly the "forced re-auth" pattern we're trying
7
+ // to eliminate (VestaTech-BV churn was this exact shape).
8
+ if (status >= 500)
9
+ return "preserve";
10
+ // 400 invalid_grant covers all three terminal cases from the server:
11
+ // • user removed from GitHub org (not_member)
12
+ // • session unreadable / pre-GCM CBC (reauth_required)
13
+ // • refresh token expired or rotated out of grace window
14
+ // All three mean: the creds cannot produce a working session. Delete them
15
+ // so the next spawn shows the re-auth prompt instead of looping forever.
16
+ if (status === 400 && body.error === "invalid_grant")
17
+ return "reauth";
18
+ // Anything else — 401 without invalid_grant, 403, 404, parse error, etc.
19
+ // Don't delete creds (might be recoverable), but don't pretend it's
20
+ // transient either. Log-friendly exit, preserve creds, let the next spawn
21
+ // retry. If this case fires in practice, we'll see it in logs and tighten
22
+ // the classification.
23
+ return "transient";
24
+ }
25
+ /**
26
+ * Attempt to renew the access token using the stored refresh token.
27
+ *
28
+ * On success: writes new credentials to disk and returns them.
29
+ * On failure: returns a classified action — caller owns the creds lifecycle.
30
+ *
31
+ * @param creds current (possibly-expired-access) credentials
32
+ * @param server Pharaoh server base URL (e.g. "https://mcp.pharaoh.so")
33
+ * @param path optional custom credentials path (for tests)
34
+ */
35
+ export async function refreshIfNeeded(creds, server, path) {
36
+ // Guard: refresh token itself expired. No point hitting the server — the
37
+ // grant will fail with invalid_grant. Short-circuit to reauth.
38
+ if (isRefreshExpired(creds)) {
39
+ return {
40
+ ok: false,
41
+ action: "reauth",
42
+ message: "Refresh token expired",
43
+ };
44
+ }
45
+ let res;
46
+ try {
47
+ // POST to /device/token-refresh — RFC 8628 device-flow refresh. Distinct
48
+ // from /token (browser OAuth) because device flow has no client_id —
49
+ // the refresh_token itself is the credential.
50
+ res = await fetch(`${server}/device/token-refresh`, {
51
+ method: "POST",
52
+ headers: { "Content-Type": "application/json" },
53
+ body: JSON.stringify({
54
+ refresh_token: creds.refresh_token,
55
+ }),
56
+ });
57
+ }
58
+ catch (err) {
59
+ // Network failure — keep creds, let the next spawn retry.
60
+ return {
61
+ ok: false,
62
+ action: "preserve",
63
+ message: `Network error: ${err instanceof Error ? err.message : String(err)}`,
64
+ };
65
+ }
66
+ if (res.ok) {
67
+ // Parse body inside try/catch — a 200 response with truncated/malformed
68
+ // JSON (CDN/proxy mangling, connection drop mid-stream) would otherwise
69
+ // throw SyntaxError out of refreshIfNeeded and hit main()'s redact-only
70
+ // catch, giving the user no classification.
71
+ let token;
72
+ try {
73
+ token = (await res.json());
74
+ }
75
+ catch (err) {
76
+ return {
77
+ ok: false,
78
+ action: "transient",
79
+ message: `Malformed JSON in refresh response: ${err instanceof Error ? err.message : String(err)}`,
80
+ };
81
+ }
82
+ if (!token.access_token ||
83
+ !token.refresh_token ||
84
+ !token.expires_in ||
85
+ !token.refresh_token_expires_in) {
86
+ // Server OK'd the request but returned an incomplete body. Treat as
87
+ // transient rather than reauth — the creds we have are still valid.
88
+ // (0 is not a legal OAuth2 TTL per RFC 6749; a server returning 0 is
89
+ // a regression, not a signal to delete credentials.)
90
+ return {
91
+ ok: false,
92
+ action: "transient",
93
+ message: "Refresh response missing required fields",
94
+ };
95
+ }
96
+ const now = Date.now();
97
+ const newCreds = {
98
+ ...creds,
99
+ access_token: token.access_token,
100
+ refresh_token: token.refresh_token,
101
+ expires_at: new Date(now + token.expires_in * 1000).toISOString(),
102
+ refresh_expires_at: new Date(now + token.refresh_token_expires_in * 1000).toISOString(),
103
+ expires_in: token.expires_in,
104
+ };
105
+ // Persist new credentials. If the disk write fails (EACCES, ENOSPC,
106
+ // EROFS), report `preserve` — the server already rotated the token
107
+ // server-side, so the old refresh_token is now in grace. Returning
108
+ // `ok: true` here would mean the next spawn reads stale creds and
109
+ // attempts to refresh with a rotated token, forcing an unnecessary
110
+ // re-auth. Preserving lets the user fix the disk issue and retry
111
+ // within the grace window.
112
+ try {
113
+ writeCredentials(newCreds, path);
114
+ }
115
+ catch (err) {
116
+ return {
117
+ ok: false,
118
+ action: "preserve",
119
+ message: `Could not persist refreshed credentials: ${err instanceof Error ? err.message : String(err)}`,
120
+ };
121
+ }
122
+ return { ok: true, creds: newCreds };
123
+ }
124
+ let body = {};
125
+ try {
126
+ body = (await res.json());
127
+ }
128
+ catch {
129
+ // ALLOWED-CATCH: non-JSON body — classify on status alone
130
+ }
131
+ const action = classifyRefreshError(res.status, body);
132
+ const message = body.error_description ?? body.error ?? `HTTP ${res.status} ${res.statusText}`;
133
+ return { ok: false, action, message };
134
+ }
135
+ /**
136
+ * Read credentials from disk and refresh if the access token is expired.
137
+ *
138
+ * Returns:
139
+ * - `null` if no credentials exist (caller should show re-auth instructions)
140
+ * - `RefreshOutcome` with `ok: true` if creds are fresh or refresh succeeded
141
+ * - `RefreshOutcome` with `ok: false` if refresh failed (check `.action`)
142
+ *
143
+ * Intended use: called at proxy startup to replace the current "die if expired"
144
+ * branch in index.ts.
145
+ */
146
+ export async function loadAndRefresh(server, path) {
147
+ const creds = readCredentials(path);
148
+ if (!creds)
149
+ return null;
150
+ // Access token still valid — no refresh needed.
151
+ const expiresAt = new Date(creds.expires_at).getTime();
152
+ const bufferMs = creds.expires_in * 1000 * 0.1;
153
+ if (Date.now() < expiresAt - bufferMs) {
154
+ return { ok: true, creds };
155
+ }
156
+ return refreshIfNeeded(creds, server, path);
157
+ }
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.21",
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",