Skip to content
Open
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
17 changes: 9 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,14 +99,15 @@ Copy-ready configs for Claude Code, Claude Desktop, Codex, Cursor, VS Code, and

All env vars are optional — the defaults point to the hosted testnet backend:

| Variable | Default | Description |
| ---------------------------- | ------------------------------------------------------ | -------------------------------------------------- |
| `MINDVAULT_URL` | `https://mindvault-hyr3.onrender.com` | MindVault API base URL |
| `SPONSORED_ACCOUNT_URL` | `https://stellar-sponsored-agent-account.onrender.com` | Sponsored wallet creation service |
| `VAULT_REGISTRY_CONTRACT_ID` | testnet contract ID | On-chain vault-registry contract |
| `HORIZON_URL` | `https://horizon-testnet.stellar.org` | Stellar Horizon endpoint (for USDC balance checks) |
| `SOROBAN_RPC_URL` | `https://soroban-testnet.stellar.org` | Soroban RPC endpoint (for tx status and payments) |
| `MINDVAULT_METRICS` | _(unset)_ | Opt-in tool-level metrics; set to `1` to enable |
| Variable | Default | Description |
| ---------------------------- | ------------------------------------------------------ | --------------------------------------------------------------------- |
| `MINDVAULT_URL` | `https://mindvault-hyr3.onrender.com` | MindVault API base URL |
| `SPONSORED_ACCOUNT_URL` | `https://stellar-sponsored-agent-account.onrender.com` | Sponsored wallet creation service |
| `VAULT_REGISTRY_CONTRACT_ID` | testnet contract ID | On-chain vault-registry contract |
| `HORIZON_URL` | `https://horizon-testnet.stellar.org` | Stellar Horizon endpoint (for USDC balance checks) |
| `SOROBAN_RPC_URL` | `https://soroban-testnet.stellar.org` | Soroban RPC endpoint (for tx status and payments) |
| `MINDVAULT_METRICS` | _(unset)_ | Opt-in tool-level metrics; set to `1` to enable |
| `MCP_LOG_LEVEL` | `info` | Logging priority level threshold: `debug`, `info`, `warn`, or `error` |

Every tool validates its arguments against an explicit schema before doing any work: unknown or malformed arguments are rejected with a deterministic error instead of reaching the API as a failed request. See **[docs/mcp-tool-arguments.md](docs/mcp-tool-arguments.md)** for the per-tool contract and error shape.

