@pharaoh-so/mcp 0.1.5 → 0.2.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/LICENSE +21 -0
  3. package/README.md +237 -13
  4. package/dist/helpers.d.ts +36 -0
  5. package/dist/helpers.js +124 -0
  6. package/dist/index.js +70 -136
  7. package/dist/inspect.d.ts +11 -0
  8. package/dist/inspect.js +45 -0
  9. package/dist/install-skills.d.ts +33 -0
  10. package/dist/install-skills.js +121 -0
  11. package/dist/proxy.d.ts +4 -0
  12. package/dist/proxy.js +14 -17
  13. package/inspect-tools.json +12 -2
  14. package/package.json +64 -32
  15. package/skills/.gitkeep +0 -0
  16. package/skills/pharaoh/SKILL.md +81 -0
  17. package/skills/pharaoh-audit-tests/SKILL.md +88 -0
  18. package/skills/pharaoh-brainstorm/SKILL.md +73 -0
  19. package/skills/pharaoh-debt/SKILL.md +33 -0
  20. package/skills/pharaoh-debug/SKILL.md +69 -0
  21. package/skills/pharaoh-execute/SKILL.md +57 -0
  22. package/skills/pharaoh-explore/SKILL.md +32 -0
  23. package/skills/pharaoh-finish/SKILL.md +79 -0
  24. package/skills/pharaoh-health/SKILL.md +36 -0
  25. package/skills/pharaoh-investigate/SKILL.md +34 -0
  26. package/skills/pharaoh-onboard/SKILL.md +32 -0
  27. package/skills/pharaoh-parallel/SKILL.md +74 -0
  28. package/skills/pharaoh-plan/SKILL.md +74 -0
  29. package/skills/pharaoh-pr/SKILL.md +52 -0
  30. package/skills/pharaoh-refactor/SKILL.md +36 -0
  31. package/skills/pharaoh-review/SKILL.md +61 -0
  32. package/skills/pharaoh-review-codex/SKILL.md +80 -0
  33. package/skills/pharaoh-review-receive/SKILL.md +81 -0
  34. package/skills/pharaoh-sessions/SKILL.md +85 -0
  35. package/skills/pharaoh-tdd/SKILL.md +104 -0
  36. package/skills/pharaoh-verify/SKILL.md +72 -0
  37. package/skills/pharaoh-wiring/SKILL.md +34 -0
  38. package/skills/pharaoh-worktree/SKILL.md +85 -0
  39. package/dist/auth.js.map +0 -1
  40. package/dist/credentials.js.map +0 -1
  41. package/dist/index.js.map +0 -1
  42. package/dist/proxy.js.map +0 -1
package/dist/index.js CHANGED
@@ -5,170 +5,104 @@
5
5
  * Presents as an MCP server on stdio (for Claude Code) and relays messages
6
6
  * to a remote Pharaoh SSE server. Authenticates via RFC 8628 device flow
7
7
  * so the user can authorize on any device with a browser.
8
+ *
9
+ * Two modes determined by whether stdin is a TTY:
10
+ * - Interactive (TTY): authenticate, print setup instructions, exit.
11
+ * - Proxy (pipe): require pre-existing credentials, bridge stdio ↔ SSE.
8
12
  */
9
13
  import { printActivationPrompt, printAuthSuccess, pollForToken, requestDeviceCode, } from "./auth.js";
10
- import { deleteCredentials, isExpired, readCredentials, writeCredentials, } from "./credentials.js";
14
+ import { deleteCredentials, isExpired, readCredentials, writeCredentials } from "./credentials.js";
15
+ import { formatIdentity, formatTtl, parseArgs, printLines, printSetupInstructions, printUsage, resolveSseUrl, tokenToCredentials, } from "./helpers.js";
11
16
  import { TokenExpiredError, TenantSuspendedError, startProxy } from "./proxy.js";
