@pharaoh-so/mcp 0.1.0
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/README.md +33 -0
- package/dist/auth.d.ts +45 -0
- package/dist/auth.js +93 -0
- package/dist/auth.js.map +1 -0
- package/dist/credentials.d.ts +33 -0
- package/dist/credentials.js +73 -0
- package/dist/credentials.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +175 -0
- package/dist/index.js.map +1 -0
- package/dist/proxy.d.ts +24 -0
- package/dist/proxy.js +157 -0
- package/dist/proxy.js.map +1 -0
- package/package.json +28 -0
- package/src/auth.ts +143 -0
- package/src/credentials.ts +92 -0
- package/src/index.ts +203 -0
- package/src/proxy.ts +168 -0
- package/tsconfig.json +18 -0
package/README.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# @pharaoh-so/mcp
|
|
2
|
+
|
|
3
|
+
Stdio-to-SSE proxy for [Pharaoh](https://pharaoh.so) — enables Claude Code in headless environments (VPS, SSH, CI) where a browser isn't available for OAuth.
|
|
4
|
+
|
|
5
|
+
## Quick Start
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
claude mcp add pharaoh -- npx @pharaoh-so/mcp
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
On first run, the proxy shows a device code and URL. Open the URL on any device (phone, laptop) to authorize. The proxy stores credentials at `~/.pharaoh/credentials.json` and reuses them until they expire (7 days).
|
|
12
|
+
|
|
13
|
+
## How It Works
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
Claude Code ← stdio → @pharaoh-so/mcp ← SSE/HTTP → mcp.pharaoh.so
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The proxy presents itself as an MCP server to Claude Code via stdio, and relays all messages to the remote Pharaoh server over SSE. Authentication uses the RFC 8628 device authorization flow — no browser required on the machine running Claude Code.
|
|
20
|
+
|
|
21
|
+
## Options
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
--server <url> Pharaoh server URL (default: https://mcp.pharaoh.so)
|
|
25
|
+
--logout Clear stored credentials and exit
|
|
26
|
+
--help Show help
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Security
|
|
30
|
+
|
|
31
|
+
- Credentials stored with `0600` permissions (owner-read/write only)
|
|
32
|
+
- Only use trusted server URLs — the proxy sends your auth token to the configured server
|
|
33
|
+
- Tokens expire after 7 days; re-authorization is automatic
|
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device flow client for Pharaoh MCP proxy (RFC 8628).
|
|
3
|
+
* All user-visible output goes to stderr — stdout is reserved for JSON-RPC.
|
|
4
|
+
*/
|
|
5
|
+
/** Response from POST /device. */
|
|
6
|
+
export interface DeviceCodeResponse {
|
|
7
|
+
device_code: string;
|
|
8
|
+
user_code: string;
|
|
9
|
+
verification_uri: string;
|
|
10
|
+
verification_uri_complete: string;
|
|
11
|
+
expires_in: number;
|
|
12
|
+
interval: number;
|
|
13
|
+
}
|
|
14
|
+
/** Successful response from POST /device/token. */
|
|
15
|
+
export interface TokenResponse {
|
|
16
|
+
access_token: string;
|
|
17
|
+
token_type: string;
|
|
18
|
+
expires_in: number;
|
|
19
|
+
sse_url: string;
|
|
20
|
+
github_login?: string;
|
|
21
|
+
tenant_name?: string;
|
|
22
|
+
repos?: string[];
|
|
23
|
+
provisional?: boolean;
|
|
24
|
+
install_url?: string;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Request a device code from the Pharaoh server (RFC 8628 §3.1).
|
|
28
|
+
* Returns the device code, user code, and verification URIs.
|
|
29
|
+
*/
|
|
30
|
+
export declare function requestDeviceCode(baseUrl: string): Promise<DeviceCodeResponse>;
|
|
31
|
+
/**
|
|
32
|
+
* Poll for device authorization completion (RFC 8628 §3.5).
|
|
33
|
+
* Blocks until the user authorizes, the code expires, or an error occurs.
|
|
34
|
+
* Adds 1s jitter to the server-specified interval to avoid thundering herd.
|
|
35
|
+
*/
|
|
36
|
+
export declare function pollForToken(baseUrl: string, deviceCode: string, interval: number): Promise<TokenResponse>;
|
|
37
|
+
/**
|
|
38
|
+
* Print the device activation prompt to stderr.
|
|
39
|
+
* Shows the user code and verification URI in a visible box.
|
|
40
|
+
*/
|
|
41
|
+
export declare function printActivationPrompt(userCode: string, verificationUri: string): void;
|
|
42
|
+
/**
|
|
43
|
+
* Print authentication success message to stderr.
|
|
44
|
+
*/
|
|
45
|
+
export declare function printAuthSuccess(login: string | null, tenant: string | null, repoCount: number): void;
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device flow client for Pharaoh MCP proxy (RFC 8628).
|
|
3
|
+
* All user-visible output goes to stderr — stdout is reserved for JSON-RPC.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Request a device code from the Pharaoh server (RFC 8628 §3.1).
|
|
7
|
+
* Returns the device code, user code, and verification URIs.
|
|
8
|
+
*/
|
|
9
|
+
export async function requestDeviceCode(baseUrl) {
|
|
10
|
+
const res = await fetch(`${baseUrl}/device`, {
|
|
11
|
+
method: "POST",
|
|
12
|
+
headers: { "Content-Type": "application/json" },
|
|
13
|
+
body: JSON.stringify({}),
|
|
14
|
+
});
|
|
15
|
+
if (!res.ok) {
|
|
16
|
+
const text = await res.text().catch(() => "");
|
|
17
|
+
throw new Error(`Device code request failed (${res.status}): ${text}`);
|
|
18
|
+
}
|
|
19
|
+
return (await res.json());
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Poll for device authorization completion (RFC 8628 §3.5).
|
|
23
|
+
* Blocks until the user authorizes, the code expires, or an error occurs.
|
|
24
|
+
* Adds 1s jitter to the server-specified interval to avoid thundering herd.
|
|
25
|
+
*/
|
|
26
|
+
export async function pollForToken(baseUrl, deviceCode, interval) {
|
|
27
|
+
const grantType = "urn:ietf:params:oauth:grant-type:device_code";
|
|
28
|
+
while (true) {
|
|
29
|
+
// Wait interval + 0–1s jitter before each poll
|
|
30
|
+
const jitterMs = Math.random() * 1000;
|
|
31
|
+
await sleep(interval * 1000 + jitterMs);
|
|
32
|
+
const res = await fetch(`${baseUrl}/device/token`, {
|
|
33
|
+
method: "POST",
|
|
34
|
+
headers: { "Content-Type": "application/json" },
|
|
35
|
+
body: JSON.stringify({ device_code: deviceCode, grant_type: grantType }),
|
|
36
|
+
});
|
|
37
|
+
if (res.ok) {
|
|
38
|
+
return (await res.json());
|
|
39
|
+
}
|
|
40
|
+
const body = (await res.json().catch(() => ({ error: "unknown" })));
|
|
41
|
+
if (body.error === "authorization_pending") {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (body.error === "slow_down") {
|
|
45
|
+
// RFC 8628 §3.5: increase interval by 5 seconds
|
|
46
|
+
interval += 5;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (body.error === "expired_token") {
|
|
50
|
+
throw new Error("Device code expired. Please try again.");
|
|
51
|
+
}
|
|
52
|
+
if (body.error === "access_denied") {
|
|
53
|
+
throw new Error("Authorization was denied.");
|
|
54
|
+
}
|
|
55
|
+
throw new Error(`Device token error: ${body.error} — ${body.error_description ?? ""}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Print the device activation prompt to stderr.
|
|
60
|
+
* Shows the user code and verification URI in a visible box.
|
|
61
|
+
*/
|
|
62
|
+
export function printActivationPrompt(userCode, verificationUri) {
|
|
63
|
+
const lines = [
|
|
64
|
+
"",
|
|
65
|
+
"┌───────────────────────────────────────────┐",
|
|
66
|
+
"│ Pharaoh — Device Authorization │",
|
|
67
|
+
"│ │",
|
|
68
|
+
`│ Code: ${userCode.padEnd(33)}│`,
|
|
69
|
+
`│ URL: ${verificationUri.padEnd(33)}│`,
|
|
70
|
+
"│ │",
|
|
71
|
+
"│ Open the URL and enter the code to sign │",
|
|
72
|
+
"│ in. You can do this on any device. │",
|
|
73
|
+
"└───────────────────────────────────────────┘",
|
|
74
|
+
"",
|
|
75
|
+
];
|
|
76
|
+
process.stderr.write(lines.join("\n") + "\n");
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Print authentication success message to stderr.
|
|
80
|
+
*/
|
|
81
|
+
export function printAuthSuccess(login, tenant, repoCount) {
|
|
82
|
+
const parts = ["Pharaoh: authenticated"];
|
|
83
|
+
if (login)
|
|
84
|
+
parts.push(`as ${login}`);
|
|
85
|
+
if (tenant)
|
|
86
|
+
parts.push(`(${tenant})`);
|
|
87
|
+
parts.push(`— ${repoCount} repo${repoCount === 1 ? "" : "s"} connected`);
|
|
88
|
+
process.stderr.write(`${parts.join(" ")}\n`);
|
|
89
|
+
}
|
|
90
|
+
function sleep(ms) {
|
|
91
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
92
|
+
}
|
|
93
|
+
//# sourceMappingURL=auth.js.map
|
package/dist/auth.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auth.js","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Current credential schema version — bump when the format changes. */
|
|
2
|
+
declare const SCHEMA_VERSION = 1;
|
|
3
|
+
/** Stored credential shape. */
|
|
4
|
+
export interface Credentials {
|
|
5
|
+
version: typeof SCHEMA_VERSION;
|
|
6
|
+
access_token: string;
|
|
7
|
+
/** ISO 8601 timestamp when the token expires. */
|
|
8
|
+
expires_at: string;
|
|
9
|
+
/** Original TTL in seconds (from the token response). */
|
|
10
|
+
expires_in: number;
|
|
11
|
+
sse_url: string;
|
|
12
|
+
github_login: string | null;
|
|
13
|
+
tenant_name: string | null;
|
|
14
|
+
repos: string[];
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Read and parse stored credentials. Returns null if missing, unreadable,
|
|
18
|
+
* malformed, or wrong schema version.
|
|
19
|
+
*/
|
|
20
|
+
export declare function readCredentials(path?: string): Credentials | null;
|
|
21
|
+
/**
|
|
22
|
+
* Write credentials to disk with restrictive permissions.
|
|
23
|
+
* Creates the parent directory (0700) if it doesn't exist.
|
|
24
|
+
*/
|
|
25
|
+
export declare function writeCredentials(creds: Credentials, path?: string): void;
|
|
26
|
+
/** Delete the credential file. No-op if it doesn't exist. */
|
|
27
|
+
export declare function deleteCredentials(path?: string): void;
|
|
28
|
+
/**
|
|
29
|
+
* Check if credentials are expired or within 10% of expiry.
|
|
30
|
+
* The 10% buffer prevents connecting with a token that will expire mid-session.
|
|
31
|
+
*/
|
|
32
|
+
export declare function isExpired(creds: Credentials): boolean;
|
|
33
|
+
export {};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credential storage for Pharaoh MCP proxy — persists device flow tokens to ~/.pharaoh/credentials.json.
|
|
3
|
+
* File permissions: 0600 (credentials.json), 0700 (~/.pharaoh/).
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { dirname, join } from "node:path";
|
|
8
|
+
/** Current credential schema version — bump when the format changes. */
|
|
9
|
+
const SCHEMA_VERSION = 1;
|
|
10
|
+
/** Default credential file path. */
|
|
11
|
+
const CREDENTIALS_PATH = join(homedir(), ".pharaoh", "credentials.json");
|
|
12
|
+
/**
|
|
13
|
+
* Read and parse stored credentials. Returns null if missing, unreadable,
|
|
14
|
+
* malformed, or wrong schema version.
|
|
15
|
+
*/
|
|
16
|
+
export function readCredentials(path = CREDENTIALS_PATH) {
|
|
17
|
+
try {
|
|
18
|
+
const raw = readFileSync(path, "utf-8");
|
|
19
|
+
const parsed = JSON.parse(raw);
|
|
20
|
+
if (!isValidCredentials(parsed))
|
|
21
|
+
return null;
|
|
22
|
+
return parsed;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Write credentials to disk with restrictive permissions.
|
|
30
|
+
* Creates the parent directory (0700) if it doesn't exist.
|
|
31
|
+
*/
|
|
32
|
+
export function writeCredentials(creds, path = CREDENTIALS_PATH) {
|
|
33
|
+
const dir = dirname(path);
|
|
34
|
+
if (!existsSync(dir)) {
|
|
35
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
36
|
+
}
|
|
37
|
+
writeFileSync(path, JSON.stringify(creds, null, "\t"), { mode: 0o600 });
|
|
38
|
+
}
|
|
39
|
+
/** Delete the credential file. No-op if it doesn't exist. */
|
|
40
|
+
export function deleteCredentials(path = CREDENTIALS_PATH) {
|
|
41
|
+
try {
|
|
42
|
+
unlinkSync(path);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// File doesn't exist — nothing to delete
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Check if credentials are expired or within 10% of expiry.
|
|
50
|
+
* The 10% buffer prevents connecting with a token that will expire mid-session.
|
|
51
|
+
*/
|
|
52
|
+
export function isExpired(creds) {
|
|
53
|
+
const expiresAt = new Date(creds.expires_at).getTime();
|
|
54
|
+
if (Number.isNaN(expiresAt))
|
|
55
|
+
return true;
|
|
56
|
+
const now = Date.now();
|
|
57
|
+
const totalTtlMs = creds.expires_in * 1000;
|
|
58
|
+
const bufferMs = totalTtlMs * 0.1;
|
|
59
|
+
return now >= expiresAt - bufferMs;
|
|
60
|
+
}
|
|
61
|
+
/** Type guard for valid credential objects. */
|
|
62
|
+
function isValidCredentials(value) {
|
|
63
|
+
if (typeof value !== "object" || value === null)
|
|
64
|
+
return false;
|
|
65
|
+
const obj = value;
|
|
66
|
+
return (obj.version === SCHEMA_VERSION &&
|
|
67
|
+
typeof obj.access_token === "string" &&
|
|
68
|
+
typeof obj.expires_at === "string" &&
|
|
69
|
+
typeof obj.expires_in === "number" &&
|
|
70
|
+
typeof obj.sse_url === "string" &&
|
|
71
|
+
Array.isArray(obj.repos));
|
|
72
|
+
}
|
|
73
|
+
//# sourceMappingURL=credentials.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"credentials.js","sourceRoot":"","sources":["../src/credentials.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,EAAE,UAAU,EAAE,SAAS,EAAE,YAAY,EAAE,UAAU,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AACzF,OAAO,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAClC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAE1C,wEAAwE;AACxE,MAAM,cAAc,GAAG,CAAC,CAAC;AAgBzB,oCAAoC;AACpC,MAAM,gBAAgB,GAAG,IAAI,CAAC,OAAO,EAAE,EAAE,UAAU,EAAE,kBAAkB,CAAC,CAAC;AAEzE;;;GAGG;AACH,MAAM,UAAU,eAAe,CAAC,IAAI,GAAG,gBAAgB;IACtD,IAAI,CAAC;QACJ,MAAM,GAAG,GAAG,YAAY,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC;QACxC,MAAM,MAAM,GAAY,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;QACxC,IAAI,CAAC,kBAAkB,CAAC,MAAM,CAAC;YAAE,OAAO,IAAI,CAAC;QAC7C,OAAO,MAAM,CAAC;IACf,CAAC;IAAC,MAAM,CAAC;QACR,OAAO,IAAI,CAAC;IACb,CAAC;AACF,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,gBAAgB,CAAC,KAAkB,EAAE,IAAI,GAAG,gBAAgB;IAC3E,MAAM,GAAG,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1B,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,EAAE,CAAC;QACtB,SAAS,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;IAClD,CAAC;IACD,aAAa,CAAC,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,KAAK,EAAE,IAAI,EAAE,IAAI,CAAC,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;AACzE,CAAC;AAED,6DAA6D;AAC7D,MAAM,UAAU,iBAAiB,CAAC,IAAI,GAAG,gBAAgB;IACxD,IAAI,CAAC;QACJ,UAAU,CAAC,IAAI,CAAC,CAAC;IAClB,CAAC;IAAC,MAAM,CAAC;QACR,yCAAyC;IAC1C,CAAC;AACF,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,SAAS,CAAC,KAAkB;IAC3C,MAAM,SAAS,GAAG,IAAI,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC,OAAO,EAAE,CAAC;IACvD,IAAI,MAAM,CAAC,KAAK,CAAC,SAAS,CAAC;QAAE,OAAO,IAAI,CAAC;IAEzC,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;IACvB,MAAM,UAAU,GAAG,KAAK,CAAC,UAAU,GAAG,IAAI,CAAC;IAC3C,MAAM,QAAQ,GAAG,UAAU,GAAG,GAAG,CAAC;IAElC,OAAO,GAAG,IAAI,SAAS,GAAG,QAAQ,CAAC;AACpC,CAAC;AAED,+CAA+C;AAC/C,SAAS,kBAAkB,CAAC,KAAc;IACzC,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,KAAK,IAAI;QAAE,OAAO,KAAK,CAAC;IAC9D,MAAM,GAAG,GAAG,KAAgC,CAAC;IAC7C,OAAO,CACN,GAAG,CAAC,OAAO,KAAK,cAAc;QAC9B,OAAO,GAAG,CAAC,YAAY,KAAK,QAAQ;QACpC,OAAO,GAAG,CAAC,UAAU,KAAK,QAAQ;QAClC,OAAO,GAAG,CAAC,UAAU,KAAK,QAAQ;QAClC,OAAO,GAAG,CAAC,OAAO,KAAK,QAAQ;QAC/B,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,CACxB,CAAC;AACH,CAAC"}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @pharaoh-so/mcp — Stdio-to-SSE proxy for headless MCP environments.
|
|
4
|
+
*
|
|
5
|
+
* Presents as an MCP server on stdio (for Claude Code) and relays messages
|
|
6
|
+
* to a remote Pharaoh SSE server. Authenticates via RFC 8628 device flow
|
|
7
|
+
* so the user can authorize on any device with a browser.
|
|
8
|
+
*/
|
|
9
|
+
import { printActivationPrompt, printAuthSuccess, pollForToken, requestDeviceCode, } from "./auth.js";
|
|
10
|
+
import { deleteCredentials, isExpired, readCredentials, writeCredentials, } from "./credentials.js";
|
|
11
|
+
import { TokenExpiredError, TenantSuspendedError, startProxy } from "./proxy.js";
|
|
12
|
+
const DEFAULT_SERVER = "https://mcp.pharaoh.so";
|
|
13
|
+
/** Parse CLI arguments. */
|
|
14
|
+
function parseArgs() {
|
|
15
|
+
const args = process.argv.slice(2);
|
|
16
|
+
let server = DEFAULT_SERVER;
|
|
17
|
+
let logout = false;
|
|
18
|
+
for (let i = 0; i < args.length; i++) {
|
|
19
|
+
if (args[i] === "--server" && args[i + 1]) {
|
|
20
|
+
server = args[i + 1];
|
|
21
|
+
i++;
|
|
22
|
+
}
|
|
23
|
+
else if (args[i] === "--logout") {
|
|
24
|
+
logout = true;
|
|
25
|
+
}
|
|
26
|
+
else if (args[i] === "--help" || args[i] === "-h") {
|
|
27
|
+
printUsage();
|
|
28
|
+
process.exit(0);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
// Strip trailing slash
|
|
32
|
+
server = server.replace(/\/+$/, "");
|
|
33
|
+
return { server, logout };
|
|
34
|
+
}
|
|
35
|
+
function printUsage() {
|
|
36
|
+
process.stderr.write([
|
|
37
|
+
"Usage: pharaoh-mcp [options]",
|
|
38
|
+
"",
|
|
39
|
+
"Options:",
|
|
40
|
+
" --server <url> Pharaoh server URL (default: https://mcp.pharaoh.so)",
|
|
41
|
+
" --logout Clear stored credentials and exit",
|
|
42
|
+
" --help, -h Show this help",
|
|
43
|
+
"",
|
|
44
|
+
"Add to Claude Code:",
|
|
45
|
+
" claude mcp add pharaoh -- npx @pharaoh-so/mcp",
|
|
46
|
+
"",
|
|
47
|
+
].join("\n") + "\n");
|
|
48
|
+
}
|
|
49
|
+
/** Run the device flow and return a token response. */
|
|
50
|
+
async function authenticate(server) {
|
|
51
|
+
const deviceCode = await requestDeviceCode(server);
|
|
52
|
+
printActivationPrompt(deviceCode.user_code, deviceCode.verification_uri);
|
|
53
|
+
return pollForToken(server, deviceCode.device_code, deviceCode.interval);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Validate that a server-supplied SSE URL shares the same origin as the configured server.
|
|
57
|
+
* Prevents a compromised auth response from redirecting the Bearer token to an attacker's host.
|
|
58
|
+
* Falls back to `${server}/sse` if the URL is missing, malformed, or cross-origin.
|
|
59
|
+
*/
|
|
60
|
+
function resolveSseUrl(tokenSseUrl, server) {
|
|
61
|
+
const fallback = `${server}/sse`;
|
|
62
|
+
if (!tokenSseUrl)
|
|
63
|
+
return fallback;
|
|
64
|
+
try {
|
|
65
|
+
const sseOrigin = new URL(tokenSseUrl).origin;
|
|
66
|
+
const serverOrigin = new URL(server).origin;
|
|
67
|
+
if (sseOrigin !== serverOrigin) {
|
|
68
|
+
process.stderr.write(`Pharaoh: ignoring cross-origin sse_url (${sseOrigin} ≠ ${serverOrigin})\n`);
|
|
69
|
+
return fallback;
|
|
70
|
+
}
|
|
71
|
+
return tokenSseUrl;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return fallback;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** Convert a token response to storable credentials. */
|
|
78
|
+
function tokenToCredentials(token, sseUrl) {
|
|
79
|
+
return {
|
|
80
|
+
version: 1,
|
|
81
|
+
access_token: token.access_token,
|
|
82
|
+
expires_at: new Date(Date.now() + token.expires_in * 1000).toISOString(),
|
|
83
|
+
expires_in: token.expires_in,
|
|
84
|
+
sse_url: sseUrl,
|
|
85
|
+
github_login: token.github_login ?? null,
|
|
86
|
+
tenant_name: token.tenant_name ?? null,
|
|
87
|
+
repos: token.repos ?? [],
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/** Format remaining TTL as human-readable string (e.g. "5d 12h"). */
|
|
91
|
+
function formatTtl(expiresAt) {
|
|
92
|
+
const remainingMs = new Date(expiresAt).getTime() - Date.now();
|
|
93
|
+
if (remainingMs <= 0)
|
|
94
|
+
return "expired";
|
|
95
|
+
const hours = Math.floor(remainingMs / 3_600_000);
|
|
96
|
+
const days = Math.floor(hours / 24);
|
|
97
|
+
const remHours = hours % 24;
|
|
98
|
+
if (days > 0)
|
|
99
|
+
return `${days}d ${remHours}h`;
|
|
100
|
+
if (hours > 0)
|
|
101
|
+
return `${hours}h`;
|
|
102
|
+
return `${Math.floor(remainingMs / 60_000)}m`;
|
|
103
|
+
}
|
|
104
|
+
async function main() {
|
|
105
|
+
const { server, logout } = parseArgs();
|
|
106
|
+
// --logout: clear credentials and exit
|
|
107
|
+
if (logout) {
|
|
108
|
+
deleteCredentials();
|
|
109
|
+
process.stderr.write("Pharaoh: credentials cleared\n");
|
|
110
|
+
process.exit(0);
|
|
111
|
+
}
|
|
112
|
+
let creds = readCredentials();
|
|
113
|
+
// If we have valid credentials, try to connect directly
|
|
114
|
+
if (creds && !isExpired(creds)) {
|
|
115
|
+
process.stderr.write(`Pharaoh: token valid for ${formatTtl(creds.expires_at)} — connecting\n`);
|
|
116
|
+
try {
|
|
117
|
+
await startProxy(creds.sse_url, creds.access_token);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
if (err instanceof TokenExpiredError) {
|
|
122
|
+
process.stderr.write("Pharaoh: token rejected by server — re-authenticating\n");
|
|
123
|
+
deleteCredentials();
|
|
124
|
+
creds = null;
|
|
125
|
+
}
|
|
126
|
+
else if (err instanceof TenantSuspendedError) {
|
|
127
|
+
process.stderr.write(`Pharaoh: ${err.message}\n`);
|
|
128
|
+
process.exit(1);
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
throw err;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// No valid credentials — run device flow
|
|
136
|
+
if (!creds || isExpired(creds)) {
|
|
137
|
+
process.stderr.write("Pharaoh: no valid credentials — starting device authorization\n");
|
|
138
|
+
const token = await authenticate(server);
|
|
139
|
+
if (token.provisional) {
|
|
140
|
+
process.stderr.write(`Pharaoh: provisional access — install the GitHub App to map your repos: ${token.install_url ?? ""}\n`);
|
|
141
|
+
}
|
|
142
|
+
const sseUrl = resolveSseUrl(token.sse_url, server);
|
|
143
|
+
creds = tokenToCredentials(token, sseUrl);
|
|
144
|
+
writeCredentials(creds);
|
|
145
|
+
printAuthSuccess(token.github_login ?? null, token.tenant_name ?? null, token.repos?.length ?? 0);
|
|
146
|
+
}
|
|
147
|
+
// Start proxy with fresh credentials
|
|
148
|
+
try {
|
|
149
|
+
await startProxy(creds.sse_url, creds.access_token);
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
if (err instanceof TokenExpiredError) {
|
|
153
|
+
// Token expired during session — delete creds and re-auth
|
|
154
|
+
process.stderr.write("Pharaoh: session expired — re-authenticating\n");
|
|
155
|
+
deleteCredentials();
|
|
156
|
+
const token = await authenticate(server);
|
|
157
|
+
const sseUrl = resolveSseUrl(token.sse_url, server);
|
|
158
|
+
creds = tokenToCredentials(token, sseUrl);
|
|
159
|
+
writeCredentials(creds);
|
|
160
|
+
await startProxy(creds.sse_url, creds.access_token);
|
|
161
|
+
}
|
|
162
|
+
else if (err instanceof TenantSuspendedError) {
|
|
163
|
+
process.stderr.write(`Pharaoh: ${err.message}\n`);
|
|
164
|
+
process.exit(1);
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
throw err;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
main().catch((err) => {
|
|
172
|
+
process.stderr.write(`Pharaoh: fatal — ${err instanceof Error ? err.message : String(err)}\n`);
|
|
173
|
+
process.exit(1);
|
|
174
|
+
});
|
|
175
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/dist/proxy.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Thrown when the remote server returns 401 — token is expired or revoked. */
|
|
2
|
+
export declare class TokenExpiredError extends Error {
|
|
3
|
+
constructor();
|
|
4
|
+
}
|
|
5
|
+
/** Thrown when the remote server returns 403 — tenant is suspended. */
|
|
6
|
+
export declare class TenantSuspendedError extends Error {
|
|
7
|
+
constructor(message: string);
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Classify an SSE error — detect 401/403 and throw typed errors.
|
|
11
|
+
* Exported for testability; used internally by the reconnect loop.
|
|
12
|
+
*/
|
|
13
|
+
export declare function classifySSEError(err: Error): void;
|
|
14
|
+
/**
|
|
15
|
+
* Start the stdio ↔ SSE proxy. Blocks until the connection is permanently closed.
|
|
16
|
+
*
|
|
17
|
+
* Message flow:
|
|
18
|
+
* Claude Code → stdin → StdioServerTransport.onmessage → SSEClientTransport.send → POST /message
|
|
19
|
+
* Pharaoh → SSE stream → SSEClientTransport.onmessage → StdioServerTransport.send → stdout
|
|
20
|
+
*
|
|
21
|
+
* On SSE disconnect, attempts reconnect with exponential backoff (5 retries, ~62s total).
|
|
22
|
+
* Throws TokenExpiredError on 401, TenantSuspendedError on 403.
|
|
23
|
+
*/
|
|
24
|
+
export declare function startProxy(sseUrl: string, token: string): Promise<void>;
|
package/dist/proxy.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
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).
|
|
4
|
+
*/
|
|
5
|
+
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
6
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
7
|
+
/** Thrown when the remote server returns 401 — token is expired or revoked. */
|
|
8
|
+
export class TokenExpiredError extends Error {
|
|
9
|
+
constructor() {
|
|
10
|
+
super("Token expired or revoked (401)");
|
|
11
|
+
this.name = "TokenExpiredError";
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/** Thrown when the remote server returns 403 — tenant is suspended. */
|
|
15
|
+
export class TenantSuspendedError extends Error {
|
|
16
|
+
constructor(message) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = "TenantSuspendedError";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** Backoff schedule for reconnect attempts (milliseconds). */
|
|
22
|
+
const BACKOFF_MS = [2000, 4000, 8000, 16000, 32000];
|
|
23
|
+
/**
|
|
24
|
+
* Classify an SSE error — detect 401/403 and throw typed errors.
|
|
25
|
+
* Exported for testability; used internally by the reconnect loop.
|
|
26
|
+
*/
|
|
27
|
+
export function classifySSEError(err) {
|
|
28
|
+
const msg = err.message ?? "";
|
|
29
|
+
// SseError from SDK includes the HTTP status code
|
|
30
|
+
if ("code" in err) {
|
|
31
|
+
const code = err.code;
|
|
32
|
+
if (code === 401)
|
|
33
|
+
throw new TokenExpiredError();
|
|
34
|
+
if (code === 403)
|
|
35
|
+
throw new TenantSuspendedError(msg || "Tenant suspended");
|
|
36
|
+
}
|
|
37
|
+
// Fallback: check message for status codes
|
|
38
|
+
if (msg.includes("401"))
|
|
39
|
+
throw new TokenExpiredError();
|
|
40
|
+
if (msg.includes("403"))
|
|
41
|
+
throw new TenantSuspendedError(msg || "Tenant suspended");
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Create an SSEClientTransport with Bearer token auth.
|
|
45
|
+
* Injects the Authorization header into both the SSE connection (via custom fetch)
|
|
46
|
+
* and POST requests (via requestInit).
|
|
47
|
+
*/
|
|
48
|
+
function createSSETransport(sseUrl, token) {
|
|
49
|
+
const authHeader = `Bearer ${token}`;
|
|
50
|
+
return new SSEClientTransport(new URL(sseUrl), {
|
|
51
|
+
eventSourceInit: {
|
|
52
|
+
fetch: (url, init) => {
|
|
53
|
+
const h = new Headers(init.headers);
|
|
54
|
+
h.set("Authorization", authHeader);
|
|
55
|
+
return fetch(url, { ...init, headers: h });
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
requestInit: {
|
|
59
|
+
headers: { Authorization: authHeader },
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Start the stdio ↔ SSE proxy. Blocks until the connection is permanently closed.
|
|
65
|
+
*
|
|
66
|
+
* Message flow:
|
|
67
|
+
* Claude Code → stdin → StdioServerTransport.onmessage → SSEClientTransport.send → POST /message
|
|
68
|
+
* Pharaoh → SSE stream → SSEClientTransport.onmessage → StdioServerTransport.send → stdout
|
|
69
|
+
*
|
|
70
|
+
* On SSE disconnect, attempts reconnect with exponential backoff (5 retries, ~62s total).
|
|
71
|
+
* Throws TokenExpiredError on 401, TenantSuspendedError on 403.
|
|
72
|
+
*/
|
|
73
|
+
export async function startProxy(sseUrl, token) {
|
|
74
|
+
const stdio = new StdioServerTransport();
|
|
75
|
+
let sse = createSSETransport(sseUrl, token);
|
|
76
|
+
let reconnectAttempt = 0;
|
|
77
|
+
/** Wire up bidirectional message relay between the two transports. */
|
|
78
|
+
function bridge(sseTransport) {
|
|
79
|
+
stdio.onmessage = (msg) => {
|
|
80
|
+
sseTransport.send(msg).catch((err) => {
|
|
81
|
+
process.stderr.write(`Pharaoh: send error — ${err instanceof Error ? err.message : String(err)}\n`);
|
|
82
|
+
});
|
|
83
|
+
};
|
|
84
|
+
sseTransport.onmessage = (msg) => {
|
|
85
|
+
stdio.send(msg).catch((err) => {
|
|
86
|
+
process.stderr.write(`Pharaoh: stdout error — ${err instanceof Error ? err.message : String(err)}\n`);
|
|
87
|
+
});
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/** Handle SSE errors — delegates to classifySSEError for 401/403 detection. */
|
|
91
|
+
const handleSSEError = classifySSEError;
|
|
92
|
+
// Wire up initial bridge
|
|
93
|
+
bridge(sse);
|
|
94
|
+
// Handle SSE connection drops with reconnect
|
|
95
|
+
sse.onerror = (err) => {
|
|
96
|
+
try {
|
|
97
|
+
handleSSEError(err);
|
|
98
|
+
}
|
|
99
|
+
catch (typed) {
|
|
100
|
+
// TokenExpiredError or TenantSuspendedError — propagate via close
|
|
101
|
+
sse.close().catch(() => { });
|
|
102
|
+
throw typed;
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
// Start both transports
|
|
106
|
+
await stdio.start();
|
|
107
|
+
// Connect SSE with reconnect loop
|
|
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
|
+
bridge(sse);
|
|
119
|
+
}
|
|
120
|
+
await sse.start();
|
|
121
|
+
reconnectAttempt = 0; // Reset on successful connection
|
|
122
|
+
// Wait for close — this promise resolves when SSE disconnects
|
|
123
|
+
await new Promise((resolve, reject) => {
|
|
124
|
+
sse.onclose = () => resolve();
|
|
125
|
+
sse.onerror = (err) => {
|
|
126
|
+
try {
|
|
127
|
+
handleSSEError(err);
|
|
128
|
+
}
|
|
129
|
+
catch (typed) {
|
|
130
|
+
reject(typed);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
// Non-fatal error — SSE will auto-close, onclose will fire
|
|
134
|
+
};
|
|
135
|
+
});
|
|
136
|
+
// SSE closed — attempt reconnect
|
|
137
|
+
reconnectAttempt++;
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
if (err instanceof TokenExpiredError || err instanceof TenantSuspendedError) {
|
|
141
|
+
await stdio.close().catch(() => { });
|
|
142
|
+
throw err;
|
|
143
|
+
}
|
|
144
|
+
reconnectAttempt++;
|
|
145
|
+
if (reconnectAttempt > BACKOFF_MS.length) {
|
|
146
|
+
process.stderr.write("Pharaoh: max reconnect attempts reached — exiting\n");
|
|
147
|
+
await stdio.close().catch(() => { });
|
|
148
|
+
throw new Error("SSE connection failed after maximum retries");
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function sleep(ms) {
|
|
155
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
156
|
+
}
|
|
157
|
+
//# sourceMappingURL=proxy.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"proxy.js","sourceRoot":"","sources":["../src/proxy.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,EAAE,kBAAkB,EAAE,MAAM,yCAAyC,CAAC;AAC7E,OAAO,EAAE,oBAAoB,EAAE,MAAM,2CAA2C,CAAC;AAEjF,+EAA+E;AAC/E,MAAM,OAAO,iBAAkB,SAAQ,KAAK;IAC3C;QACC,KAAK,CAAC,gCAAgC,CAAC,CAAC;QACxC,IAAI,CAAC,IAAI,GAAG,mBAAmB,CAAC;IACjC,CAAC;CACD;AAED,uEAAuE;AACvE,MAAM,OAAO,oBAAqB,SAAQ,KAAK;IAC9C,YAAY,OAAe;QAC1B,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,IAAI,CAAC,IAAI,GAAG,sBAAsB,CAAC;IACpC,CAAC;CACD;AAED,8DAA8D;AAC9D,MAAM,UAAU,GAAG,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,KAAK,CAAC,CAAC;AAEpD;;;GAGG;AACH,MAAM,UAAU,gBAAgB,CAAC,GAAU;IAC1C,MAAM,GAAG,GAAG,GAAG,CAAC,OAAO,IAAI,EAAE,CAAC;IAC9B,kDAAkD;IAClD,IAAI,MAAM,IAAI,GAAG,EAAE,CAAC;QACnB,MAAM,IAAI,GAAI,GAAyB,CAAC,IAAI,CAAC;QAC7C,IAAI,IAAI,KAAK,GAAG;YAAE,MAAM,IAAI,iBAAiB,EAAE,CAAC;QAChD,IAAI,IAAI,KAAK,GAAG;YAAE,MAAM,IAAI,oBAAoB,CAAC,GAAG,IAAI,kBAAkB,CAAC,CAAC;IAC7E,CAAC;IACD,2CAA2C;IAC3C,IAAI,GAAG,CAAC,QAAQ,CAAC,KAAK,CAAC;QAAE,MAAM,IAAI,iBAAiB,EAAE,CAAC;IACvD,IAAI,GAAG,CAAC,QAAQ,CAAC,KAAK,CAAC;QAAE,MAAM,IAAI,oBAAoB,CAAC,GAAG,IAAI,kBAAkB,CAAC,CAAC;AACpF,CAAC;AAED;;;;GAIG;AACH,SAAS,kBAAkB,CAAC,MAAc,EAAE,KAAa;IACxD,MAAM,UAAU,GAAG,UAAU,KAAK,EAAE,CAAC;IACrC,OAAO,IAAI,kBAAkB,CAAC,IAAI,GAAG,CAAC,MAAM,CAAC,EAAE;QAC9C,eAAe,EAAE;YAChB,KAAK,EAAE,CAAC,GAAG,EAAE,IAAI,EAAE,EAAE;gBACpB,MAAM,CAAC,GAAG,IAAI,OAAO,CAAC,IAAI,CAAC,OAAsB,CAAC,CAAC;gBACnD,CAAC,CAAC,GAAG,CAAC,eAAe,EAAE,UAAU,CAAC,CAAC;gBACnC,OAAO,KAAK,CAAC,GAAG,EAAE,EAAE,GAAG,IAAI,EAAE,OAAO,EAAE,CAAC,EAAiB,CAAC,CAAC;YAC3D,CAAC;SACD;QACD,WAAW,EAAE;YACZ,OAAO,EAAE,EAAE,aAAa,EAAE,UAAU,EAAE;SACtC;KACD,CAAC,CAAC;AACJ,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,CAAC,KAAK,UAAU,UAAU,CAAC,MAAc,EAAE,KAAa;IAC7D,MAAM,KAAK,GAAG,IAAI,oBAAoB,EAAE,CAAC;IACzC,IAAI,GAAG,GAAG,kBAAkB,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;IAC5C,IAAI,gBAAgB,GAAG,CAAC,CAAC;IAEzB,sEAAsE;IACtE,SAAS,MAAM,CAAC,YAAgC;QAC/C,KAAK,CAAC,SAAS,GAAG,CAAC,GAAG,EAAE,EAAE;YACzB,YAAY,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC,GAAG,EAAE,EAAE;gBACpC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,yBAAyB,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;YACrG,CAAC,CAAC,CAAC;QACJ,CAAC,CAAC;QACF,YAAY,CAAC,SAAS,GAAG,CAAC,GAAG,EAAE,EAAE;YAChC,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC,GAAG,EAAE,EAAE;gBAC7B,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,2BAA2B,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;YACvG,CAAC,CAAC,CAAC;QACJ,CAAC,CAAC;IACH,CAAC;IAED,+EAA+E;IAC/E,MAAM,cAAc,GAAG,gBAAgB,CAAC;IAExC,yBAAyB;IACzB,MAAM,CAAC,GAAG,CAAC,CAAC;IAEZ,6CAA6C;IAC7C,GAAG,CAAC,OAAO,GAAG,CAAC,GAAG,EAAE,EAAE;QACrB,IAAI,CAAC;YACJ,cAAc,CAAC,GAAG,CAAC,CAAC;QACrB,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YAChB,kEAAkE;YAClE,GAAG,CAAC,KAAK,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAC;YAC5B,MAAM,KAAK,CAAC;QACb,CAAC;IACF,CAAC,CAAC;IAEF,wBAAwB;IACxB,MAAM,KAAK,CAAC,KAAK,EAAE,CAAC;IAEpB,kCAAkC;IAClC,MAAM,oBAAoB,EAAE,CAAC;IAE7B,kEAAkE;IAClE,KAAK,UAAU,oBAAoB;QAClC,OAAO,gBAAgB,IAAI,UAAU,CAAC,MAAM,EAAE,CAAC;YAC9C,IAAI,CAAC;gBACJ,IAAI,gBAAgB,GAAG,CAAC,EAAE,CAAC;oBAC1B,MAAM,KAAK,GAAG,UAAU,CAAC,gBAAgB,GAAG,CAAC,CAAC,CAAC;oBAC/C,OAAO,CAAC,MAAM,CAAC,KAAK,CACnB,4BAA4B,KAAK,GAAG,IAAI,cAAc,gBAAgB,IAAI,UAAU,CAAC,MAAM,QAAQ,CACnG,CAAC;oBACF,MAAM,KAAK,CAAC,KAAK,CAAC,CAAC;oBACnB,GAAG,GAAG,kBAAkB,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;oBACxC,MAAM,CAAC,GAAG,CAAC,CAAC;gBACb,CAAC;gBAED,MAAM,GAAG,CAAC,KAAK,EAAE,CAAC;gBAClB,gBAAgB,GAAG,CAAC,CAAC,CAAC,iCAAiC;gBAEvD,8DAA8D;gBAC9D,MAAM,IAAI,OAAO,CAAO,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;oBAC3C,GAAG,CAAC,OAAO,GAAG,GAAG,EAAE,CAAC,OAAO,EAAE,CAAC;oBAC9B,GAAG,CAAC,OAAO,GAAG,CAAC,GAAG,EAAE,EAAE;wBACrB,IAAI,CAAC;4BACJ,cAAc,CAAC,GAAG,CAAC,CAAC;wBACrB,CAAC;wBAAC,OAAO,KAAK,EAAE,CAAC;4BAChB,MAAM,CAAC,KAAK,CAAC,CAAC;4BACd,OAAO;wBACR,CAAC;wBACD,2DAA2D;oBAC5D,CAAC,CAAC;gBACH,CAAC,CAAC,CAAC;gBAEH,iCAAiC;gBACjC,gBAAgB,EAAE,CAAC;YACpB,CAAC;YAAC,OAAO,GAAG,EAAE,CAAC;gBACd,IAAI,GAAG,YAAY,iBAAiB,IAAI,GAAG,YAAY,oBAAoB,EAAE,CAAC;oBAC7E,MAAM,KAAK,CAAC,KAAK,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAC;oBACpC,MAAM,GAAG,CAAC;gBACX,CAAC;gBACD,gBAAgB,EAAE,CAAC;gBACnB,IAAI,gBAAgB,GAAG,UAAU,CAAC,MAAM,EAAE,CAAC;oBAC1C,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,qDAAqD,CAAC,CAAC;oBAC5E,MAAM,KAAK,CAAC,KAAK,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAC;oBACpC,MAAM,IAAI,KAAK,CAAC,6CAA6C,CAAC,CAAC;gBAChE,CAAC;YACF,CAAC;QACF,CAAC;IACF,CAAC;AACF,CAAC;AAED,SAAS,KAAK,CAAC,EAAU;IACxB,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,UAAU,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC,CAAC;AAC1D,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pharaoh-so/mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Stdio-to-SSE proxy for Pharaoh MCP — enables headless environments (VPS, SSH, CI) via device flow auth",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"pharaoh-mcp": "./dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsc && node -e \"const fs=require('fs'),f='dist/index.js',c=fs.readFileSync(f,'utf8');if(!c.startsWith('#!'))fs.writeFileSync(f,'#!/usr/bin/env node\\n'+c);fs.chmodSync(f,0o755)\"",
|
|
12
|
+
"typecheck": "tsc --noEmit",
|
|
13
|
+
"test": "vitest run",
|
|
14
|
+
"lint": "biome check src/"
|
|
15
|
+
},
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=18"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@modelcontextprotocol/sdk": "^1.26.0"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@biomejs/biome": "^2.3.15",
|
|
25
|
+
"typescript": "^5.9.3",
|
|
26
|
+
"vitest": "^4.0.18"
|
|
27
|
+
}
|
|
28
|
+
}
|
package/src/auth.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device flow client for Pharaoh MCP proxy (RFC 8628).
|
|
3
|
+
* All user-visible output goes to stderr — stdout is reserved for JSON-RPC.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Response from POST /device. */
|
|
7
|
+
export interface DeviceCodeResponse {
|
|
8
|
+
device_code: string;
|
|
9
|
+
user_code: string;
|
|
10
|
+
verification_uri: string;
|
|
11
|
+
verification_uri_complete: string;
|
|
12
|
+
expires_in: number;
|
|
13
|
+
interval: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Successful response from POST /device/token. */
|
|
17
|
+
export interface TokenResponse {
|
|
18
|
+
access_token: string;
|
|
19
|
+
token_type: string;
|
|
20
|
+
expires_in: number;
|
|
21
|
+
sse_url: string;
|
|
22
|
+
github_login?: string;
|
|
23
|
+
tenant_name?: string;
|
|
24
|
+
repos?: string[];
|
|
25
|
+
provisional?: boolean;
|
|
26
|
+
install_url?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Error response from POST /device/token while still waiting. */
|
|
30
|
+
interface DeviceTokenError {
|
|
31
|
+
error: string;
|
|
32
|
+
error_description?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Request a device code from the Pharaoh server (RFC 8628 §3.1).
|
|
37
|
+
* Returns the device code, user code, and verification URIs.
|
|
38
|
+
*/
|
|
39
|
+
export async function requestDeviceCode(baseUrl: string): Promise<DeviceCodeResponse> {
|
|
40
|
+
const res = await fetch(`${baseUrl}/device`, {
|
|
41
|
+
method: "POST",
|
|
42
|
+
headers: { "Content-Type": "application/json" },
|
|
43
|
+
body: JSON.stringify({}),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
if (!res.ok) {
|
|
47
|
+
const text = await res.text().catch(() => "");
|
|
48
|
+
throw new Error(`Device code request failed (${res.status}): ${text}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return (await res.json()) as DeviceCodeResponse;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Poll for device authorization completion (RFC 8628 §3.5).
|
|
56
|
+
* Blocks until the user authorizes, the code expires, or an error occurs.
|
|
57
|
+
* Adds 1s jitter to the server-specified interval to avoid thundering herd.
|
|
58
|
+
*/
|
|
59
|
+
export async function pollForToken(
|
|
60
|
+
baseUrl: string,
|
|
61
|
+
deviceCode: string,
|
|
62
|
+
interval: number,
|
|
63
|
+
): Promise<TokenResponse> {
|
|
64
|
+
const grantType = "urn:ietf:params:oauth:grant-type:device_code";
|
|
65
|
+
|
|
66
|
+
while (true) {
|
|
67
|
+
// Wait interval + 0–1s jitter before each poll
|
|
68
|
+
const jitterMs = Math.random() * 1000;
|
|
69
|
+
await sleep(interval * 1000 + jitterMs);
|
|
70
|
+
|
|
71
|
+
const res = await fetch(`${baseUrl}/device/token`, {
|
|
72
|
+
method: "POST",
|
|
73
|
+
headers: { "Content-Type": "application/json" },
|
|
74
|
+
body: JSON.stringify({ device_code: deviceCode, grant_type: grantType }),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
if (res.ok) {
|
|
78
|
+
return (await res.json()) as TokenResponse;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const body = (await res.json().catch(() => ({ error: "unknown" }))) as DeviceTokenError;
|
|
82
|
+
|
|
83
|
+
if (body.error === "authorization_pending") {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (body.error === "slow_down") {
|
|
88
|
+
// RFC 8628 §3.5: increase interval by 5 seconds
|
|
89
|
+
interval += 5;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (body.error === "expired_token") {
|
|
94
|
+
throw new Error("Device code expired. Please try again.");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (body.error === "access_denied") {
|
|
98
|
+
throw new Error("Authorization was denied.");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
throw new Error(`Device token error: ${body.error} — ${body.error_description ?? ""}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Print the device activation prompt to stderr.
|
|
107
|
+
* Shows the user code and verification URI in a visible box.
|
|
108
|
+
*/
|
|
109
|
+
export function printActivationPrompt(userCode: string, verificationUri: string): void {
|
|
110
|
+
const lines = [
|
|
111
|
+
"",
|
|
112
|
+
"┌───────────────────────────────────────────┐",
|
|
113
|
+
"│ Pharaoh — Device Authorization │",
|
|
114
|
+
"│ │",
|
|
115
|
+
`│ Code: ${userCode.padEnd(33)}│`,
|
|
116
|
+
`│ URL: ${verificationUri.padEnd(33)}│`,
|
|
117
|
+
"│ │",
|
|
118
|
+
"│ Open the URL and enter the code to sign │",
|
|
119
|
+
"│ in. You can do this on any device. │",
|
|
120
|
+
"└───────────────────────────────────────────┘",
|
|
121
|
+
"",
|
|
122
|
+
];
|
|
123
|
+
process.stderr.write(lines.join("\n") + "\n");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Print authentication success message to stderr.
|
|
128
|
+
*/
|
|
129
|
+
export function printAuthSuccess(
|
|
130
|
+
login: string | null,
|
|
131
|
+
tenant: string | null,
|
|
132
|
+
repoCount: number,
|
|
133
|
+
): void {
|
|
134
|
+
const parts: string[] = ["Pharaoh: authenticated"];
|
|
135
|
+
if (login) parts.push(`as ${login}`);
|
|
136
|
+
if (tenant) parts.push(`(${tenant})`);
|
|
137
|
+
parts.push(`— ${repoCount} repo${repoCount === 1 ? "" : "s"} connected`);
|
|
138
|
+
process.stderr.write(`${parts.join(" ")}\n`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function sleep(ms: number): Promise<void> {
|
|
142
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
143
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credential storage for Pharaoh MCP proxy — persists device flow tokens to ~/.pharaoh/credentials.json.
|
|
3
|
+
* File permissions: 0600 (credentials.json), 0700 (~/.pharaoh/).
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { dirname, join } from "node:path";
|
|
8
|
+
|
|
9
|
+
/** Current credential schema version — bump when the format changes. */
|
|
10
|
+
const SCHEMA_VERSION = 1;
|
|
11
|
+
|
|
12
|
+
/** Stored credential shape. */
|
|
13
|
+
export interface Credentials {
|
|
14
|
+
version: typeof SCHEMA_VERSION;
|
|
15
|
+
access_token: string;
|
|
16
|
+
/** ISO 8601 timestamp when the token expires. */
|
|
17
|
+
expires_at: string;
|
|
18
|
+
/** Original TTL in seconds (from the token response). */
|
|
19
|
+
expires_in: number;
|
|
20
|
+
sse_url: string;
|
|
21
|
+
github_login: string | null;
|
|
22
|
+
tenant_name: string | null;
|
|
23
|
+
repos: string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Default credential file path. */
|
|
27
|
+
const CREDENTIALS_PATH = join(homedir(), ".pharaoh", "credentials.json");
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Read and parse stored credentials. Returns null if missing, unreadable,
|
|
31
|
+
* malformed, or wrong schema version.
|
|
32
|
+
*/
|
|
33
|
+
export function readCredentials(path = CREDENTIALS_PATH): Credentials | null {
|
|
34
|
+
try {
|
|
35
|
+
const raw = readFileSync(path, "utf-8");
|
|
36
|
+
const parsed: unknown = JSON.parse(raw);
|
|
37
|
+
if (!isValidCredentials(parsed)) return null;
|
|
38
|
+
return parsed;
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Write credentials to disk with restrictive permissions.
|
|
46
|
+
* Creates the parent directory (0700) if it doesn't exist.
|
|
47
|
+
*/
|
|
48
|
+
export function writeCredentials(creds: Credentials, path = CREDENTIALS_PATH): void {
|
|
49
|
+
const dir = dirname(path);
|
|
50
|
+
if (!existsSync(dir)) {
|
|
51
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
52
|
+
}
|
|
53
|
+
writeFileSync(path, JSON.stringify(creds, null, "\t"), { mode: 0o600 });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Delete the credential file. No-op if it doesn't exist. */
|
|
57
|
+
export function deleteCredentials(path = CREDENTIALS_PATH): void {
|
|
58
|
+
try {
|
|
59
|
+
unlinkSync(path);
|
|
60
|
+
} catch {
|
|
61
|
+
// File doesn't exist — nothing to delete
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Check if credentials are expired or within 10% of expiry.
|
|
67
|
+
* The 10% buffer prevents connecting with a token that will expire mid-session.
|
|
68
|
+
*/
|
|
69
|
+
export function isExpired(creds: Credentials): boolean {
|
|
70
|
+
const expiresAt = new Date(creds.expires_at).getTime();
|
|
71
|
+
if (Number.isNaN(expiresAt)) return true;
|
|
72
|
+
|
|
73
|
+
const now = Date.now();
|
|
74
|
+
const totalTtlMs = creds.expires_in * 1000;
|
|
75
|
+
const bufferMs = totalTtlMs * 0.1;
|
|
76
|
+
|
|
77
|
+
return now >= expiresAt - bufferMs;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Type guard for valid credential objects. */
|
|
81
|
+
function isValidCredentials(value: unknown): value is Credentials {
|
|
82
|
+
if (typeof value !== "object" || value === null) return false;
|
|
83
|
+
const obj = value as Record<string, unknown>;
|
|
84
|
+
return (
|
|
85
|
+
obj.version === SCHEMA_VERSION &&
|
|
86
|
+
typeof obj.access_token === "string" &&
|
|
87
|
+
typeof obj.expires_at === "string" &&
|
|
88
|
+
typeof obj.expires_in === "number" &&
|
|
89
|
+
typeof obj.sse_url === "string" &&
|
|
90
|
+
Array.isArray(obj.repos)
|
|
91
|
+
);
|
|
92
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @pharaoh-so/mcp — Stdio-to-SSE proxy for headless MCP environments.
|
|
4
|
+
*
|
|
5
|
+
* Presents as an MCP server on stdio (for Claude Code) and relays messages
|
|
6
|
+
* to a remote Pharaoh SSE server. Authenticates via RFC 8628 device flow
|
|
7
|
+
* so the user can authorize on any device with a browser.
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
printActivationPrompt,
|
|
11
|
+
printAuthSuccess,
|
|
12
|
+
pollForToken,
|
|
13
|
+
requestDeviceCode,
|
|
14
|
+
type TokenResponse,
|
|
15
|
+
} from "./auth.js";
|
|
16
|
+
import {
|
|
17
|
+
type Credentials,
|
|
18
|
+
deleteCredentials,
|
|
19
|
+
isExpired,
|
|
20
|
+
readCredentials,
|
|
21
|
+
writeCredentials,
|
|
22
|
+
} from "./credentials.js";
|
|
23
|
+
import { TokenExpiredError, TenantSuspendedError, startProxy } from "./proxy.js";
|
|
24
|
+
|
|
25
|
+
const DEFAULT_SERVER = "https://mcp.pharaoh.so";
|
|
26
|
+
|
|
27
|
+
/** Parse CLI arguments. */
|
|
28
|
+
function parseArgs(): { server: string; logout: boolean } {
|
|
29
|
+
const args = process.argv.slice(2);
|
|
30
|
+
let server = DEFAULT_SERVER;
|
|
31
|
+
let logout = false;
|
|
32
|
+
|
|
33
|
+
for (let i = 0; i < args.length; i++) {
|
|
34
|
+
if (args[i] === "--server" && args[i + 1]) {
|
|
35
|
+
server = args[i + 1];
|
|
36
|
+
i++;
|
|
37
|
+
} else if (args[i] === "--logout") {
|
|
38
|
+
logout = true;
|
|
39
|
+
} else if (args[i] === "--help" || args[i] === "-h") {
|
|
40
|
+
printUsage();
|
|
41
|
+
process.exit(0);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Strip trailing slash
|
|
46
|
+
server = server.replace(/\/+$/, "");
|
|
47
|
+
|
|
48
|
+
return { server, logout };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function printUsage(): void {
|
|
52
|
+
process.stderr.write(
|
|
53
|
+
[
|
|
54
|
+
"Usage: pharaoh-mcp [options]",
|
|
55
|
+
"",
|
|
56
|
+
"Options:",
|
|
57
|
+
" --server <url> Pharaoh server URL (default: https://mcp.pharaoh.so)",
|
|
58
|
+
" --logout Clear stored credentials and exit",
|
|
59
|
+
" --help, -h Show this help",
|
|
60
|
+
"",
|
|
61
|
+
"Add to Claude Code:",
|
|
62
|
+
" claude mcp add pharaoh -- npx @pharaoh-so/mcp",
|
|
63
|
+
"",
|
|
64
|
+
].join("\n") + "\n",
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Run the device flow and return a token response. */
|
|
69
|
+
async function authenticate(server: string): Promise<TokenResponse> {
|
|
70
|
+
const deviceCode = await requestDeviceCode(server);
|
|
71
|
+
printActivationPrompt(deviceCode.user_code, deviceCode.verification_uri);
|
|
72
|
+
return pollForToken(server, deviceCode.device_code, deviceCode.interval);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Validate that a server-supplied SSE URL shares the same origin as the configured server.
|
|
77
|
+
* Prevents a compromised auth response from redirecting the Bearer token to an attacker's host.
|
|
78
|
+
* Falls back to `${server}/sse` if the URL is missing, malformed, or cross-origin.
|
|
79
|
+
*/
|
|
80
|
+
function resolveSseUrl(tokenSseUrl: string | undefined, server: string): string {
|
|
81
|
+
const fallback = `${server}/sse`;
|
|
82
|
+
if (!tokenSseUrl) return fallback;
|
|
83
|
+
try {
|
|
84
|
+
const sseOrigin = new URL(tokenSseUrl).origin;
|
|
85
|
+
const serverOrigin = new URL(server).origin;
|
|
86
|
+
if (sseOrigin !== serverOrigin) {
|
|
87
|
+
process.stderr.write(
|
|
88
|
+
`Pharaoh: ignoring cross-origin sse_url (${sseOrigin} ≠ ${serverOrigin})\n`,
|
|
89
|
+
);
|
|
90
|
+
return fallback;
|
|
91
|
+
}
|
|
92
|
+
return tokenSseUrl;
|
|
93
|
+
} catch {
|
|
94
|
+
return fallback;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Convert a token response to storable credentials. */
|
|
99
|
+
function tokenToCredentials(token: TokenResponse, sseUrl: string): Credentials {
|
|
100
|
+
return {
|
|
101
|
+
version: 1,
|
|
102
|
+
access_token: token.access_token,
|
|
103
|
+
expires_at: new Date(Date.now() + token.expires_in * 1000).toISOString(),
|
|
104
|
+
expires_in: token.expires_in,
|
|
105
|
+
sse_url: sseUrl,
|
|
106
|
+
github_login: token.github_login ?? null,
|
|
107
|
+
tenant_name: token.tenant_name ?? null,
|
|
108
|
+
repos: token.repos ?? [],
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Format remaining TTL as human-readable string (e.g. "5d 12h"). */
|
|
113
|
+
function formatTtl(expiresAt: string): string {
|
|
114
|
+
const remainingMs = new Date(expiresAt).getTime() - Date.now();
|
|
115
|
+
if (remainingMs <= 0) return "expired";
|
|
116
|
+
const hours = Math.floor(remainingMs / 3_600_000);
|
|
117
|
+
const days = Math.floor(hours / 24);
|
|
118
|
+
const remHours = hours % 24;
|
|
119
|
+
if (days > 0) return `${days}d ${remHours}h`;
|
|
120
|
+
if (hours > 0) return `${hours}h`;
|
|
121
|
+
return `${Math.floor(remainingMs / 60_000)}m`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function main(): Promise<void> {
|
|
125
|
+
const { server, logout } = parseArgs();
|
|
126
|
+
|
|
127
|
+
// --logout: clear credentials and exit
|
|
128
|
+
if (logout) {
|
|
129
|
+
deleteCredentials();
|
|
130
|
+
process.stderr.write("Pharaoh: credentials cleared\n");
|
|
131
|
+
process.exit(0);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
let creds = readCredentials();
|
|
135
|
+
|
|
136
|
+
// If we have valid credentials, try to connect directly
|
|
137
|
+
if (creds && !isExpired(creds)) {
|
|
138
|
+
process.stderr.write(`Pharaoh: token valid for ${formatTtl(creds.expires_at)} — connecting\n`);
|
|
139
|
+
try {
|
|
140
|
+
await startProxy(creds.sse_url, creds.access_token);
|
|
141
|
+
return;
|
|
142
|
+
} catch (err) {
|
|
143
|
+
if (err instanceof TokenExpiredError) {
|
|
144
|
+
process.stderr.write("Pharaoh: token rejected by server — re-authenticating\n");
|
|
145
|
+
deleteCredentials();
|
|
146
|
+
creds = null;
|
|
147
|
+
} else if (err instanceof TenantSuspendedError) {
|
|
148
|
+
process.stderr.write(`Pharaoh: ${err.message}\n`);
|
|
149
|
+
process.exit(1);
|
|
150
|
+
} else {
|
|
151
|
+
throw err;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// No valid credentials — run device flow
|
|
157
|
+
if (!creds || isExpired(creds)) {
|
|
158
|
+
process.stderr.write("Pharaoh: no valid credentials — starting device authorization\n");
|
|
159
|
+
const token = await authenticate(server);
|
|
160
|
+
|
|
161
|
+
if (token.provisional) {
|
|
162
|
+
process.stderr.write(
|
|
163
|
+
`Pharaoh: provisional access — install the GitHub App to map your repos: ${token.install_url ?? ""}\n`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const sseUrl = resolveSseUrl(token.sse_url, server);
|
|
168
|
+
creds = tokenToCredentials(token, sseUrl);
|
|
169
|
+
writeCredentials(creds);
|
|
170
|
+
|
|
171
|
+
printAuthSuccess(
|
|
172
|
+
token.github_login ?? null,
|
|
173
|
+
token.tenant_name ?? null,
|
|
174
|
+
token.repos?.length ?? 0,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Start proxy with fresh credentials
|
|
179
|
+
try {
|
|
180
|
+
await startProxy(creds.sse_url, creds.access_token);
|
|
181
|
+
} catch (err) {
|
|
182
|
+
if (err instanceof TokenExpiredError) {
|
|
183
|
+
// Token expired during session — delete creds and re-auth
|
|
184
|
+
process.stderr.write("Pharaoh: session expired — re-authenticating\n");
|
|
185
|
+
deleteCredentials();
|
|
186
|
+
const token = await authenticate(server);
|
|
187
|
+
const sseUrl = resolveSseUrl(token.sse_url, server);
|
|
188
|
+
creds = tokenToCredentials(token, sseUrl);
|
|
189
|
+
writeCredentials(creds);
|
|
190
|
+
await startProxy(creds.sse_url, creds.access_token);
|
|
191
|
+
} else if (err instanceof TenantSuspendedError) {
|
|
192
|
+
process.stderr.write(`Pharaoh: ${err.message}\n`);
|
|
193
|
+
process.exit(1);
|
|
194
|
+
} else {
|
|
195
|
+
throw err;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
main().catch((err) => {
|
|
201
|
+
process.stderr.write(`Pharaoh: fatal — ${err instanceof Error ? err.message : String(err)}\n`);
|
|
202
|
+
process.exit(1);
|
|
203
|
+
});
|
package/src/proxy.ts
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
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).
|
|
4
|
+
*/
|
|
5
|
+
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
6
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
7
|
+
|
|
8
|
+
/** Thrown when the remote server returns 401 — token is expired or revoked. */
|
|
9
|
+
export class TokenExpiredError extends Error {
|
|
10
|
+
constructor() {
|
|
11
|
+
super("Token expired or revoked (401)");
|
|
12
|
+
this.name = "TokenExpiredError";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Thrown when the remote server returns 403 — tenant is suspended. */
|
|
17
|
+
export class TenantSuspendedError extends Error {
|
|
18
|
+
constructor(message: string) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = "TenantSuspendedError";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Backoff schedule for reconnect attempts (milliseconds). */
|
|
25
|
+
const BACKOFF_MS = [2000, 4000, 8000, 16000, 32000];
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Classify an SSE error — detect 401/403 and throw typed errors.
|
|
29
|
+
* Exported for testability; used internally by the reconnect loop.
|
|
30
|
+
*/
|
|
31
|
+
export function classifySSEError(err: Error): void {
|
|
32
|
+
const msg = err.message ?? "";
|
|
33
|
+
// SseError from SDK includes the HTTP status code
|
|
34
|
+
if ("code" in err) {
|
|
35
|
+
const code = (err as { code?: number }).code;
|
|
36
|
+
if (code === 401) throw new TokenExpiredError();
|
|
37
|
+
if (code === 403) throw new TenantSuspendedError(msg || "Tenant suspended");
|
|
38
|
+
}
|
|
39
|
+
// Fallback: check message for status codes
|
|
40
|
+
if (msg.includes("401")) throw new TokenExpiredError();
|
|
41
|
+
if (msg.includes("403")) throw new TenantSuspendedError(msg || "Tenant suspended");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Create an SSEClientTransport with Bearer token auth.
|
|
46
|
+
* Injects the Authorization header into both the SSE connection (via custom fetch)
|
|
47
|
+
* and POST requests (via requestInit).
|
|
48
|
+
*/
|
|
49
|
+
function createSSETransport(sseUrl: string, token: string): SSEClientTransport {
|
|
50
|
+
const authHeader = `Bearer ${token}`;
|
|
51
|
+
return new SSEClientTransport(new URL(sseUrl), {
|
|
52
|
+
eventSourceInit: {
|
|
53
|
+
fetch: (url, init) => {
|
|
54
|
+
const h = new Headers(init.headers as HeadersInit);
|
|
55
|
+
h.set("Authorization", authHeader);
|
|
56
|
+
return fetch(url, { ...init, headers: h } as RequestInit);
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
requestInit: {
|
|
60
|
+
headers: { Authorization: authHeader },
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Start the stdio ↔ SSE proxy. Blocks until the connection is permanently closed.
|
|
67
|
+
*
|
|
68
|
+
* Message flow:
|
|
69
|
+
* Claude Code → stdin → StdioServerTransport.onmessage → SSEClientTransport.send → POST /message
|
|
70
|
+
* Pharaoh → SSE stream → SSEClientTransport.onmessage → StdioServerTransport.send → stdout
|
|
71
|
+
*
|
|
72
|
+
* On SSE disconnect, attempts reconnect with exponential backoff (5 retries, ~62s total).
|
|
73
|
+
* Throws TokenExpiredError on 401, TenantSuspendedError on 403.
|
|
74
|
+
*/
|
|
75
|
+
export async function startProxy(sseUrl: string, token: string): Promise<void> {
|
|
76
|
+
const stdio = new StdioServerTransport();
|
|
77
|
+
let sse = createSSETransport(sseUrl, token);
|
|
78
|
+
let reconnectAttempt = 0;
|
|
79
|
+
|
|
80
|
+
/** Wire up bidirectional message relay between the two transports. */
|
|
81
|
+
function bridge(sseTransport: SSEClientTransport): void {
|
|
82
|
+
stdio.onmessage = (msg) => {
|
|
83
|
+
sseTransport.send(msg).catch((err) => {
|
|
84
|
+
process.stderr.write(`Pharaoh: send error — ${err instanceof Error ? err.message : String(err)}\n`);
|
|
85
|
+
});
|
|
86
|
+
};
|
|
87
|
+
sseTransport.onmessage = (msg) => {
|
|
88
|
+
stdio.send(msg).catch((err) => {
|
|
89
|
+
process.stderr.write(`Pharaoh: stdout error — ${err instanceof Error ? err.message : String(err)}\n`);
|
|
90
|
+
});
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Handle SSE errors — delegates to classifySSEError for 401/403 detection. */
|
|
95
|
+
const handleSSEError = classifySSEError;
|
|
96
|
+
|
|
97
|
+
// Wire up initial bridge
|
|
98
|
+
bridge(sse);
|
|
99
|
+
|
|
100
|
+
// Handle SSE connection drops with reconnect
|
|
101
|
+
sse.onerror = (err) => {
|
|
102
|
+
try {
|
|
103
|
+
handleSSEError(err);
|
|
104
|
+
} catch (typed) {
|
|
105
|
+
// TokenExpiredError or TenantSuspendedError — propagate via close
|
|
106
|
+
sse.close().catch(() => {});
|
|
107
|
+
throw typed;
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// Start both transports
|
|
112
|
+
await stdio.start();
|
|
113
|
+
|
|
114
|
+
// Connect SSE with reconnect loop
|
|
115
|
+
await connectWithReconnect();
|
|
116
|
+
|
|
117
|
+
/** Attempt SSE connection with exponential backoff on failure. */
|
|
118
|
+
async function connectWithReconnect(): Promise<void> {
|
|
119
|
+
while (reconnectAttempt <= BACKOFF_MS.length) {
|
|
120
|
+
try {
|
|
121
|
+
if (reconnectAttempt > 0) {
|
|
122
|
+
const delay = BACKOFF_MS[reconnectAttempt - 1];
|
|
123
|
+
process.stderr.write(
|
|
124
|
+
`Pharaoh: reconnecting in ${delay / 1000}s (attempt ${reconnectAttempt}/${BACKOFF_MS.length})...\n`,
|
|
125
|
+
);
|
|
126
|
+
await sleep(delay);
|
|
127
|
+
sse = createSSETransport(sseUrl, token);
|
|
128
|
+
bridge(sse);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
await sse.start();
|
|
132
|
+
reconnectAttempt = 0; // Reset on successful connection
|
|
133
|
+
|
|
134
|
+
// Wait for close — this promise resolves when SSE disconnects
|
|
135
|
+
await new Promise<void>((resolve, reject) => {
|
|
136
|
+
sse.onclose = () => resolve();
|
|
137
|
+
sse.onerror = (err) => {
|
|
138
|
+
try {
|
|
139
|
+
handleSSEError(err);
|
|
140
|
+
} catch (typed) {
|
|
141
|
+
reject(typed);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
// Non-fatal error — SSE will auto-close, onclose will fire
|
|
145
|
+
};
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// SSE closed — attempt reconnect
|
|
149
|
+
reconnectAttempt++;
|
|
150
|
+
} catch (err) {
|
|
151
|
+
if (err instanceof TokenExpiredError || err instanceof TenantSuspendedError) {
|
|
152
|
+
await stdio.close().catch(() => {});
|
|
153
|
+
throw err;
|
|
154
|
+
}
|
|
155
|
+
reconnectAttempt++;
|
|
156
|
+
if (reconnectAttempt > BACKOFF_MS.length) {
|
|
157
|
+
process.stderr.write("Pharaoh: max reconnect attempts reached — exiting\n");
|
|
158
|
+
await stdio.close().catch(() => {});
|
|
159
|
+
throw new Error("SSE connection failed after maximum retries");
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function sleep(ms: number): Promise<void> {
|
|
167
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
168
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"outDir": "dist",
|
|
7
|
+
"rootDir": "src",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"forceConsistentCasingInFileNames": true,
|
|
12
|
+
"declaration": true,
|
|
13
|
+
"sourceMap": true,
|
|
14
|
+
"resolveJsonModule": true
|
|
15
|
+
},
|
|
16
|
+
"include": ["src/**/*.ts"],
|
|
17
|
+
"exclude": ["node_modules", "dist"]
|
|
18
|
+
}
|