Expand Down
4 changes: 4 additions & 0 deletions docs/mcp-client-configs.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,10 @@ and the hosted MindVault backend.
| `MINDVAULT_ALLOW_MAINNET` | unset | Allows mainnet mutations without per-call confirmation. See [security notes](#security-notes) |
| `MINDVAULT_METRICS` | unset | Set `1` to collect opt-in tool metrics ([docs](mcp-metrics.md)) |
| `MINDVAULT_MOCK` | unset | Set `1` for offline mock mode — no network, no funds, deterministic fixtures |
| `MINDVAULT_AUDIT_LOG` | unset | Set `1` to enable audit logging of tool calls and network requests |
| `MCP_LOG_LEVEL` | `info` | Logging priority level threshold: `debug`, `info`, `warn`, or `error` |
| `MINDVAULT_AGENT_SECRET` | unset | Pre-configured secret key for agent wallet initialization |
| `MINDVAULT_PURCHASES_FILE` | unset | File path to store purchase history registry |

Overriding a network value without changing `STELLAR_NETWORK` is a common
mistake, so the server cross-checks them at startup and refuses to launch on a
Expand Down
25 changes: 14 additions & 11 deletions mcp/src/__snapshots__/toolMetadata.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,7 @@ exports[`MCP tool metadata > exposes the expected tool surface 1`] = `
"mindvault_preview",
"mindvault_register",
"mindvault_publish",
"mindvault_publish_status",
"mindvault_buy",
"mindvault_purchase_history",
"mindvault_register_onchain",
"mindvault_agent_status",
"mindvault_registry_info",
Expand All @@ -27,49 +25,54 @@ exports[`MCP tool metadata > exposes the expected tool surface 1`] = `
"mindvault_backup_state",
"mindvault_restore_state",
"mindvault_metrics",
"mindvault_set_tags",
"mindvault_update_metadata",
"mindvault_set_price",
"mindvault_transfer_ownership",
"mindvault_set_listed",
"mindvault_check_state_permissions",
"mindvault_registry_health",
"mindvault_import_wallet",
"mindvault_rotate_publisher_key",
]
`;

exports[`MCP tool metadata > mindvault_publish inputSchema 1`] = `
{
"properties": {
"confirmMainnet": {
"description": "Required on mainnet (or set MINDVAULT_ALLOW_MAINNET=1). Explicitly confirm this mutation/payment on the public Stellar network.",
"type": "boolean",
},
"description": {
"description": "Optional detailed description of the resource content (max 2048 characters).",
"description": "Optional detailed description of the resource content. Example: 'A beginner-friendly guide covering Stellar's Federated Byzantine Agreement protocol.'",
"examples": [
"A beginner-friendly guide covering Stellar's Federated Byzantine Agreement protocol.",
"Step-by-step tutorial on building Soroban smart contracts with Rust.",
],
"type": "string",
},
"externalUrl": {
"description": "Public http(s) URL buyers receive after payment.",
"description": "Public URL buyers receive after payment. Example: 'https://docs.stellar.org/consensus'",
"examples": [
"https://docs.stellar.org/consensus",
"https://example.com/data.json",
"https://example.com/soroban-tutorial",
"https://stellar-anchor-guide.com",
],
"type": "string",
},
"price": {
"description": "Price in USDC as a decimal string. Example: '5.00' charges 5 USDC per access.",
"description": "Price in USDC (decimal string). Example: '5.00' charges 5 USDC per access.",
"examples": [
"5.00",
"10.50",
"0.99",
"25.00",
],
"type": "string",
},
"title": {
"description": "Resource title shown in the catalog (concise, descriptive; 1–256 characters).",
"description": "Resource title shown in the catalog (concise, descriptive). Example: 'Intro to Stellar Consensus'",
"examples": [
"Intro to Stellar Consensus",
"Soroban Smart Contract Tutorial",
"Stellar Anchor Guide",
],
"type": "string",
},
Expand Down
20 changes: 12 additions & 8 deletions mcp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ import {
type PublishStatusFetch,
} from "./publishStatus.js";
import { safeErrorMessage, safeLog } from "./redaction.js";
import { logger } from "./logger.js";
import { signMutatingHeaders } from "./requestSignature.js";
import { exportState, restoreState, checkStatePermissions } from "./stateBackup.js";
import { formatResetPreview, isResetConfirmed, type ResetScope } from "./resetGuard.js";
Expand Down Expand Up @@ -136,7 +137,13 @@ const networkPreset = registryNetworks[STELLAR_NETWORK];
// so unit runs and offline local development never exit the process.
if (!process.env.VITEST && !mockEnabledFromEnv(process.env)) {
const diagnostics = collectStartupDiagnostics(process.env);
if (diagnostics.length > 0) console.error(formatDiagnostics(diagnostics));
if (diagnostics.length > 0) {
if (hasBlockingDiagnostics(diagnostics)) {
logger.error(formatDiagnostics(diagnostics));
} else {
logger.warn(formatDiagnostics(diagnostics));
}
}
if (hasBlockingDiagnostics(diagnostics)) process.exit(1);
}

Expand Down Expand Up @@ -187,7 +194,7 @@ const RETRY_POLICY = retryPolicyFromEnv(process.env);
*/
const logRetry = process.env.VITEST
? undefined
: (info: RetryAttemptInfo) => console.error(`MindVault MCP: ${formatRetryLog(info)}`);
: (info: RetryAttemptInfo) => logger.info(`MindVault MCP: ${formatRetryLog(info)}`);

/** Shared retry options for an idempotent HTTP call returning a Response. */
function httpRetryOptions(label: string) {
Expand Down Expand Up @@ -311,7 +318,7 @@ function saveState(): void {
};
writeFileSync(STATE_FILE, JSON.stringify(state, null, 2), { mode: 0o600 });
} catch (err) {
console.error("MindVault MCP: failed to persist state:", safeErrorMessage(err));
logger.error("MindVault MCP: failed to persist state:", safeErrorMessage(err));
}
}

Expand Down Expand Up @@ -1502,7 +1509,7 @@ export async function buy(
...(title ? { title } : {}),
});
} catch (err) {
console.error("MindVault MCP: failed to persist purchase receipt:", safeErrorMessage(err));
logger.error("MindVault MCP: failed to persist purchase receipt:", safeErrorMessage(err));
}

