Skip to content

Commit 1d9a03e

Browse files
szhygulinclaude
andcommitted
feat(diagnostics): get_vaultpilot_config_status — read-only config snapshot
Item 2.1 (server-side half) from claude-work/HIGH-plan-broad-audience- onboarding.md. Ships the diagnostic tool the future agent-guided /setup skill (separate repo) will call to know what the user's already configured. Independently useful for triage today: "is my config the way I think it is?" ## What ships New tool `get_vaultpilot_config_status` registered in `src/index.ts` and backed by `src/modules/diagnostics/index.ts`. Read-only, pure local I/O — reads `~/.vaultpilot-mcp/config.json` and inspects `process.env`. No RPC calls, no network. ## Output shape (every field is non-secret) - `configPath` + `configFileExists` + `serverVersion`. - `rpc.<chain>.source` per EVM chain — one of `env-var` / `provider-key-env` / `provider-key-config` / `custom-url-config` / `public-fallback`. Mirrors the priority order in `src/config/chains.ts:resolveRpcUrlRaw` so the diagnostic answer matches what the resolver actually produces. - `rpc.solana.source` — `env-var` / `config-url` / `public-fallback`. - `apiKeys.{etherscan,oneInch,tronGrid,walletConnectProjectId}` — `{ set: boolean, source: "env-var" | "config" | "unset" }`. - `pairings.solana.count`, `pairings.tron.count` — integer counts (never the addresses). - `pairings.walletConnect.sessionTopicSuffix` — last 8 chars only, matching the existing `get_ledger_status` convention. Full topic is never returned. - `preflightSkill.{expectedPath, installed}` — boolean install state + the marker path we checked (respects `VAULTPILOT_SKILL_MARKER_PATH` override). ## Strict no-secrets contract The output deliberately surfaces only booleans, counts, source- classification enums, and a session-topic suffix. Test `never echoes any planted secret value anywhere in the output` plants seven distinct secrets across env vars + config and asserts none of them appear in the serialized response. ## Tests `test/diagnostics-config-status.test.ts` — 18 cases covering: - Five EVM RPC source-classification branches per chain. - Three Solana RPC source-classification branches. - API-key env-vs-config priority. - WC session-topic suffix extraction (full topic must NOT leak). - Pairings count from persisted config. - Preflight-skill detection + `VAULTPILOT_SKILL_MARKER_PATH` override. - The strict no-secrets sweep. ## Verification - `npm test` — 848/848 pass (+18 new diagnostics tests). - `npm run build` — clean TS. - README "Tools" section gains the new tool with a short summary + the no-secrets-contract note. ## Deferred Agent-guided `/setup` slash command (the SKILL side of item 2.1) is a separate external repo (`vaultpilot-setup-skill`) — out of scope for this PR. This tool is the contract the skill will call against. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 997cdb3 commit 1d9a03e

5 files changed

Lines changed: 536 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ Ledger Live's WalletConnect bridge does not honor the `tron:` namespace (verifie
8181
- `get_transaction_history` — merged recent-tx reader across external / ERC-20 / internal (and Solana `program_interaction`) with 4byte-decoded methods and historical USD values (Etherscan for EVM, TronGrid for TRON, Solana RPC for Solana)
8282
- `get_tron_staking`, `list_tron_witnesses` — TRON staking state + SR list
8383
- `get_solana_setup_status` — cheap probe of a wallet's Solana setup PDAs (nonce + MarginFi account existence)
84+
- `get_vaultpilot_config_status` — diagnostic snapshot of the local server config (RPC source per chain, API-key presence per service, paired-account counts, WC session-topic suffix, preflight-skill state). Strict no-secrets contract — booleans / counts / source enums / topic suffix only, never values. Use to triage "why isn't my balance read working" before suggesting `vaultpilot-mcp-setup`.
8485
- `resolve_ens_name`, `reverse_resolve_ens` — ENS forward/reverse
8586
- `get_swap_quote` (LiFi, EVM), `get_solana_swap_quote` (Jupiter v6)
8687
- `check_contract_security`, `check_permission_risks`, `get_protocol_risk_score` — risk tooling

