@yawlabs/mcp 0.64.2 → 0.66.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +100 -0
- package/dist/index.js +712 -59
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -373,6 +373,8 @@ Rotate a credential in one place (the dashboard), every machine picks up the new
|
|
|
373
373
|
| `YAW_MCP_DISABLE_PERSISTENCE` | No | Set to `1` or `true` to keep learning + pack-history scoped to the current process -- nothing loaded at start, nothing written on shutdown. Intended for ephemeral / shared environments (CI, containers). Default: cross-session persistence enabled at `~/.yaw-mcp/state.json`. |
|
|
374
374
|
| `YAW_MCP_AUTO_LOAD` | No | Set to `1` or `true` to pre-activate the top recurring pack (from persisted pack-history) on startup -- no LLM round-trip required. Skips silently when history is empty or no pack's namespaces are all installed. Default: off. Requires persistence to be enabled. |
|
|
375
375
|
| `YAW_MCP_MIN_COMPLIANCE` | No | Minimum compliance grade (`A`, `B`, `C`, `D`, or `F`, case-insensitive) an installed server must report before `mcp_connect_activate` will load it. Ungraded servers always pass (don't punish unknown). `discover()` annotates below-grade servers in place and shows a "Compliance filter active" header when set. Invalid values log a warning and disable the filter. Default: unset (no filter). |
|
|
376
|
+
| `YAW_MCP_VAULT_PASSPHRASE` | No | Passphrase for the local secret vault (`~/.yaw-mcp/secrets.json`). Required for spawn-time `${secret:NAME}` substitution and to avoid the interactive prompt on `yaw-mcp secrets`. Stripped from every spawned upstream server's env. |
|
|
377
|
+
| `YAW_MCP_VAULT_PASSPHRASE_NEW` | No | The NEW passphrase for `yaw-mcp secrets rotate`. When unset, rotate prompts (confirm-twice) on a TTY instead. |
|
|
376
378
|
| `MCP_CONNECT_TIMEOUT` | No | Connection timeout in ms for upstream servers (default: `15000`) |
|
|
377
379
|
| `MCP_CONNECT_IDLE_THRESHOLD` | No | Baseline for idle auto-unload (default: `10`). The per-namespace adaptive cap is `[5, 50]` -- bursty namespaces extend past the baseline, long-idle ones unload at it. |
|
|
378
380
|
|
|
@@ -388,6 +390,104 @@ The popular Python-based MCP servers (`sqlite`, `time`, `sentry`, and other uvx-
|
|
|
388
390
|
|
|
389
391
|
`uvx ARGS` is always rewritten to `uv tool run ARGS` at spawn time -- so only `uv` needs to be reachable, not `uvx` separately. Fixes Windows setups where one was on PATH and the other wasn't.
|
|
390
392
|
|
|
393
|
+
## Local secret vault (no account required)
|
|
394
|
+
|
|
395
|
+
There are two ways a secret reaches a server yaw-mcp spawns, and they have
|
|
396
|
+
different threat models:
|
|
397
|
+
|
|
398
|
+
1. **Backend-credential path (account required).** Paste a token into a
|
|
399
|
+
server's `env` block in the yaw.sh/mcp dashboard. The value is encrypted
|
|
400
|
+
at rest on the backend and injected at spawn time. It syncs across every
|
|
401
|
+
machine signed in to the same account, and revoking the account token cuts
|
|
402
|
+
off access everywhere on the next poll. This is the path the
|
|
403
|
+
"Cross-machine config sync" and "Trust & security" sections describe.
|
|
404
|
+
|
|
405
|
+
2. **Local secret vault (no account required).** Keep the value in an
|
|
406
|
+
encrypted file on your own machine at `~/.yaw-mcp/secrets.json` and
|
|
407
|
+
reference it from any server's `env` with a `${secret:NAME}` placeholder.
|
|
408
|
+
yaw-mcp substitutes the decrypted value into the child env at spawn time.
|
|
409
|
+
The value never leaves your machine, never goes to the backend, and works
|
|
410
|
+
with no login. This is the right path when you want a credential that is
|
|
411
|
+
strictly local to one machine, or you don't have an account at all.
|
|
412
|
+
|
|
413
|
+
### How `${secret:NAME}` references work
|
|
414
|
+
|
|
415
|
+
Put a placeholder in a server's `env` value -- it can stand alone or be
|
|
416
|
+
composed inline:
|
|
417
|
+
|
|
418
|
+
```jsonc
|
|
419
|
+
{
|
|
420
|
+
"namespace": "gh",
|
|
421
|
+
"command": "npx",
|
|
422
|
+
"args": ["-y", "@modelcontextprotocol/server-github"],
|
|
423
|
+
"env": {
|
|
424
|
+
"GITHUB_PERSONAL_ACCESS_TOKEN": "${secret:gh}",
|
|
425
|
+
"AUTH_HEADER": "Bearer ${secret:gh}"
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
At spawn time, if `YAW_MCP_VAULT_PASSPHRASE` is set in yaw-mcp's own
|
|
431
|
+
environment, yaw-mcp loads the vault, decrypts the referenced names, and
|
|
432
|
+
substitutes the values into the child's env. If the passphrase is absent, the
|
|
433
|
+
vault is missing, or a referenced name isn't stored, the literal
|
|
434
|
+
`${secret:NAME}` is passed through unchanged -- the child then surfaces its
|
|
435
|
+
own "missing/invalid credential" error, which is louder than silently passing
|
|
436
|
+
an empty string.
|
|
437
|
+
|
|
438
|
+
### Managing the vault -- `yaw-mcp secrets`
|
|
439
|
+
|
|
440
|
+
```bash
|
|
441
|
+
yaw-mcp secrets set <name> # store a value (stdin prompt, no echo; or --value/--stdin)
|
|
442
|
+
yaw-mcp secrets get <name> # decrypt and print one value
|
|
443
|
+
yaw-mcp secrets list # show entry NAMES (values stay encrypted)
|
|
444
|
+
yaw-mcp secrets remove <name> # delete an entry
|
|
445
|
+
yaw-mcp secrets lock # clear the in-process passphrase cache
|
|
446
|
+
yaw-mcp secrets rotate [--push] # re-encrypt the whole vault under a NEW passphrase
|
|
447
|
+
yaw-mcp secrets audit [--secret NAME] [--server NS] [--json] # who consumed which secret, when
|
|
448
|
+
yaw-mcp secrets push # upload the encrypted blob to your account (login required)
|
|
449
|
+
yaw-mcp secrets pull # download it back (login required)
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
The passphrase derives the encryption key via scrypt and is cached in memory
|
|
453
|
+
for the lifetime of one yaw-mcp process; the on-disk file only ever holds
|
|
454
|
+
ciphertext (AES-256-GCM, per-entry IV + auth tag, one vault-level salt). Set
|
|
455
|
+
`YAW_MCP_VAULT_PASSPHRASE` in the env to avoid the prompt -- this is required
|
|
456
|
+
for spawn-time substitution, since a spawned MCP server runs non-interactively
|
|
457
|
+
and can't prompt on a TTY.
|
|
458
|
+
|
|
459
|
+
`rotate` re-encrypts every entry under a fresh salt + key derived from a NEW
|
|
460
|
+
passphrase (read from `YAW_MCP_VAULT_PASSPHRASE_NEW` or a confirm-twice
|
|
461
|
+
prompt). It decrypts every entry under the current passphrase FIRST and aborts
|
|
462
|
+
the whole operation -- leaving the on-disk vault untouched -- if any entry
|
|
463
|
+
fails to decrypt. **Rotate re-wraps the ENCRYPTION, not the underlying token
|
|
464
|
+
values:** a token that has leaked is still leaked after a rotate; rotate it at
|
|
465
|
+
its source. Rotate does NOT push to your account unless you pass `--push`.
|
|
466
|
+
|
|
467
|
+
`audit` reads an append-only NDJSON log at `~/.yaw-mcp/secrets-audit.log`
|
|
468
|
+
(mode `0600`, tail-capped) recording that a secret NAME was `injected` into
|
|
469
|
+
(or was `missing` for) a given server namespace, with a timestamp. **The log
|
|
470
|
+
never records a value** -- only names, namespaces, and times. Writes to it are
|
|
471
|
+
fail-open: a broken or unwritable log never blocks a server spawn.
|
|
472
|
+
|
|
473
|
+
The `mcp_connect_secrets` meta-tool gives the same picture to the model
|
|
474
|
+
without any decryption: per server it lists `injectedSecrets` (names the vault
|
|
475
|
+
has and the server references) and `missing` (referenced names the vault
|
|
476
|
+
lacks). It reads only the vault's key list and the servers' reference names --
|
|
477
|
+
it never decrypts or returns a value, and needs no passphrase.
|
|
478
|
+
|
|
479
|
+
### Offline threat model
|
|
480
|
+
|
|
481
|
+
The vault protects the on-disk file against **offline brute-force after
|
|
482
|
+
exfiltration** -- a stolen laptop, a leaked backup, a synced dotfile repo. The
|
|
483
|
+
file is useless without the passphrase: scrypt key derivation makes guessing
|
|
484
|
+
expensive, and AES-256-GCM means a tampered ciphertext fails to decrypt rather
|
|
485
|
+
than yielding garbage plaintext. What the vault does **not** defend against: a
|
|
486
|
+
process running as you while the passphrase is cached in memory (it can ask
|
|
487
|
+
yaw-mcp to decrypt), a keylogger capturing the passphrase, or a value already
|
|
488
|
+
leaked at its source. For those, rotate the underlying token, not the vault
|
|
489
|
+
encryption.
|
|
490
|
+
|
|
391
491
|
## Trust & security
|
|
392
492
|
|
|
393
493
|
MCP servers are third-party code that you choose to run, and yaw-mcp launches them on your machine or calls them over the network. We don't sandbox arbitrary code and we're not an antivirus -- that's your OS and network. What yaw-mcp gives you is **visibility and a gate**:
|
package/dist/index.js
CHANGED
|
@@ -825,6 +825,7 @@ async function readConfigAt(path5, scope, warnings) {
|
|
|
825
825
|
}
|
|
826
826
|
const servers = Array.isArray(obj.servers) ? obj.servers.filter((v) => typeof v === "string") : void 0;
|
|
827
827
|
const blocked = Array.isArray(obj.blocked) ? obj.blocked.filter((v) => typeof v === "string") : void 0;
|
|
828
|
+
const installNudge = typeof obj.installNudge === "boolean" ? obj.installNudge : void 0;
|
|
828
829
|
if (token5) {
|
|
829
830
|
if (scope === "project") {
|
|
830
831
|
warnings.push(
|
|
@@ -833,7 +834,7 @@ async function readConfigAt(path5, scope, warnings) {
|
|
|
833
834
|
}
|
|
834
835
|
await checkPermissions(path5, warnings);
|
|
835
836
|
}
|
|
836
|
-
return { path: path5, scope, version, token: token5, apiBase, servers, blocked };
|
|
837
|
+
return { path: path5, scope, version, token: token5, apiBase, servers, blocked, installNudge };
|
|
837
838
|
}
|
|
838
839
|
async function checkPermissions(path5, warnings) {
|
|
839
840
|
if (process.platform === "win32") return;
|
|
@@ -855,6 +856,13 @@ function pickServers(files) {
|
|
|
855
856
|
if (project !== void 0) return project;
|
|
856
857
|
return files.find((f) => f.scope === "global")?.servers;
|
|
857
858
|
}
|
|
859
|
+
function pickInstallNudge(files) {
|
|
860
|
+
const local = files.find((f) => f.scope === "local")?.installNudge;
|
|
861
|
+
if (local !== void 0) return local;
|
|
862
|
+
const project = files.find((f) => f.scope === "project")?.installNudge;
|
|
863
|
+
if (project !== void 0) return project;
|
|
864
|
+
return files.find((f) => f.scope === "global")?.installNudge;
|
|
865
|
+
}
|
|
858
866
|
function unionBlocked(files) {
|
|
859
867
|
const set = /* @__PURE__ */ new Set();
|
|
860
868
|
let touched = false;
|
|
@@ -934,6 +942,7 @@ async function loadYawMcpConfig(opts = {}) {
|
|
|
934
942
|
apiBaseSource,
|
|
935
943
|
servers: pickServers(loadedFiles),
|
|
936
944
|
blocked: unionBlocked(loadedFiles),
|
|
945
|
+
installNudge: pickInstallNudge(loadedFiles),
|
|
937
946
|
projectConfigDir,
|
|
938
947
|
loadedFiles,
|
|
939
948
|
warnings
|
|
@@ -963,10 +972,6 @@ function toProfile(config) {
|
|
|
963
972
|
}
|
|
964
973
|
return result;
|
|
965
974
|
}
|
|
966
|
-
async function loadEffectiveProfile(cwd, home) {
|
|
967
|
-
const config = await loadYawMcpConfig({ cwd, home });
|
|
968
|
-
return toProfile(config);
|
|
969
|
-
}
|
|
970
975
|
function isAllowed(rules, namespace) {
|
|
971
976
|
if (!rules) return true;
|
|
972
977
|
if (rules.blocked?.includes(namespace)) return false;
|
|
@@ -2060,6 +2065,18 @@ function cliToNamespaces() {
|
|
|
2060
2065
|
reverseIndexCache = map;
|
|
2061
2066
|
return map;
|
|
2062
2067
|
}
|
|
2068
|
+
var SHADOW_INSTALL_TARGETS = {
|
|
2069
|
+
aws: { package: "@yawlabs/aws-mcp", namespace: "aws", name: "AWS" },
|
|
2070
|
+
caddy: { package: "@yawlabs/caddy-mcp", namespace: "caddy", name: "Caddy" },
|
|
2071
|
+
curl: { package: "@yawlabs/fetch-mcp", namespace: "fetch", name: "Fetch" },
|
|
2072
|
+
wget: { package: "@yawlabs/fetch-mcp", namespace: "fetch", name: "Fetch" },
|
|
2073
|
+
psql: { package: "@yawlabs/postgres-mcp", namespace: "postgres", name: "Postgres" },
|
|
2074
|
+
pg_dump: { package: "@yawlabs/postgres-mcp", namespace: "postgres", name: "Postgres" },
|
|
2075
|
+
tailscale: { package: "@yawlabs/tailscale-mcp", namespace: "tailscale", name: "Tailscale" }
|
|
2076
|
+
};
|
|
2077
|
+
function installTargetForCli(cli) {
|
|
2078
|
+
return SHADOW_INSTALL_TARGETS[cli];
|
|
2079
|
+
}
|
|
2063
2080
|
function formatShadowLine(server) {
|
|
2064
2081
|
const shadows = resolveShadowedClis(server);
|
|
2065
2082
|
if (shadows.length === 0) return null;
|
|
@@ -3987,7 +4004,7 @@ async function runUpgrade(opts = {}) {
|
|
|
3987
4004
|
return { exitCode: 3, lines };
|
|
3988
4005
|
}
|
|
3989
4006
|
function readCurrentVersion() {
|
|
3990
|
-
return true ? "0.
|
|
4007
|
+
return true ? "0.66.0" : "dev";
|
|
3991
4008
|
}
|
|
3992
4009
|
|
|
3993
4010
|
// src/usage-hints.ts
|
|
@@ -4049,7 +4066,7 @@ function selectFlakyNamespaces(entries, limit) {
|
|
|
4049
4066
|
}
|
|
4050
4067
|
|
|
4051
4068
|
// src/doctor-cmd.ts
|
|
4052
|
-
var VERSION = true ? "0.
|
|
4069
|
+
var VERSION = true ? "0.66.0" : "dev";
|
|
4053
4070
|
function isPersistenceDisabled(env) {
|
|
4054
4071
|
const raw = env.YAW_MCP_DISABLE_PERSISTENCE;
|
|
4055
4072
|
return raw !== void 0 && raw !== "" && (raw === "1" || raw.toLowerCase() === "true");
|
|
@@ -5663,13 +5680,85 @@ function isFileNotFound2(err) {
|
|
|
5663
5680
|
}
|
|
5664
5681
|
|
|
5665
5682
|
// src/secrets-cmd.ts
|
|
5683
|
+
import { existsSync as existsSync6 } from "fs";
|
|
5684
|
+
import { homedir as homedir15 } from "os";
|
|
5685
|
+
|
|
5686
|
+
// src/secrets-audit.ts
|
|
5666
5687
|
import { existsSync as existsSync5 } from "fs";
|
|
5667
|
-
import {
|
|
5688
|
+
import { appendFile as appendFile2, chmod as chmod4, readFile as readFile9, writeFile } from "fs/promises";
|
|
5689
|
+
import { homedir as homedir13 } from "os";
|
|
5690
|
+
import { join as join10 } from "path";
|
|
5691
|
+
var SECRETS_AUDIT_FILENAME = "secrets-audit.log";
|
|
5692
|
+
var AUDIT_TAIL_CAP = 5e3;
|
|
5693
|
+
function auditLogPath(home = homedir13()) {
|
|
5694
|
+
return join10(home, CONFIG_DIRNAME, SECRETS_AUDIT_FILENAME);
|
|
5695
|
+
}
|
|
5696
|
+
async function appendAuditEvent(input, home = homedir13()) {
|
|
5697
|
+
try {
|
|
5698
|
+
const event = {
|
|
5699
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
5700
|
+
server: input.server,
|
|
5701
|
+
secret: input.secret,
|
|
5702
|
+
event: input.event
|
|
5703
|
+
};
|
|
5704
|
+
const path5 = auditLogPath(home);
|
|
5705
|
+
const line = `${JSON.stringify(event)}
|
|
5706
|
+
`;
|
|
5707
|
+
if (!existsSync5(path5)) {
|
|
5708
|
+
await atomicWriteFile(path5, line);
|
|
5709
|
+
} else {
|
|
5710
|
+
await appendFile2(path5, line, "utf8");
|
|
5711
|
+
}
|
|
5712
|
+
if (process.platform !== "win32") {
|
|
5713
|
+
await chmod4(path5, 384).catch(() => void 0);
|
|
5714
|
+
}
|
|
5715
|
+
await trimToTailCap(path5);
|
|
5716
|
+
} catch {
|
|
5717
|
+
}
|
|
5718
|
+
}
|
|
5719
|
+
async function trimToTailCap(path5) {
|
|
5720
|
+
const raw = await readFile9(path5, "utf8");
|
|
5721
|
+
const lines = raw.split("\n").filter((l) => l.length > 0);
|
|
5722
|
+
if (lines.length <= AUDIT_TAIL_CAP) return;
|
|
5723
|
+
const kept = lines.slice(lines.length - AUDIT_TAIL_CAP);
|
|
5724
|
+
await writeFile(path5, `${kept.join("\n")}
|
|
5725
|
+
`, "utf8");
|
|
5726
|
+
}
|
|
5727
|
+
async function readAuditLog(filter = {}, home = homedir13()) {
|
|
5728
|
+
const path5 = auditLogPath(home);
|
|
5729
|
+
if (!existsSync5(path5)) return [];
|
|
5730
|
+
let raw;
|
|
5731
|
+
try {
|
|
5732
|
+
raw = await readFile9(path5, "utf8");
|
|
5733
|
+
} catch {
|
|
5734
|
+
return [];
|
|
5735
|
+
}
|
|
5736
|
+
const out = [];
|
|
5737
|
+
for (const line of raw.split("\n")) {
|
|
5738
|
+
if (line.length === 0) continue;
|
|
5739
|
+
let parsed;
|
|
5740
|
+
try {
|
|
5741
|
+
parsed = JSON.parse(line);
|
|
5742
|
+
} catch {
|
|
5743
|
+
continue;
|
|
5744
|
+
}
|
|
5745
|
+
if (!isAuditEvent(parsed)) continue;
|
|
5746
|
+
if (filter.secret !== void 0 && parsed.secret !== filter.secret) continue;
|
|
5747
|
+
if (filter.server !== void 0 && parsed.server !== filter.server) continue;
|
|
5748
|
+
out.push(parsed);
|
|
5749
|
+
}
|
|
5750
|
+
return out;
|
|
5751
|
+
}
|
|
5752
|
+
function isAuditEvent(v) {
|
|
5753
|
+
if (!v || typeof v !== "object") return false;
|
|
5754
|
+
const e = v;
|
|
5755
|
+
return typeof e.ts === "string" && typeof e.server === "string" && typeof e.secret === "string" && (e.event === "injected" || e.event === "missing");
|
|
5756
|
+
}
|
|
5668
5757
|
|
|
5669
5758
|
// src/secrets-vault.ts
|
|
5670
|
-
import { chmod as
|
|
5671
|
-
import { homedir as
|
|
5672
|
-
import { dirname as dirname2, join as
|
|
5759
|
+
import { chmod as chmod5, mkdir as mkdir4, readFile as readFile10 } from "fs/promises";
|
|
5760
|
+
import { homedir as homedir14 } from "os";
|
|
5761
|
+
import { dirname as dirname2, join as join11 } from "path";
|
|
5673
5762
|
|
|
5674
5763
|
// src/secrets-crypto.ts
|
|
5675
5764
|
import { createCipheriv, createDecipheriv, randomBytes, scrypt as scryptCb } from "crypto";
|
|
@@ -5728,8 +5817,8 @@ function decryptEntry(entry, key) {
|
|
|
5728
5817
|
var SECRETS_FILENAME = "secrets.json";
|
|
5729
5818
|
var SECRETS_SCHEMA_VERSION = 1;
|
|
5730
5819
|
var VAULT_CHECK_PLAINTEXT = "yaw-mcp-vault-v1";
|
|
5731
|
-
function vaultPath(home =
|
|
5732
|
-
return
|
|
5820
|
+
function vaultPath(home = homedir14()) {
|
|
5821
|
+
return join11(home, CONFIG_DIRNAME, SECRETS_FILENAME);
|
|
5733
5822
|
}
|
|
5734
5823
|
function emptyVault() {
|
|
5735
5824
|
return {
|
|
@@ -5741,7 +5830,7 @@ function emptyVault() {
|
|
|
5741
5830
|
async function loadVault(path5) {
|
|
5742
5831
|
let raw;
|
|
5743
5832
|
try {
|
|
5744
|
-
raw = await
|
|
5833
|
+
raw = await readFile10(path5, "utf8");
|
|
5745
5834
|
} catch (err) {
|
|
5746
5835
|
const code = err.code;
|
|
5747
5836
|
if (code === "ENOENT") return null;
|
|
@@ -5786,7 +5875,7 @@ async function saveVault(path5, vault) {
|
|
|
5786
5875
|
await mkdir4(dir, { recursive: true });
|
|
5787
5876
|
if (process.platform !== "win32") {
|
|
5788
5877
|
try {
|
|
5789
|
-
await
|
|
5878
|
+
await chmod5(dir, 448);
|
|
5790
5879
|
} catch {
|
|
5791
5880
|
}
|
|
5792
5881
|
}
|
|
@@ -5794,7 +5883,7 @@ async function saveVault(path5, vault) {
|
|
|
5794
5883
|
`, "utf8", 384, 448);
|
|
5795
5884
|
if (process.platform !== "win32") {
|
|
5796
5885
|
try {
|
|
5797
|
-
await
|
|
5886
|
+
await chmod5(path5, 384);
|
|
5798
5887
|
} catch {
|
|
5799
5888
|
}
|
|
5800
5889
|
}
|
|
@@ -5828,6 +5917,44 @@ function ensureCheck(vault, key) {
|
|
|
5828
5917
|
if (vault.check) return vault;
|
|
5829
5918
|
return { ...vault, check: encryptEntry(VAULT_CHECK_PLAINTEXT, key) };
|
|
5830
5919
|
}
|
|
5920
|
+
async function rotateVault(vault, oldKey, newPassphrase) {
|
|
5921
|
+
if (vault.check) {
|
|
5922
|
+
try {
|
|
5923
|
+
const probe2 = decryptEntry(vault.check, oldKey);
|
|
5924
|
+
if (probe2 !== VAULT_CHECK_PLAINTEXT) {
|
|
5925
|
+
throw new Error("vault check marker did not match expected plaintext");
|
|
5926
|
+
}
|
|
5927
|
+
} catch {
|
|
5928
|
+
throw new Error("rotate aborted: current passphrase is wrong (vault check failed to decrypt)");
|
|
5929
|
+
}
|
|
5930
|
+
}
|
|
5931
|
+
const plaintext = /* @__PURE__ */ new Map();
|
|
5932
|
+
for (const [name, entry] of Object.entries(vault.entries)) {
|
|
5933
|
+
try {
|
|
5934
|
+
plaintext.set(name, decryptEntry(entry, oldKey));
|
|
5935
|
+
} catch {
|
|
5936
|
+
plaintext.clear();
|
|
5937
|
+
throw new Error(`rotate aborted: entry "${name}" failed to decrypt under the current passphrase`);
|
|
5938
|
+
}
|
|
5939
|
+
}
|
|
5940
|
+
const newSalt = generateSalt();
|
|
5941
|
+
const newKey = await deriveKey(newPassphrase, newSalt);
|
|
5942
|
+
try {
|
|
5943
|
+
const entries = {};
|
|
5944
|
+
for (const [name, value] of plaintext) {
|
|
5945
|
+
entries[name] = encryptEntry(value, newKey);
|
|
5946
|
+
}
|
|
5947
|
+
return {
|
|
5948
|
+
version: SECRETS_SCHEMA_VERSION,
|
|
5949
|
+
salt: newSalt.toString("base64"),
|
|
5950
|
+
entries,
|
|
5951
|
+
check: encryptEntry(VAULT_CHECK_PLAINTEXT, newKey)
|
|
5952
|
+
};
|
|
5953
|
+
} finally {
|
|
5954
|
+
plaintext.clear();
|
|
5955
|
+
newKey.fill(0);
|
|
5956
|
+
}
|
|
5957
|
+
}
|
|
5831
5958
|
function listKeys(vault) {
|
|
5832
5959
|
return Object.keys(vault.entries).sort();
|
|
5833
5960
|
}
|
|
@@ -5919,6 +6046,19 @@ Actions:
|
|
|
5919
6046
|
\`yaw-mcp login\` first. Refuses when the local
|
|
5920
6047
|
vault has a different salt (different passphrase
|
|
5921
6048
|
lineage) unless --force is passed.
|
|
6049
|
+
rotate Re-encrypt every entry under a NEW passphrase
|
|
6050
|
+
(fresh salt + derived key). Re-wraps the
|
|
6051
|
+
ENCRYPTION, NOT the underlying token values -- a
|
|
6052
|
+
leaked token is still leaked; rotate it at its
|
|
6053
|
+
source. Reads the current passphrase, then the
|
|
6054
|
+
new one (env YAW_MCP_VAULT_PASSPHRASE_NEW or a
|
|
6055
|
+
confirm-twice TTY prompt). Pass --push to also
|
|
6056
|
+
upload the re-encrypted blob to mcp_secrets.
|
|
6057
|
+
audit [--secret NAME] [--server NS]
|
|
6058
|
+
Show the local secret-resolution audit trail
|
|
6059
|
+
(~/.yaw-mcp/secrets-audit.log): which secret
|
|
6060
|
+
NAMES were injected into (or missing for) which
|
|
6061
|
+
server, and when. Never shows a value.
|
|
5922
6062
|
|
|
5923
6063
|
Flags:
|
|
5924
6064
|
--json Machine-readable output (where applicable).
|
|
@@ -5930,12 +6070,18 @@ Flags:
|
|
|
5930
6070
|
--replace (push only) Overwrite even when the remote vault
|
|
5931
6071
|
salt differs from the local (different passphrase
|
|
5932
6072
|
lineage). Coordinate with your team first.
|
|
6073
|
+
--push (rotate only) After re-encrypting, push the new
|
|
6074
|
+
blob to mcp_secrets (requires a login session).
|
|
6075
|
+
--secret <name> (audit only) Filter to one secret name.
|
|
6076
|
+
--server <ns> (audit only) Filter to one server namespace.
|
|
5933
6077
|
|
|
5934
6078
|
Passphrase:
|
|
5935
6079
|
Set YAW_MCP_VAULT_PASSPHRASE in the env, or you will be prompted on
|
|
5936
6080
|
the controlling TTY. The passphrase derives the encryption key via
|
|
5937
6081
|
scrypt and is cached in memory for the lifetime of this yaw-mcp
|
|
5938
|
-
process; the on-disk vault only ever holds ciphertext
|
|
6082
|
+
process; the on-disk vault only ever holds ciphertext. For rotate, the
|
|
6083
|
+
NEW passphrase comes from YAW_MCP_VAULT_PASSPHRASE_NEW (or a TTY
|
|
6084
|
+
confirm-twice prompt).`;
|
|
5939
6085
|
function parseSecretsArgs(argv) {
|
|
5940
6086
|
const opts = {};
|
|
5941
6087
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -5957,6 +6103,10 @@ function parseSecretsArgs(argv) {
|
|
|
5957
6103
|
opts.replace = true;
|
|
5958
6104
|
continue;
|
|
5959
6105
|
}
|
|
6106
|
+
if (a === "--push") {
|
|
6107
|
+
opts.push = true;
|
|
6108
|
+
continue;
|
|
6109
|
+
}
|
|
5960
6110
|
if (a === "--value") {
|
|
5961
6111
|
const v = argv[++i];
|
|
5962
6112
|
if (v === void 0 || v.startsWith("-")) {
|
|
@@ -5970,13 +6120,31 @@ ${SECRETS_USAGE}`
|
|
|
5970
6120
|
opts.value = v;
|
|
5971
6121
|
continue;
|
|
5972
6122
|
}
|
|
6123
|
+
if (a === "--secret") {
|
|
6124
|
+
const v = argv[++i];
|
|
6125
|
+
if (v === void 0)
|
|
6126
|
+
return { ok: false, error: `yaw-mcp secrets: --secret requires a value
|
|
6127
|
+
|
|
6128
|
+
${SECRETS_USAGE}` };
|
|
6129
|
+
opts.secretFilter = v;
|
|
6130
|
+
continue;
|
|
6131
|
+
}
|
|
6132
|
+
if (a === "--server") {
|
|
6133
|
+
const v = argv[++i];
|
|
6134
|
+
if (v === void 0)
|
|
6135
|
+
return { ok: false, error: `yaw-mcp secrets: --server requires a value
|
|
6136
|
+
|
|
6137
|
+
${SECRETS_USAGE}` };
|
|
6138
|
+
opts.serverFilter = v;
|
|
6139
|
+
continue;
|
|
6140
|
+
}
|
|
5973
6141
|
if (a.startsWith("-")) {
|
|
5974
6142
|
return { ok: false, error: `yaw-mcp secrets: unknown flag "${a}"
|
|
5975
6143
|
|
|
5976
6144
|
${SECRETS_USAGE}` };
|
|
5977
6145
|
}
|
|
5978
6146
|
if (!opts.action) {
|
|
5979
|
-
if (a !== "set" && a !== "get" && a !== "list" && a !== "remove" && a !== "lock" && a !== "push" && a !== "pull") {
|
|
6147
|
+
if (a !== "set" && a !== "get" && a !== "list" && a !== "remove" && a !== "lock" && a !== "push" && a !== "pull" && a !== "rotate" && a !== "audit") {
|
|
5980
6148
|
return { ok: false, error: `yaw-mcp secrets: unknown action "${a}"
|
|
5981
6149
|
|
|
5982
6150
|
${SECRETS_USAGE}` };
|
|
@@ -6040,6 +6208,35 @@ async function resolvePassphrase(opts) {
|
|
|
6040
6208
|
}
|
|
6041
6209
|
return null;
|
|
6042
6210
|
}
|
|
6211
|
+
async function resolveNewPassphrase(opts) {
|
|
6212
|
+
if (opts.newPassphrase !== void 0) return opts.newPassphrase.length > 0 ? opts.newPassphrase : null;
|
|
6213
|
+
const fromEnv = process.env.YAW_MCP_VAULT_PASSPHRASE_NEW;
|
|
6214
|
+
if (typeof fromEnv === "string" && fromEnv.length > 0) {
|
|
6215
|
+
if (fromEnv.length < MIN_PASSPHRASE_WARN_LEN) {
|
|
6216
|
+
const stderr = opts.io?.stderr ?? process.stderr;
|
|
6217
|
+
stderr.write(
|
|
6218
|
+
`yaw-mcp secrets: warning -- the new passphrase is shorter than ${MIN_PASSPHRASE_WARN_LEN} characters; consider a longer passphrase.
|
|
6219
|
+
`
|
|
6220
|
+
);
|
|
6221
|
+
}
|
|
6222
|
+
return fromEnv;
|
|
6223
|
+
}
|
|
6224
|
+
const stdin = opts.io?.stdin ?? process.stdin;
|
|
6225
|
+
const stdout = opts.io?.stdout ?? process.stdout;
|
|
6226
|
+
const isTTY = stdin.isTTY === true && stdout.isTTY === true;
|
|
6227
|
+
if (!isTTY) return null;
|
|
6228
|
+
for (let attempt = 0; attempt < MAX_PASSPHRASE_PROMPTS; attempt++) {
|
|
6229
|
+
const first = await readPassphraseFromTTY(stdin, stdout, "New vault passphrase: ");
|
|
6230
|
+
if (first.length === 0) {
|
|
6231
|
+
stdout.write("Passphrase cannot be empty.\n");
|
|
6232
|
+
continue;
|
|
6233
|
+
}
|
|
6234
|
+
const second = await readPassphraseFromTTY(stdin, stdout, "Confirm new passphrase: ");
|
|
6235
|
+
if (first === second) return first;
|
|
6236
|
+
stdout.write("Passphrases did not match. Try again.\n");
|
|
6237
|
+
}
|
|
6238
|
+
return null;
|
|
6239
|
+
}
|
|
6043
6240
|
var MAX_PASSPHRASE_PROMPTS = 3;
|
|
6044
6241
|
var MIN_PASSPHRASE_WARN_LEN = 12;
|
|
6045
6242
|
function readPassphraseFromTTY(stdin, stdout, prompt = "Vault passphrase: ") {
|
|
@@ -6108,7 +6305,7 @@ async function runSecrets(opts, io = {
|
|
|
6108
6305
|
out: (s) => process.stdout.write(s),
|
|
6109
6306
|
err: (s) => process.stderr.write(s)
|
|
6110
6307
|
}) {
|
|
6111
|
-
const home = opts.home ??
|
|
6308
|
+
const home = opts.home ?? homedir15();
|
|
6112
6309
|
const path5 = vaultPath(home);
|
|
6113
6310
|
if (opts.action === "lock") {
|
|
6114
6311
|
lock();
|
|
@@ -6123,12 +6320,18 @@ async function runSecrets(opts, io = {
|
|
|
6123
6320
|
if (opts.action === "pull") {
|
|
6124
6321
|
return await runSecretsPull(opts, io);
|
|
6125
6322
|
}
|
|
6323
|
+
if (opts.action === "rotate") {
|
|
6324
|
+
return await runSecretsRotate(opts, io);
|
|
6325
|
+
}
|
|
6326
|
+
if (opts.action === "audit") {
|
|
6327
|
+
return await runSecretsAudit(opts, io);
|
|
6328
|
+
}
|
|
6126
6329
|
if (opts.action === "list") {
|
|
6127
6330
|
const loaded = await safeLoadVault(path5, io, opts.json, "list");
|
|
6128
6331
|
if (!loaded.ok) return loaded.result;
|
|
6129
6332
|
const vault2 = loaded.vault;
|
|
6130
6333
|
const keys = vault2 ? listKeys(vault2) : [];
|
|
6131
|
-
if (opts.json) io.out(`${JSON.stringify({ ok: true, vault:
|
|
6334
|
+
if (opts.json) io.out(`${JSON.stringify({ ok: true, vault: existsSync6(path5), keys }, null, 2)}
|
|
6132
6335
|
`);
|
|
6133
6336
|
else if (!vault2) io.out(`No vault at ${path5}. Run \`yaw-mcp secrets set <name>\` to create one.
|
|
6134
6337
|
`);
|
|
@@ -6159,7 +6362,7 @@ async function runSecrets(opts, io = {
|
|
|
6159
6362
|
const loadedForMutate = await safeLoadVault(path5, io, opts.json, opts.action ?? "");
|
|
6160
6363
|
if (!loadedForMutate.ok) return loadedForMutate.result;
|
|
6161
6364
|
let vault = loadedForMutate.vault ?? newVault();
|
|
6162
|
-
const isFresh = !
|
|
6365
|
+
const isFresh = !existsSync6(path5);
|
|
6163
6366
|
const passphrase = await resolvePassphrase(opts);
|
|
6164
6367
|
if (passphrase === null) {
|
|
6165
6368
|
const msg = "Passphrase required. Set YAW_MCP_VAULT_PASSPHRASE or run from a TTY so we can prompt.";
|
|
@@ -6261,7 +6464,7 @@ async function runSecrets(opts, io = {
|
|
|
6261
6464
|
}
|
|
6262
6465
|
var MCP_SECRETS_RESOURCE = "mcp_secrets";
|
|
6263
6466
|
async function runSecretsPush(opts, io) {
|
|
6264
|
-
const home = opts.home ??
|
|
6467
|
+
const home = opts.home ?? homedir15();
|
|
6265
6468
|
const path5 = vaultPath(home);
|
|
6266
6469
|
const session = await getSession({ home, baseUrl: opts.baseUrl });
|
|
6267
6470
|
if (!session) {
|
|
@@ -6335,7 +6538,7 @@ async function runSecretsPush(opts, io) {
|
|
|
6335
6538
|
}
|
|
6336
6539
|
}
|
|
6337
6540
|
async function runSecretsPull(opts, io) {
|
|
6338
|
-
const home = opts.home ??
|
|
6541
|
+
const home = opts.home ?? homedir15();
|
|
6339
6542
|
const path5 = vaultPath(home);
|
|
6340
6543
|
const session = await getSession({ home, baseUrl: opts.baseUrl });
|
|
6341
6544
|
if (!session) {
|
|
@@ -6403,10 +6606,159 @@ async function runSecretsPull(opts, io) {
|
|
|
6403
6606
|
return { exitCode: 1 };
|
|
6404
6607
|
}
|
|
6405
6608
|
}
|
|
6609
|
+
async function runSecretsRotate(opts, io) {
|
|
6610
|
+
const home = opts.home ?? homedir15();
|
|
6611
|
+
const path5 = vaultPath(home);
|
|
6612
|
+
const vault = await loadVault(path5);
|
|
6613
|
+
if (!vault) {
|
|
6614
|
+
const msg = `No vault at ${path5} to rotate. Run \`yaw-mcp secrets set <name>\` first.`;
|
|
6615
|
+
if (opts.json) io.err(`${JSON.stringify({ ok: false, error: msg })}
|
|
6616
|
+
`);
|
|
6617
|
+
else io.err(`yaw-mcp secrets rotate: ${msg}
|
|
6618
|
+
`);
|
|
6619
|
+
return { exitCode: 1 };
|
|
6620
|
+
}
|
|
6621
|
+
const currentPassphrase = await resolvePassphrase(opts);
|
|
6622
|
+
if (currentPassphrase === null) {
|
|
6623
|
+
const msg = "Current passphrase required. Set YAW_MCP_VAULT_PASSPHRASE or run from a TTY so we can prompt.";
|
|
6624
|
+
if (opts.json) io.err(`${JSON.stringify({ ok: false, error: msg })}
|
|
6625
|
+
`);
|
|
6626
|
+
else io.err(`yaw-mcp secrets rotate: ${msg}
|
|
6627
|
+
`);
|
|
6628
|
+
return { exitCode: 1 };
|
|
6629
|
+
}
|
|
6630
|
+
let oldKey;
|
|
6631
|
+
try {
|
|
6632
|
+
oldKey = await unlock(vault, currentPassphrase);
|
|
6633
|
+
} catch (err) {
|
|
6634
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
6635
|
+
if (opts.json) io.err(`${JSON.stringify({ ok: false, error: msg })}
|
|
6636
|
+
`);
|
|
6637
|
+
else io.err(`yaw-mcp secrets rotate: ${msg}
|
|
6638
|
+
`);
|
|
6639
|
+
return { exitCode: 1 };
|
|
6640
|
+
}
|
|
6641
|
+
const newPassphrase = await resolveNewPassphrase(opts);
|
|
6642
|
+
if (newPassphrase === null) {
|
|
6643
|
+
const msg = "New passphrase required (and must be confirmed). Set YAW_MCP_VAULT_PASSPHRASE_NEW or run from a TTY so we can prompt.";
|
|
6644
|
+
if (opts.json) io.err(`${JSON.stringify({ ok: false, error: msg })}
|
|
6645
|
+
`);
|
|
6646
|
+
else io.err(`yaw-mcp secrets rotate: ${msg}
|
|
6647
|
+
`);
|
|
6648
|
+
return { exitCode: 1 };
|
|
6649
|
+
}
|
|
6650
|
+
let rotated;
|
|
6651
|
+
try {
|
|
6652
|
+
rotated = await rotateVault(vault, oldKey, newPassphrase);
|
|
6653
|
+
} catch (err) {
|
|
6654
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
6655
|
+
if (opts.json) io.err(`${JSON.stringify({ ok: false, error: msg })}
|
|
6656
|
+
`);
|
|
6657
|
+
else io.err(`yaw-mcp secrets rotate: ${msg}
|
|
6658
|
+
`);
|
|
6659
|
+
lock();
|
|
6660
|
+
return { exitCode: 1 };
|
|
6661
|
+
}
|
|
6662
|
+
await saveVault(path5, rotated);
|
|
6663
|
+
lock();
|
|
6664
|
+
const count = Object.keys(rotated.entries).length;
|
|
6665
|
+
let pushedVersion = null;
|
|
6666
|
+
if (opts.push) {
|
|
6667
|
+
const session = await getSession({ home, baseUrl: opts.baseUrl });
|
|
6668
|
+
if (!session) {
|
|
6669
|
+
const msg = "Rotated locally, but --push needs a session. Run `yaw-mcp login --key <license-key>` then push.";
|
|
6670
|
+
if (opts.json) io.err(`${JSON.stringify({ ok: true, rotated: true, pushed: false, note: msg })}
|
|
6671
|
+
`);
|
|
6672
|
+
else io.err(`yaw-mcp secrets rotate: ${msg}
|
|
6673
|
+
`);
|
|
6674
|
+
return { exitCode: 0 };
|
|
6675
|
+
}
|
|
6676
|
+
try {
|
|
6677
|
+
const remote = await getResource(MCP_SECRETS_RESOURCE, { home, baseUrl: opts.baseUrl });
|
|
6678
|
+
const result = await putResource(MCP_SECRETS_RESOURCE, remote.version, rotated, {
|
|
6679
|
+
home,
|
|
6680
|
+
baseUrl: opts.baseUrl
|
|
6681
|
+
});
|
|
6682
|
+
pushedVersion = result.version;
|
|
6683
|
+
} catch (err) {
|
|
6684
|
+
if (err instanceof TeamSyncStaleVersionError) {
|
|
6685
|
+
const hint = `Rotated locally. Push skipped -- remote is at v${err.currentVersion}; pull and reconcile, then push.`;
|
|
6686
|
+
if (opts.json) io.err(`${JSON.stringify({ ok: true, rotated: true, pushed: false, note: hint })}
|
|
6687
|
+
`);
|
|
6688
|
+
else io.err(`yaw-mcp secrets rotate: ${hint}
|
|
6689
|
+
`);
|
|
6690
|
+
return { exitCode: 0 };
|
|
6691
|
+
}
|
|
6692
|
+
if (err instanceof TeamSyncAuthError) {
|
|
6693
|
+
const hint = "Rotated locally. Push skipped -- session expired. Run `yaw-mcp login` again, then push.";
|
|
6694
|
+
if (opts.json) io.err(`${JSON.stringify({ ok: true, rotated: true, pushed: false, note: hint })}
|
|
6695
|
+
`);
|
|
6696
|
+
else io.err(`yaw-mcp secrets rotate: ${hint}
|
|
6697
|
+
`);
|
|
6698
|
+
return { exitCode: 0 };
|
|
6699
|
+
}
|
|
6700
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
6701
|
+
if (opts.json) io.err(`${JSON.stringify({ ok: true, rotated: true, pushed: false, error: message })}
|
|
6702
|
+
`);
|
|
6703
|
+
else io.err(`yaw-mcp secrets rotate: rotated locally but push failed: ${message}
|
|
6704
|
+
`);
|
|
6705
|
+
return { exitCode: 0 };
|
|
6706
|
+
}
|
|
6707
|
+
}
|
|
6708
|
+
if (opts.json) {
|
|
6709
|
+
io.out(
|
|
6710
|
+
`${JSON.stringify({ ok: true, rotated: true, secret_count: count, pushed: pushedVersion !== null, ...pushedVersion !== null ? { new_version: pushedVersion } : {} })}
|
|
6711
|
+
`
|
|
6712
|
+
);
|
|
6713
|
+
} else {
|
|
6714
|
+
io.out(
|
|
6715
|
+
`Rotated ${count} secret${count === 1 ? "" : "s"} under a new passphrase (encryption re-wrapped, token values unchanged).
|
|
6716
|
+
`
|
|
6717
|
+
);
|
|
6718
|
+
if (pushedVersion !== null) io.out(`Pushed the re-encrypted vault -> mcp_secrets v${pushedVersion}.
|
|
6719
|
+
`);
|
|
6720
|
+
io.out("Vault locked -- the next secrets command will prompt for the new passphrase.\n");
|
|
6721
|
+
}
|
|
6722
|
+
return { exitCode: 0 };
|
|
6723
|
+
}
|
|
6724
|
+
async function runSecretsAudit(opts, io) {
|
|
6725
|
+
const home = opts.home ?? homedir15();
|
|
6726
|
+
let events;
|
|
6727
|
+
try {
|
|
6728
|
+
events = await readAuditLog(
|
|
6729
|
+
{
|
|
6730
|
+
...opts.secretFilter !== void 0 ? { secret: opts.secretFilter } : {},
|
|
6731
|
+
...opts.serverFilter !== void 0 ? { server: opts.serverFilter } : {}
|
|
6732
|
+
},
|
|
6733
|
+
home
|
|
6734
|
+
);
|
|
6735
|
+
} catch (err) {
|
|
6736
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
6737
|
+
if (opts.json) io.err(`${JSON.stringify({ ok: false, error: msg })}
|
|
6738
|
+
`);
|
|
6739
|
+
else io.err(`yaw-mcp secrets audit: ${msg}
|
|
6740
|
+
`);
|
|
6741
|
+
return { exitCode: 1 };
|
|
6742
|
+
}
|
|
6743
|
+
if (opts.json) {
|
|
6744
|
+
io.out(`${JSON.stringify({ ok: true, count: events.length, events }, null, 2)}
|
|
6745
|
+
`);
|
|
6746
|
+
return { exitCode: 0 };
|
|
6747
|
+
}
|
|
6748
|
+
if (events.length === 0) {
|
|
6749
|
+
io.out("No secret-resolution audit events recorded yet.\n");
|
|
6750
|
+
return { exitCode: 0 };
|
|
6751
|
+
}
|
|
6752
|
+
for (const e of events) {
|
|
6753
|
+
io.out(`${e.ts} ${e.event === "injected" ? "injected" : "missing "} ${e.server} ${e.secret}
|
|
6754
|
+
`);
|
|
6755
|
+
}
|
|
6756
|
+
return { exitCode: 0 };
|
|
6757
|
+
}
|
|
6406
6758
|
|
|
6407
6759
|
// src/server.ts
|
|
6408
|
-
import { readFile as
|
|
6409
|
-
import { homedir as
|
|
6760
|
+
import { readFile as readFile12 } from "fs/promises";
|
|
6761
|
+
import { homedir as homedir16 } from "os";
|
|
6410
6762
|
import { isAbsolute as isAbsolute2, relative, resolve as resolve6 } from "path";
|
|
6411
6763
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
6412
6764
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
@@ -6522,7 +6874,7 @@ function defaultSpawn2(cmd, args) {
|
|
|
6522
6874
|
async function maybeAutoUpgrade(deps = {}) {
|
|
6523
6875
|
const optOut = process.env.YAW_MCP_AUTO_UPGRADE;
|
|
6524
6876
|
if (optOut === "0" || optOut?.toLowerCase() === "false") return;
|
|
6525
|
-
const current = deps.currentVersion ?? (true ? "0.
|
|
6877
|
+
const current = deps.currentVersion ?? (true ? "0.66.0" : "dev");
|
|
6526
6878
|
if (current === "dev") return;
|
|
6527
6879
|
const method = (deps.isSeaImpl ? await deps.isSeaImpl() : await detectSea()) ? "binary" : detectInstallMethod(deps.argvPath ?? process.argv[1]);
|
|
6528
6880
|
const latest = await (deps.fetchLatestImpl ?? fetchLatestVersion2)();
|
|
@@ -7028,14 +7380,14 @@ function closestNames(query, candidates, limit) {
|
|
|
7028
7380
|
}
|
|
7029
7381
|
|
|
7030
7382
|
// src/guide.ts
|
|
7031
|
-
import { readFile as
|
|
7383
|
+
import { readFile as readFile11 } from "fs/promises";
|
|
7032
7384
|
var GUIDE_READ_TIMEOUT_MS = 1e3;
|
|
7033
7385
|
async function readGuide(path5, scope) {
|
|
7034
7386
|
let raw;
|
|
7035
7387
|
const ac = new AbortController();
|
|
7036
7388
|
const timer = setTimeout(() => ac.abort(new Error("guide read timeout")), GUIDE_READ_TIMEOUT_MS);
|
|
7037
7389
|
try {
|
|
7038
|
-
raw = await
|
|
7390
|
+
raw = await readFile11(path5, { encoding: "utf8", signal: ac.signal });
|
|
7039
7391
|
} catch (err) {
|
|
7040
7392
|
const isTimeout = err instanceof Error && err.code === "ABORT_ERR";
|
|
7041
7393
|
if (isTimeout) {
|
|
@@ -7255,6 +7607,63 @@ function pushToolCall(history, record, limit = HISTORY_LIMIT) {
|
|
|
7255
7607
|
return history;
|
|
7256
7608
|
}
|
|
7257
7609
|
|
|
7610
|
+
// src/install-nudge.ts
|
|
7611
|
+
import { existsSync as existsSync7, mkdirSync as mkdirSync2, readFileSync as readFileSync4, writeFileSync as writeFileSync2 } from "fs";
|
|
7612
|
+
import { dirname as dirname4, join as join12 } from "path";
|
|
7613
|
+
var INSTALL_NUDGE_MIN_COUNT = 5;
|
|
7614
|
+
var INSTALL_NUDGE_COOLDOWN_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
7615
|
+
var INSTALL_NUDGE_STATE_FILENAME = "install-nudge-state.json";
|
|
7616
|
+
function installNudgeStatePath(home) {
|
|
7617
|
+
return join12(home, CONFIG_DIRNAME, INSTALL_NUDGE_STATE_FILENAME);
|
|
7618
|
+
}
|
|
7619
|
+
function installNudgeEnabled(env, config) {
|
|
7620
|
+
if (env.YAW_MCP_INSTALL_NUDGE === "1") return true;
|
|
7621
|
+
if (config?.installNudge === true) return true;
|
|
7622
|
+
return false;
|
|
7623
|
+
}
|
|
7624
|
+
function readState(home) {
|
|
7625
|
+
const path5 = installNudgeStatePath(home);
|
|
7626
|
+
if (!existsSync7(path5)) return { nudges: [] };
|
|
7627
|
+
try {
|
|
7628
|
+
const parsed = JSON.parse(readFileSync4(path5, "utf8"));
|
|
7629
|
+
if (!parsed || typeof parsed !== "object") return { nudges: [] };
|
|
7630
|
+
const raw = parsed.nudges;
|
|
7631
|
+
if (!Array.isArray(raw)) return { nudges: [] };
|
|
7632
|
+
const nudges = [];
|
|
7633
|
+
for (const entry of raw) {
|
|
7634
|
+
if (entry && typeof entry === "object" && typeof entry.cli === "string" && typeof entry.nudgedAt === "number") {
|
|
7635
|
+
nudges.push({ cli: entry.cli, nudgedAt: entry.nudgedAt });
|
|
7636
|
+
}
|
|
7637
|
+
}
|
|
7638
|
+
return { nudges };
|
|
7639
|
+
} catch {
|
|
7640
|
+
return { nudges: [] };
|
|
7641
|
+
}
|
|
7642
|
+
}
|
|
7643
|
+
function shouldNudge(cli, home, now = Date.now) {
|
|
7644
|
+
const state = readState(home);
|
|
7645
|
+
const rec = state.nudges.find((n) => n.cli === cli);
|
|
7646
|
+
if (!rec) return true;
|
|
7647
|
+
return now() - rec.nudgedAt >= INSTALL_NUDGE_COOLDOWN_MS;
|
|
7648
|
+
}
|
|
7649
|
+
function recordNudge(cli, home, now = Date.now) {
|
|
7650
|
+
try {
|
|
7651
|
+
const at = now();
|
|
7652
|
+
const state = readState(home);
|
|
7653
|
+
const kept = state.nudges.filter((n) => n.cli !== cli && at - n.nudgedAt < INSTALL_NUDGE_COOLDOWN_MS);
|
|
7654
|
+
kept.push({ cli, nudgedAt: at });
|
|
7655
|
+
const path5 = installNudgeStatePath(home);
|
|
7656
|
+
mkdirSync2(dirname4(path5), { recursive: true });
|
|
7657
|
+
writeFileSync2(path5, `${JSON.stringify({ nudges: kept }, null, 2)}
|
|
7658
|
+
`, "utf8");
|
|
7659
|
+
} catch (err) {
|
|
7660
|
+
log("debug", "install-nudge: failed to record nudge state", {
|
|
7661
|
+
cli,
|
|
7662
|
+
error: err instanceof Error ? err.message : String(err)
|
|
7663
|
+
});
|
|
7664
|
+
}
|
|
7665
|
+
}
|
|
7666
|
+
|
|
7258
7667
|
// src/learning.ts
|
|
7259
7668
|
var LEARNING_MIN_OBSERVATIONS = 3;
|
|
7260
7669
|
var LEARNING_MAX_BOOST = 1.1;
|
|
@@ -7647,6 +8056,26 @@ var META_TOOLS = {
|
|
|
7647
8056
|
openWorldHint: false
|
|
7648
8057
|
}
|
|
7649
8058
|
},
|
|
8059
|
+
secrets: {
|
|
8060
|
+
name: "mcp_connect_secrets",
|
|
8061
|
+
description: "List, per installed server, which local-vault secrets its `${secret:NAME}` env references resolve to -- by NAME only, never a value. Use this to confirm a server will get the credentials it needs before activating it, or to spot a typo'd / un-set secret reference. `injectedSecrets` are the names the local vault HAS and the server references; `missing` are names the server references but the vault LACKS (set them via `yaw-mcp secrets set <name>`). This is a values-free preview: it reads the vault's KEY LIST and the server's env-reference NAMES, and never decrypts or returns any secret value. Servers with no `${secret:...}` references are omitted. Requires no passphrase (no decryption happens).",
|
|
8062
|
+
inputSchema: {
|
|
8063
|
+
type: "object",
|
|
8064
|
+
properties: {
|
|
8065
|
+
server: {
|
|
8066
|
+
type: "string",
|
|
8067
|
+
description: 'Optional: restrict the report to a single server namespace (e.g. "gh"). Omit to report every installed server that references a vault secret.'
|
|
8068
|
+
}
|
|
8069
|
+
}
|
|
8070
|
+
},
|
|
8071
|
+
annotations: {
|
|
8072
|
+
title: "Inspect Vault Secret Resolution",
|
|
8073
|
+
readOnlyHint: true,
|
|
8074
|
+
destructiveHint: false,
|
|
8075
|
+
idempotentHint: true,
|
|
8076
|
+
openWorldHint: false
|
|
8077
|
+
}
|
|
8078
|
+
},
|
|
7650
8079
|
exec: {
|
|
7651
8080
|
name: "mcp_connect_exec",
|
|
7652
8081
|
description: "Run a short DECLARATIVE pipeline of upstream tool calls in a single round-trip. Use this when you already know the exact 2-4 tool calls to make and one call's output feeds another's args \u2014 e.g. `a = gh_list_prs(); b = gh_get_pr(a[0].number); return b`. NOT a code sandbox: there is no expression language, no loops, no branching, no arithmetic. The only control flow is sequential step execution; the only data-flow primitive is `{\"$ref\": \"<stepId>[.path.to.value]\"}` which substitutes a prior step's output (or a nested field of it) into the next step's args. Paths support dot keys and `[N]` / `.N` array indexing. Each step's `tool` must be a namespaced, already-loaded tool name (the exec does not auto-activate \u2014 call `mcp_connect_activate` first). Max 16 steps per exec. If any step fails, the whole pipeline fails and returns `{ ok: false, failedStep, error, partial: { ...completed outputs } }`. On success returns `{ ok: true, result: <return-step output>, steps: { ...all outputs } }`. Prefer this over back-to-back tool calls when the chain is deterministic \u2014 it saves prompt-token replay and client round-trips.",
|
|
@@ -7764,6 +8193,30 @@ function buildInstallPayload(args) {
|
|
|
7764
8193
|
}
|
|
7765
8194
|
return { ok: true, payload };
|
|
7766
8195
|
}
|
|
8196
|
+
var SECRETS_REPORT_REF_RE = /\$\{secret:([a-zA-Z0-9_.-]+)\}/g;
|
|
8197
|
+
function computeSecretsReport(servers, vaultKeys) {
|
|
8198
|
+
const rows = [];
|
|
8199
|
+
for (const server of servers) {
|
|
8200
|
+
const referenced = /* @__PURE__ */ new Set();
|
|
8201
|
+
for (const v of Object.values(server.env ?? {})) {
|
|
8202
|
+
if (typeof v !== "string") continue;
|
|
8203
|
+
for (const m of v.matchAll(SECRETS_REPORT_REF_RE)) referenced.add(m[1]);
|
|
8204
|
+
}
|
|
8205
|
+
if (referenced.size === 0) continue;
|
|
8206
|
+
const injectedSecrets = [];
|
|
8207
|
+
const missing = [];
|
|
8208
|
+
for (const name of referenced) {
|
|
8209
|
+
if (vaultKeys.has(name)) injectedSecrets.push(name);
|
|
8210
|
+
else missing.push(name);
|
|
8211
|
+
}
|
|
8212
|
+
rows.push({
|
|
8213
|
+
server: server.namespace,
|
|
8214
|
+
injectedSecrets: injectedSecrets.sort(),
|
|
8215
|
+
missing: missing.sort()
|
|
8216
|
+
});
|
|
8217
|
+
}
|
|
8218
|
+
return rows;
|
|
8219
|
+
}
|
|
7767
8220
|
var META_TOOL_NAMES = /* @__PURE__ */ new Set([
|
|
7768
8221
|
META_TOOLS.discover.name,
|
|
7769
8222
|
META_TOOLS.activate.name,
|
|
@@ -7775,7 +8228,8 @@ var META_TOOL_NAMES = /* @__PURE__ */ new Set([
|
|
|
7775
8228
|
META_TOOLS.read_tool.name,
|
|
7776
8229
|
META_TOOLS.suggest.name,
|
|
7777
8230
|
META_TOOLS.exec.name,
|
|
7778
|
-
META_TOOLS.bundles.name
|
|
8231
|
+
META_TOOLS.bundles.name,
|
|
8232
|
+
META_TOOLS.secrets.name
|
|
7779
8233
|
]);
|
|
7780
8234
|
|
|
7781
8235
|
// src/pack-detect.ts
|
|
@@ -8973,6 +9427,62 @@ import {
|
|
|
8973
9427
|
ToolListChangedNotificationSchema
|
|
8974
9428
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
8975
9429
|
|
|
9430
|
+
// src/oam-spawn.ts
|
|
9431
|
+
import { execFileSync } from "child_process";
|
|
9432
|
+
import { createRequire } from "module";
|
|
9433
|
+
var requireFrom = createRequire(import.meta.url);
|
|
9434
|
+
function packageName(spec) {
|
|
9435
|
+
const start = spec.startsWith("@") ? 1 : 0;
|
|
9436
|
+
const at = spec.indexOf("@", start);
|
|
9437
|
+
return at === -1 ? spec : spec.slice(0, at);
|
|
9438
|
+
}
|
|
9439
|
+
var oamBinCache;
|
|
9440
|
+
function oamBin() {
|
|
9441
|
+
if (oamBinCache !== void 0) return oamBinCache;
|
|
9442
|
+
const bin = process.env.OAM_BIN || (process.platform === "win32" ? "oam.exe" : "oam");
|
|
9443
|
+
try {
|
|
9444
|
+
execFileSync(bin, ["--version"], { stdio: "ignore" });
|
|
9445
|
+
oamBinCache = bin;
|
|
9446
|
+
} catch {
|
|
9447
|
+
oamBinCache = null;
|
|
9448
|
+
}
|
|
9449
|
+
return oamBinCache;
|
|
9450
|
+
}
|
|
9451
|
+
function rewriteForOam(command, args, deps) {
|
|
9452
|
+
const bin = deps.oamBin;
|
|
9453
|
+
if (!bin) return { command, args };
|
|
9454
|
+
const toOam = (entry, rest) => ({
|
|
9455
|
+
command: bin,
|
|
9456
|
+
args: rest.length > 0 ? ["run", entry, "--", ...rest] : ["run", entry]
|
|
9457
|
+
});
|
|
9458
|
+
if (command === "node") {
|
|
9459
|
+
const [entry, ...rest] = args;
|
|
9460
|
+
if (!entry) return { command, args };
|
|
9461
|
+
return toOam(entry, rest);
|
|
9462
|
+
}
|
|
9463
|
+
if (command === "npx") {
|
|
9464
|
+
const positional = args.filter((a) => a !== "-y" && a !== "--yes");
|
|
9465
|
+
const [spec, ...rest] = positional;
|
|
9466
|
+
if (!spec) return { command, args };
|
|
9467
|
+
const entry = deps.resolveEntry(packageName(spec));
|
|
9468
|
+
if (!entry) return { command, args };
|
|
9469
|
+
return toOam(entry, rest);
|
|
9470
|
+
}
|
|
9471
|
+
return { command, args };
|
|
9472
|
+
}
|
|
9473
|
+
function resolveOamSpawn(command, args) {
|
|
9474
|
+
return rewriteForOam(command, args, {
|
|
9475
|
+
oamBin: oamBin(),
|
|
9476
|
+
resolveEntry: (pkg) => {
|
|
9477
|
+
try {
|
|
9478
|
+
return requireFrom.resolve(pkg);
|
|
9479
|
+
} catch {
|
|
9480
|
+
return null;
|
|
9481
|
+
}
|
|
9482
|
+
}
|
|
9483
|
+
});
|
|
9484
|
+
}
|
|
9485
|
+
|
|
8976
9486
|
// src/uv-bootstrap.ts
|
|
8977
9487
|
import { spawn as spawn5 } from "child_process";
|
|
8978
9488
|
import { createHash as createHash3 } from "crypto";
|
|
@@ -9213,7 +9723,7 @@ async function resolveUvSpawn(command, args) {
|
|
|
9213
9723
|
}
|
|
9214
9724
|
|
|
9215
9725
|
// src/upstream.ts
|
|
9216
|
-
async function resolveServerEnv(env) {
|
|
9726
|
+
async function resolveServerEnv(env, namespace) {
|
|
9217
9727
|
if (!hasSecretRefs(env)) return env;
|
|
9218
9728
|
const refKeys = Object.entries(env).filter(([, v]) => typeof v === "string" && v.includes("${secret:")).map(([k]) => k);
|
|
9219
9729
|
const passphrase = process.env.YAW_MCP_VAULT_PASSPHRASE;
|
|
@@ -9235,8 +9745,36 @@ async function resolveServerEnv(env) {
|
|
|
9235
9745
|
if (missing.length > 0) {
|
|
9236
9746
|
throw new Error(`vault: missing or undecryptable secret refs: ${missing.join(", ")}`);
|
|
9237
9747
|
}
|
|
9748
|
+
try {
|
|
9749
|
+
await recordResolveAudit(namespace, env, missing);
|
|
9750
|
+
} catch (auditErr) {
|
|
9751
|
+
log("warn", "Failed to record secret-resolve audit (non-fatal)", {
|
|
9752
|
+
namespace,
|
|
9753
|
+
error: auditErr instanceof Error ? auditErr.message : String(auditErr)
|
|
9754
|
+
});
|
|
9755
|
+
}
|
|
9238
9756
|
return resolved;
|
|
9239
9757
|
}
|
|
9758
|
+
async function recordResolveAudit(namespace, env, missing) {
|
|
9759
|
+
const missingSet = new Set(missing);
|
|
9760
|
+
const referenced = collectSecretNames(env);
|
|
9761
|
+
for (const name of referenced) {
|
|
9762
|
+
if (missingSet.has(name)) continue;
|
|
9763
|
+
await appendAuditEvent({ server: namespace, secret: name, event: "injected" });
|
|
9764
|
+
}
|
|
9765
|
+
for (const name of missingSet) {
|
|
9766
|
+
await appendAuditEvent({ server: namespace, secret: name, event: "missing" });
|
|
9767
|
+
}
|
|
9768
|
+
}
|
|
9769
|
+
function collectSecretNames(env) {
|
|
9770
|
+
const names = /* @__PURE__ */ new Set();
|
|
9771
|
+
const re = /\$\{secret:([a-zA-Z0-9_.-]+)\}/g;
|
|
9772
|
+
for (const v of Object.values(env)) {
|
|
9773
|
+
if (typeof v !== "string") continue;
|
|
9774
|
+
for (const m of v.matchAll(re)) names.add(m[1]);
|
|
9775
|
+
}
|
|
9776
|
+
return [...names];
|
|
9777
|
+
}
|
|
9240
9778
|
var DEFAULT_CONNECT_TIMEOUT = (() => {
|
|
9241
9779
|
const env = process.env.MCP_CONNECT_TIMEOUT;
|
|
9242
9780
|
if (!env) return 15e3;
|
|
@@ -9284,7 +9822,7 @@ function categorizeSpawnError(err) {
|
|
|
9284
9822
|
}
|
|
9285
9823
|
async function connectToUpstream(config, onDisconnect, onListChanged) {
|
|
9286
9824
|
const client = new Client(
|
|
9287
|
-
{ name: "yaw-mcp", version: true ? "0.
|
|
9825
|
+
{ name: "yaw-mcp", version: true ? "0.66.0" : "dev" },
|
|
9288
9826
|
{ capabilities: {} }
|
|
9289
9827
|
);
|
|
9290
9828
|
let transport;
|
|
@@ -9299,8 +9837,11 @@ async function connectToUpstream(config, onDisconnect, onListChanged) {
|
|
|
9299
9837
|
YAW_MCP_VAULT_PASSPHRASE: _excludedVaultPassphrase,
|
|
9300
9838
|
...parentEnv
|
|
9301
9839
|
} = process.env;
|
|
9302
|
-
|
|
9303
|
-
|
|
9840
|
+
let resolved = await resolveUvSpawn(config.command, config.args ?? []);
|
|
9841
|
+
if (config.runtime === "oam") {
|
|
9842
|
+
resolved = resolveOamSpawn(resolved.command, resolved.args);
|
|
9843
|
+
}
|
|
9844
|
+
const serverEnv = await resolveServerEnv(config.env ?? {}, config.namespace);
|
|
9304
9845
|
resolvedServerEnv = serverEnv;
|
|
9305
9846
|
const stdioTransport = new StdioClientTransport({
|
|
9306
9847
|
command: resolved.command,
|
|
@@ -9616,7 +10157,7 @@ var ConnectServer = class _ConnectServer {
|
|
|
9616
10157
|
this.apiUrl = apiUrl5;
|
|
9617
10158
|
this.token = token5;
|
|
9618
10159
|
this.server = new Server(
|
|
9619
|
-
{ name: "yaw-mcp", version: true ? "0.
|
|
10160
|
+
{ name: "yaw-mcp", version: true ? "0.66.0" : "dev" },
|
|
9620
10161
|
{
|
|
9621
10162
|
capabilities: {
|
|
9622
10163
|
tools: { listChanged: true },
|
|
@@ -9662,6 +10203,19 @@ var ConnectServer = class _ConnectServer {
|
|
|
9662
10203
|
// same namespace, on deactivate, and on config reconcile.
|
|
9663
10204
|
toolFilters = /* @__PURE__ */ new Map();
|
|
9664
10205
|
profile = null;
|
|
10206
|
+
// Shadow-driven install-nudge gate. Resolved once at start() from the
|
|
10207
|
+
// env override (YAW_MCP_INSTALL_NUDGE=1) OR config (installNudge: true);
|
|
10208
|
+
// off by default. When false, discover NEVER runs the shell-history scan
|
|
10209
|
+
// and its output is byte-identical to today (the load-bearing privacy
|
|
10210
|
+
// property). See install-nudge.ts. Stays false in unit tests that skip
|
|
10211
|
+
// start(), so the nudge block is opt-in there too.
|
|
10212
|
+
installNudge = false;
|
|
10213
|
+
// home / env used by the install-nudge shell-history scan. Default to the
|
|
10214
|
+
// real process values; overridable so tests can point the scan at a
|
|
10215
|
+
// synthetic home + stubbed env without touching the developer's real
|
|
10216
|
+
// shell history or ~/.yaw-mcp/ state file.
|
|
10217
|
+
nudgeHome = homedir16();
|
|
10218
|
+
nudgeEnv = process.env;
|
|
9665
10219
|
// Loaded YAW-MCP.md guides (user-global + project-local). Null until
|
|
9666
10220
|
// start() has run the loader; fail-open if either file is missing,
|
|
9667
10221
|
// unreadable, or empty.
|
|
@@ -9886,7 +10440,8 @@ var ConnectServer = class _ConnectServer {
|
|
|
9886
10440
|
}
|
|
9887
10441
|
this.persistenceReady = true;
|
|
9888
10442
|
}
|
|
9889
|
-
|
|
10443
|
+
const resolvedConfig = await loadYawMcpConfig({ cwd: process.cwd() }).catch(() => null);
|
|
10444
|
+
this.profile = resolvedConfig ? toProfile(resolvedConfig) : null;
|
|
9890
10445
|
if (this.profile) {
|
|
9891
10446
|
log("info", "Loaded profile", {
|
|
9892
10447
|
path: this.profile.path,
|
|
@@ -9895,6 +10450,10 @@ var ConnectServer = class _ConnectServer {
|
|
|
9895
10450
|
block: this.profile.blocked
|
|
9896
10451
|
});
|
|
9897
10452
|
}
|
|
10453
|
+
this.installNudge = installNudgeEnabled(process.env, resolvedConfig);
|
|
10454
|
+
if (this.installNudge) {
|
|
10455
|
+
log("info", "Shadow-driven install nudge enabled");
|
|
10456
|
+
}
|
|
9898
10457
|
this.guides = await loadGuides(process.cwd()).catch(() => ({ user: null, project: null }));
|
|
9899
10458
|
if (this.guides.user || this.guides.project) {
|
|
9900
10459
|
log("info", "Loaded YAW-MCP.md guide", {
|
|
@@ -10198,6 +10757,17 @@ var ConnectServer = class _ConnectServer {
|
|
|
10198
10757
|
recordConnectEvent({ namespace: null, toolName: null, action: "bundles", latencyMs: null, success: true });
|
|
10199
10758
|
return this.attachGuideNudge(this.handleBundles(action));
|
|
10200
10759
|
}
|
|
10760
|
+
if (name === META_TOOLS.secrets.name) {
|
|
10761
|
+
const serverArg = typeof args.server === "string" ? args.server : void 0;
|
|
10762
|
+
recordConnectEvent({
|
|
10763
|
+
namespace: serverArg ?? null,
|
|
10764
|
+
toolName: null,
|
|
10765
|
+
action: "secrets",
|
|
10766
|
+
latencyMs: null,
|
|
10767
|
+
success: true
|
|
10768
|
+
});
|
|
10769
|
+
return this.attachGuideNudge(await this.handleSecretsReport(serverArg));
|
|
10770
|
+
}
|
|
10201
10771
|
let routes = this.toolRoutes;
|
|
10202
10772
|
let route = routes.get(name);
|
|
10203
10773
|
if (route?.deferred) {
|
|
@@ -10678,6 +11248,7 @@ var ConnectServer = class _ConnectServer {
|
|
|
10678
11248
|
lines.push(` ${server.namespace} \u2014 ${server.name} (disabled in dashboard)`);
|
|
10679
11249
|
}
|
|
10680
11250
|
}
|
|
11251
|
+
lines.push(...this.buildInstallCandidatesLines(activeServers));
|
|
10681
11252
|
const activeCount = this.connections.size;
|
|
10682
11253
|
const totalTools = Array.from(this.connections.values()).reduce((sum, c) => {
|
|
10683
11254
|
const f = this.toolFilters.get(c.config.namespace);
|
|
@@ -10696,6 +11267,51 @@ ${activeCount} loaded in this session, ${totalTools} tools in context${tokenSumm
|
|
|
10696
11267
|
}
|
|
10697
11268
|
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
10698
11269
|
}
|
|
11270
|
+
// Build the opt-in "Install candidates" block from the offline shell-
|
|
11271
|
+
// history shadow scan. Returns [] (no lines, byte-identical output) when
|
|
11272
|
+
// the gate is off — the load-bearing privacy property: with the gate
|
|
11273
|
+
// unset the scan never runs and nothing about shell history is read.
|
|
11274
|
+
//
|
|
11275
|
+
// When ON, for each heavily-used CLI the scan found:
|
|
11276
|
+
// - skip unless count >= INSTALL_NUDGE_MIN_COUNT (noise floor),
|
|
11277
|
+
// - skip unless a FIRST-PARTY install target exists (installTargetForCli;
|
|
11278
|
+
// a CLI like kubectl/npm/ssh with no target produces no nudge),
|
|
11279
|
+
// - skip if ANY namespace the CLI maps to is already installed (the user
|
|
11280
|
+
// already has a server that covers it — intersect the hit's namespaces
|
|
11281
|
+
// with the installed set),
|
|
11282
|
+
// - skip if the per-CLI cooldown hasn't elapsed (shouldNudge).
|
|
11283
|
+
// Surviving CLIs are recorded (recordNudge) so they stay suppressed for
|
|
11284
|
+
// the cooldown, and rendered as one line + an mcp_connect_install sketch.
|
|
11285
|
+
//
|
|
11286
|
+
// Privacy: the only data emitted is the aggregate integer count + the
|
|
11287
|
+
// first-party package / namespace / name. No raw history line, command
|
|
11288
|
+
// text, or argument ever reaches this output, and nothing here is sent to
|
|
11289
|
+
// analytics — scanShellHistoryForShadows is local-only and returns just
|
|
11290
|
+
// { cli, count, namespaces }.
|
|
11291
|
+
buildInstallCandidatesLines(activeServers) {
|
|
11292
|
+
if (!this.installNudge) return [];
|
|
11293
|
+
const installedNamespaces = new Set(activeServers.map((s) => s.namespace));
|
|
11294
|
+
const hits = scanShellHistoryForShadows({ home: this.nudgeHome, env: this.nudgeEnv });
|
|
11295
|
+
const candidates = [];
|
|
11296
|
+
for (const hit of hits) {
|
|
11297
|
+
if (hit.count < INSTALL_NUDGE_MIN_COUNT) continue;
|
|
11298
|
+
if (hit.namespaces.some((ns) => installedNamespaces.has(ns))) continue;
|
|
11299
|
+
const target = installTargetForCli(hit.cli);
|
|
11300
|
+
if (!target) continue;
|
|
11301
|
+
if (installedNamespaces.has(target.namespace)) continue;
|
|
11302
|
+
if (!shouldNudge(hit.cli, this.nudgeHome)) continue;
|
|
11303
|
+
candidates.push({ cli: hit.cli, count: hit.count, target });
|
|
11304
|
+
}
|
|
11305
|
+
if (candidates.length === 0) return [];
|
|
11306
|
+
const lines = ["\nInstall candidates (from your recent shell usage; history stays local):"];
|
|
11307
|
+
for (const { cli, count, target } of candidates) {
|
|
11308
|
+
lines.push(` ${cli.padEnd(10)} (ran ${count}x recently) -> install ${target.package}`);
|
|
11309
|
+
const sketch = `mcp_connect_install({ name: ${JSON.stringify(target.name)}, namespace: ${JSON.stringify(target.namespace)}, type: "local", command: "npx", args: ["-y", ${JSON.stringify(target.package)}] })`;
|
|
11310
|
+
lines.push(` ${sketch}`);
|
|
11311
|
+
recordNudge(cli, this.nudgeHome);
|
|
11312
|
+
}
|
|
11313
|
+
return lines;
|
|
11314
|
+
}
|
|
10699
11315
|
// Activate a single server by namespace. Shared by handleActivate,
|
|
10700
11316
|
// handleDispatch, and handleDiscoverWithAutoWarm so error handling,
|
|
10701
11317
|
// retries, caching, and tool-report round-trips live in one place.
|
|
@@ -11293,7 +11909,7 @@ ${activeCount} loaded in this session, ${totalTools} tools in context${tokenSumm
|
|
|
11293
11909
|
}
|
|
11294
11910
|
const ALLOWED_FILENAMES = ["claude_desktop_config.json", "mcp.json", "settings.json", "mcp_config.json"];
|
|
11295
11911
|
try {
|
|
11296
|
-
const resolved = filepath.startsWith("~/") || filepath.startsWith("~\\") ? resolve6(
|
|
11912
|
+
const resolved = filepath.startsWith("~/") || filepath.startsWith("~\\") ? resolve6(homedir16(), filepath.slice(2)) : resolve6(filepath);
|
|
11297
11913
|
const resolvedBasename = resolved.split(/[/\\]/).pop() || "";
|
|
11298
11914
|
if (!ALLOWED_FILENAMES.includes(resolvedBasename)) {
|
|
11299
11915
|
return {
|
|
@@ -11310,7 +11926,7 @@ ${activeCount} loaded in this session, ${totalTools} tools in context${tokenSumm
|
|
|
11310
11926
|
const rel = relative(base, p);
|
|
11311
11927
|
return rel === "" || !rel.startsWith("..") && !isAbsolute2(rel);
|
|
11312
11928
|
};
|
|
11313
|
-
if (!isUnder(
|
|
11929
|
+
if (!isUnder(homedir16(), resolved) && !isUnder(process.cwd(), resolved)) {
|
|
11314
11930
|
return {
|
|
11315
11931
|
content: [
|
|
11316
11932
|
{ type: "text", text: "Import path must be under your home directory or the current working directory." }
|
|
@@ -11318,7 +11934,7 @@ ${activeCount} loaded in this session, ${totalTools} tools in context${tokenSumm
|
|
|
11318
11934
|
isError: true
|
|
11319
11935
|
};
|
|
11320
11936
|
}
|
|
11321
|
-
const raw = await
|
|
11937
|
+
const raw = await readFile12(resolved, "utf-8");
|
|
11322
11938
|
const parsed = JSON.parse(raw);
|
|
11323
11939
|
if (!parsed.mcpServers || typeof parsed.mcpServers !== "object" || Array.isArray(parsed.mcpServers)) {
|
|
11324
11940
|
return {
|
|
@@ -11610,6 +12226,43 @@ Use mcp_connect_discover to see imported servers.`
|
|
|
11610
12226
|
);
|
|
11611
12227
|
}
|
|
11612
12228
|
}
|
|
12229
|
+
// Values-free preview of which local-vault secrets each installed
|
|
12230
|
+
// server's `${secret:NAME}` env refs resolve to. NAMES ONLY -- this
|
|
12231
|
+
// reads the vault's KEY LIST (listKeys, no unlock, no passphrase) and
|
|
12232
|
+
// the servers' env-reference names, and NEVER calls getSecret /
|
|
12233
|
+
// decryptEntry. Servers with no refs are omitted.
|
|
12234
|
+
async handleSecretsReport(serverArg) {
|
|
12235
|
+
const vault = await loadVault(vaultPath()).catch(() => null);
|
|
12236
|
+
const vaultKeys = new Set(vault ? listKeys(vault) : []);
|
|
12237
|
+
let servers = this.getProfiledActiveServers().map((s) => ({ namespace: s.namespace, env: s.env }));
|
|
12238
|
+
if (serverArg) servers = servers.filter((s) => s.namespace === serverArg);
|
|
12239
|
+
const rows = computeSecretsReport(servers, vaultKeys);
|
|
12240
|
+
if (serverArg && servers.length === 0) {
|
|
12241
|
+
return {
|
|
12242
|
+
content: [
|
|
12243
|
+
{
|
|
12244
|
+
type: "text",
|
|
12245
|
+
text: `No installed server with namespace "${serverArg}". Call mcp_connect_discover to list installed servers.`
|
|
12246
|
+
}
|
|
12247
|
+
],
|
|
12248
|
+
isError: true
|
|
12249
|
+
};
|
|
12250
|
+
}
|
|
12251
|
+
if (rows.length === 0) {
|
|
12252
|
+
const scope = serverArg ? `Server "${serverArg}"` : "No installed server";
|
|
12253
|
+
return {
|
|
12254
|
+
content: [
|
|
12255
|
+
{
|
|
12256
|
+
type: "text",
|
|
12257
|
+
text: `${scope} references any \${secret:NAME} vault values. Add a reference in a server's env (e.g. GITHUB_TOKEN=\${secret:gh}) and store the value with \`yaw-mcp secrets set <name>\`.`
|
|
12258
|
+
}
|
|
12259
|
+
]
|
|
12260
|
+
};
|
|
12261
|
+
}
|
|
12262
|
+
return {
|
|
12263
|
+
content: [{ type: "text", text: JSON.stringify(rows, null, 2) }]
|
|
12264
|
+
};
|
|
12265
|
+
}
|
|
11613
12266
|
handleHealth() {
|
|
11614
12267
|
const lines = [];
|
|
11615
12268
|
if (this.profile) {
|
|
@@ -12122,21 +12775,21 @@ function truncateVersion(v) {
|
|
|
12122
12775
|
}
|
|
12123
12776
|
|
|
12124
12777
|
// src/set-active-cmd.ts
|
|
12125
|
-
import { homedir as
|
|
12778
|
+
import { homedir as homedir17 } from "os";
|
|
12126
12779
|
|
|
12127
12780
|
// src/sync-state.ts
|
|
12128
|
-
import { existsSync as
|
|
12129
|
-
import { mkdir as mkdir5, readFile as
|
|
12130
|
-
import { dirname as
|
|
12781
|
+
import { existsSync as existsSync8 } from "fs";
|
|
12782
|
+
import { mkdir as mkdir5, readFile as readFile13 } from "fs/promises";
|
|
12783
|
+
import { dirname as dirname5, join as join13 } from "path";
|
|
12131
12784
|
var SYNC_STATE_FILENAME = "sync-state.json";
|
|
12132
12785
|
function syncStatePath(home) {
|
|
12133
|
-
return
|
|
12786
|
+
return join13(home, CONFIG_DIRNAME, SYNC_STATE_FILENAME);
|
|
12134
12787
|
}
|
|
12135
12788
|
async function readSyncState(home) {
|
|
12136
12789
|
const path5 = syncStatePath(home);
|
|
12137
|
-
if (!
|
|
12790
|
+
if (!existsSync8(path5)) return {};
|
|
12138
12791
|
try {
|
|
12139
|
-
const raw = await
|
|
12792
|
+
const raw = await readFile13(path5, "utf8");
|
|
12140
12793
|
const parsed = JSON.parse(raw);
|
|
12141
12794
|
if (!parsed || typeof parsed !== "object") return {};
|
|
12142
12795
|
return parsed;
|
|
@@ -12146,7 +12799,7 @@ async function readSyncState(home) {
|
|
|
12146
12799
|
}
|
|
12147
12800
|
async function writeSyncState(home, state) {
|
|
12148
12801
|
const path5 = syncStatePath(home);
|
|
12149
|
-
await mkdir5(
|
|
12802
|
+
await mkdir5(dirname5(path5), { recursive: true });
|
|
12150
12803
|
const existing = await readSyncState(home);
|
|
12151
12804
|
const merged = { ...existing, ...state };
|
|
12152
12805
|
await atomicWriteFile(path5, `${JSON.stringify(merged, null, 2)}
|
|
@@ -12240,7 +12893,7 @@ async function runSetActive(opts, io = { out: (s) => process.stdout.write(s), er
|
|
|
12240
12893
|
base
|
|
12241
12894
|
);
|
|
12242
12895
|
if (typeof putRes.version === "number") {
|
|
12243
|
-
await deps.writeSyncState(opts.home ??
|
|
12896
|
+
await deps.writeSyncState(opts.home ?? homedir17(), {
|
|
12244
12897
|
mcp_bundles: { lastPulledVersion: putRes.version }
|
|
12245
12898
|
}).catch(() => {
|
|
12246
12899
|
});
|
|
@@ -12286,7 +12939,7 @@ function fail(io, json, message, code) {
|
|
|
12286
12939
|
}
|
|
12287
12940
|
|
|
12288
12941
|
// src/stats-cmd.ts
|
|
12289
|
-
import { homedir as
|
|
12942
|
+
import { homedir as homedir18 } from "os";
|
|
12290
12943
|
var STATS_USAGE = `Usage: yaw-mcp stats [--json] [--limit N] [--days N]
|
|
12291
12944
|
|
|
12292
12945
|
Print a digest of recent AI tool calls recorded against your Yaw
|
|
@@ -12410,7 +13063,7 @@ async function runStats(opts, io = {
|
|
|
12410
13063
|
out: (s) => process.stdout.write(s),
|
|
12411
13064
|
err: (s) => process.stderr.write(s)
|
|
12412
13065
|
}) {
|
|
12413
|
-
const home = opts.home ??
|
|
13066
|
+
const home = opts.home ?? homedir18();
|
|
12414
13067
|
const session = await getSession({ home, baseUrl: opts.baseUrl });
|
|
12415
13068
|
if (!session) {
|
|
12416
13069
|
const msg = "Not signed in. Yaw MCP analytics requires a Yaw Team account.\n - Yaw Team: $15/seat/mo or $150/seat/yr -- https://yaw.sh/mcp\nSign in with: yaw-mcp login --key <license-key>";
|
|
@@ -12511,10 +13164,10 @@ function suggestFlag(input, limit = 2) {
|
|
|
12511
13164
|
}
|
|
12512
13165
|
|
|
12513
13166
|
// src/sync-cmd.ts
|
|
12514
|
-
import { existsSync as
|
|
12515
|
-
import { mkdir as mkdir6, readFile as
|
|
12516
|
-
import { homedir as
|
|
12517
|
-
import { dirname as
|
|
13167
|
+
import { existsSync as existsSync9 } from "fs";
|
|
13168
|
+
import { mkdir as mkdir6, readFile as readFile14 } from "fs/promises";
|
|
13169
|
+
import { homedir as homedir19 } from "os";
|
|
13170
|
+
import { dirname as dirname6, join as join14 } from "path";
|
|
12518
13171
|
var SYNC_USAGE = `Usage: yaw-mcp sync <push|pull|status> [--json]
|
|
12519
13172
|
|
|
12520
13173
|
Replicate ~/.yaw-mcp/bundles.json across machines via your Yaw
|
|
@@ -12561,12 +13214,12 @@ ${SYNC_USAGE}` };
|
|
|
12561
13214
|
return { ok: true, options: opts };
|
|
12562
13215
|
}
|
|
12563
13216
|
function bundlesPath(home) {
|
|
12564
|
-
return
|
|
13217
|
+
return join14(home, CONFIG_DIRNAME, BUNDLES_FILENAME2);
|
|
12565
13218
|
}
|
|
12566
13219
|
async function readLocalBundles(home) {
|
|
12567
13220
|
const path5 = bundlesPath(home);
|
|
12568
|
-
if (!
|
|
12569
|
-
const raw = await
|
|
13221
|
+
if (!existsSync9(path5)) return { version: 1, servers: [] };
|
|
13222
|
+
const raw = await readFile14(path5, "utf8");
|
|
12570
13223
|
let parsed;
|
|
12571
13224
|
try {
|
|
12572
13225
|
parsed = JSON.parse(raw);
|
|
@@ -12581,7 +13234,7 @@ async function readLocalBundles(home) {
|
|
|
12581
13234
|
}
|
|
12582
13235
|
async function writeLocalBundles(home, file) {
|
|
12583
13236
|
const path5 = bundlesPath(home);
|
|
12584
|
-
await mkdir6(
|
|
13237
|
+
await mkdir6(dirname6(path5), { recursive: true });
|
|
12585
13238
|
await atomicWriteFile(path5, `${JSON.stringify(file, null, 2)}
|
|
12586
13239
|
`);
|
|
12587
13240
|
return path5;
|
|
@@ -12617,7 +13270,7 @@ async function runSync(opts, io = {
|
|
|
12617
13270
|
out: (s) => process.stdout.write(s),
|
|
12618
13271
|
err: (s) => process.stderr.write(s)
|
|
12619
13272
|
}) {
|
|
12620
|
-
const home = opts.home ??
|
|
13273
|
+
const home = opts.home ?? homedir19();
|
|
12621
13274
|
const session = await getSession({ home, baseUrl: opts.baseUrl });
|
|
12622
13275
|
if (!session) {
|
|
12623
13276
|
const msg = "Not signed in. Run `yaw-mcp login --key <license-key>` first.";
|
|
@@ -13192,7 +13845,7 @@ if (subcommand === "compliance") {
|
|
|
13192
13845
|
`);
|
|
13193
13846
|
process.exit(0);
|
|
13194
13847
|
} else if (subcommand === "--version" || subcommand === "-V") {
|
|
13195
|
-
process.stdout.write(`yaw-mcp ${true ? "0.
|
|
13848
|
+
process.stdout.write(`yaw-mcp ${true ? "0.66.0" : "dev"}
|
|
13196
13849
|
`);
|
|
13197
13850
|
process.exit(0);
|
|
13198
13851
|
} else if (subcommand && !subcommand.startsWith("-")) {
|
package/package.json
CHANGED