const summary = {
Expand Down Expand Up @@ -3035,7 +3042,7 @@ if (!process.env.VITEST && !MOCK) {
network: STELLAR_NETWORK,
})
.then((result: { status: string; message: string }) => {
if (result.status === "mismatch") console.error(`MindVault MCP: ${result.message}`);
if (result.status === "mismatch") logger.warn(`MindVault MCP: ${result.message}`);
})
.catch(() => {
/* offline or unreachable — the mindvault_check_bindings tool can report details */
Expand Down Expand Up @@ -3085,7 +3092,4 @@ if (!process.env.VITEST) {

await server.connect(transport);
await await await server.connect(transport);

// Setup graceful shutdown
setupGracefulShutdown(server, transport, console.log);;
}
111 changes: 111 additions & 0 deletions mcp/src/logger.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { logger } from "./logger.js";

describe("MCP Logger", () => {
let stdoutWriteSpy: any;
let stderrWriteSpy: any;
let originalEnvLevel: string | undefined;

beforeEach(() => {
stdoutWriteSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true as any);
stderrWriteSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true as any);
originalEnvLevel = process.env.MCP_LOG_LEVEL;
});

afterEach(() => {
vi.restoreAllMocks();
if (originalEnvLevel === undefined) {
delete process.env.MCP_LOG_LEVEL;
} else {
process.env.MCP_LOG_LEVEL = originalEnvLevel;
}
});

it("defaults to info level if MCP_LOG_LEVEL is unset", () => {
delete process.env.MCP_LOG_LEVEL;

logger.debug("debug msg");
logger.info("info msg");
logger.warn("warn msg");
logger.error("error msg");

expect(stdoutWriteSpy).not.toHaveBeenCalled();
expect(stderrWriteSpy).toHaveBeenCalledTimes(3);

expect(stderrWriteSpy).toHaveBeenNthCalledWith(1, "[INFO] info msg\n");
expect(stderrWriteSpy).toHaveBeenNthCalledWith(2, "[WARN] warn msg\n");
expect(stderrWriteSpy).toHaveBeenNthCalledWith(3, "[ERROR] error msg\n");
});

it("defaults to info level if MCP_LOG_LEVEL is invalid", () => {
process.env.MCP_LOG_LEVEL = "invalid-level";

logger.debug("debug msg");
logger.info("info msg");
logger.warn("warn msg");
logger.error("error msg");

expect(stdoutWriteSpy).not.toHaveBeenCalled();
expect(stderrWriteSpy).toHaveBeenCalledTimes(3);

expect(stderrWriteSpy).toHaveBeenNthCalledWith(1, "[INFO] info msg\n");
expect(stderrWriteSpy).toHaveBeenNthCalledWith(2, "[WARN] warn msg\n");
expect(stderrWriteSpy).toHaveBeenNthCalledWith(3, "[ERROR] error msg\n");
});

it("supports debug level (case-insensitive) and logs all levels", () => {
process.env.MCP_LOG_LEVEL = "DeBuG";

logger.debug("debug msg");
logger.info("info msg");
logger.warn("warn msg");
logger.error("error msg");

expect(stdoutWriteSpy).not.toHaveBeenCalled();
expect(stderrWriteSpy).toHaveBeenCalledTimes(4);

expect(stderrWriteSpy).toHaveBeenNthCalledWith(1, "[DEBUG] debug msg\n");
expect(stderrWriteSpy).toHaveBeenNthCalledWith(2, "[INFO] info msg\n");
expect(stderrWriteSpy).toHaveBeenNthCalledWith(3, "[WARN] warn msg\n");
expect(stderrWriteSpy).toHaveBeenNthCalledWith(4, "[ERROR] error msg\n");
});

it("supports warn level and filters out debug and info logs", () => {
process.env.MCP_LOG_LEVEL = "warn";

logger.debug("debug msg");
logger.info("info msg");
logger.warn("warn msg");
logger.error("error msg");

expect(stdoutWriteSpy).not.toHaveBeenCalled();
expect(stderrWriteSpy).toHaveBeenCalledTimes(2);

expect(stderrWriteSpy).toHaveBeenNthCalledWith(1, "[WARN] warn msg\n");
expect(stderrWriteSpy).toHaveBeenNthCalledWith(2, "[ERROR] error msg\n");
});

it("supports error level and filters out debug, info, and warn logs", () => {
process.env.MCP_LOG_LEVEL = "error";

logger.debug("debug msg");
logger.info("info msg");
logger.warn("warn msg");
logger.error("error msg");

expect(stdoutWriteSpy).not.toHaveBeenCalled();
expect(stderrWriteSpy).toHaveBeenCalledTimes(1);

expect(stderrWriteSpy).toHaveBeenNthCalledWith(1, "[ERROR] error msg\n");
});

it("formats multiple arguments correctly via util.format style", () => {
process.env.MCP_LOG_LEVEL = "info";

logger.info("hello %s: %d", "world", 42, { extra: true });

expect(stdoutWriteSpy).not.toHaveBeenCalled();
expect(stderrWriteSpy).toHaveBeenCalledTimes(1);
expect(stderrWriteSpy).toHaveBeenCalledWith("[INFO] hello world: 42 { extra: true }\n");
});
});
58 changes: 58 additions & 0 deletions mcp/src/logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { format } from "util";