12
- const DEFAULT_SERVER = "https://mcp.pharaoh.so";
13
- /** Parse CLI arguments. */
14
- function parseArgs() {
17
+ async function main() {
15
18
  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
- }
19
+ if (args.includes("--help") || args.includes("-h")) {
20
+ printUsage();
21
+ process.exit(0);
30
22
  }
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;
23
+ // Inspect mode — serve tool schemas over stdio for Glama directory inspection
24
+ if (args.includes("--inspect")) {
25
+ const { runInspect } = await import("./inspect.js");
26
+ await runInspect();
27
+ return;
72
28
  }
73
- catch {
74
- return fallback;
29
+ // Install-skills mode — copy bundled skills to ~/.openclaw/skills/
30
+ if (args.includes("--install-skills")) {
31
+ const { runInstallSkills } = await import("./install-skills.js");
32
+ runInstallSkills();
33
+ return;
75
34
  }
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
35
+ const { server, logout } = parseArgs(args);
107
36
  if (logout) {
108
37
  deleteCredentials();
109
- process.stderr.write("Pharaoh: credentials cleared\n");
38
+ printLines("Pharaoh: credentials cleared");
110
39
  process.exit(0);
111
40
  }
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
- }
41
+ const creds = readCredentials();
42
+ const isInteractive = Boolean(process.stdin.isTTY);
43
+ // ── Interactive mode (user running in a terminal) ──
44
+ // Authenticate if needed, print setup instructions, and exit.
45
+ // The proxy is useless without Claude Code on the other end of stdin.
46
+ if (isInteractive) {
47
+ if (creds && !isExpired(creds)) {
48
+ printLines(`Pharaoh: authenticated as ${formatIdentity(creds)} — token valid for ${formatTtl(creds.expires_at)}, ${creds.repos.length} repo${creds.repos.length === 1 ? "" : "s"} connected`);
49
+ printSetupInstructions();
50
+ process.exit(0);
133
51
  }
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);
52
+ // No valid credentials — run device flow
53
+ printLines("Pharaoh: no valid credentials — starting device authorization");
54
+ const deviceCode = await requestDeviceCode(server);
55
+ printActivationPrompt(deviceCode.user_code, deviceCode.verification_uri);
56
+ const token = await pollForToken(server, deviceCode.device_code, deviceCode.interval);
139
57
  if (token.provisional) {
140
- process.stderr.write(`Pharaoh: provisional access — install the GitHub App to map your repos: ${token.install_url ?? ""}\n`);
58
+ printLines(`Pharaoh: provisional access — install the GitHub App to map your repos: ${token.install_url ?? ""}`);
141
59
  }
142
60
  const sseUrl = resolveSseUrl(token.sse_url, server);
143
- creds = tokenToCredentials(token, sseUrl);
144
- writeCredentials(creds);
61
+ const newCreds = tokenToCredentials(token, sseUrl);
62
+ writeCredentials(newCreds);
145
63
  printAuthSuccess(token.github_login ?? null, token.tenant_name ?? null, token.repos?.length ?? 0);
64
+ printSetupInstructions();
65
+ process.exit(0);
66
+ }
67
+ // ── Proxy mode (Claude Code spawned us as a stdio MCP server) ──
68
+ // If no credentials, we can't run the device flow (no TTY for user interaction).
69
+ if (!creds || isExpired(creds)) {
70
+ printLines("Pharaoh: no valid credentials — cannot start proxy.", "Run this command first to authenticate:", " npx @pharaoh-so/mcp", "");
71
+ process.exit(1);
146
72
  }
147
- // Start proxy with fresh credentials
73
+ // Valid credentials start the proxy
74
+ printLines(`Pharaoh: token valid for ${formatTtl(creds.expires_at)} — connecting`);
148
75
  try {
149
76
  await startProxy(creds.sse_url, creds.access_token);
150
77
  }
151
78
  catch (err) {
152
79
  if (err instanceof TokenExpiredError) {
153
- // Token expired during session delete creds and re-auth
154
- process.stderr.write("Pharaoh: session expired — re-authenticating\n");
80
+ printLines("Pharaoh: token expired or revoked.", "Run this command to re-authenticate:", " npx @pharaoh-so/mcp", "");
155
81
  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
82
  process.exit(1);
165
83
  }
166
- else {
167
- throw err;
84
+ if (err instanceof TenantSuspendedError) {
85
+ printLines(`Pharaoh: ${err.message}`);
86
+ process.exit(1);
168
87
  }
88
+ throw err;
169
89
  }
170
90
  }
