@pharaoh-so/mcp 0.3.25 → 0.3.27

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.
@@ -7,12 +7,22 @@
7
7
  */
8
8
  import { type Credentials } from "./credentials.js";
9
9
  import { type RefreshFailureAction } from "./refresh.js";
10
+ /** Discriminated result from tryMidSessionRefresh. */
11
+ export type MidSessionRefreshResult = {
12
+ ok: true;
13
+ creds: Credentials;
14
+ } | {
15
+ ok: false;
16
+ action: RefreshFailureAction;
17
+ message: string;
18
+ };
10
19
  /**
11
20
  * Attempt a silent refresh triggered by a mid-session TokenExpiredError.
12
- * Returns new credentials on success, null on any failure (messages already
13
- * printed). Caller decides whether to retry startProxy.
21
+ * Returns new credentials on success, or a tagged failure carrying the
22
+ * RefreshFailureAction so the caller can branch on the real classification
23
+ * instead of re-inferring from disk state.
14
24
  */
15
- export declare function tryMidSessionRefresh(creds: Credentials, server: string): Promise<Credentials | null>;
25
+ export declare function tryMidSessionRefresh(creds: Credentials, server: string): Promise<MidSessionRefreshResult>;
16
26
  /**
17
27
  * Handle a failed `loadAndRefresh` at startup. Exits the process with the
18
28
  * appropriate message and creds-file state per the classified action.
package/dist/lifecycle.js CHANGED
@@ -10,14 +10,15 @@ import { NPX_COMMAND, printLines } from "./helpers.js";
10
10
  import { refreshIfNeeded } from "./refresh.js";
11
11
  /**
12
12
  * Attempt a silent refresh triggered by a mid-session TokenExpiredError.
13
- * Returns new credentials on success, null on any failure (messages already
14
- * printed). Caller decides whether to retry startProxy.
13
+ * Returns new credentials on success, or a tagged failure carrying the
14
+ * RefreshFailureAction so the caller can branch on the real classification
15
+ * instead of re-inferring from disk state.
15
16
  */