export type LogLevel = "debug" | "info" | "warn" | "error";

const LEVEL_PRIORITIES: Record<LogLevel, number> = {
debug: 0,
info: 1,
warn: 2,
error: 3,
};

/**
* Get the current active log level based on MCP_LOG_LEVEL.
* Defaults to "info" if missing or invalid.
*/
function getActiveLogLevel(): LogLevel {
const envLevel = process.env.MCP_LOG_LEVEL;
if (!envLevel) {
return "info";
}
const normalized = envLevel.toLowerCase();
if (
normalized === "debug" ||
normalized === "info" ||
normalized === "warn" ||
normalized === "error"
) {
return normalized as LogLevel;
}
return "info";
}

/**
* Determine if a log level should be printed.
*/
function shouldLog(level: LogLevel): boolean {
const activeLevel = getActiveLogLevel();
return LEVEL_PRIORITIES[level] >= LEVEL_PRIORITIES[activeLevel];
}

/**
* Format and write a log message to process.stderr.
*/
function writeLog(level: LogLevel, ...args: any[]): void {
if (!shouldLog(level)) {
return;
}
const formatted = format(...args);
const prefix = `[${level.toUpperCase()}]`;
process.stderr.write(`${prefix} ${formatted}\n`);
}

export const logger = {
debug: (...args: any[]) => writeLog("debug", ...args),
info: (...args: any[]) => writeLog("info", ...args),
warn: (...args: any[]) => writeLog("warn", ...args),
error: (...args: any[]) => writeLog("error", ...args),
};