171
91
  main().catch((err) => {
172
- process.stderr.write(`Pharaoh: fatal ${err instanceof Error ? err.message : String(err)}\n`);
92
+ // Default: print only error name/code to avoid leaking tokens, internal URLs,
93
+ // or stack fragments that persist in CI logs. Full message behind PHARAOH_DEBUG.
94
+ if (process.env.PHARAOH_DEBUG === "1" && err instanceof Error) {
95
+ // Use main's comprehensive redaction patterns when showing the full message
96
+ const safeMsg = err.message
97
+ .replace(/Bearer\s+\S+/gi, "Bearer [REDACTED]")
98
+ .replace(/(?:phat|phrt|ghp|gho|ghs|ghu)_\S+/gi, "[REDACTED_TOKEN]")
99
+ .replace(/[?&](?:code|token|key|secret|password|state)=[^&\s]+/gi, "?[REDACTED_PARAM]")
100
+ .replace(/\b[0-9a-f]{32,}\b/gi, "[REDACTED_HEX]");
101
+ printLines(`Pharaoh: fatal — ${safeMsg}`);
102
+ }
103
+ else {
104
+ const label = err instanceof Error ? err.name : "Error";
105
+ printLines(`Pharaoh: fatal — ${label}. Set PHARAOH_DEBUG=1 for details.`);
106
+ }
173
107
  process.exit(1);
174
108
  });
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Inspect mode — serves Pharaoh's tool schemas over stdio for Glama inspection.
3
+ *
4
+ * Reads pre-generated tool schemas from inspect-tools.json (generated at build
5
+ * time by the main repo's `generate-inspect-schemas.ts`). Registers each tool
6
+ * on an MCP server that returns a "connect to mcp.pharaoh.so" message if invoked.
7
+ *
8
+ * Zero-drift: schemas are extracted from the same registerXxx() code that the
9
+ * production server uses, serialized to JSON during build, and shipped in the npm package.
10
+ */
11
+ export declare function runInspect(): Promise<void>;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Inspect mode — serves Pharaoh's tool schemas over stdio for Glama inspection.
3
+ *
4
+ * Reads pre-generated tool schemas from inspect-tools.json (generated at build
5
+ * time by the main repo's `generate-inspect-schemas.ts`). Registers each tool
6
+ * on an MCP server that returns a "connect to mcp.pharaoh.so" message if invoked.
7
+ *
8
+ * Zero-drift: schemas are extracted from the same registerXxx() code that the
9
+ * production server uses, serialized to JSON during build, and shipped in the npm package.
10
+ */
11
+ import { readFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
14
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
15
+ const INSPECT_MSG = "This is Pharaoh's inspection server for directory listings. " +
16
+ "Connect to https://mcp.pharaoh.so for actual usage.";
17
+ export async function runInspect() {
18
+ // Load pre-generated schemas (shipped in the npm package)
19
+ const schemasPath = join(import.meta.dirname, "..", "inspect-tools.json");
20
+ let schemas;
21
+ try {
22
+ schemas = JSON.parse(readFileSync(schemasPath, "utf-8"));
23
+ }
24
+ catch {
25
+ process.stderr.write("Pharaoh: inspect-tools.json not found — rebuild the package.\n");
26
+ process.exit(1);
27
+ }
28
+ const server = new McpServer({ name: "pharaoh", version: "0.1.0" }, {
29
+ instructions: "Pharaoh turns codebases into knowledge graphs that AI agents can think with. " +
30
+ "Connect at https://mcp.pharaoh.so for full functionality.",
31
+ });
32
+ // Register each tool with pre-serialized JSON Schema.
33
+ // Cast inputSchema to satisfy TypeScript — at runtime registerTool passes it through as-is.
34
+ for (const tool of schemas.tools) {
35
+ // biome-ignore lint/suspicious/noExplicitAny: JSON Schema from inspect-tools.json, not Zod
36
+ const schema = tool.inputSchema;
37
+ server.registerTool(tool.name, {
38
+ description: tool.description,
39
+ inputSchema: schema,
40
+ annotations: tool.annotations,
41
+ }, async () => ({ content: [{ type: "text", text: INSPECT_MSG }] }));
42
+ }
43
+ const transport = new StdioServerTransport();
44
+ await server.connect(transport);
45
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Detect whether OpenClaw is installed by checking for ~/.openclaw/.
3
+ *
4
+ * @param home - Home directory override (defaults to os.homedir()).
5
+ * @returns True if ~/.openclaw/ exists.
6
+ */
7
+ export declare function detectOpenClaw(home?: string): boolean;
8
+ /**
9
+ * Copy all bundled skill directories to ~/.openclaw/skills/.
10
+ * Overwrites existing skill dirs on reinstall (cpSync recursive + force).
11
+ *
12
+ * @param home - Home directory override (defaults to os.homedir()).
13
+ * @returns Number of skill directories copied.
14
+ */
15
+ export declare function installSkills(home?: string): number;
16
+ /**
17
+ * Read ~/.openclaw/openclaw.json, add the Pharaoh MCP server under `mcpServers`,
18
+ * and write it back. Creates the file if it does not exist. Does NOT overwrite
19
+ * an existing `pharaoh` entry — skips if already present.
20
+ *
21
+ * @param home - Home directory override (defaults to os.homedir()).
22
+ * @returns True if the config was written/updated, false if pharaoh was already present.
23
+ */
24
+ export declare function mergeOpenClawConfig(home?: string): boolean;
25
+ /**
26
+ * Main entry point for --install-skills.
27
+ *
28
+ * Detects OpenClaw, copies skills, merges config, and prints a summary.
29
+ * If OpenClaw is not detected, prints manual installation instructions instead.
30
+ *
31
+ * @param home - Home directory override (defaults to os.homedir()).
32
+ */
33
+ export declare function runInstallSkills(home?: string): void;
@@ -0,0 +1,121 @@
1
+ /**
2
+ * --install-skills implementation for the Pharaoh MCP proxy CLI.
3
+ *
4
+ * Copies bundled skill directories to ~/.openclaw/skills/ and merges
5
+ * the Pharaoh MCP server entry into ~/.openclaw/openclaw.json.
6
+ * If OpenClaw is not detected, prints manual installation instructions.
7
+ */
8
+ import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
9
+ import { homedir } from "node:os";
10
+ import { dirname, join } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ const __dirname = dirname(fileURLToPath(import.meta.url));
13
+ /** Path to the bundled skills directory (one level up from dist/). */
14
+ const BUNDLED_SKILLS_DIR = join(__dirname, "..", "skills");
15
+ /**
16
+ * Detect whether OpenClaw is installed by checking for ~/.openclaw/.
17
+ *
18
+ * @param home - Home directory override (defaults to os.homedir()).
19
+ * @returns True if ~/.openclaw/ exists.
20
+ */
21
+ export function detectOpenClaw(home = homedir()) {
22
+ return existsSync(join(home, ".openclaw"));
23
+ }
24
+ /**
25
+ * Copy all bundled skill directories to ~/.openclaw/skills/.
26
+ * Overwrites existing skill dirs on reinstall (cpSync recursive + force).
27
+ *
28
+ * @param home - Home directory override (defaults to os.homedir()).
29
+ * @returns Number of skill directories copied.
30
+ */
31
+ export function installSkills(home = homedir()) {
32
+ const targetDir = join(home, ".openclaw", "skills");
33
+ if (!existsSync(targetDir)) {
34
+ mkdirSync(targetDir, { recursive: true });
35
+ }
36
+ const entries = readdirSync(BUNDLED_SKILLS_DIR, { withFileTypes: true });
37
+ const skillDirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
38
+ for (const skillName of skillDirs) {
39
+ const src = join(BUNDLED_SKILLS_DIR, skillName);
40
+ const dst = join(targetDir, skillName);
41
+ cpSync(src, dst, { recursive: true, force: true });
42
+ }
43
+ return skillDirs.length;
44
+ }
45
+ /**
46
+ * Read ~/.openclaw/openclaw.json, add the Pharaoh MCP server under `mcpServers`,
47
+ * and write it back. Creates the file if it does not exist. Does NOT overwrite
48
+ * an existing `pharaoh` entry — skips if already present.
49
+ *
50
+ * @param home - Home directory override (defaults to os.homedir()).
51
+ * @returns True if the config was written/updated, false if pharaoh was already present.
52
+ */
53
+ export function mergeOpenClawConfig(home = homedir()) {
54
+ const configPath = join(home, ".openclaw", "openclaw.json");
55
+ let config = {};
56
+ if (existsSync(configPath)) {
57
+ try {
58
+ const raw = readFileSync(configPath, "utf-8");
59
+ config = JSON.parse(raw);
60
+ }
61
+ catch {
62
+ // Corrupted JSON — refuse to overwrite to avoid losing existing config
63
+ process.stderr.write([
64
+ "Pharaoh: ~/.openclaw/openclaw.json exists but is not valid JSON.",
65
+ "Fix or delete it manually, then re-run --install-skills.",
66
+ "",
67
+ ].join("\n"));
68
+ return false;
69
+ }
70
+ }
71
+ if (!config.mcpServers) {
72
+ config.mcpServers = {};
73
+ }
74
+ // Don't overwrite an existing pharaoh entry
75
+ if (config.mcpServers.pharaoh) {
76
+ return false;
77
+ }
78
+ config.mcpServers.pharaoh = {
79
+ command: "npx",
80
+ args: ["@pharaoh-so/mcp"],
81
+ };
82
+ writeFileSync(configPath, JSON.stringify(config, null, "\t"));
83
+ return true;
84
+ }
85
+ /**
86
+ * Main entry point for --install-skills.
87
+ *
88
+ * Detects OpenClaw, copies skills, merges config, and prints a summary.
89
+ * If OpenClaw is not detected, prints manual installation instructions instead.
90
+ *
91
+ * @param home - Home directory override (defaults to os.homedir()).
92
+ */
93
+ export function runInstallSkills(home = homedir()) {
94
+ const hasOpenClaw = detectOpenClaw(home);
95
+ if (!hasOpenClaw) {
96
+ process.stderr.write([
97
+ "Pharaoh: OpenClaw not detected (~/.openclaw/ not found).",
98
+ "",
99
+ "To install OpenClaw: https://openclaw.dev/install",
100
+ "",
101
+ "Or install skills manually:",
102
+ ` 1. Copy the skills/ directory from this package to ~/.openclaw/skills/`,
103
+ ` 2. Add Pharaoh to ~/.openclaw/openclaw.json under mcpServers:`,
104
+ ` "pharaoh": { "command": "npx", "args": ["@pharaoh-so/mcp"] }`,
105
+ "",
106
+ ].join("\n"));
107
+ return;
108
+ }
109
+ const count = installSkills(home);
110
+ const configUpdated = mergeOpenClawConfig(home);
111
+ const configMsg = configUpdated
112
+ ? "Pharaoh MCP server added to ~/.openclaw/openclaw.json"
113
+ : "Pharaoh already present in ~/.openclaw/openclaw.json — skipped";
114
+ process.stderr.write([
115
+ `Pharaoh: installed ${count} skills to ~/.openclaw/skills/`,
116
+ configMsg,
117
+ "",
118
+ "Restart OpenClaw to pick up the new skills.",
119
+ "",
120
+ ].join("\n"));
121
+ }
package/dist/proxy.d.ts CHANGED
@@ -18,6 +18,10 @@ export declare function classifySSEError(err: Error): void;
18
18
  * Claude Code → stdin → StdioServerTransport.onmessage → SSEClientTransport.send → POST /message
19
19
  * Pharaoh → SSE stream → SSEClientTransport.onmessage → StdioServerTransport.send → stdout
20
20
  *
21
+ * CRITICAL ordering: SSE must be connected BEFORE stdio starts reading stdin.
22
+ * Otherwise the MCP `initialize` message arrives before the remote transport is ready,
23
+ * gets permanently lost ("send error — Not connected"), and the client times out.
24
+ *
21
25
  * On SSE disconnect, attempts reconnect with exponential backoff (5 retries, ~62s total).
22
26
  * Throws TokenExpiredError on 401, TenantSuspendedError on 403.
23
27
  */
package/dist/proxy.js CHANGED
@@ -67,6 +67,10 @@ function createSSETransport(sseUrl, token) {
67
67
  * Claude Code → stdin → StdioServerTransport.onmessage → SSEClientTransport.send → POST /message
68
68
  * Pharaoh → SSE stream → SSEClientTransport.onmessage → StdioServerTransport.send → stdout
69
69
  *
70
+ * CRITICAL ordering: SSE must be connected BEFORE stdio starts reading stdin.
71
+ * Otherwise the MCP `initialize` message arrives before the remote transport is ready,
72
+ * gets permanently lost ("send error — Not connected"), and the client times out.
73
+ *
70
74
  * On SSE disconnect, attempts reconnect with exponential backoff (5 retries, ~62s total).
71
75
  * Throws TokenExpiredError on 401, TenantSuspendedError on 403.
72
76
  */
@@ -74,6 +78,7 @@ export async function startProxy(sseUrl, token) {
74
78
  const stdio = new StdioServerTransport();
75
79
  let sse = createSSETransport(sseUrl, token);
76
80
  let reconnectAttempt = 0;
81
+ let stdioStarted = false;
77
82
  /** Wire up bidirectional message relay between the two transports. */
78
83
  function bridge(sseTransport) {
79
84
  stdio.onmessage = (msg) => {
@@ -89,22 +94,7 @@ export async function startProxy(sseUrl, token) {
89
94
  }
90
95
  /** Handle SSE errors — delegates to classifySSEError for 401/403 detection. */
91
96
  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
97
+ // Connect SSE with reconnect loop — stdio starts AFTER first successful connection
108
98
  await connectWithReconnect();
109
99
  /** Attempt SSE connection with exponential backoff on failure. */
110
100
  async function connectWithReconnect() {
@@ -115,10 +105,17 @@ export async function startProxy(sseUrl, token) {
115
105
  process.stderr.write(`Pharaoh: reconnecting in ${delay / 1000}s (attempt ${reconnectAttempt}/${BACKOFF_MS.length})...\n`);
116
106
  await sleep(delay);
117
107
  sse = createSSETransport(sseUrl, token);
118
- bridge(sse);
119
108
  }
120
109
  await sse.start();
121
110
  reconnectAttempt = 0; // Reset on successful connection
111
+ // Bridge AFTER SSE is connected — messages can now be forwarded
112
+ bridge(sse);
113
+ // Start stdio AFTER first SSE connection — prevents initialize race
114
+ if (!stdioStarted) {
115
+ process.stderr.write("Pharaoh: connected\n");
116
+ await stdio.start();
117
+ stdioStarted = true;
118
+ }
122
119
  // Wait for close — this promise resolves when SSE disconnects
123
120
  await new Promise((resolve, reject) => {
124
121
  sse.onclose = () => resolve();
@@ -633,11 +633,21 @@
633
633
  },
634
634
  {
635
635
  "name": "setup_environment",
636
- "description": "Set up the optimal Claude Code environment for this codebase — install Pharaoh's curated plugin bundle.\n\nCALL THIS WHEN:\n• The user says \"set up my environment\", \"install plugins\", or \"optimize my setup\"\n• You want to proactively improve the development experience with proven plugins\n• After noticing the user could benefit from LSP, security scanning, or code review tools\n\nRETURNS: A curated list of recommended plugins with install commands, tailored to the languages in this codebase. Run the install commands to set everything up.\n\nThis is a one-time setup. Once installed, plugins persist across sessions.",
636
+ "description": "Set up the optimal development environment for this codebase — install Pharaoh's curated plugin bundle.\n\nCALL THIS WHEN:\n• The user says \"set up my environment\", \"install plugins\", or \"optimize my setup\"\n• You want to proactively improve the development experience with proven plugins\n• After noticing the user could benefit from LSP, security scanning, or code review tools\n• The user is on OpenClaw or Cursor and wants to install Pharaoh skills/prompts\n\nRETURNS: A curated list of recommended plugins and install instructions, tailored to the client platform (claude-code, openclaw, or cursor) and languages in this codebase.\n\nThis is a one-time setup. Once installed, plugins persist across sessions.",
637
637
  "inputSchema": {
638
638
  "$schema": "http://json-schema.org/draft-07/schema#",
639
639
  "type": "object",
640
- "properties": {}
640
+ "properties": {
641
+ "client": {
642
+ "description": "Client platform — determines install instructions format. Defaults to claude-code.",
643
+ "type": "string",
644
+ "enum": [
645
+ "claude-code",
646
+ "openclaw",
647
+ "cursor"
648
+ ]
649
+ }
650
+ }
641
651
  },
642
652
  "annotations": {
643
653
  "title": "Environment Setup",
package/package.json CHANGED
@@ -1,33 +1,65 @@
1
1
  {
2
- "name": "@pharaoh-so/mcp",
3
- "version": "0.1.5",
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
- "files": [
17
- "dist",
18
- "inspect-tools.json",
19
- "README.md"
20
- ],
21
- "license": "MIT",
22
- "engines": {
23
- "node": ">=18"
24
- },
25
- "dependencies": {
26
- "@modelcontextprotocol/sdk": "^1.26.0"
27
- },
28
- "devDependencies": {
29
- "@biomejs/biome": "^2.3.15",
30
- "typescript": "^5.9.3",
31
- "vitest": "^4.0.18"
32
- }
33
- }
2
+ "name": "@pharaoh-so/mcp",
3
+ "version": "0.2.0",
4
+ "description": "MCP proxy for Pharaoh — maps codebases into queryable knowledge graphs for AI agents. Enables Claude Code in headless environments (VPS, SSH, CI) via device flow auth.",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "bin": {
9
+ "pharaoh-mcp": "./dist/index.js"
10
+ },
11
+ "files": [
12
+ "dist",
13
+ "skills",
14
+ "inspect-tools.json",
15
+ "README.md",
16
+ "CHANGELOG.md",
17
+ "LICENSE"
18
+ ],
19
+ "keywords": [
20
+ "mcp",
21
+ "model-context-protocol",
22
+ "pharaoh",
23
+ "codebase",
24
+ "knowledge-graph",
25
+ "code-analysis",
26
+ "architecture",
27
+ "ai-agent",
28
+ "claude",
29
+ "claude-code",
30
+ "developer-tools",
31
+ "static-analysis",
32
+ "dependency-graph"
33
+ ],
34
+ "author": "Pharaoh <hello@pharaoh.so> (https://pharaoh.so)",
35
+ "license": "MIT",
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "git+https://github.com/Pharaoh-so/pharaoh-mcp.git"
39
+ },
40
+ "homepage": "https://pharaoh.so",
41
+ "bugs": {
42
+ "url": "https://github.com/Pharaoh-so/pharaoh-mcp/issues"
43
+ },
44
+ "funding": {
45
+ "type": "individual",
46
+ "url": "https://pharaoh.so"
47
+ },
48
+ "engines": {
49
+ "node": ">=18"
50
+ },
51
+ "dependencies": {
52
+ "@modelcontextprotocol/sdk": "^1.26.0"
53
+ },
54
+ "devDependencies": {
55
+ "@biomejs/biome": "^2.3.15",
56
+ "typescript": "^5.9.3",
57
+ "vitest": "^4.0.18"
58
+ },
59
+ "scripts": {
60
+ "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)\"",
61
+ "typecheck": "tsc --noEmit",
62
+ "test": "vitest run",
63
+ "lint": "biome check src/"
64
+ }
65
+ }
File without changes