Repository navigation
fix(mcp-server): isolate wallet config per runtime #352
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
2c69b2b
f14e8c6
66a554e
e9082c1
c06418b
86b098a
e1ce4f6
40523dd
f7364c9
1cb693b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. |
| 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'; | ||
|
|
@@ -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); | ||
|
|
@@ -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> { | ||
| 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.'); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, After this PR every production read is scoped — registration runs inside Separately, |
||
| } | ||
| return config; | ||
| } | ||
|
|
||
|
|
@@ -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; | ||
| } | ||
| 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 { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [nit] The fallback branch can reuse the new helper: |
||
| 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())); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| } | ||
|
|
||
| /** | ||
| * 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 { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [nit] Since If the intent is to drop key material promptly, note this only drops the reference — the evicted |
||
| providersByConfig.delete(appConfig); | ||
| } | ||
|
|
||
| export { DisabledWalletProvider } from './providers/disabled.js'; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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(); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [suggestion] |
||
| } | ||
|
|
||
| isAvailable(): boolean { | ||
|
|
||
| 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'; | ||
|
|
@@ -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 { | ||
|
|
@@ -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); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [nit] Worth being precise in the changeset/README wording: this is a |
||
| } | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| })(); | ||
| } | ||
| return stopPromise; | ||
|
|
||
| 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--; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 — Worth either wrapping callbacks found anywhere in |
||
| 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; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [nit] |
||
| } catch (error) { | ||
| console.error('Failed to initialize server:', sanitizeError(error)); | ||
| throw error; | ||
| } | ||
| }); | ||
| }; | ||
There was a problem hiding this comment.
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
runWithAppConfigscope; when the scope is missing,getRuntimeConfig()silently returns the mutableconfig. 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-shotconsole.errorwhen called with no store whileconfig.walletMode !== 'disabled'. Cheap, stdio-safe (stderr, per the repo convention), and it surfaces a missed scope in tests instead of in production.