Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ Ledger Live's WalletConnect bridge does not honor the `tron:` namespace (verifie
- `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)
- `get_tron_staking`, `list_tron_witnesses` — TRON staking state + SR list
- `get_solana_setup_status` — cheap probe of a wallet's Solana setup PDAs (nonce + MarginFi account existence)
- `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`.
- `resolve_ens_name`, `reverse_resolve_ens` — ENS forward/reverse
- `get_swap_quote` (LiFi, EVM), `get_solana_swap_quote` (Jupiter v6)
- `check_contract_security`, `check_permission_risks`, `get_protocol_risk_score` — risk tooling
Expand Down
22 changes: 22 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ import {
import { getPortfolioSummary } from "./modules/portfolio/index.js";
import { getPortfolioSummaryInput } from "./modules/portfolio/schemas.js";

import { getVaultPilotConfigStatus } from "./modules/diagnostics/index.js";

import { getTransactionHistory } from "./modules/history/index.js";
import { getTransactionHistoryInput } from "./modules/history/schemas.js";

Expand Down Expand Up @@ -113,6 +115,7 @@ import {
getSolanaStakingPositionsInput,
getMarginfiDiagnosticsInput,
getSolanaSetupStatusInput,
getVaultPilotConfigStatusInput,
getLedgerStatusInput,
prepareAaveSupplyInput,
prepareAaveWithdrawInput,
Expand Down Expand Up @@ -1451,6 +1454,25 @@ async function main() {
handler(getSolanaSetupStatus)
);

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

server.registerTool(
"get_marginfi_positions",
{
Expand Down
194 changes: 194 additions & 0 deletions src/modules/diagnostics/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
/**
* Read-only diagnostic tool: report what the server knows about its config
* without revealing any secret values. Intended for the future agent-guided
* `/setup` skill (separate repo) but immediately useful for a user
* diagnosing "is my server configured the way I think it is?".
*
* Strict no-secrets contract:
* - Never echoes raw API keys, RPC URLs (which may carry keys in the path),
* WC session symkeys, or paired-account private material.
* - WC session topic surfaces only as the last 8 chars (matches the
* existing `get_ledger_status` convention — enough to cross-check
* against Ledger Live's connected-apps list).
* - Per-key fields are reduced to `{ set: boolean; source: "env-var" |
* "config" | "unset" }`.
*
* Pure local I/O: reads `~/.vaultpilot-mcp/config.json` and inspects
* `process.env`. No RPC calls, no network. Cheap to invoke on every
* `/setup` step.
*/
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { readUserConfig, getConfigPath } from "../../config/user-config.js";
import { SUPPORTED_CHAINS, type SupportedChain } from "../../types/index.js";

type EvmRpcSource =
| "env-var"
| "provider-key-env"
| "provider-key-config"
| "custom-url-config"
| "public-fallback";

type SolanaRpcSource = "env-var" | "config-url" | "public-fallback";

type ApiKeySource = "env-var" | "config" | "unset";

const ENV_URL_VAR: Record<SupportedChain, string> = {
ethereum: "ETHEREUM_RPC_URL",
arbitrum: "ARBITRUM_RPC_URL",
polygon: "POLYGON_RPC_URL",
base: "BASE_RPC_URL",
optimism: "OPTIMISM_RPC_URL",
};

/**
* Determine the source of the EVM RPC URL for a given chain. Mirrors the
* priority order in `src/config/chains.ts:resolveRpcUrlRaw`. Replicated
* deliberately rather than refactored-and-shared so a refactor of the
* resolver doesn't accidentally change diagnostic output.
*/
function classifyEvmRpcSource(chain: SupportedChain): EvmRpcSource {
if (process.env[ENV_URL_VAR[chain]]) return "env-var";
const envProvider = process.env.RPC_PROVIDER?.toLowerCase();
if (
(envProvider === "infura" || envProvider === "alchemy") &&
process.env.RPC_API_KEY
) {
return "provider-key-env";
}
const cfg = readUserConfig();
if (cfg) {
if (cfg.rpc.provider === "custom" && cfg.rpc.customUrls?.[chain]) {
return "custom-url-config";
}
if (
(cfg.rpc.provider === "infura" || cfg.rpc.provider === "alchemy") &&
cfg.rpc.apiKey
) {
return "provider-key-config";
}
}
return "public-fallback";
}

function classifySolanaRpcSource(): SolanaRpcSource {
if (process.env.SOLANA_RPC_URL) return "env-var";
if (readUserConfig()?.solanaRpcUrl) return "config-url";
return "public-fallback";
}

function classifyApiKey(envName: string, configValue: unknown): { set: boolean; source: ApiKeySource } {
if (process.env[envName]) return { set: true, source: "env-var" };
if (typeof configValue === "string" && configValue.length > 0) {
return { set: true, source: "config" };
}
return { set: false, source: "unset" };
}

interface VaultPilotConfigStatus {
/** Where this server expects to read / write its config file. */
configPath: string;
/** Whether the config file exists on disk right now. */
configFileExists: boolean;
/** vaultpilot-mcp version (read from package.json at process start). */
serverVersion: string;
/** Per-chain RPC URL source classification (no URLs leaked). */
rpc: Record<SupportedChain | "solana", { source: EvmRpcSource | SolanaRpcSource }>;
/** Per-service API key presence + source. Boolean-only — values never leak. */
apiKeys: {
etherscan: { set: boolean; source: ApiKeySource };
oneInch: { set: boolean; source: ApiKeySource };
tronGrid: { set: boolean; source: ApiKeySource };
walletConnectProjectId: { set: boolean; source: ApiKeySource };
};
/** Counts of paired Ledger accounts + WC session-topic suffix (last 8 chars). */
pairings: {
walletConnect: { sessionTopicSuffix?: string };
solana: { count: number };
tron: { count: number };
};
/**
* Agent-side preflight skill state — checked by path, no content read.
* Override path via VAULTPILOT_SKILL_MARKER_PATH env var (read-only sniff —
* we don't validate the skill content here).
*/
preflightSkill: {
expectedPath: string;
installed: boolean;
};
}

/**
* Resolve the server version by reading `package.json` relative to this
* file's compiled location. Falls back to `"unknown"` if the file isn't
* found (e.g. unusual install layouts) — diagnostic output, not load-bearing.
*/
function readServerVersion(): string {
try {
const here = fileURLToPath(import.meta.url);
// Compiled location: dist/modules/diagnostics/index.js → ../../../package.json
const pkgPath = join(here, "..", "..", "..", "..", "package.json");
const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { version?: string };
return pkg.version ?? "unknown";
} catch {
return "unknown";
}
}

const DEFAULT_SKILL_MARKER = join(
homedir(),
".claude",
"skills",
"vaultpilot-preflight",
"SKILL.md",
);

function skillMarkerPath(): string {
return process.env.VAULTPILOT_SKILL_MARKER_PATH ?? DEFAULT_SKILL_MARKER;
}

export function getVaultPilotConfigStatus(_args: Record<string, never> = {}): VaultPilotConfigStatus {
const cfg = readUserConfig();
const configPath = getConfigPath();

const rpc = {} as VaultPilotConfigStatus["rpc"];
for (const chain of SUPPORTED_CHAINS) {
rpc[chain] = { source: classifyEvmRpcSource(chain) };
}
rpc.solana = { source: classifySolanaRpcSource() };

// WC session-topic last-8-chars suffix only (mirrors `get_ledger_status`).
const sessionTopic = cfg?.walletConnect?.sessionTopic;
const sessionTopicSuffix =
typeof sessionTopic === "string" && sessionTopic.length >= 8
? sessionTopic.slice(-8)
: undefined;

const skillPath = skillMarkerPath();
return {
configPath,
configFileExists: existsSync(configPath),
serverVersion: readServerVersion(),
rpc,
apiKeys: {
etherscan: classifyApiKey("ETHERSCAN_API_KEY", cfg?.etherscanApiKey),
oneInch: classifyApiKey("ONEINCH_API_KEY", cfg?.oneInchApiKey),
tronGrid: classifyApiKey("TRON_API_KEY", cfg?.tronApiKey),
walletConnectProjectId: classifyApiKey(
"WALLETCONNECT_PROJECT_ID",
cfg?.walletConnect?.projectId,
),
},
pairings: {
walletConnect: sessionTopicSuffix ? { sessionTopicSuffix } : {},
solana: { count: cfg?.pairings?.solana?.length ?? 0 },
tron: { count: cfg?.pairings?.tron?.length ?? 0 },
},
preflightSkill: {
expectedPath: skillPath,
installed: existsSync(skillPath),
},
};
}
11 changes: 11 additions & 0 deletions src/modules/execution/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,16 @@ export const getSolanaSetupStatusInput = z.object({
),
});

/**
* No args — `get_vaultpilot_config_status` returns a structured snapshot of
* the local server config, intended for diagnostic / onboarding flows.
* The output deliberately never echoes any secret values (API keys, RPC
* URLs that may carry keys, full WC session topics) — every field is
* either a boolean, a count, a category enum, or a session-topic suffix
* (last 8 chars).
*/
export const getVaultPilotConfigStatusInput = z.object({});

export const getMarginfiPositionsInput = z.object({
wallet: solanaAddressSchema.describe(
"Solana wallet to enumerate MarginFi positions for. Probes the first 4 MarginfiAccount " +
Expand Down Expand Up @@ -613,3 +623,4 @@ export type PrepareMarinadeUnstakeImmediateArgs = z.infer<
export type GetMarginfiPositionsArgs = z.infer<typeof getMarginfiPositionsInput>;
export type GetSolanaStakingPositionsArgs = z.infer<typeof getSolanaStakingPositionsInput>;
export type GetSolanaSetupStatusArgs = z.infer<typeof getSolanaSetupStatusInput>;
export type GetVaultPilotConfigStatusArgs = z.infer<typeof getVaultPilotConfigStatusInput>;
Loading
Loading