@pharaoh-so/mcp 0.3.46 → 0.3.50
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/.mcp.json +3 -1
- package/CHANGELOG.md +29 -0
- package/README.md +29 -2
- package/dist/backend.d.ts +6 -2
- package/dist/backend.js +43 -4
- package/dist/credentials.d.ts +9 -0
- package/dist/credentials.js +15 -6
- package/dist/index.js +17 -2
- package/dist/inspect.js +2 -0
- package/dist/install-skills.d.ts +63 -10
- package/dist/install-skills.js +162 -59
- package/dist/proxy-server.d.ts +4 -0
- package/dist/proxy-server.js +45 -3
- package/dist/setup.d.ts +13 -4
- package/dist/setup.js +19 -19
- package/dist/trace-resource.d.ts +3 -0
- package/dist/trace-resource.js +41 -0
- package/dist/version.d.ts +30 -0
- package/dist/version.js +66 -4
- package/inspect-tools.json +35 -10
- package/package.json +72 -69
- package/skills/pharaoh/SKILL.md +1 -1
- package/trace-app.html +296 -0
package/dist/install-skills.js
CHANGED
|
@@ -5,12 +5,13 @@
|
|
|
5
5
|
* through `claude plugin`) and OpenClaw skills in ~/.openclaw/skills/. Also
|
|
6
6
|
* merges the Pharaoh MCP server config into the target platform.
|
|
7
7
|
*/
|
|
8
|
-
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
|
|
8
|
+
import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
|
|
9
9
|
import { homedir } from "node:os";
|
|
10
10
|
import { dirname, join } from "node:path";
|
|
11
11
|
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { ensurePharaohHome } from "./credentials.js";
|
|
12
13
|
import { claudeCliFor } from "./setup.js";
|
|
13
|
-
import { getPackageVersion } from "./version.js";
|
|
14
|
+
import { getPackageVersion, PACKAGE_NAME, pinnedPackageSpec } from "./version.js";
|
|
14
15
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
15
16
|
/** Path to the bundled package root (one level up from dist/). */
|
|
16
17
|
const PKG_ROOT = join(__dirname, "..");
|
|
@@ -388,34 +389,81 @@ function cleanupRemovedSkills(srcDir, dstDir, oldManifest) {
|
|
|
388
389
|
return deleted;
|
|
389
390
|
}
|
|
390
391
|
/**
|
|
391
|
-
*
|
|
392
|
-
*
|
|
393
|
-
*
|
|
394
|
-
*
|
|
395
|
-
*
|
|
396
|
-
*
|
|
397
|
-
|
|
398
|
-
|
|
392
|
+
* Marker the installer writes into the front-matter of every command file it
|
|
393
|
+
* installs, as a YAML comment Claude Code ignores. A file in
|
|
394
|
+
* `~/.claude/commands/` is rewritten only when it is Pharaoh's: it carries
|
|
395
|
+
* this marker, or the exact header and `$ARGUMENTS` footer every installer
|
|
396
|
+
* before the marker shipped. Any other file with the same name is the user's
|
|
397
|
+
* and is left alone (security review SC-7).
|
|
398
|
+
*/
|
|
399
|
+
export const PHARAOH_COMMAND_MARKER = "# managed-by: pharaoh-mcp";
|
|
400
|
+
/** The full marker line, with the one thing a reader of the file needs to know. */
|
|
401
|
+
const COMMAND_MARKER_LINE = `${PHARAOH_COMMAND_MARKER} (rewritten by npx ${PACKAGE_NAME} on upgrade; rename this file to keep your own version)`;
|
|
402
|
+
/** The front-matter block of a bundled command, `---` to `---` inclusive. */
|
|
403
|
+
function commandHeader(bundled) {
|
|
404
|
+
const closing = bundled.indexOf("\n---\n", 4);
|
|
405
|
+
if (!bundled.startsWith("---\n") || closing === -1) {
|
|
406
|
+
throw new Error("bundled command file has no front-matter — rebuild the package");
|
|
407
|
+
}
|
|
408
|
+
return bundled.slice(0, closing + "\n---\n".length);
|
|
409
|
+
}
|
|
410
|
+
/** The bundled command with the marker as the last front-matter line. */
|
|
411
|
+
function markCommand(bundled) {
|
|
412
|
+
const header = commandHeader(bundled);
|
|
413
|
+
const closingAt = header.length - "---\n".length;
|
|
414
|
+
return `${header.slice(0, closingAt)}${COMMAND_MARKER_LINE}\n${header.slice(closingAt)}${bundled.slice(header.length)}`;
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Whether an existing command file is Pharaoh's and may be rewritten: marked
|
|
418
|
+
* by this installer, or shaped exactly like the file an earlier installer
|
|
419
|
+
* copied (its header and the `$ARGUMENTS` footer the generator appends).
|
|
420
|
+
*/
|
|
421
|
+
function isPharaohCommand(existing, bundled) {
|
|
422
|
+
if (existing.includes(PHARAOH_COMMAND_MARKER))
|
|
423
|
+
return true;
|
|
424
|
+
return existing.startsWith(commandHeader(bundled)) && existing.trimEnd().endsWith("$ARGUMENTS");
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Install the bundled slash commands (`.md` files in the package's `commands/`
|
|
428
|
+
* directory) to `~/.claude/commands/`. A destination that is missing or is
|
|
429
|
+
* Pharaoh's own file is written; anything else (the user's file, or a symlink,
|
|
430
|
+
* which is never followed) is kept and reported.
|
|
399
431
|
*
|
|
400
432
|
* @param home - Home directory override (defaults to os.homedir()).
|
|
401
|
-
* @returns
|
|
433
|
+
* @returns How many commands were written, and the names of the files kept.
|
|
402
434
|
*/
|
|
403
435
|
function installClaudeCodeCommands(home) {
|
|
436
|
+
const result = { installed: 0, kept: [] };
|
|
404
437
|
if (!existsSync(BUNDLED_COMMANDS_DIR))
|
|
405
|
-
return
|
|
438
|
+
return result;
|
|
406
439
|
const commandsDst = join(home, ".claude", "commands");
|
|
407
440
|
mkdirSync(commandsDst, { recursive: true });
|
|
408
|
-
const
|
|
409
|
-
let count = 0;
|
|
410
|
-
for (const entry of entries) {
|
|
441
|
+
for (const entry of readdirSync(BUNDLED_COMMANDS_DIR, { withFileTypes: true })) {
|
|
411
442
|
if (!entry.isFile() || !entry.name.endsWith(".md"))
|
|
412
443
|
continue;
|
|
413
|
-
const
|
|
444
|
+
const bundled = readFileSync(join(BUNDLED_COMMANDS_DIR, entry.name), "utf-8");
|
|
414
445
|
const dst = join(commandsDst, entry.name);
|
|
415
|
-
|
|
416
|
-
|
|
446
|
+
const existing = lstatSync(dst, { throwIfNoEntry: false });
|
|
447
|
+
if (existing &&
|
|
448
|
+
(!existing.isFile() || !isPharaohCommand(readFileSync(dst, "utf-8"), bundled))) {
|
|
449
|
+
result.kept.push(entry.name);
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
writeFileSync(dst, markCommand(bundled));
|
|
453
|
+
result.installed++;
|
|
417
454
|
}
|
|
418
|
-
return
|
|
455
|
+
return result;
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* The one-line notice for command files the installer left alone, or null
|
|
459
|
+
* when there were none.
|
|
460
|
+
*
|
|
461
|
+
* @param kept - File names from `runInstallSkills().keptCommands`.
|
|
462
|
+
*/
|
|
463
|
+
export function formatKeptCommands(kept) {
|
|
464
|
+
if (kept.length === 0)
|
|
465
|
+
return null;
|
|
466
|
+
return `Pharaoh: kept your own ~/.claude/commands/${kept.join(", ")} (not Pharaoh's file). Delete one and re-run to take Pharaoh's version.`;
|
|
419
467
|
}
|
|
420
468
|
// ── OpenClaw installer ──────────────────────────────────────────────
|
|
421
469
|
/**
|
|
@@ -449,12 +497,14 @@ export function installSkills(home = homedir()) {
|
|
|
449
497
|
return count;
|
|
450
498
|
}
|
|
451
499
|
/**
|
|
452
|
-
* Read ~/.openclaw/openclaw.json,
|
|
453
|
-
* and write it back. Creates the file
|
|
454
|
-
*
|
|
500
|
+
* Read ~/.openclaw/openclaw.json, set the Pharaoh MCP server under `mcpServers`
|
|
501
|
+
* to `npx @pharaoh-so/mcp@<this version>`, and write it back. Creates the file
|
|
502
|
+
* if it does not exist. An existing `pharaoh` entry is re-pinned when it is an
|
|
503
|
+
* `npx @pharaoh-so/mcp` command (extra arguments kept) and left alone when it
|
|
504
|
+
* is anything else.
|
|
455
505
|
*
|
|
456
506
|
* @param home - Home directory override (defaults to os.homedir()).
|
|
457
|
-
* @returns True if the config was written
|
|
507
|
+
* @returns True if the config was written, false if it was already current or is not ours.
|
|
458
508
|
*/
|
|
459
509
|
export function mergeOpenClawConfig(home = homedir()) {
|
|
460
510
|
const configPath = join(home, ".openclaw", "openclaw.json");
|
|
@@ -476,74 +526,125 @@ export function mergeOpenClawConfig(home = homedir()) {
|
|
|
476
526
|
if (!config.mcpServers) {
|
|
477
527
|
config.mcpServers = {};
|
|
478
528
|
}
|
|
479
|
-
|
|
480
|
-
|
|
529
|
+
const pinned = pinnedPackageSpec();
|
|
530
|
+
const current = config.mcpServers.pharaoh;
|
|
531
|
+
if (current) {
|
|
532
|
+
const spec = Array.isArray(current.args) ? current.args[0] : undefined;
|
|
533
|
+
const ours = current.command === "npx" &&
|
|
534
|
+
(spec === PACKAGE_NAME || (spec?.startsWith(`${PACKAGE_NAME}@`) ?? false));
|
|
535
|
+
if (!ours || spec === pinned)
|
|
536
|
+
return false;
|
|
481
537
|
}
|
|
482
538
|
config.mcpServers.pharaoh = {
|
|
483
539
|
command: "npx",
|
|
484
|
-
args: [
|
|
540
|
+
args: [pinned, ...(current?.args.slice(1) ?? [])],
|
|
485
541
|
};
|
|
486
542
|
writeFileSync(configPath, JSON.stringify(config, null, "\t"));
|
|
487
543
|
return true;
|
|
488
544
|
}
|
|
489
|
-
// ── Claude Code
|
|
490
|
-
/**
|
|
491
|
-
|
|
545
|
+
// ── Claude Code permissions ─────────────────────────────────────────
|
|
546
|
+
/**
|
|
547
|
+
* Rules the interactive setup adds to `permissions.allow` in
|
|
548
|
+
* ~/.claude/settings.json: the six read-only tools and `pharaoh_account`,
|
|
549
|
+
* each by full name. Never a prefix rule — `mcp__pharaoh` also auto-approves
|
|
550
|
+
* account deletion and `pharaoh_admin`, so a prompt injection could run them
|
|
551
|
+
* with no human in the loop (security review SC-6). Those tools keep prompting.
|
|
552
|
+
*/
|
|
553
|
+
export const PHARAOH_ALLOW_RULES = [
|
|
554
|
+
"mcp__pharaoh__get_codebase_map",
|
|
555
|
+
"mcp__pharaoh__get_module_context",
|
|
556
|
+
"mcp__pharaoh__search_functions",
|
|
557
|
+
"mcp__pharaoh__get_blast_radius",
|
|
558
|
+
"mcp__pharaoh__get_unused_code",
|
|
559
|
+
"mcp__pharaoh__pharaoh_recon",
|
|
560
|
+
"mcp__pharaoh__pharaoh_account",
|
|
561
|
+
];
|
|
562
|
+
/** The prefix rule proxies 0.3.46 and earlier wrote on every start; replaced by the list above. */
|
|
563
|
+
const LEGACY_ALLOW_RULE = "mcp__pharaoh";
|
|
492
564
|
/**
|
|
493
|
-
* Add
|
|
494
|
-
*
|
|
565
|
+
* Add `PHARAOH_ALLOW_RULES` to `permissions.allow` in ~/.claude/settings.json,
|
|
566
|
+
* replacing the blanket `mcp__pharaoh` rule an earlier version wrote. Runs
|
|
567
|
+
* only from the interactive setup, which prints what happened; never on a
|
|
568
|
+
* proxy start. Never creates the file: with no settings.json the tools
|
|
569
|
+
* simply prompt on first use.
|
|
495
570
|
*
|
|
496
571
|
* @param home - Home directory override (defaults to os.homedir()).
|
|
497
|
-
* @returns "added"
|
|
572
|
+
* @returns "added" or "migrated" when the file was written, "current" when
|
|
573
|
+
* nothing was missing, "no-settings" when there is no file, "error" when
|
|
574
|
+
* the file is not valid JSON (left untouched).
|
|
498
575
|
*/
|
|
499
|
-
function
|
|
576
|
+
export function allowReadTools(home = homedir()) {
|
|
500
577
|
const settingsPath = join(home, ".claude", "settings.json");
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
catch {
|
|
507
|
-
return "error";
|
|
508
|
-
}
|
|
509
|
-
}
|
|
510
|
-
if (!settings.permissions) {
|
|
511
|
-
settings.permissions = {};
|
|
512
|
-
}
|
|
513
|
-
if (!Array.isArray(settings.permissions.allow)) {
|
|
514
|
-
settings.permissions.allow = [];
|
|
578
|
+
if (!existsSync(settingsPath))
|
|
579
|
+
return "no-settings";
|
|
580
|
+
let settings;
|
|
581
|
+
try {
|
|
582
|
+
settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
|
|
515
583
|
}
|
|
516
|
-
|
|
517
|
-
|
|
584
|
+
catch {
|
|
585
|
+
// ALLOWED-CATCH: a file we cannot parse is a file we must not rewrite
|
|
586
|
+
return "error";
|
|
518
587
|
}
|
|
519
|
-
settings.permissions.allow
|
|
588
|
+
const allow = Array.isArray(settings.permissions?.allow) ? settings.permissions.allow : [];
|
|
589
|
+
const migrated = allow.includes(LEGACY_ALLOW_RULE);
|
|
590
|
+
const kept = allow.filter((rule) => rule !== LEGACY_ALLOW_RULE);
|
|
591
|
+
const next = [...kept, ...PHARAOH_ALLOW_RULES.filter((rule) => !kept.includes(rule))];
|
|
592
|
+
if (!migrated && next.length === allow.length)
|
|
593
|
+
return "current";
|
|
594
|
+
settings.permissions = { ...settings.permissions, allow: next };
|
|
520
595
|
writeFileSync(settingsPath, JSON.stringify(settings, null, "\t"));
|
|
521
|
-
return "added";
|
|
596
|
+
return migrated ? "migrated" : "added";
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* The line the interactive setup prints for an `allowReadTools` result, so
|
|
600
|
+
* the user always learns what was done to their settings.
|
|
601
|
+
*
|
|
602
|
+
* @param result - What `allowReadTools` returned.
|
|
603
|
+
*/
|
|
604
|
+
export function describeAllowReadTools(result) {
|
|
605
|
+
const count = PHARAOH_ALLOW_RULES.length;
|
|
606
|
+
switch (result) {
|
|
607
|
+
case "added":
|
|
608
|
+
return `Pharaoh: allowed ${count} tools by name in ~/.claude/settings.json (permissions.allow): the read-only tools and pharaoh_account. Account deletion and pharaoh_admin still prompt.`;
|
|
609
|
+
case "migrated":
|
|
610
|
+
return `Pharaoh: replaced the blanket mcp__pharaoh rule in ~/.claude/settings.json with ${count} tools by name. Account deletion and pharaoh_admin prompt again.`;
|
|
611
|
+
case "current":
|
|
612
|
+
return `Pharaoh: ~/.claude/settings.json already allows the ${count} read-only and account tools by name.`;
|
|
613
|
+
case "no-settings":
|
|
614
|
+
return "Pharaoh: no ~/.claude/settings.json yet, so Pharaoh's tools ask before they run. The rules to allow the read-only tools are in the README (Security).";
|
|
615
|
+
case "error":
|
|
616
|
+
return "Pharaoh: ~/.claude/settings.json is not valid JSON; left as is. Pharaoh's tools ask before they run.";
|
|
617
|
+
}
|
|
522
618
|
}
|
|
523
|
-
// ── Main entry point ────────────────────────────────────────────────
|
|
524
619
|
/**
|
|
525
|
-
* Main entry point for --install-skills.
|
|
620
|
+
* Main entry point for --install-skills, also run on every proxy start.
|
|
526
621
|
*
|
|
527
622
|
* Detection priority: Claude Code > OpenClaw > manual instructions.
|
|
528
|
-
* Installs to all detected platforms (a user may use both).
|
|
623
|
+
* Installs to all detected platforms (a user may use both). Never touches
|
|
624
|
+
* Claude Code permissions: that is `allowReadTools`, interactive setup only.
|
|
529
625
|
*
|
|
530
626
|
* @param home - Home directory override (defaults to os.homedir()).
|
|
531
627
|
* @param options.silent - Print nothing (the interactive setup prints its own summary).
|
|
532
628
|
* @param options.claude - `claude` CLI runner (defaults to the real CLI for `home`).
|
|
629
|
+
* @returns What was kept, for the caller's own summary.
|
|
533
630
|
*/
|
|
534
631
|
export function runInstallSkills(home = homedir(), options) {
|
|
535
632
|
const silent = options?.silent ?? false;
|
|
536
633
|
const hasClaudeCode = detectClaudeCode(home);
|
|
537
634
|
const hasOpenClaw = detectOpenClaw(home);
|
|
538
635
|
let installed = false;
|
|
636
|
+
const keptCommands = [];
|
|
539
637
|
// ── Claude Code ──
|
|
540
638
|
if (hasClaudeCode) {
|
|
639
|
+
// The marketplace lives under ~/.pharaoh, which may not exist yet: create
|
|
640
|
+
// it 0700 (and keep it 0700) before anything is written there.
|
|
641
|
+
ensurePharaohHome(join(home, ".pharaoh"));
|
|
541
642
|
removeLegacyClaudePlugin(home);
|
|
542
643
|
const skillCount = writeClaudeMarketplace(home);
|
|
543
644
|
const registration = registerClaudePlugin(home, options?.claude ?? claudeCliFor(home));
|
|
544
|
-
const
|
|
545
|
-
|
|
546
|
-
|
|
645
|
+
const commands = installClaudeCodeCommands(home);
|
|
646
|
+
keptCommands.push(...commands.kept);
|
|
647
|
+
const keptLine = formatKeptCommands(commands.kept);
|
|
547
648
|
if (!silent) {
|
|
548
649
|
const registrationLine = {
|
|
549
650
|
installed: " → Installed through `claude plugin` — start a new Claude Code session to load it",
|
|
@@ -554,11 +655,12 @@ export function runInstallSkills(home = homedir(), options) {
|
|
|
554
655
|
process.stderr.write([
|
|
555
656
|
`Pharaoh: Claude Code plugin with ${skillCount} skills and the Grep routing hook`,
|
|
556
657
|
registrationLine[registration],
|
|
557
|
-
...(
|
|
658
|
+
...(commands.installed > 0
|
|
558
659
|
? [
|
|
559
|
-
` → Installed ${
|
|
660
|
+
` → Installed ${commands.installed} slash command${commands.installed === 1 ? "" : "s"} to ~/.claude/commands/`,
|
|
560
661
|
]
|
|
561
662
|
: []),
|
|
663
|
+
...(keptLine ? [` → ${keptLine}`] : []),
|
|
562
664
|
"",
|
|
563
665
|
].join("\n"));
|
|
564
666
|
}
|
|
@@ -595,4 +697,5 @@ export function runInstallSkills(home = homedir(), options) {
|
|
|
595
697
|
"",
|
|
596
698
|
].join("\n"));
|
|
597
699
|
}
|
|
700
|
+
return { keptCommands };
|
|
598
701
|
}
|
package/dist/proxy-server.d.ts
CHANGED
|
@@ -34,6 +34,10 @@ export declare class ProxyServer {
|
|
|
34
34
|
private transport;
|
|
35
35
|
private backend;
|
|
36
36
|
private started;
|
|
37
|
+
private stopping;
|
|
38
|
+
private clientCapabilities;
|
|
39
|
+
private clientInitialized;
|
|
40
|
+
private capabilityUpdate;
|
|
37
41
|
private transportClosed;
|
|
38
42
|
private transportClosedResolve;
|
|
39
43
|
constructor(initialBackend: Backend);
|
package/dist/proxy-server.js
CHANGED
|
@@ -27,6 +27,8 @@ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextpro
|
|
|
27
27
|
import { AuthRequiredBackend, createSseBackend } from "./backend.js";
|
|
28
28
|
import { loadBundledCatalog } from "./catalog.js";
|
|
29
29
|
import { createGateway } from "./gateway.js";
|
|
30
|
+
import { TenantSuspendedError, TokenExpiredError } from "./proxy.js";
|
|
31
|
+
import { registerTraceResource } from "./trace-resource.js";
|
|
30
32
|
import { getPackageVersion } from "./version.js";
|
|
31
33
|
/**
|
|
32
34
|
* Stdio MCP server with a swappable backend. The Server instance is
|
|
@@ -37,6 +39,10 @@ export class ProxyServer {
|
|
|
37
39
|
transport = null;
|
|
38
40
|
backend;
|
|
39
41
|
started = false;
|
|
42
|
+
stopping = false;
|
|
43
|
+
clientCapabilities = {};
|
|
44
|
+
clientInitialized = false;
|
|
45
|
+
capabilityUpdate = Promise.resolve();
|
|
40
46
|
transportClosed;
|
|
41
47
|
transportClosedResolve;
|
|
42
48
|
constructor(initialBackend) {
|
|
@@ -53,6 +59,30 @@ export class ProxyServer {
|
|
|
53
59
|
this.transportClosedResolve = resolve;
|
|
54
60
|
});
|
|
55
61
|
this.registerHandlers();
|
|
62
|
+
registerTraceResource(this.server);
|
|
63
|
+
this.server.oninitialized = () => {
|
|
64
|
+
if (this.clientInitialized)
|
|
65
|
+
return;
|
|
66
|
+
this.clientInitialized = true;
|
|
67
|
+
this.clientCapabilities = this.server.getClientCapabilities() ?? {};
|
|
68
|
+
const next = this.backend.withClientCapabilities?.(this.clientCapabilities) ?? this.backend;
|
|
69
|
+
if (next === this.backend)
|
|
70
|
+
return;
|
|
71
|
+
// Warm startup happens before the frontend initializes. Replace that
|
|
72
|
+
// conservative connection only when real UI support is negotiated.
|
|
73
|
+
this.capabilityUpdate = this.setBackend(next).catch((error) => {
|
|
74
|
+
if (error instanceof TokenExpiredError || error instanceof TenantSuspendedError)
|
|
75
|
+
throw error;
|
|
76
|
+
// UI negotiation is optional: keep the already-started plain-text
|
|
77
|
+
// backend healthy when its replacement hits a transient failure.
|
|
78
|
+
process.stderr.write("Pharaoh: compact UI negotiation unavailable; continuing with text responses.\n");
|
|
79
|
+
});
|
|
80
|
+
this.capabilityUpdate.catch(() => {
|
|
81
|
+
// Tool handlers and waitForLifetime consume this error, including
|
|
82
|
+
// typed auth failures that must reach the existing recovery loop.
|
|
83
|
+
});
|
|
84
|
+
this.backendSwappedSignal();
|
|
85
|
+
};
|
|
56
86
|
}
|
|
57
87
|
/**
|
|
58
88
|
* Wire up list/call handlers. Each handler reads this.backend fresh at
|
|
@@ -61,10 +91,12 @@ export class ProxyServer {
|
|
|
61
91
|
* makes mutable-backend swaps safe.
|
|
62
92
|
*/
|
|
63
93
|
registerHandlers() {
|
|
64
|
-
this.server.setRequestHandler(ListToolsRequestSchema, async () =>
|
|
65
|
-
|
|
66
|
-
|
|
94
|
+
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
95
|
+
await this.capabilityUpdate;
|
|
96
|
+
return { tools: await this.backend.listTools() };
|
|
97
|
+
});
|
|
67
98
|
this.server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
99
|
+
await this.capabilityUpdate;
|
|
68
100
|
const { name } = req.params;
|
|
69
101
|
const args = req.params.arguments ?? {};
|
|
70
102
|
const backend = this.backend;
|
|
@@ -131,6 +163,9 @@ export class ProxyServer {
|
|
|
131
163
|
this.transportClosed.then(() => "closed"),
|
|
132
164
|
backendExit.then(() => "exit"),
|
|
133
165
|
swapSignal,
|
|
166
|
+
// A successful negotiation is not the end of the connection. Its
|
|
167
|
+
// failure must still reach the supervisor if no tool is in flight.
|
|
168
|
+
this.capabilityUpdate.then(() => new Promise(() => { })),
|
|
134
169
|
]);
|
|
135
170
|
if (outcome === "swap")
|
|
136
171
|
continue; // re-sample waitForExit against the new backend
|
|
@@ -151,6 +186,7 @@ export class ProxyServer {
|
|
|
151
186
|
* to whichever backend is current at dispatch time.
|
|
152
187
|
*/
|
|
153
188
|
async setBackend(next) {
|
|
189
|
+
next = next.withClientCapabilities?.(this.clientCapabilities) ?? next;
|
|
154
190
|
const prev = this.backend;
|
|
155
191
|
// Start the new backend BEFORE swapping the reference. If start() rejects
|
|
156
192
|
// (e.g. SSE connect fails), `prev` stays active — otherwise the proxy
|
|
@@ -160,7 +196,12 @@ export class ProxyServer {
|
|
|
160
196
|
// reference swap and a slow SSE handshake.
|
|
161
197
|
if (next.start)
|
|
162
198
|
await next.start();
|
|
199
|
+
if (this.stopping) {
|
|
200
|
+
await next.stop?.();
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
163
203
|
this.backend = next;
|
|
204
|
+
this.capabilityUpdate = Promise.resolve();
|
|
164
205
|
// Fire the notification after the swap so a client that races a list
|
|
165
206
|
// request against it sees the new catalog.
|
|
166
207
|
await this.server.sendToolListChanged();
|
|
@@ -179,6 +220,7 @@ export class ProxyServer {
|
|
|
179
220
|
}
|
|
180
221
|
}
|
|
181
222
|
async stop() {
|
|
223
|
+
this.stopping = true;
|
|
182
224
|
if (this.backend.stop) {
|
|
183
225
|
await this.backend.stop().catch((err) => {
|
|
184
226
|
const msg = err instanceof Error ? err.message : String(err);
|
package/dist/setup.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/** Runs one `claude` CLI command; true on success. */
|
|
2
|
+
export type ClaudeCli = (args: string[]) => boolean;
|
|
1
3
|
/**
|
|
2
4
|
* A `claude` CLI runner for the Claude Code config under `home`: true when the
|
|
3
5
|
* command succeeded, false when it failed or `claude` isn't installed. Another
|
|
@@ -5,7 +7,7 @@
|
|
|
5
7
|
*
|
|
6
8
|
* @param home - Home whose `.claude` config the commands act on.
|
|
7
9
|
*/
|
|
8
|
-
export declare function claudeCliFor(home?: string):
|
|
10
|
+
export declare function claudeCliFor(home?: string): ClaudeCli;
|
|
9
11
|
/**
|
|
10
12
|
* Remove all traces of pharaoh from Claude Code's internal config (~/.claude.json).
|
|
11
13
|
*
|
|
@@ -18,11 +20,18 @@ export declare function claudeCliFor(home?: string): (args: string[]) => boolean
|
|
|
18
20
|
*/
|
|
19
21
|
export declare function cleanSlate(home?: string): void;
|
|
20
22
|
/**
|
|
21
|
-
* Full automated setup: clean slate removal, register MCP server
|
|
22
|
-
*
|
|
23
|
+
* Full automated setup: clean slate removal, then register the MCP server
|
|
24
|
+
* globally as `npx @pharaoh-so/mcp@<this version>`, so a new release on npm
|
|
25
|
+
* runs only after the user upgrades on purpose (security review SC-2).
|
|
26
|
+
* Requires valid credentials (caller handles auth first).
|
|
23
27
|
*
|
|
24
|
-
* @
|
|
28
|
+
* @param options.silent - Print nothing on success.
|
|
29
|
+
* @param options.home - Home whose Claude Code config is edited (tests).
|
|
30
|
+
* @param options.claude - `claude` CLI runner (defaults to the real CLI for `home`).
|
|
31
|
+
* @returns true if setup succeeded, false if Claude CLI not found or registration failed.
|
|
25
32
|
*/
|
|
26
33
|
export declare function runSetup(options?: {
|
|
27
34
|
silent?: boolean;
|
|
35
|
+
home?: string;
|
|
36
|
+
claude?: ClaudeCli;
|
|
28
37
|
}): boolean;
|
package/dist/setup.js
CHANGED
|
@@ -10,6 +10,7 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
10
10
|
import { homedir } from "node:os";
|
|
11
11
|
import { join } from "node:path";
|
|
12
12
|
import { NPX_COMMAND, printLines } from "./helpers.js";
|
|
13
|
+
import { pinnedPackageSpec } from "./version.js";
|
|
13
14
|
/** Check if `claude` CLI is available in PATH. */
|
|
14
15
|
function hasClaude() {
|
|
15
16
|
try {
|
|
@@ -113,41 +114,40 @@ export function cleanSlate(home = homedir()) {
|
|
|
113
114
|
}
|
|
114
115
|
}
|
|
115
116
|
/**
|
|
116
|
-
* Full automated setup: clean slate removal, register MCP server
|
|
117
|
-
*
|
|
117
|
+
* Full automated setup: clean slate removal, then register the MCP server
|
|
118
|
+
* globally as `npx @pharaoh-so/mcp@<this version>`, so a new release on npm
|
|
119
|
+
* runs only after the user upgrades on purpose (security review SC-2).
|
|
120
|
+
* Requires valid credentials (caller handles auth first).
|
|
118
121
|
*
|
|
119
|
-
* @
|
|
122
|
+
* @param options.silent - Print nothing on success.
|
|
123
|
+
* @param options.home - Home whose Claude Code config is edited (tests).
|
|
124
|
+
* @param options.claude - `claude` CLI runner (defaults to the real CLI for `home`).
|
|
125
|
+
* @returns true if setup succeeded, false if Claude CLI not found or registration failed.
|
|
120
126
|
*/
|
|
121
127
|
export function runSetup(options) {
|
|
122
128
|
const silent = options?.silent ?? false;
|
|
123
|
-
|
|
129
|
+
const home = options?.home ?? homedir();
|
|
130
|
+
if (!options?.claude && !hasClaude()) {
|
|
124
131
|
printLines("Pharaoh: Claude Code CLI not found.", "", "Install Claude Code first: https://claude.ai/download", `Then re-run: ${NPX_COMMAND}`, "");
|
|
125
132
|
return false;
|
|
126
133
|
}
|
|
134
|
+
const claude = options?.claude ?? claudeCliFor(home);
|
|
127
135
|
if (!silent)
|
|
128
136
|
printLines("Pharaoh: setting up...");
|
|
129
137
|
// Step 1: Clean slate — remove pharaoh from all scopes, disabled lists,
|
|
130
138
|
// and project-scoped configs. Direct file edit handles what CLI cannot.
|
|
131
|
-
cleanSlate();
|
|
139
|
+
cleanSlate(home);
|
|
132
140
|
// Step 2: Belt-and-suspenders — also remove via CLI (both scopes)
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
// Step 3: Register as global stdio MCP server
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
"add",
|
|
139
|
-
"--scope",
|
|
140
|
-
"user",
|
|
141
|
-
"pharaoh",
|
|
142
|
-
"--",
|
|
143
|
-
"npx",
|
|
144
|
-
"@pharaoh-so/mcp",
|
|
145
|
-
]);
|
|
141
|
+
claude(["mcp", "remove", "pharaoh", "-s", "user"]);
|
|
142
|
+
claude(["mcp", "remove", "pharaoh", "-s", "project"]);
|
|
143
|
+
// Step 3: Register as global stdio MCP server, pinned to the running version
|
|
144
|
+
const spec = pinnedPackageSpec();
|
|
145
|
+
const added = claude(["mcp", "add", "--scope", "user", "pharaoh", "--", "npx", spec]);
|
|
146
146
|
if (!added) {
|
|
147
147
|
printLines("Pharaoh: failed to register MCP server.", `Try manually: ${NPX_COMMAND}`, "");
|
|
148
148
|
return false;
|
|
149
149
|
}
|
|
150
150
|
if (!silent)
|
|
151
|
-
printLines(
|
|
151
|
+
printLines(`Pharaoh: registered as global MCP server (scope: user) running ${spec}`);
|
|
152
152
|
return true;
|
|
153
153
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** The bundled, tenant-neutral renderer. Resource reads never touch a backend. */
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { ErrorCode, ListResourcesRequestSchema, McpError, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
4
|
+
const TRACE_RESOURCE_URI = "ui://pharaoh/trace/v1.1.html";
|
|
5
|
+
export const TRACE_MIME_TYPE = "text/html;profile=mcp-app";
|
|
6
|
+
const resourceMeta = {
|
|
7
|
+
ui: {
|
|
8
|
+
prefersBorder: false,
|
|
9
|
+
csp: { connectDomains: [], resourceDomains: [], frameDomains: [], baseUriDomains: [] },
|
|
10
|
+
permissions: {},
|
|
11
|
+
},
|
|
12
|
+
};
|
|
13
|
+
let html;
|
|
14
|
+
export function registerTraceResource(server) {
|
|
15
|
+
server.registerCapabilities({ resources: {} });
|
|
16
|
+
server.setRequestHandler(ListResourcesRequestSchema, async () => ({
|
|
17
|
+
resources: [
|
|
18
|
+
{
|
|
19
|
+
name: "pharaoh-trace",
|
|
20
|
+
title: "Pharaoh Trace",
|
|
21
|
+
description: "Compact, expandable evidence from Pharaoh tool results.",
|
|
22
|
+
uri: TRACE_RESOURCE_URI,
|
|
23
|
+
mimeType: TRACE_MIME_TYPE,
|
|
24
|
+
_meta: resourceMeta,
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
}));
|
|
28
|
+
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
29
|
+
if (request.params.uri !== TRACE_RESOURCE_URI) {
|
|
30
|
+
throw new McpError(ErrorCode.InvalidParams, "Unknown Trace resource");
|
|
31
|
+
}
|
|
32
|
+
// Package-root assets are shared by src/ and dist/. The request URI is
|
|
33
|
+
// never used as a filesystem path or forwarded to the authenticated server.
|
|
34
|
+
html ??= readFileSync(new URL("../trace-app.html", import.meta.url), "utf8");
|
|
35
|
+
return {
|
|
36
|
+
contents: [
|
|
37
|
+
{ uri: TRACE_RESOURCE_URI, mimeType: TRACE_MIME_TYPE, text: html, _meta: resourceMeta },
|
|
38
|
+
],
|
|
39
|
+
};
|
|
40
|
+
});
|
|
41
|
+
}
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,32 @@
|
|
|
1
|
+
/** The npm package name, as `npx` resolves it. */
|
|
2
|
+
export declare const PACKAGE_NAME = "@pharaoh-so/mcp";
|
|
3
|
+
/** The deliberate upgrade: re-runs setup with the newest release, which re-pins every registration. */
|
|
4
|
+
export declare const UPGRADE_COMMAND = "npx @pharaoh-so/mcp@latest";
|
|
1
5
|
/** Read the mcp-proxy package version from package.json. */
|
|
2
6
|
export declare function getPackageVersion(): string;
|
|
7
|
+
/**
|
|
8
|
+
* The package spec a registration runs: this exact version, so a new release
|
|
9
|
+
* on npm changes nothing on the user's machine until they upgrade on purpose
|
|
10
|
+
* (security review SC-2). Falls back to the bare name only when the version
|
|
11
|
+
* is unreadable, which would otherwise pin a version that does not exist.
|
|
12
|
+
*
|
|
13
|
+
* @returns `@pharaoh-so/mcp@<version>` as `npx` accepts it.
|
|
14
|
+
*/
|
|
15
|
+
export declare function pinnedPackageSpec(): string;
|
|
16
|
+
/**
|
|
17
|
+
* Whether `candidate` is a strictly newer release than `current`. Anything
|
|
18
|
+
* that is not a plain `major.minor.patch` compares as not newer.
|
|
19
|
+
*
|
|
20
|
+
* @param candidate - Version reported by the registry.
|
|
21
|
+
* @param current - Version this process runs.
|
|
22
|
+
*/
|
|
23
|
+
export declare function isNewerVersion(candidate: string, current: string): boolean;
|
|
24
|
+
/**
|
|
25
|
+
* The newest version on the npm registry, or null when it cannot be learned
|
|
26
|
+
* (offline, slow, or an unexpected answer). Called only by the interactive
|
|
27
|
+
* setup, never on a proxy start, and sends nothing but the request itself.
|
|
28
|
+
*
|
|
29
|
+
* @param fetchImpl - `fetch` stand-in for tests.
|
|
30
|
+
* @param timeoutMs - How long to wait for the registry before giving up.
|
|
31
|
+
*/
|
|
32
|
+
export declare function fetchLatestVersion(fetchImpl?: typeof fetch, timeoutMs?: number): Promise<string | null>;
|