src/index.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ import {
4444
import { getPortfolioSummary } from "./modules/portfolio/index.js";
4545
import { getPortfolioSummaryInput } from "./modules/portfolio/schemas.js";
4646

47+
import { getVaultPilotConfigStatus } from "./modules/diagnostics/index.js";
48+
4749
import { getTransactionHistory } from "./modules/history/index.js";
4850
import { getTransactionHistoryInput } from "./modules/history/schemas.js";
4951

@@ -113,6 +115,7 @@ import {
113115
getSolanaStakingPositionsInput,
114116
getMarginfiDiagnosticsInput,
115117
getSolanaSetupStatusInput,
118+
getVaultPilotConfigStatusInput,
116119
getLedgerStatusInput,
117120
prepareAaveSupplyInput,
118121
prepareAaveWithdrawInput,
@@ -1451,6 +1454,25 @@ async function main() {
14511454
handler(getSolanaSetupStatus)
14521455
);
14531456

1457+
server.registerTool(
1458+
"get_vaultpilot_config_status",
1459+
{
1460+
description:
1461+
"READ-ONLY — report what the server knows about its local config without revealing any " +
1462+
"secret values. Returns the config-file path + existence, server version, per-chain RPC " +
1463+
"URL source classification (env-var / provider-key / custom-url / public-fallback), " +
1464+
"API-key presence + source per service (Etherscan, 1inch, TronGrid, WalletConnect — " +
1465+
"boolean + source enum, never values), counts of paired Ledger accounts (Solana / TRON), " +
1466+
"the WC session-topic SUFFIX (last 8 chars only — same convention as get_ledger_status), " +
1467+
"and the agent-side preflight-skill install state. Pure local I/O — reads " +
1468+
"~/.vaultpilot-mcp/config.json + process.env, no RPC calls, no network. Use this when " +
1469+
"the user asks 'is my config set up correctly' or 'why is my Solana balance read failing' " +
1470+
"before suggesting they re-run setup or paste keys.",
1471+
inputSchema: getVaultPilotConfigStatusInput.shape,
1472+
},
1473+
handler(getVaultPilotConfigStatus)
1474+
);
1475+
14541476
server.registerTool(
14551477
"get_marginfi_positions",
14561478
{

src/modules/diagnostics/index.ts

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
/**
2+
* Read-only diagnostic tool: report what the server knows about its config
3+
* without revealing any secret values. Intended for the future agent-guided
4+
* `/setup` skill (separate repo) but immediately useful for a user
5+
* diagnosing "is my server configured the way I think it is?".
6+
*
7+
* Strict no-secrets contract:
8+
* - Never echoes raw API keys, RPC URLs (which may carry keys in the path),
9+
* WC session symkeys, or paired-account private material.
10+
* - WC session topic surfaces only as the last 8 chars (matches the
11+
* existing `get_ledger_status` convention — enough to cross-check
12+
* against Ledger Live's connected-apps list).
13+
* - Per-key fields are reduced to `{ set: boolean; source: "env-var" |
14+
* "config" | "unset" }`.
15+
*
16+
* Pure local I/O: reads `~/.vaultpilot-mcp/config.json` and inspects
17+
* `process.env`. No RPC calls, no network. Cheap to invoke on every
18+
* `/setup` step.
19+
*/
20+
import { existsSync, readFileSync } from "node:fs";
21+
import { homedir } from "node:os";
22+
import { join } from "node:path";
23+
import { fileURLToPath } from "node:url";
24+
import { readUserConfig, getConfigPath } from "../../config/user-config.js";
25+
import { SUPPORTED_CHAINS, type SupportedChain } from "../../types/index.js";
26+
27+
type EvmRpcSource =
28+
| "env-var"
29+
| "provider-key-env"
30+
| "provider-key-config"
31+
| "custom-url-config"
32+
| "public-fallback";
33+
34+
type SolanaRpcSource = "env-var" | "config-url" | "public-fallback";
35+
36+
type ApiKeySource = "env-var" | "config" | "unset";
37+
38+
const ENV_URL_VAR: Record<SupportedChain, string> = {
39+
ethereum: "ETHEREUM_RPC_URL",
40+
arbitrum: "ARBITRUM_RPC_URL",
41+
polygon: "POLYGON_RPC_URL",
42+
base: "BASE_RPC_URL",
43+
optimism: "OPTIMISM_RPC_URL",
44+
};
45+
46+
/**
47+
* Determine the source of the EVM RPC URL for a given chain. Mirrors the
48+
* priority order in `src/config/chains.ts:resolveRpcUrlRaw`. Replicated
49+
* deliberately rather than refactored-and-shared so a refactor of the
50+
* resolver doesn't accidentally change diagnostic output.
51+
*/
52+
function classifyEvmRpcSource(chain: SupportedChain): EvmRpcSource {
53+
if (process.env[ENV_URL_VAR[chain]]) return "env-var";
54+
const envProvider = process.env.RPC_PROVIDER?.toLowerCase();
55+
if (
56+
(envProvider === "infura" || envProvider === "alchemy") &&
57+
process.env.RPC_API_KEY
58+
) {
59+
return "provider-key-env";
60+
}
61+
const cfg = readUserConfig();
62+
if (cfg) {
63+
if (cfg.rpc.provider === "custom" && cfg.rpc.customUrls?.[chain]) {
64+
return "custom-url-config";
65+
}
66+
if (
67+
(cfg.rpc.provider === "infura" || cfg.rpc.provider === "alchemy") &&
68+
cfg.rpc.apiKey
69+
) {
70+
return "provider-key-config";
71+
}
72+
}
73+
return "public-fallback";
74+
}
75+
76+
function classifySolanaRpcSource(): SolanaRpcSource {
77+
if (process.env.SOLANA_RPC_URL) return "env-var";
78+
if (readUserConfig()?.solanaRpcUrl) return "config-url";
79+
return "public-fallback";
80+
}
81+
82+
function classifyApiKey(envName: string, configValue: unknown): { set: boolean; source: ApiKeySource } {
83+
if (process.env[envName]) return { set: true, source: "env-var" };
84+
if (typeof configValue === "string" && configValue.length > 0) {
85+
return { set: true, source: "config" };
86+
}
87+
return { set: false, source: "unset" };
88+
}
89+
90+
interface VaultPilotConfigStatus {
91+
/** Where this server expects to read / write its config file. */
92+
configPath: string;
93+
/** Whether the config file exists on disk right now. */
94+
configFileExists: boolean;
95+
/** vaultpilot-mcp version (read from package.json at process start). */
96+
serverVersion: string;
97+
/** Per-chain RPC URL source classification (no URLs leaked). */
98+
rpc: Record<SupportedChain | "solana", { source: EvmRpcSource | SolanaRpcSource }>;
99+
/** Per-service API key presence + source. Boolean-only — values never leak. */
100+
apiKeys: {
101+
etherscan: { set: boolean; source: ApiKeySource };
102+
oneInch: { set: boolean; source: ApiKeySource };
103+
tronGrid: { set: boolean; source: ApiKeySource };
104+
walletConnectProjectId: { set: boolean; source: ApiKeySource };
105+
};
106+
/** Counts of paired Ledger accounts + WC session-topic suffix (last 8 chars). */
107+
pairings: {
108+
walletConnect: { sessionTopicSuffix?: string };
109+
solana: { count: number };
110+
tron: { count: number };
111+
};
112+
/**
113+
* Agent-side preflight skill state — checked by path, no content read.
114+
* Override path via VAULTPILOT_SKILL_MARKER_PATH env var (read-only sniff —
115+
* we don't validate the skill content here).
116+
*/
117+
preflightSkill: {
118+
expectedPath: string;
119+
installed: boolean;
120+
};
121+
}
122+
123+
/**
124+
* Resolve the server version by reading `package.json` relative to this
125+
* file's compiled location. Falls back to `"unknown"` if the file isn't
126+
* found (e.g. unusual install layouts) — diagnostic output, not load-bearing.
127+
*/
128+
function readServerVersion(): string {
129+
try {
130+
const here = fileURLToPath(import.meta.url);
131+
// Compiled location: dist/modules/diagnostics/index.js → ../../../package.json
132+
const pkgPath = join(here, "..", "..", "..", "..", "package.json");
133+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { version?: string };
134+
return pkg.version ?? "unknown";
135+
} catch {
136+
return "unknown";
137+
}
138+
}
139+
140+
const DEFAULT_SKILL_MARKER = join(
141+
homedir(),
142+
".claude",
143+
"skills",
144+
"vaultpilot-preflight",
145+
"SKILL.md",
146+
);
147+
148+
function skillMarkerPath(): string {
149+
return process.env.VAULTPILOT_SKILL_MARKER_PATH ?? DEFAULT_SKILL_MARKER;
150+
}
151+
152+
export function getVaultPilotConfigStatus(_args: Record<string, never> = {}): VaultPilotConfigStatus {
153+
const cfg = readUserConfig();
154+
const configPath = getConfigPath();
155+
156+
const rpc = {} as VaultPilotConfigStatus["rpc"];
157+
for (const chain of SUPPORTED_CHAINS) {
158+
rpc[chain] = { source: classifyEvmRpcSource(chain) };
159+
}
160+
rpc.solana = { source: classifySolanaRpcSource() };
161+
162+
// WC session-topic last-8-chars suffix only (mirrors `get_ledger_status`).
163+
const sessionTopic = cfg?.walletConnect?.sessionTopic;
164+
const sessionTopicSuffix =
165+
typeof sessionTopic === "string" && sessionTopic.length >= 8
166+
? sessionTopic.slice(-8)
167+
: undefined;
168+
169+
const skillPath = skillMarkerPath();
170+
return {
171+
configPath,
172+
configFileExists: existsSync(configPath),
173+
serverVersion: readServerVersion(),
174+
rpc,
175+
apiKeys: {
176+
etherscan: classifyApiKey("ETHERSCAN_API_KEY", cfg?.etherscanApiKey),
177+
oneInch: classifyApiKey("ONEINCH_API_KEY", cfg?.oneInchApiKey),
178+
tronGrid: classifyApiKey("TRON_API_KEY", cfg?.tronApiKey),
179+
walletConnectProjectId: classifyApiKey(
180+
"WALLETCONNECT_PROJECT_ID",
181+
cfg?.walletConnect?.projectId,
182+
),
183+
},
184+
pairings: {
185+
walletConnect: sessionTopicSuffix ? { sessionTopicSuffix } : {},
186+
solana: { count: cfg?.pairings?.solana?.length ?? 0 },
187+
tron: { count: cfg?.pairings?.tron?.length ?? 0 },
188+
},
189+
preflightSkill: {
190+
expectedPath: skillPath,
191+
installed: existsSync(skillPath),
192+
},
193+
};
194+
}

src/modules/execution/schemas.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,16 @@ export const getSolanaSetupStatusInput = z.object({
310310
),
311311
});
312312

313+
/**
314+
* No args — `get_vaultpilot_config_status` returns a structured snapshot of
315+
* the local server config, intended for diagnostic / onboarding flows.
316+
* The output deliberately never echoes any secret values (API keys, RPC
317+
* URLs that may carry keys, full WC session topics) — every field is
318+
* either a boolean, a count, a category enum, or a session-topic suffix
319+
* (last 8 chars).
320+
*/
321+
export const getVaultPilotConfigStatusInput = z.object({});
322+
313323
export const getMarginfiPositionsInput = z.object({
314324
wallet: solanaAddressSchema.describe(
315325
"Solana wallet to enumerate MarginFi positions for. Probes the first 4 MarginfiAccount " +
@@ -613,3 +623,4 @@ export type PrepareMarinadeUnstakeImmediateArgs = z.infer<
613623
export type GetMarginfiPositionsArgs = z.infer<typeof getMarginfiPositionsInput>;
614624
export type GetSolanaStakingPositionsArgs = z.infer<typeof getSolanaStakingPositionsInput>;
615625
export type GetSolanaSetupStatusArgs = z.infer<typeof getSolanaSetupStatusInput>;
626+
export type GetVaultPilotConfigStatusArgs = z.infer<typeof getVaultPilotConfigStatusInput>;

0 commit comments

Comments
 (0)