16
17
  export async function tryMidSessionRefresh(creds, server) {
17
18
  const outcome = await refreshIfNeeded(creds, server);
18
19
  if (outcome.ok)
19
- return outcome.creds;
20
- // Log the failure reason before caller's exit path runs.
20
+ return { ok: true, creds: outcome.creds };
21
+ // Log the failure reason before the caller decides what to do.
21
22
  if (outcome.action === "reauth") {
22
23
  printLines("Pharaoh: refresh rejected — re-authentication required.", ` ${NPX_COMMAND}`, "");
23
24
  deleteCredentials();
@@ -27,7 +28,7 @@ export async function tryMidSessionRefresh(creds, server) {
27
28
  // the user knows their credentials file is intact (matches startup path).
28
29
  printLines(`Pharaoh: refresh failed (${outcome.action}) — ${outcome.message}`, "Credentials preserved; next startup will retry.", "");
29
30
  }
30
- return null;
31
+ return { ok: false, action: outcome.action, message: outcome.message };
31
32
  }
32
33
  /**
33
34
  * Handle a failed `loadAndRefresh` at startup. Exits the process with the
@@ -8,11 +8,18 @@
8
8
  *
9
9
  * - startInAuthRequiredMode: cold start, no (or expired-refresh) credentials.
10
10
  * Spins up a gateway, wraps it in AuthRequiredBackend, starts the server.
11
- * Session C then polls hasValidCredentials() and calls setBackend() to swap
12
- * to the SSE backend once the user finishes the device flow.
11
+ * The caller supplies an `onAuthorized` callback that fires from inside the
12
+ * backend the moment device flow completes — the callback swaps ProxyServer
13
+ * over to an SseProxyBackend in place.
13
14
  *
14
15
  * - startInSseMode: happy path — valid credentials are already on disk. Starts
15
16
  * the server directly with an SSE backend.
17
+ *
18
+ * Lifecycle:
19
+ * - `waitForLifetime()` is the single awaitable the main loop blocks on. It
20
+ * resolves when Claude Code disconnects (stdio transport closed) and rejects
21
+ * with typed errors when the active backend hits a terminal failure. Backend
22
+ * swaps happen internally via callbacks; the main loop does NOT re-await.
16
23
  */
17
24
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
18
25
  import { type Backend } from "./backend.js";
@@ -27,6 +34,8 @@ export declare class ProxyServer {
27
34
  private transport;
28
35
  private backend;
29
36
  private started;
37
+ private transportClosed;
38
+ private transportClosedResolve;
30
39
  constructor(initialBackend: Backend);
31
40
  /**
32
41
  * Wire up list/call handlers. Each handler reads this.backend fresh at
@@ -45,6 +54,26 @@ export declare class ProxyServer {
45
54
  * Kept separate from start() so the stdio wiring stays implicit in prod.
46
55
  */
47
56
  startWithTransport(transport: StdioServerTransport): Promise<void>;
57
+ /**
58
+ * Block until either (a) the stdio transport closes cleanly (Claude Code
59
+ * disconnected) or (b) the current backend's supervisor promise rejects
60
+ * with a terminal error. Returns when the transport closes. Throws when
61
+ * a backend's waitForExit rejects.
62
+ *
63
+ * Swap-aware: if setBackend() is called while waitForLifetime is blocked,
64
+ * the old backend's exit promise is abandoned and the new backend's is
65
+ * awaited. This matters for "SSE → auth-required → SSE2" flows where we
66
+ * want errors from SSE2 to bubble up, not the already-dead SSE1.
67
+ */
68
+ waitForLifetime(): Promise<void>;
69
+ /**
70
+ * Used by setBackend to wake waitForLifetime so it can re-sample. Default
71
+ * no-op so a swap fired before waitForLifetime's first iteration (e.g. an
72
+ * onAuthorized that completes between proxy.start() and the first
73
+ * waitForLifetime call) doesn't crash. The missed wake is harmless — the
74
+ * next waitForLifetime iteration samples this.backend directly.
75
+ */
76
+ private backendSwappedSignal;
48
77
  /**
49
78
  * Swap the backend reference and fire a tools/list_changed notification so
50
79
  * the client re-fetches. No in-flight tracking — the SDK routes each call
@@ -59,12 +88,15 @@ export declare class ProxyServer {
59
88
  * the proxy in auth-required mode so the stdio connection stays alive while
60
89
  * the user completes the device flow.
61
90
  *
62
- * Returns the running ProxyServer and its gateway. Session C awaits
63
- * gateway.hasValidCredentials() on each tool call boundary, and once that
64
- * flips true, calls proxy.setBackend(createSseBackend(...)) to swap in the
65
- * real catalog.
91
+ * The caller supplies `opts.onAuthorized`, which fires from inside the
92
+ * AuthRequiredBackend the moment device flow completes. Typical implementation:
93
+ * swap the ProxyServer over to an SseProxyBackend so the next tool call goes
94
+ * to the real server. This keeps the proxy reference under the caller's
95
+ * control — the backend never imports ProxyServer.
66
96
  */
67
- export declare function startInAuthRequiredMode(server: string): Promise<{
97
+ export declare function startInAuthRequiredMode(server: string, opts: {
98
+ onAuthorized: (creds: Credentials) => Promise<void>;
99
+ }): Promise<{
68
100
  proxy: ProxyServer;
69
101
  gateway: DeviceFlowGateway;
70
102
  }>;
@@ -8,11 +8,18 @@
8
8
  *
9
9
  * - startInAuthRequiredMode: cold start, no (or expired-refresh) credentials.
10
10
  * Spins up a gateway, wraps it in AuthRequiredBackend, starts the server.
11
- * Session C then polls hasValidCredentials() and calls setBackend() to swap
12
- * to the SSE backend once the user finishes the device flow.
11
+ * The caller supplies an `onAuthorized` callback that fires from inside the
12
+ * backend the moment device flow completes — the callback swaps ProxyServer
13
+ * over to an SseProxyBackend in place.
13
14
  *
14
15
  * - startInSseMode: happy path — valid credentials are already on disk. Starts
15
16
  * the server directly with an SSE backend.
17
+ *
18
+ * Lifecycle:
19
+ * - `waitForLifetime()` is the single awaitable the main loop blocks on. It
20
+ * resolves when Claude Code disconnects (stdio transport closed) and rejects
21
+ * with typed errors when the active backend hits a terminal failure. Backend
22
+ * swaps happen internally via callbacks; the main loop does NOT re-await.
16
23
  */
17
24
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
18
25
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -29,6 +36,8 @@ export class ProxyServer {
29
36
  transport = null;
30
37
  backend;
31
38
  started = false;
39
+ transportClosed;
40
+ transportClosedResolve;
32
41
  constructor(initialBackend) {
33
42
  this.backend = initialBackend;
34
43
  this.server = new Server({ name: "pharaoh-proxy", version: getPackageVersion() }, {
@@ -36,6 +45,9 @@ export class ProxyServer {
36
45
  // to throw; the SDK's assertNotificationCapability gates it.
37
46
  capabilities: { tools: { listChanged: true } },
38
47
  });
48
+ this.transportClosed = new Promise((resolve) => {
49
+ this.transportClosedResolve = resolve;
50
+ });
39
51
  this.registerHandlers();
40
52
  }
41
53
  /**
@@ -64,6 +76,11 @@ export class ProxyServer {
64
76
  await this.backend.start();
65
77
  this.transport = new StdioServerTransport();
66
78
  await this.server.connect(this.transport);
79
+ // Server.connect() wraps transport.onclose; use the Protocol-level hook
80
+ // instead so we don't clobber the SDK's chain. Fires when the transport
81
+ // closes cleanly (client disconnect) — not on error paths, which surface
82
+ // through backend.waitForExit() and the server's own onerror plumbing.
83
+ this.server.onclose = () => this.transportClosedResolve();
67
84
  this.started = true;
68
85
  }
69
86
  /**
@@ -78,8 +95,44 @@ export class ProxyServer {
78
95
  this.transport = transport;
79
96
  // biome-ignore lint/suspicious/noExplicitAny: test transport is a Transport, but the type import chain is awkward here
80
97
  await this.server.connect(transport);
98
+ this.server.onclose = () => this.transportClosedResolve();
81
99
  this.started = true;
82
100
  }
101
+ /**
102
+ * Block until either (a) the stdio transport closes cleanly (Claude Code
103
+ * disconnected) or (b) the current backend's supervisor promise rejects
104
+ * with a terminal error. Returns when the transport closes. Throws when
105
+ * a backend's waitForExit rejects.
106
+ *
107
+ * Swap-aware: if setBackend() is called while waitForLifetime is blocked,
108
+ * the old backend's exit promise is abandoned and the new backend's is
109
+ * awaited. This matters for "SSE → auth-required → SSE2" flows where we
110
+ * want errors from SSE2 to bubble up, not the already-dead SSE1.
111
+ */
112
+ async waitForLifetime() {
113
+ while (true) {
114
+ const swapSignal = new Promise((resolve) => {
115
+ this.backendSwappedSignal = () => resolve("swap");
116
+ });
117
+ const backendExit = this.backend.waitForExit?.() ?? new Promise(() => { });
118
+ const outcome = await Promise.race([
119
+ this.transportClosed.then(() => "closed"),
120
+ backendExit.then(() => "exit"),
121
+ swapSignal,
122
+ ]);
123
+ if (outcome === "swap")
124
+ continue; // re-sample waitForExit against the new backend
125
+ return; // "closed" or "exit" (the backend's waitForExit resolved cleanly)
126
+ }
127
+ }
128
+ /**
129
+ * Used by setBackend to wake waitForLifetime so it can re-sample. Default
130
+ * no-op so a swap fired before waitForLifetime's first iteration (e.g. an
131
+ * onAuthorized that completes between proxy.start() and the first
132
+ * waitForLifetime call) doesn't crash. The missed wake is harmless — the
133
+ * next waitForLifetime iteration samples this.backend directly.
134
+ */
135
+ backendSwappedSignal = () => { };
83
136
  /**
84
137
  * Swap the backend reference and fire a tools/list_changed notification so
85
138
  * the client re-fetches. No in-flight tracking — the SDK routes each call
@@ -99,15 +152,26 @@ export class ProxyServer {
99
152
  // Fire the notification after the swap so a client that races a list
100
153
  // request against it sees the new catalog.
101
154
  await this.server.sendToolListChanged();
102
- // Fire-and-forget stop of the old backend. Errors here don't affect
103
- // the running session — the swap has already happened.
155
+ // Wake any in-flight waitForLifetime so it re-samples the new backend's
156
+ // waitForExit. Without this, SSE2 errors after a swap-back would be lost.
157
+ this.backendSwappedSignal();
158
+ // Fire-and-forget stop of the old backend. The swap has already happened
159
+ // so stop failures can't break the running session — but log them so
160
+ // resource leaks (file descriptors, keepalive timers, EventSource auto-
161
+ // reconnect surviving a failed close) are visible.
104
162
  if (prev !== next && prev.stop) {
105
- prev.stop().catch(() => { });
163
+ prev.stop().catch((err) => {
164
+ const msg = err instanceof Error ? err.message : String(err);
165
+ process.stderr.write(`Pharaoh: previous backend stop() failed — ${msg}\n`);
166
+ });
106
167
  }
107
168
  }
108
169
  async stop() {
109
170
  if (this.backend.stop) {
110
- await this.backend.stop().catch(() => { });
171
+ await this.backend.stop().catch((err) => {
172
+ const msg = err instanceof Error ? err.message : String(err);
173
+ process.stderr.write(`Pharaoh: backend stop() failed during shutdown — ${msg}\n`);
174
+ });
111
175
  }
112
176
  if (this.transport) {
113
177
  await this.server.close().catch(() => { });
@@ -124,14 +188,28 @@ export class ProxyServer {
124
188
  * the proxy in auth-required mode so the stdio connection stays alive while
125
189
  * the user completes the device flow.
126
190
  *
127
- * Returns the running ProxyServer and its gateway. Session C awaits
128
- * gateway.hasValidCredentials() on each tool call boundary, and once that
129
- * flips true, calls proxy.setBackend(createSseBackend(...)) to swap in the
130
- * real catalog.
191
+ * The caller supplies `opts.onAuthorized`, which fires from inside the
192
+ * AuthRequiredBackend the moment device flow completes. Typical implementation:
193
+ * swap the ProxyServer over to an SseProxyBackend so the next tool call goes
194
+ * to the real server. This keeps the proxy reference under the caller's
195
+ * control — the backend never imports ProxyServer.
131
196
  */
132
- export async function startInAuthRequiredMode(server) {
197
+ export async function startInAuthRequiredMode(server, opts) {
133
198
  const gateway = createGateway({ server });
134
- const backend = new AuthRequiredBackend(gateway);
199
+ const backend = new AuthRequiredBackend(gateway, async () => {
200
+ // Pull fresh creds from the gateway (which just wrote them to disk) so
201
+ // the callback doesn't race a separate disk read. The gateway is the
202
+ // authoritative post-auth source.
203
+ const creds = gateway.getCredentials();
204
+ if (!creds) {
205
+ // Should never happen — gateway.handleToolCall() only returns null
206
+ // after persistAuthorizedToken sets lastCreds. Keep the guard so a
207
+ // future refactor doesn't silently ship a swap-to-nothing bug.
208
+ process.stderr.write("Pharaoh: authorized but no credentials available — skipping swap\n");
209
+ return;
210
+ }
211
+ await opts.onAuthorized(creds);
212
+ });
135
213
  const proxy = new ProxyServer(backend);
136
214
  await proxy.start();
137
215
  return { proxy, gateway };
package/dist/proxy.d.ts CHANGED
@@ -1,16 +1,10 @@
1
1
  /**
2
- * Stdio ↔ SSE proxy entry point. Legacy export surface kept for the
3
- * in-flight handoff between Sessions B and C: index.ts (Session C's scope)
4
- * still imports `startProxy`, `TokenExpiredError`, and `TenantSuspendedError`
5
- * from here. Session C will rewrite index.ts to use ProxyServer/Backend
6
- * directly and `startProxy` will disappear at that point.
2
+ * Typed SSE errors + classifier. The stdio ↔ SSE wiring itself lives in
3
+ * backend.ts (SseProxyBackend); this module exports only the error types and
4
+ * the classifier so both the backend and tests can produce/assert on the
5
+ * same typed errors.
7
6
  *
8
- * Typed error classes and SSE classification live here because both the
9
- * legacy startProxy wrapper and the new SseProxyBackend (in backend.ts) need
10
- * to produce the same typed errors so downstream consumers don't care which
11
- * path surfaced them.
12
- *
13
- * RECONNECTION DESIGN (inherited by SseProxyBackend):
7
+ * RECONNECTION DESIGN (implemented in backend.ts SseProxyBackend):
14
8
  * The MCP SDK's SSEClientTransport wraps an EventSource, which has built-in
15
9
  * auto-reconnect (every 3s by default). This is problematic because:
16
10
  * 1. Each reconnect creates a NEW server-side session (new auth, new ID)
@@ -33,14 +27,13 @@ export declare class TenantSuspendedError extends Error {
33
27
  * Classify an SSE error — detect 401/403 and throw typed errors.
34
28
  * Exported for testability; used internally by the reconnect loop in
35
29
  * backend.ts.
36
- */
37
- export declare function classifySSEError(err: Error): void;
38
- /**
39
- * Legacy thin wrapper that starts the new ProxyServer in SSE mode and blocks
40
- * until the underlying SSE backend exits. Preserved so index.ts (Session C)
41
- * keeps compiling until the wiring PR; the return/throw contract matches the
42
- * original (resolves on clean exit, throws TokenExpiredError / TenantSuspendedError).
43
30
  *
44
- * @deprecated Session C will call ProxyServer / startInSseMode directly.
31
+ * The fallback uses status-prefix regex patterns rather than a bare
32
+ * `msg.includes("401")`. Session C calls this classifier on every reconnect
33
+ * error (not just SSE transport onerror events), so a substring match would
34
+ * misclassify any unrelated error text containing "401" — e.g. a port number,
35
+ * a timestamp suffix, or HTML body text echoed from a proxy. The regex
36
+ * requires an HTTP-shaped context so only actual status codes trigger a reauth
37
+ * loop.
45
38
  */
46
- export declare function startProxy(sseUrl: string, token: string): Promise<void>;
39
+ export declare function classifySSEError(err: Error): void;
package/dist/proxy.js CHANGED
@@ -1,16 +1,10 @@
1
1
  /**
2
- * Stdio ↔ SSE proxy entry point. Legacy export surface kept for the
3
- * in-flight handoff between Sessions B and C: index.ts (Session C's scope)
4
- * still imports `startProxy`, `TokenExpiredError`, and `TenantSuspendedError`
5
- * from here. Session C will rewrite index.ts to use ProxyServer/Backend
6
- * directly and `startProxy` will disappear at that point.
2
+ * Typed SSE errors + classifier. The stdio ↔ SSE wiring itself lives in
3
+ * backend.ts (SseProxyBackend); this module exports only the error types and
4
+ * the classifier so both the backend and tests can produce/assert on the
5
+ * same typed errors.
7
6
  *
8
- * Typed error classes and SSE classification live here because both the
9
- * legacy startProxy wrapper and the new SseProxyBackend (in backend.ts) need
10
- * to produce the same typed errors so downstream consumers don't care which
11
- * path surfaced them.
12
- *
13
- * RECONNECTION DESIGN (inherited by SseProxyBackend):
7
+ * RECONNECTION DESIGN (implemented in backend.ts SseProxyBackend):
14
8
  * The MCP SDK's SSEClientTransport wraps an EventSource, which has built-in
15
9
  * auto-reconnect (every 3s by default). This is problematic because:
16
10
  * 1. Each reconnect creates a NEW server-side session (new auth, new ID)
@@ -39,10 +33,19 @@ export class TenantSuspendedError extends Error {
39
33
  * Classify an SSE error — detect 401/403 and throw typed errors.
40
34
  * Exported for testability; used internally by the reconnect loop in
41
35
  * backend.ts.
36
+ *
37
+ * The fallback uses status-prefix regex patterns rather than a bare
38
+ * `msg.includes("401")`. Session C calls this classifier on every reconnect
39
+ * error (not just SSE transport onerror events), so a substring match would
40
+ * misclassify any unrelated error text containing "401" — e.g. a port number,
41
+ * a timestamp suffix, or HTML body text echoed from a proxy. The regex
42
+ * requires an HTTP-shaped context so only actual status codes trigger a reauth
43
+ * loop.
42
44
  */
43
45
  export function classifySSEError(err) {
44
46
  const msg = err.message ?? "";
45
- // SseError from SDK includes the HTTP status code
47
+ // SseError from SDK includes the HTTP status code on `.code` — the
48
+ // authoritative signal.
46
49
  if ("code" in err) {
47
50
  const code = err.code;
48
51
  if (code === 401)
@@ -50,35 +53,15 @@ export function classifySSEError(err) {
50
53
  if (code === 403)
51
54
  throw new TenantSuspendedError(msg || "Tenant suspended");
52
55
  }
53
- // Fallback: check message for status codes
54
- if (msg.includes("401"))
55
- throw new TokenExpiredError();
56
- if (msg.includes("403"))
57
- throw new TenantSuspendedError(msg || "Tenant suspended");
58
- }
59
- /**
60
- * Legacy thin wrapper that starts the new ProxyServer in SSE mode and blocks
61
- * until the underlying SSE backend exits. Preserved so index.ts (Session C)
62
- * keeps compiling until the wiring PR; the return/throw contract matches the
63
- * original (resolves on clean exit, throws TokenExpiredError / TenantSuspendedError).
64
- *
65
- * @deprecated Session C will call ProxyServer / startInSseMode directly.
66
- */
67
- export async function startProxy(sseUrl, token) {
68
- // Lazy imports to keep module load order clean — backend.ts imports from
69
- // proxy.ts for the typed errors and classifier above, so a top-level
70
- // import would create a cycle.
71
- const { createSseBackend } = await import("./backend.js");
72
- const { ProxyServer } = await import("./proxy-server.js");
73
- const backend = createSseBackend(sseUrl, token);
74
- const proxy = new ProxyServer(backend);
75
- await proxy.start();
76
- // Block on the SSE reconnect loop — matches the original semantics of
77
- // startProxy, which resolved only when the connection terminated.
78
- try {
79
- await backend.waitForExit();
80
- }
81
- finally {
82
- await proxy.stop().catch(() => { });
56
+ // Fallback regex for errors that don't expose `.code`. Matches common HTTP
57
+ // status renderings: "HTTP 401", "HTTP/1.1 401", "401 Unauthorized",
58
+ // "status 401", "status: 401" — but NOT bare "401" anywhere in the string.
59
+ const m = msg.match(/\b(?:HTTP(?:\/[\d.]+)?|status:?)\s+(\d{3})\b|\b(\d{3})\s+(?:Unauthorized|Forbidden)\b/i);
60
+ if (m) {
61
+ const code = Number(m[1] ?? m[2]);
62
+ if (code === 401)
63
+ throw new TokenExpiredError();
64
+ if (code === 403)
65
+ throw new TenantSuspendedError(msg || "Tenant suspended");
83
66
  }
84
67
  }
@@ -207,7 +207,7 @@
207
207
  },
208
208
  {
209
209
  "name": "get_design_system",
210
- "description": "Discover the design system BEFORE creating any UI component.\n\nCALL THIS WHEN:\n• You're about to create a React/Vue/Svelte component — check what already exists\n• You need to know the canonical component for a UI pattern (button, input, modal)\n• You want to find design tokens (colors, spacing, typography) to use\n• You suspect raw HTML elements are used instead of existing components\n\nRETURNS: Components with props and usage count, design tokens with values, and anti-patterns where raw HTML is used instead of existing components. Screen/page components are excluded by default.\n\nEXAMPLES:\n• \"What components does this design system have?\"\n• \"Is there already a Button component I should use?\"\n• \"What color tokens are available?\"\n• \"Where are raw HTML elements used instead of components?\"\n\nWHY NOT JUST READ FILES: Design systems span dozens of files across multiple directories. This returns the complete inventory with usage frequency — the most-used components are the most canonical.",
210
+ "description": "Discover the design system BEFORE creating any UI component.\n\nCALL THIS WHEN:\n• You're about to create a React/Vue/Svelte component — check what already exists\n• You need to know the canonical component for a UI pattern (button, input, modal)\n• You want to find design tokens (colors, spacing, typography) to use\n• You suspect raw HTML elements are used instead of existing components\n\nRETURNS: Components with props and usage count (paginated), design tokens with values, and anti-patterns where raw HTML is used instead of existing components. Pass `path_pattern`, `name_prefix`, `offset`, or `limit` to narrow or page through results. Screen/page components are excluded by default.\n\nEXAMPLES:\n• \"What components does this design system have?\"\n• \"Is there already a Button component I should use?\"\n• \"What color tokens are available?\"\n• \"Show me all components in /atoms/\" → use path_pattern=\"/atoms/\"\n• \"Show me the next page\" → use offset=50\n\nWHY NOT JUST READ FILES: Design systems span dozens of files across multiple directories. This returns the complete inventory with usage frequency — the most-used components are the most canonical.",
211
211
  "inputSchema": {
212
212
  "$schema": "http://json-schema.org/draft-07/schema#",
213
213
  "type": "object",
@@ -231,6 +231,32 @@
231
231
  "default": false,
232
232
  "description": "Include screen/page components (*Screen, *Page, *View, *Route) that are typically app-level consumers, not reusable primitives",
233
233
  "type": "boolean"
234
+ },
235
+ "path_pattern": {
236
+ "description": "Substring match against File.path (e.g. '/atoms/', '/ui/Button'). Narrows to components whose file path contains this exact substring.",
237
+ "type": "string",
238
+ "minLength": 1,
239
+ "maxLength": 200
240
+ },
241
+ "name_prefix": {
242
+ "description": "Exact prefix match against component name (e.g. 'Button' matches Button, ButtonGroup, ButtonIcon). Case-sensitive.",
243
+ "type": "string",
244
+ "minLength": 1,
245
+ "maxLength": 200
246
+ },
247
+ "offset": {
248
+ "default": 0,
249
+ "description": "Pagination offset (0-based). Capped at 10000.",
250
+ "type": "integer",
251
+ "minimum": 0,
252
+ "maximum": 10000
253
+ },
254
+ "limit": {
255
+ "default": 50,
256
+ "description": "Page size. Default 50, max 200.",
257
+ "type": "integer",
258
+ "minimum": 1,
259
+ "maximum": 200
234
260
  }
235
261
  },
236
262
  "required": [
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pharaoh-so/mcp",
3
3
  "mcpName": "so.pharaoh/pharaoh",
4
- "version": "0.3.25",
4
+ "version": "0.3.27",
5
5
  "description": "MCP proxy for Pharaoh — maps codebases into queryable knowledge graphs for AI agents. Enables Claude Code in headless environments (VPS, SSH, CI) via device flow auth.",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",
@@ -27,14 +27,23 @@ For unmapped private repos, direct the user to install the Pharaoh GitHub App
27
27
 
28
28
  | Trigger | Call |
29
29
  |---------|------|
30
+ | Before ANY architectural change | `get_vision_docs` — read the relevant MUST-bullets first |
30
31
  | Before modifying code | `get_module_context` + `get_blast_radius` |
31
32
  | Before writing new code | `search_functions` |
32
33
  | Before creating UI components | `get_design_system` |
33
34
  | Before tracing module connections | `query_dependencies` |
34
35
  | Before implementing a spec | `get_vision_docs` |
36
+ | After a large refactor | `get_vision_gaps` — surface intent/implementation drift |
35
37
  | Before deleting code | `check_reachability` |
36
38
  | Initial orientation | `pharaoh_recon` (batches the above) |
37
39
 
40
+ **Vision docs are first-class.** If the repo has a `docs/VISION.md`, it's
41
+ the contract for what must always be true. Read it before touching
42
+ architecture. If a change would violate a MUST-bullet, stop and raise it
43
+ with the user before coding. If the repo has no VISION.md, offer to run
44
+ `/pharaoh:vision` at the next natural break — it's the highest-leverage
45
+ setup step for any team using Pharaoh.
46
+
38
47
  ## Anti-patterns
39
48
 
40
49
  - Do NOT read files one-by-one to understand architecture — call `get_codebase_map`.
@@ -45,10 +54,12 @@ For unmapped private repos, direct the user to install the Pharaoh GitHub App
45
54
 
46
55
  ## Automatic workflows
47
56
 
48
- **PR review:** `pharaoh_recon` (map + touched modules + blast radius) → `get_regression_risk` → `get_consolidation_opportunities`. Verdict: SHIP, SHIP WITH CHANGES, or BLOCK.
57
+ **PR review:** `pharaoh_recon` (map + touched modules + blast radius) → `get_vision_docs` (relevant assertions) → `get_regression_risk` → `get_consolidation_opportunities`. Verdict: SHIP, SHIP WITH CHANGES, or BLOCK. Any PR touching code governed by a MUST-bullet should reference the assertion in the review.
49
58
 
50
59
  **Health audit:** `get_unused_code` → `get_test_coverage` → `get_regression_risk` → `get_vision_gaps` → `get_cross_repo_audit`. Grade A–F.
51
60
 
61
+ **Vision maintenance:** When an architectural decision lands in a PR, update `docs/VISION.md` in the same commit — add an assertion if one is new, retire one if it no longer applies. Stale vision docs are worse than none.
62
+
52
63
  ## Using results
53
64
 
54
65
  - Lead with what the data reveals about this codebase — not a raw dump. Highlight surprises.
@@ -45,11 +45,12 @@ Do NOT review from memory or assumptions. Query the actual codebase first.
45
45
  ### If Pharaoh MCP tools are available:
46
46
 
47
47
  1. `get_codebase_map` — current modules, hot files, dependency graph
48
- 2. `search_functions` for keywords related to the plan — find existing code to reuse/extend
49
- 3. `get_module_context` on affected modules — entry points, patterns, conventions
50
- 4. `query_dependencies` between affected modules — coupling, circular deps
51
- 5. `get_blast_radius` on the primary target of the change — know what breaks
52
- 6. `check_reachability` on the primary target — verify it's actually reachable from entry points before worrying about it
48
+ 2. `get_vision_docs` — read the repo's architectural contract. If the plan touches a domain with a MUST-bullet, the assertion is a hard constraint on the plan. Raise it explicitly in Section 1. If `get_vision_docs` returns an empty-state scaffold, flag that the repo needs `/pharaoh:vision` before planning anything architectural.
49
+ 3. `search_functions` for keywords related to the plan — find existing code to reuse/extend
50
+ 4. `get_module_context` on affected modules — entry points, patterns, conventions
51
+ 5. `query_dependencies` between affected modules — coupling, circular deps
52
+ 6. `get_blast_radius` on the primary target of the change — know what breaks
53
+ 7. `check_reachability` on the primary target — verify it's actually reachable from entry points before worrying about it
53
54
 
54
55
  ### Without Pharaoh (graceful fallback):
55
56
 
@@ -77,10 +78,17 @@ Adapt depth to change size. Skip sections that don't apply. **After each section
77
78
 
78
79
  ### Section 1 — Architecture (skip for small/single-file changes)
79
80
 
81
+ - **Vision alignment (check FIRST).** Does this plan touch a domain
82
+ governed by a MUST-bullet in `docs/VISION.md`? If yes, quote the
83
+ assertion and verify the plan doesn't violate it. If the plan would
84
+ violate a MUST, STOP and raise with the user — either the plan changes
85
+ or VISION.md needs an update first (with user approval).
80
86
  - Component boundaries and coupling concerns
81
87
  - Dependency graph: does this change shrink or expand surface area?
82
88
  - Data flow bottlenecks and single points of failure
83
89
  - Does this need new code at all, or can a human process / existing pattern solve it?
90
+ - **If this plan introduces a new invariant**, call it out and include a
91
+ step in Section 6 to add a `### assertion` to VISION.md with a pairing TEST.
84
92
 
85
93
  ### Section 2 — Code Quality (always)
86
94
 
@@ -71,7 +71,8 @@ Then call these additional tools for data recon doesn't cover:
71
71
  - `get_regression_risk` — overall change risk score for the repo
72
72
  - `get_consolidation_opportunities` — duplicate logic the PR may introduce
73
73
  - `check_reachability` — are new exports wired to entry points?
74
- - `get_vision_gaps` — do changes align with or drift from specs?
74
+ - `get_vision_docs` — read the repo's architectural contract. For every touched module, pull its relevant assertions. Any change touching code governed by a MUST-bullet must be checked against that assertion — if the change would violate the MUST, the verdict tips toward BLOCK unless VISION.md is updated in the same PR with user approval.
75
+ - `get_vision_gaps` — do changes align with or drift from specs? New complex functions without a matching assertion are flagged; retired specs with implementing code are flagged.
75
76
 
76
77
  **Output of Phase 1:** Architecture map, module profiles for every touched module, blast radius for high-risk changes, dependency paths between coupled modules, regression risk level, duplication findings, reachability status, and spec alignment.
77
78
 
@@ -255,7 +256,8 @@ If none of these triggers are met, **skip Phase 3** and proceed to Phase 4.
255
256
  - Unreachable exports (new public code with zero callers)
256
257
  - New circular dependencies between modules
257
258
  - HIGH regression risk without corresponding test coverage
258
- - Vision spec violations (building against spec intent)
259
+ - **A MUST-bullet in `docs/VISION.md` is violated by this change** (without VISION.md being updated in the same PR with user approval)
260
+ - Vision spec drift: new complex function has no matching assertion AND the domain it lives in has existing assertions (implies invariants are expected here)
259
261
  - Any CRITICAL finding from Phase 2 or Phase 3 that is confirmed and unfixed
260
262
  - DISAGREE verdict from adversarial review on security-sensitive code, confirmed after verification
261
263
  - Unanchored Cypher query (MATCH without traversing through `Repo {name: $repo}`)