@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.
@@ -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
+ }
package/dist/helpers.d.ts CHANGED
@@ -20,7 +20,14 @@ export declare function printUsage(): void;
20
20
  * Falls back to `${server}/sse` if the URL is missing, malformed, or cross-origin.
21
21
  */
22
22
  export declare function resolveSseUrl(tokenSseUrl: string | undefined, server: string): string;
23
- /** Convert a token response to storable credentials. */
23
+ /**
24
+ * Convert a token response to storable credentials.
25
+ *
26
+ * @throws if the server response omits refresh_token — this is a breaking
27
+ * contract, not a graceful degradation: without a refresh token the proxy
28
+ * will eventually hit the same silent-expiry cliff we're trying to eliminate.
29
+ * Fail loud so the server regression surfaces instead of being absorbed.
30
+ */
24
31
  export declare function tokenToCredentials(token: TokenResponse, sseUrl: string): Credentials;
25
32
  /** Format remaining TTL as human-readable string (e.g. "5d 12h"). */
26
33
  export declare function formatTtl(expiresAt: string): string;
package/dist/helpers.js CHANGED
@@ -66,12 +66,26 @@ export function resolveSseUrl(tokenSseUrl, server) {
66
66
  return fallback;
67
67
  }
68
68
  }
69
- /** Convert a token response to storable credentials. */
69
+ /**
70
+ * Convert a token response to storable credentials.
71
+ *
72
+ * @throws if the server response omits refresh_token — this is a breaking
73
+ * contract, not a graceful degradation: without a refresh token the proxy
74
+ * will eventually hit the same silent-expiry cliff we're trying to eliminate.
75
+ * Fail loud so the server regression surfaces instead of being absorbed.
76
+ */
70
77
  export function tokenToCredentials(token, sseUrl) {
78
+ if (!token.refresh_token || !token.refresh_token_expires_in) {
79
+ throw new Error("Pharaoh server response missing refresh_token — server is older than this client. " +
80
+ "Update the server to a version that issues refresh tokens, or downgrade the client.");
81
+ }
82
+ const now = Date.now();
71
83
  return {
72
- version: 1,
84
+ version: 2,
73
85
  access_token: token.access_token,
74
- expires_at: new Date(Date.now() + token.expires_in * 1000).toISOString(),
86
+ refresh_token: token.refresh_token,
87
+ expires_at: new Date(now + token.expires_in * 1000).toISOString(),
88
+ refresh_expires_at: new Date(now + token.refresh_token_expires_in * 1000).toISOString(),
75
89
  expires_in: token.expires_in,
76
90
  sse_url: sseUrl,
77
91
  github_login: token.github_login ?? null,
package/dist/index.js CHANGED
@@ -11,10 +11,12 @@
11
11
  * - Proxy (pipe): require pre-existing credentials, bridge stdio ↔ SSE.
12
12
  */
13
13
  import { pollForToken, printActivationPrompt, printAuthSuccess, requestDeviceCode, } from "./auth.js";
14
- import { deleteCredentials, isExpired, readCredentials, writeCredentials } from "./credentials.js";
15
- import { formatTtl, NPX_COMMAND, parseArgs, printLines, printUsage, resolveSseUrl, tokenToCredentials, } from "./helpers.js";
14
+ import { deleteCredentials, writeCredentials } from "./credentials.js";
15
+ import { formatTtl, parseArgs, printLines, printUsage, resolveSseUrl, tokenToCredentials, } from "./helpers.js";
16
16
  import { runInstallSkills } from "./install-skills.js";
17
+ import { exitForReauth, handleRefreshFailure, tryMidSessionRefresh } from "./lifecycle.js";
17
18
  import { startProxy, TenantSuspendedError, TokenExpiredError } from "./proxy.js";
19
+ import { loadAndRefresh } from "./refresh.js";
18
20
  const BANNER = [
19
21
  "",
20
22
  " ___ _ _ _ ___ _ ___ _ _ ",
@@ -57,7 +59,6 @@ async function main() {
57
59
  printLines("Pharaoh: credentials and MCP config cleared");
58
60
  process.exit(0);
59
61
  }
60
- const creds = readCredentials();
61
62
  const isInteractive = Boolean(process.stdin.isTTY);
62
63
  // ── Interactive mode (user running in a terminal) ──
63
64
  // Full setup every time: fresh auth → register MCP → install skills → done.
@@ -86,11 +87,18 @@ async function main() {
86
87
  process.exit(0);
87
88
  }
88
89
  // ── Proxy mode (Claude Code spawned us as a stdio MCP server) ──
89
- // If no credentials, we can't run the device flow (no TTY for user interaction).
90
- if (!creds || isExpired(creds)) {
91
- printLines("Pharaoh: no valid credentials — cannot start proxy.", "Run this command first to authenticate:", ` ${NPX_COMMAND}`, "");
92
- process.exit(1);
90
+ // Load credentials; refresh silently if the access token is expired but the
91
+ // refresh token is still valid. Anything else → exit with guidance.
92
+ const outcome = await loadAndRefresh(server);
93
+ if (outcome === null) {
94
+ // No credentials file at all — first-time run or user logged out.
95
+ exitForReauth("no credentials — run authentication first");
93
96
  }
97
+ if (!outcome.ok) {
98
+ handleRefreshFailure(outcome.action, outcome.message);
99
+ return; // unreachable — handleRefreshFailure calls process.exit
100
+ }
101
+ let creds = outcome.creds;
94
102
  // Valid credentials — ensure skills are installed before starting proxy
95
103
  // Silently installs/updates on first run or package update; fast no-op if current.
96
104
  try {
@@ -105,8 +113,35 @@ async function main() {
105
113
  }
106
114
  catch (err) {
107
115
  if (err instanceof TokenExpiredError) {
108
- printLines("Pharaoh: token expired or revoked.", "Run this command to re-authenticate:", ` ${NPX_COMMAND}`, "");
109
- deleteCredentials();
116
+ // Mid-session token expired — attempt one silent refresh + retry
117
+ // before giving up. This is what makes the proxy self-heal across
118
+ // long sessions rather than dying at arbitrary moments.
119
+ const retryCreds = await tryMidSessionRefresh(creds, server);
120
+ if (retryCreds) {
121
+ creds = retryCreds;
122
+ printLines("Pharaoh: token refreshed mid-session — reconnecting");
123
+ try {
124
+ await startProxy(creds.sse_url, creds.access_token);
125
+ return;
126
+ }
127
+ catch (retryErr) {
128
+ if (retryErr instanceof TokenExpiredError) {
129
+ // Second 401 after a successful refresh — the fresh tokens
130
+ // are already rejected, so the session is genuinely dead.
131
+ // Delete creds before exit so the next spawn shows the
132
+ // re-auth prompt instead of looping on the just-rejected
133
+ // access token (which `isExpired` would still say is fresh).
134
+ deleteCredentials();
135
+ exitForReauth("token rejected after refresh");
136
+ }
137
+ if (retryErr instanceof TenantSuspendedError) {
138
+ printLines(`Pharaoh: ${retryErr.message}`);
139
+ process.exit(1);
140
+ }
141
+ throw retryErr;
142
+ }
143
+ }
144
+ // Refresh failed — exit message already printed by tryMidSessionRefresh
110
145
  process.exit(1);
111
146
  }
112
147
  if (err instanceof TenantSuspendedError) {
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Proxy lifecycle helpers — credential state transitions and re-auth messaging.
3
+ *
4
+ * Extracted from index.ts so the branching logic can be unit-tested without
5
+ * booting the full CLI. Imported by main() and exercised as a real runtime
6
+ * dependency — no "test-only exports" here.
7
+ */
8
+ import { type Credentials } from "./credentials.js";
9
+ import { type RefreshFailureAction } from "./refresh.js";
10
+ /**
11
+ * Attempt a silent refresh triggered by a mid-session TokenExpiredError.
12
+ * Returns new credentials on success, null on any failure (messages already
13
+ * printed). Caller decides whether to retry startProxy.
14
+ */
15
+ export declare function tryMidSessionRefresh(creds: Credentials, server: string): Promise<Credentials | null>;
16
+ /**
17
+ * Handle a failed `loadAndRefresh` at startup. Exits the process with the
18
+ * appropriate message and creds-file state per the classified action.
19
+ */
20
+ export declare function handleRefreshFailure(action: RefreshFailureAction, message: string): never;
21
+ /**
22
+ * Print a re-auth prompt and exit.
23
+ *
24
+ * Used for terminal lifecycle states where no refresh is possible:
25
+ * - Missing credentials file (first-time run, post-logout)
26
+ * - A fresh access token is rejected mid-session AFTER a successful refresh
27
+ * (deleteCredentials is called explicitly before this in that case so the
28
+ * next spawn sees a cold-start rather than looping on stale creds)
29
+ */
30
+ export declare function exitForReauth(reason: string): never;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Proxy lifecycle helpers — credential state transitions and re-auth messaging.
3
+ *
4
+ * Extracted from index.ts so the branching logic can be unit-tested without
5
+ * booting the full CLI. Imported by main() and exercised as a real runtime
6
+ * dependency — no "test-only exports" here.
7
+ */
8
+ import { deleteCredentials } from "./credentials.js";
9
+ import { NPX_COMMAND, printLines } from "./helpers.js";
10
+ import { refreshIfNeeded } from "./refresh.js";
11
+ /**
12
+ * Attempt a silent refresh triggered by a mid-session TokenExpiredError.
13
+ * Returns new credentials on success, null on any failure (messages already
14
+ * printed). Caller decides whether to retry startProxy.
15
+ */
16
+ export async function tryMidSessionRefresh(creds, server) {
17
+ const outcome = await refreshIfNeeded(creds, server);
18
+ if (outcome.ok)
19
+ return outcome.creds;
20
+ // Log the failure reason before caller's exit path runs.
21
+ if (outcome.action === "reauth") {
22
+ printLines("Pharaoh: refresh rejected — re-authentication required.", ` ${NPX_COMMAND}`, "");
23
+ deleteCredentials();
24
+ }
25
+ else {
26
+ // preserve / transient — keep creds, print diagnostic + next-step hint so
27
+ // the user knows their credentials file is intact (matches startup path).
28
+ printLines(`Pharaoh: refresh failed (${outcome.action}) — ${outcome.message}`, "Credentials preserved; next startup will retry.", "");
29
+ }
30
+ return null;
31
+ }
32
+ /**
33
+ * Handle a failed `loadAndRefresh` at startup. Exits the process with the
34
+ * appropriate message and creds-file state per the classified action.
35
+ */
36
+ export function handleRefreshFailure(action, message) {
37
+ if (action === "reauth") {
38
+ deleteCredentials();
39
+ printLines("Pharaoh: session no longer valid — re-authenticate.", ` ${NPX_COMMAND}`, "");
40
+ process.exit(1);
41
+ }
42
+ // preserve / transient — keep creds, exit with diagnostic. Next spawn will
43
+ // try again; if the underlying cause clears, it just works.
44
+ printLines(`Pharaoh: refresh failed (${action}) — ${message}`, "Credentials preserved; next startup will retry.", "");
45
+ process.exit(1);
46
+ }
47
+ /**
48
+ * Print a re-auth prompt and exit.
49
+ *
50
+ * Used for terminal lifecycle states where no refresh is possible:
51
+ * - Missing credentials file (first-time run, post-logout)
52
+ * - A fresh access token is rejected mid-session AFTER a successful refresh
53
+ * (deleteCredentials is called explicitly before this in that case so the
54
+ * next spawn sees a cold-start rather than looping on stale creds)
55
+ */
56
+ export function exitForReauth(reason) {
57
+ printLines(`Pharaoh: ${reason}.`, "Run this command to authenticate:", ` ${NPX_COMMAND}`, "");
58
+ process.exit(1);
59
+ }
@@ -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
+ }