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
9 changes: 9 additions & 0 deletions .changeset/fix-mcp-server-instance-scoped-wallet.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@sei-js/mcp-server': patch
---

Keep each MCP runtime on the wallet configuration that passed its security check.

A later programmatic `main()` could overwrite the process-wide config object while an HTTP listener started earlier was still serving requests. New sessions on that listener then built their tool list from the updated singleton, so a wallet-disabled HTTP server could expose signing tools after a trusted stdio start in the same process. No shipped CLI or host spawn does that, but the public lifecycle returned independent runtimes without isolating their keys.

`parseArgs()` now returns a frozen `AppConfig` snapshot, and every transport handles requests against that snapshot. Stopping one runtime evicts only its provider cache entry, while other runtimes keep their original signer. HTTP transports require that snapshot and derive signing policy from it.
2 changes: 1 addition & 1 deletion packages/mcp-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ Legacy SSE accepts at most 100 concurrent sessions by default. Set `SSE_MAX_SESS

Streamable HTTP accepts at most 100 active requests by default. Set `STREAMABLE_HTTP_MAX_REQUESTS` to a positive integer to tune the limit; excess requests receive HTTP 503.

HTTP transports create and close an isolated MCP server for each session or request; startup does not allocate an unused bootstrap server. Programmatic callers can use `main()`, which reports and rethrows ordinary startup failures. The packaged executable uses `runCli()` to convert those failures into exit code 1. The wallet-on-HTTP guard is stricter: it terminates immediately with exit code 1 before any listener or signing surface is created.
HTTP transports create and close an isolated MCP server for each session or request; startup does not allocate an unused bootstrap server. Programmatic callers can use `main()`, which reports and rethrows ordinary startup failures. The packaged executable uses `runCli()` to convert those failures into exit code 1. The wallet-on-HTTP guard is stricter: it terminates immediately with exit code 1 before any listener or signing surface is created. Each `main()` call snapshots wallet configuration for that runtime, so a later start cannot expose signing tools on an already-running HTTP listener or rebind a still-running stdio server's signer.

## Configuration

Expand Down
59 changes: 56 additions & 3 deletions packages/mcp-server/src/core/config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { AsyncLocalStorage } from 'node:async_hooks';
import { config as loadDotenv } from 'dotenv';
import type { Hex } from 'viem';
import { z } from 'zod';
Expand Down Expand Up @@ -25,6 +26,9 @@ export interface AppConfig {
walletApiKey: string | undefined;
}

declare const snapshotBrand: unique symbol;
export type AppConfigSnapshot = Readonly<AppConfig> & { readonly [snapshotBrand]: true };

export const loadConfig = (environment: Record<string, unknown> = process.env): AppConfig => {
const env = envSchema.parse(environment);
const privateKey = formatPrivateKey(env.PRIVATE_KEY);
Expand All @@ -42,8 +46,57 @@ export const config: AppConfig = {
walletApiKey: undefined
};

let warnedAboutUnscopedWalletRead = false;

export function initializeConfig(environment: Record<string, unknown> = process.env): AppConfig {
Object.assign(config, loadConfig(environment));
warnedAboutUnscopedWalletRead = false;
return config;
}

const runtimeConfig = new AsyncLocalStorage<AppConfigSnapshot>();

/**
* Copy of the process singleton as it existed at a given start.
* Transports close over this object so a later initializeConfig() cannot
* change an already-running runtime's tool policy or signer.
*/
export function snapshotConfig(source: Readonly<AppConfig> = config): AppConfigSnapshot {
if (Object.isFrozen(source)) return source as AppConfigSnapshot;
return Object.freeze({ ...source }) as AppConfigSnapshot;
}

export function runWithAppConfig<T>(appConfig: AppConfigSnapshot, fn: () => T): T {
if (!Object.isFrozen(appConfig)) throw new TypeError('runWithAppConfig requires a frozen AppConfig snapshot.');
return runtimeConfig.run(appConfig, fn);
}

/**
* Bind a callback to an AppConfig snapshot so later initializeConfig()
* mutations cannot change wallet policy mid-request.
*/
export function wrapWithAppConfig<Args extends unknown[], Result>(appConfig: AppConfigSnapshot, fn: (...args: Args) => Result): (...args: Args) => Result {
return (...args: Args): Result => runWithAppConfig(appConfig, () => fn(...args));
}

export function getScopedAppConfig(): AppConfigSnapshot | undefined {
return runtimeConfig.getStore();
}

/**
* Runtime request paths must establish an AsyncLocalStorage scope. The mutable
* process config remains the fallback for direct configuration helpers.
*/
export function getRuntimeConfig(): Readonly<AppConfig> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] The whole isolation property rests on every wallet-sensitive read happening inside a runWithAppConfig scope; when the scope is missing, getRuntimeConfig() silently returns the mutable config. That is the right fallback for the direct configuration helpers the comment mentions, but nothing distinguishes "legitimately outside a runtime" from "a request path that lost its scope" — and the latter is exactly the regression this PR exists to prevent.

A low-cost guard: have the wallet-sensitive getters (getPrivateKeyAsHex, isWalletEnabled, getWalletMode) emit a one-shot console.error when called with no store while config.walletMode !== 'disabled'. Cheap, stdio-safe (stderr, per the repo convention), and it surfaces a missed scope in tests instead of in production.

const scopedConfig = getScopedAppConfig();
if (scopedConfig) return scopedConfig;
// Direct helpers intentionally retain the process fallback. Warn once per
// initialization in either wallet direction so a lost runtime scope is
// visible without flooding stderr.
if (!warnedAboutUnscopedWalletRead) {
warnedAboutUnscopedWalletRead = true;
console.error('Wallet configuration was read outside an MCP runtime scope; using the mutable process configuration.');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] This diagnostic fires on every out-of-scope read while the wallet is enabled (the new test asserts exactly that: two reads → two console.error calls). The doc comment above declares the mutable config a supported fallback for "direct configuration helpers," so an embedder that calls initializeConfig() and then drives services/contracts.ts directly hits getPrivateKeyAsHex() twice per write path and floods stderr — which under stdio is the same stream the host reads for diagnostics.

A once-per-process (or once-per-config-object) warning conveys the same information without the unbounded volume. If the per-read repetition is deliberate for auditability, a brief comment saying so would help, since it reads as an oversight.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] This fallback is fail-open for the one invariant the package treats as load-bearing: if an ALS scope is ever lost, getPrivateKeyAsHex() / isWalletEnabled() silently serve the current mutable process config instead of the runtime's snapshot.

After this PR every production read is scoped — registration runs inside runWithAppConfig in getServer, and each callback is individually wrapped, which is presumably why index.ts:55 was rewritten to read config.appConfig.walletMode directly rather than call isWalletEnabled(). That makes this branch effectively dead in production, which is exactly when it's cheapest to make it fail closed (throw, or return a walletMode: 'disabled' view) rather than warn.

Separately, warnedAboutUnscopedWalletRead is a module-global latch reset only by initializeConfig(). In a long-running stdio server that calls initializeConfig() once at startup, a scope lost hours later is diagnosed at most once — and if anything else tripped the warning first, never. If you keep the fallback, consider not latching it (or latching per-callsite) so the diagnostic is actually usable.

}
return config;
}

Expand All @@ -53,21 +106,21 @@ export function initializeConfig(environment: Record<string, unknown> = process.
* @returns Private key from environment variable as Hex or undefined
*/
export function getPrivateKeyAsHex(): Hex | undefined {
return config.privateKey as Hex | undefined;
return getRuntimeConfig().privateKey as Hex | undefined;
}

/**
* Check if wallet functionality is enabled based on configuration
* @returns True if wallet functionality should be available
*/
export function isWalletEnabled(): boolean {
return config.walletMode !== 'disabled';
return getRuntimeConfig().walletMode !== 'disabled';
}

/**
* Get the current wallet mode
* @returns The configured wallet mode
*/
export function getWalletMode(): WalletMode {
return config.walletMode;
return getRuntimeConfig().walletMode;
}
52 changes: 30 additions & 22 deletions packages/mcp-server/src/core/wallet/index.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,48 @@
import { getWalletMode } from '../config.js';
import { type AppConfigSnapshot, getRuntimeConfig, getScopedAppConfig, snapshotConfig } from '../config.js';
import { DisabledWalletProvider } from './providers/disabled.js';
import { PrivateKeyWalletProvider } from './providers/private-key.js';
import type { WalletProvider } from './types.js';

// Cache wallet provider instance
let walletProviderInstance: WalletProvider | null = null;
const providersByConfig = new WeakMap<AppConfigSnapshot, WalletProvider>();

function createWalletProvider(appConfig: AppConfigSnapshot): WalletProvider {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] createWalletProvider() now duplicates the wallet-mode switch that still lives in the singleton fallback path of getWalletProvider() (including the Unknown wallet mode: ${mode} throw). Adding a third wallet mode later requires editing both, and only one of them is covered by the new snapshot tests.

The fallback branch can reuse the new helper: if (!walletProviderInstance) walletProviderInstance = createWalletProvider(snapshotConfig()); return walletProviderInstance;. That preserves the memo and the error message. Note it would change which config accessor the existing 'Unknown wallet mode: unknown-mode' unit test has to mock (config rather than getWalletMode), so it's a judgment call whether the churn is worth it.

switch (appConfig.walletMode) {
case 'private-key':
return new PrivateKeyWalletProvider({ privateKey: appConfig.privateKey });
case 'disabled':
return new DisabledWalletProvider();
default:
throw new Error(`Unknown wallet mode: ${appConfig.walletMode}`);
}
}

function providerForSnapshot(appConfig: AppConfigSnapshot): WalletProvider {
const cached = providersByConfig.get(appConfig);
if (cached) return cached;
const provider = createWalletProvider(appConfig);
providersByConfig.set(appConfig, provider);
return provider;
}

/**
* Get the wallet provider instance based on configuration
*/
export function getWalletProvider(): WalletProvider {
if (walletProviderInstance) {
return walletProviderInstance;
}

const mode = getWalletMode();

switch (mode) {
case 'private-key':
walletProviderInstance = new PrivateKeyWalletProvider();
break;
case 'disabled':
walletProviderInstance = new DisabledWalletProvider();
break;
default:
throw new Error(`Unknown wallet mode: ${mode}`);
const runtime = getScopedAppConfig();
if (runtime) {
return providerForSnapshot(runtime);
}

return walletProviderInstance;
// Unscoped callers get a fresh provider so mutable process config cannot
// leave a stale signer memoized across initializeConfig() calls.
return createWalletProvider(snapshotConfig(getRuntimeConfig()));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] The unscoped path allocates a fresh frozen snapshot and a fresh provider on every call, so getWalletProvider() !== getWalletProvider() outside a runtime scope. That's a deliberate change (the removed test asserted the old caching, and the new one asserts non-memoization), and providers are cheap here — PrivateKeyWalletProvider only stores the key, and getWalletClient() already builds a new viem client per call. Nothing in src/ compares providers by identity, so this is fine; flagging only so it's a conscious contract for future callers.

}

/**
* Reset the wallet provider instance (useful for testing)
* Evict one runtime's cached provider.
*/
export function resetWalletProvider(): void {
walletProviderInstance = null;
export function resetWalletProvider(appConfig: AppConfigSnapshot): void {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] Since providersByConfig is a WeakMap keyed by the snapshot, and the snapshot is only reachable from the runtime being stopped, the entry is collectible as soon as stop() returns — and no further request can reach that snapshot anyway. The explicit eviction is defensive rather than load-bearing; the doc comment reading "Evict one runtime's cached provider" slightly oversells it.

If the intent is to drop key material promptly, note this only drops the reference — the evicted PrivateKeyWalletProvider still holds this.privateKey until GC. Zeroing it would need an explicit dispose() on the provider.

providersByConfig.delete(appConfig);
}

export { DisabledWalletProvider } from './providers/disabled.js';
Expand Down
8 changes: 6 additions & 2 deletions packages/mcp-server/src/core/wallet/providers/private-key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,19 @@ import { getPrivateKeyAsHex } from '../../config.js';
import type { TransactionRequest, WalletProvider } from '../types.js';
import { WalletProviderError } from '../types.js';

export interface PrivateKeyWalletProviderOptions {
privateKey: string | undefined;
}

/**
* Private Key Wallet Provider
* Uses a private key from environment variables
*/
export class PrivateKeyWalletProvider implements WalletProvider {
private privateKey: string | undefined;

constructor() {
this.privateKey = getPrivateKeyAsHex();
constructor(options?: PrivateKeyWalletProviderOptions) {
this.privateKey = options ? options.privateKey : getPrivateKeyAsHex();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] createWalletProvider() now always passes options, so the getPrivateKeyAsHex() fallback only fires for direct new PrivateKeyWalletProvider() calls — i.e. the one remaining path that reads the mutable singleton for key material, which is what this PR is removing everywhere else. Consider making options required (constructor(options: PrivateKeyWalletProviderOptions)); the only caller already supplies it, and the new test at private-key.test.ts already asserts the explicit-options path doesn't touch the singleton.

}

isAvailable(): boolean {
Expand Down
14 changes: 9 additions & 5 deletions packages/mcp-server/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { realpathSync } from 'node:fs';
import { pathToFileURL } from 'node:url';
import { isWalletEnabled } from './core/config.js';
import { sanitizeError } from './core/errors.js';
import { resetWalletProvider } from './core/wallet/index.js';
import { parseArgs } from './server/args.js';
import { getServer } from './server/server.js';
import { createTransport } from './server/transport/index.js';
Expand Down Expand Up @@ -38,7 +38,7 @@ export function registerShutdownHandlers(stop: () => Promise<void>): () => void

export async function startMcpServer(): Promise<RunningMcpServer> {
const config = parseArgs();
const server = config.mode === 'stdio' ? await getServer() : undefined;
const server = config.mode === 'stdio' ? await getServer(config.appConfig) : undefined;
let transport: McpTransport | undefined;

try {
Expand All @@ -52,16 +52,20 @@ export async function startMcpServer(): Promise<RunningMcpServer> {
throw error;
}

if (!isWalletEnabled()) console.error('Wallet functionality is disabled. Signing and broadcasting tools are not available.');
if (config.appConfig.walletMode === 'disabled') console.error('Wallet functionality is disabled. Signing and broadcasting tools are not available.');

let stopPromise: Promise<void> | undefined;
let removeShutdownHandlers = () => {};
const stop = (): Promise<void> => {
if (!stopPromise) {
removeShutdownHandlers();
stopPromise = (async () => {
const errors = await collectOperationErrors([() => transport.stop(), () => server?.close()]);
throwCollectedErrors(errors, 'Failed to stop all MCP server resources.');
try {
const errors = await collectOperationErrors([() => transport.stop(), () => server?.close()]);
throwCollectedErrors(errors, 'Failed to stop all MCP server resources.');
} finally {
resetWalletProvider(config.appConfig);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] Worth being precise in the changeset/README wording: this is a WeakMap cache eviction, not a release of key material. The private key stays live in the frozen config.appConfig snapshot that this closure (and the transport) still holds, and an in-flight request still inside the snapshot's scope would just get a freshly constructed PrivateKeyWalletProvider with the same key. Harmless in practice — stop() is terminal and the transport is already down — but "Stopping one runtime releases only its provider" reads as a key-lifetime guarantee that isn't being made.

}
Comment thread
cursor[bot] marked this conversation as resolved.
})();
}
return stopPromise;
Expand Down
6 changes: 3 additions & 3 deletions packages/mcp-server/src/server/args.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Command } from 'commander';
import { config as dotenvConfig } from 'dotenv';
import { initializeConfig } from '../core/config.js';
import { initializeConfig, snapshotConfig } from '../core/config.js';
import { validatePrivateKeyConfiguration } from '../core/private-key.js';
import { getPackageInfo } from './package-info.js';
import type { TransportConfig, TransportMode } from './transport/types.js';
Expand Down Expand Up @@ -147,14 +147,14 @@ Security Note:
const config = loadConfig();

validateConfig(config);
initializeConfig(process.env);
const appConfig = snapshotConfig(initializeConfig(process.env));

return {
mode: config.server.transport,
port: Number.isNaN(config.server.port) ? DEFAULT_CONFIG.server.port : config.server.port,
host: config.server.host,
path: config.server.path,
walletMode: config.wallet.mode,
appConfig,
maxSseSessions:
Number.isInteger(config.server.sseMaxSessions) && config.server.sseMaxSessions > 0 ? config.server.sseMaxSessions : DEFAULT_CONFIG.server.sseMaxSessions,
maxStreamableRequests:
Expand Down
61 changes: 44 additions & 17 deletions packages/mcp-server/src/server/server.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,57 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { getSupportedNetworks } from '../core/chains.js';
import { type AppConfigSnapshot, runWithAppConfig, snapshotConfig, wrapWithAppConfig } from '../core/config.js';
import { sanitizeError } from '../core/errors.js';
import { registerEVMPrompts } from '../core/prompts.js';
import { registerEVMResources } from '../core/resources.js';
import { registerEVMTools } from '../core/tools.js';
import { createDocsSearchTool } from '../docs/index.js';
import { getPackageInfo } from './package-info.js';

export const getServer = async () => {
try {
const packageInfo = getPackageInfo();
const server = new McpServer({
name: packageInfo.name,
version: packageInfo.version
});
const SCOPED_REGISTRATION_METHODS = new Set<PropertyKey>(['tool', 'resource', 'prompt', 'registerTool', 'registerResource', 'registerPrompt']);

registerEVMResources(server);
registerEVMTools(server);
registerEVMPrompts(server);
createDocsSearchTool(server, packageInfo);
function withScopedCallbacks(server: McpServer, appConfig: AppConfigSnapshot): McpServer {
return new Proxy(server, {
get(target, property) {
const value = Reflect.get(target, property, target);
if (typeof value !== 'function') return value;
if (!SCOPED_REGISTRATION_METHODS.has(property)) return value.bind(target);

console.error('Supported networks:', getSupportedNetworks().join(', '));
return (...args: unknown[]) => {
let callbackIndex = args.length - 1;
while (callbackIndex >= 0 && typeof args[callbackIndex] !== 'function') callbackIndex--;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] The backward scan wraps only the last function argument, so callbacks that arrive in a non-final position stay unscoped. Today that's safe — resources.ts constructs every ResourceTemplate with { list: undefined }, and nothing passes a completable() completer — but a ResourceTemplate list/complete callback or a prompt-argument completer added later would silently run against the mutable process config, reopening the exact leak this PR closes, with no test failure to signal it.

Worth either wrapping callbacks found anywhere in args (rather than just the last one), or leaving a comment here stating the assumption that registration callbacks are always terminal.

if (callbackIndex < 0) throw new Error(`MCP ${String(property)} registration requires a callback.`);
args[callbackIndex] = wrapWithAppConfig(appConfig, args[callbackIndex] as (...callbackArgs: unknown[]) => unknown);
return Reflect.apply(value, target, args);
};
}
});
}

return server;
} catch (error) {
console.error('Failed to initialize server:', sanitizeError(error));
throw error;
}
export const getServer = async (appConfig: AppConfigSnapshot) => {
const config = snapshotConfig(appConfig);
return runWithAppConfig(config, async () => {
try {
const packageInfo = getPackageInfo();
const server = new McpServer({
name: packageInfo.name,
version: packageInfo.version
});
const scopedServer = withScopedCallbacks(server, config);

// Registration stays inside this scope for wallet-gated policy, and
// every request callback is permanently bound to the same snapshot.
registerEVMResources(scopedServer);
registerEVMTools(scopedServer);
registerEVMPrompts(scopedServer);
createDocsSearchTool(scopedServer, packageInfo);

console.error('Supported networks:', getSupportedNetworks().join(', '));

return server;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] getServer returns the unproxied server, not scopedServer. That's fine for the current callers (they only call connect/close), but any future registration performed on the returned instance would bypass snapshot binding entirely. Returning scopedServer would close that off — the proxy forwards non-registration methods via value.bind(target), so connect/close keep working.

} catch (error) {
console.error('Failed to initialize server:', sanitizeError(error));
throw error;
}
});
};
10 changes: 5 additions & 5 deletions packages/mcp-server/src/server/transport/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,24 @@ import type { McpTransport, TransportConfig } from './types.js';
export const createTransport = (config: TransportConfig): McpTransport => {
switch (config.mode) {
case 'stdio':
return new StdioTransport();
return new StdioTransport(config.appConfig);

case 'streamable-http':
return new StreamableHttpTransport({
port: config.port,
host: config.host,
path: config.path,
walletMode: config.walletMode,
maxActiveRequests: config.maxStreamableRequests
maxActiveRequests: config.maxStreamableRequests,
appConfig: config.appConfig
});

case 'http-sse':
return new HttpSseTransport({
port: config.port,
host: config.host,
path: config.path,
walletMode: config.walletMode,
maxSessions: config.maxSseSessions
maxSessions: config.maxSseSessions,
appConfig: config.appConfig
});

default:
Expand Down
Loading
Loading