diff --git a/.changeset/fix-mcp-server-instance-scoped-wallet.md b/.changeset/fix-mcp-server-instance-scoped-wallet.md new file mode 100644 index 00000000..43305e90 --- /dev/null +++ b/.changeset/fix-mcp-server-instance-scoped-wallet.md @@ -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. diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 88270b28..60690d1e 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -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 diff --git a/packages/mcp-server/src/core/config.ts b/packages/mcp-server/src/core/config.ts index 5a719aaf..5796f0df 100644 --- a/packages/mcp-server/src/core/config.ts +++ b/packages/mcp-server/src/core/config.ts @@ -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 & { readonly [snapshotBrand]: true }; + export const loadConfig = (environment: Record = 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 = process.env): AppConfig { Object.assign(config, loadConfig(environment)); + warnedAboutUnscopedWalletRead = false; + return config; +} + +const runtimeConfig = new AsyncLocalStorage(); + +/** + * 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 = config): AppConfigSnapshot { + if (Object.isFrozen(source)) return source as AppConfigSnapshot; + return Object.freeze({ ...source }) as AppConfigSnapshot; +} + +export function runWithAppConfig(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(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 { + 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.'); + } return config; } @@ -53,7 +106,7 @@ export function initializeConfig(environment: Record = 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; } /** @@ -61,7 +114,7 @@ export function getPrivateKeyAsHex(): Hex | undefined { * @returns True if wallet functionality should be available */ export function isWalletEnabled(): boolean { - return config.walletMode !== 'disabled'; + return getRuntimeConfig().walletMode !== 'disabled'; } /** @@ -69,5 +122,5 @@ export function isWalletEnabled(): boolean { * @returns The configured wallet mode */ export function getWalletMode(): WalletMode { - return config.walletMode; + return getRuntimeConfig().walletMode; } diff --git a/packages/mcp-server/src/core/wallet/index.ts b/packages/mcp-server/src/core/wallet/index.ts index 6743e29d..d59b7282 100644 --- a/packages/mcp-server/src/core/wallet/index.ts +++ b/packages/mcp-server/src/core/wallet/index.ts @@ -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(); + +function createWalletProvider(appConfig: AppConfigSnapshot): WalletProvider { + 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())); } /** - * 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 { + providersByConfig.delete(appConfig); } export { DisabledWalletProvider } from './providers/disabled.js'; diff --git a/packages/mcp-server/src/core/wallet/providers/private-key.ts b/packages/mcp-server/src/core/wallet/providers/private-key.ts index d1df9ef1..7ba9b43c 100644 --- a/packages/mcp-server/src/core/wallet/providers/private-key.ts +++ b/packages/mcp-server/src/core/wallet/providers/private-key.ts @@ -6,6 +6,10 @@ 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 @@ -13,8 +17,8 @@ import { WalletProviderError } from '../types.js'; export class PrivateKeyWalletProvider implements WalletProvider { private privateKey: string | undefined; - constructor() { - this.privateKey = getPrivateKeyAsHex(); + constructor(options?: PrivateKeyWalletProviderOptions) { + this.privateKey = options ? options.privateKey : getPrivateKeyAsHex(); } isAvailable(): boolean { diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts index abd0ea1a..5d2f69ce 100755 --- a/packages/mcp-server/src/index.ts +++ b/packages/mcp-server/src/index.ts @@ -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 export async function startMcpServer(): Promise { 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,7 +52,7 @@ export async function startMcpServer(): Promise { 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 | undefined; let removeShutdownHandlers = () => {}; @@ -60,8 +60,12 @@ export async function startMcpServer(): Promise { 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); + } })(); } return stopPromise; diff --git a/packages/mcp-server/src/server/args.ts b/packages/mcp-server/src/server/args.ts index a7ed8fef..699f4f05 100644 --- a/packages/mcp-server/src/server/args.ts +++ b/packages/mcp-server/src/server/args.ts @@ -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'; @@ -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: diff --git a/packages/mcp-server/src/server/server.ts b/packages/mcp-server/src/server/server.ts index 41d8ca7a..2f42e26e 100644 --- a/packages/mcp-server/src/server/server.ts +++ b/packages/mcp-server/src/server/server.ts @@ -1,5 +1,6 @@ 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'; @@ -7,24 +8,50 @@ 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(['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--; + 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; + } catch (error) { + console.error('Failed to initialize server:', sanitizeError(error)); + throw error; + } + }); }; diff --git a/packages/mcp-server/src/server/transport/factory.ts b/packages/mcp-server/src/server/transport/factory.ts index 687abb2f..fc4d156f 100644 --- a/packages/mcp-server/src/server/transport/factory.ts +++ b/packages/mcp-server/src/server/transport/factory.ts @@ -6,15 +6,15 @@ 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': @@ -22,8 +22,8 @@ export const createTransport = (config: TransportConfig): McpTransport => { port: config.port, host: config.host, path: config.path, - walletMode: config.walletMode, - maxSessions: config.maxSseSessions + maxSessions: config.maxSseSessions, + appConfig: config.appConfig }); default: diff --git a/packages/mcp-server/src/server/transport/http-sse.ts b/packages/mcp-server/src/server/transport/http-sse.ts index a5c26dda..343f38ba 100644 --- a/packages/mcp-server/src/server/transport/http-sse.ts +++ b/packages/mcp-server/src/server/transport/http-sse.ts @@ -3,11 +3,12 @@ import type { Socket } from 'node:net'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; import express, { type Request, type Response } from 'express'; +import { type AppConfigSnapshot, runWithAppConfig, snapshotConfig } from '../../core/config.js'; import { sanitizeError } from '../../core/errors.js'; import { getServer } from '../server.js'; import { closeHttpServer, collectOperationErrors, runAllOperations, throwCollectedErrors } from './lifecycle.js'; import { createCorsMiddleware, validateSecurityConfig } from './security.js'; -import type { McpTransport, WalletMode } from './types.js'; +import type { McpTransport } from './types.js'; export type McpServerFactory = () => Promise; export type SseServerTransportFactory = (endpoint: string, response: Response) => SSEServerTransport; @@ -19,7 +20,7 @@ export interface HttpSseTransportOptions { port: number; host: string; path: string; - walletMode?: WalletMode; + appConfig: AppConfigSnapshot; maxSessions?: number; } @@ -53,7 +54,7 @@ export class HttpSseTransport implements McpTransport { private readonly port: number; private readonly host: string; private readonly path: string; - private readonly walletMode: WalletMode; + private readonly appConfig: AppConfigSnapshot; private readonly serverFactory: McpServerFactory; private readonly transportFactory: SseServerTransportFactory; private readonly listenFactory: SseListenFactory; @@ -63,9 +64,10 @@ export class HttpSseTransport implements McpTransport { this.port = options.port; this.host = options.host; this.path = options.path; - this.walletMode = options.walletMode ?? 'disabled'; + if (!options.appConfig) throw new Error('appConfig is required.'); + this.appConfig = snapshotConfig(options.appConfig); this.maxSessions = options.maxSessions ?? DEFAULT_MAX_SSE_SESSIONS; - this.serverFactory = dependencies.serverFactory ?? getServer; + this.serverFactory = dependencies.serverFactory ?? (() => getServer(this.appConfig)); this.transportFactory = dependencies.transportFactory ?? ((endpoint, response) => new SSEServerTransport(endpoint, response)); this.listenFactory = dependencies.listenFactory ?? ((app, port, host) => app.listen(port, host)); this.app = express(); @@ -140,7 +142,7 @@ export class HttpSseTransport implements McpTransport { req.socket.on('error', onDisconnect); try { - const server = await this.serverFactory(); + const server = await runWithAppConfig(this.appConfig, () => this.serverFactory()); session = { server, transport, releaseSlot }; if (this.state !== 'running' || disconnected) { @@ -149,7 +151,7 @@ export class HttpSseTransport implements McpTransport { } this.connections.set(sessionId, session); - await server.connect(transport); + await runWithAppConfig(this.appConfig, () => server.connect(transport)); if (this.state !== 'running' || disconnected) { await this.closeSession(sessionId); @@ -188,7 +190,7 @@ export class HttpSseTransport implements McpTransport { } try { - await session.transport.handlePostMessage(req, res, req.body); + await runWithAppConfig(this.appConfig, () => session.transport.handlePostMessage(req, res, req.body)); } catch (error) { console.error('Error handling SSE message:', sanitizeError(error)); if (!res.headersSent) { @@ -221,7 +223,7 @@ export class HttpSseTransport implements McpTransport { } async start(_server?: McpServer): Promise { - validateSecurityConfig(this.mode, this.walletMode); + validateSecurityConfig(this.mode, this.appConfig.walletMode); if (this.host.trim().length === 0) { throw new Error('SERVER_HOST must not be empty.'); } diff --git a/packages/mcp-server/src/server/transport/stdio.ts b/packages/mcp-server/src/server/transport/stdio.ts index 8fdcb340..ca495e20 100644 --- a/packages/mcp-server/src/server/transport/stdio.ts +++ b/packages/mcp-server/src/server/transport/stdio.ts @@ -1,15 +1,23 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { type AppConfigSnapshot, runWithAppConfig, snapshotConfig } from '../../core/config.js'; import type { McpTransport, TransportMode } from './types.js'; export class StdioTransport implements McpTransport { public readonly mode: TransportMode = 'stdio'; + private readonly appConfig: AppConfigSnapshot; private transport?: StdioServerTransport; + constructor(appConfig: AppConfigSnapshot) { + if (!appConfig) throw new Error('appConfig is required.'); + this.appConfig = snapshotConfig(appConfig); + } + async start(server?: McpServer): Promise { if (!server) throw new Error('STDIO transport requires an MCP server.'); - this.transport = new StdioServerTransport(); - await server.connect(this.transport); + const transport = new StdioServerTransport(); + this.transport = transport; + await runWithAppConfig(this.appConfig, () => server.connect(transport)); console.error('MCP Server ready (stdio transport)'); } diff --git a/packages/mcp-server/src/server/transport/streamable-http.ts b/packages/mcp-server/src/server/transport/streamable-http.ts index 5b369a8c..bb012a1f 100644 --- a/packages/mcp-server/src/server/transport/streamable-http.ts +++ b/packages/mcp-server/src/server/transport/streamable-http.ts @@ -3,11 +3,12 @@ import type { Socket } from 'node:net'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import express, { type Request, type Response } from 'express'; +import { type AppConfigSnapshot, runWithAppConfig, snapshotConfig } from '../../core/config.js'; import { sanitizeError } from '../../core/errors.js'; import { getServer } from '../server.js'; import { closeHttpServer, collectOperationErrors, runAllOperations, throwCollectedErrors } from './lifecycle.js'; import { createCorsMiddleware, validateSecurityConfig } from './security.js'; -import type { McpTransport, TransportMode, WalletMode } from './types.js'; +import type { McpTransport, TransportMode } from './types.js'; export type StreamableServerFactory = () => Promise; export type StreamableTransportFactory = () => StreamableHTTPServerTransport; @@ -19,7 +20,7 @@ export interface StreamableHttpTransportOptions { port?: number; host?: string; path?: string; - walletMode?: WalletMode; + appConfig: AppConfigSnapshot; maxActiveRequests?: number; } @@ -53,19 +54,20 @@ export class StreamableHttpTransport implements McpTransport { private readonly port: number; private readonly host: string; private readonly path: string; - private readonly walletMode: WalletMode; + private readonly appConfig: AppConfigSnapshot; private readonly serverFactory: StreamableServerFactory; private readonly transportFactory: StreamableTransportFactory; private readonly listenFactory: StreamableListenFactory; private readonly maxActiveRequests: number; - constructor(options: StreamableHttpTransportOptions = {}, dependencies: StreamableHttpTransportDependencies = {}) { + constructor(options: StreamableHttpTransportOptions, dependencies: StreamableHttpTransportDependencies = {}) { this.port = options.port ?? 8080; this.host = options.host ?? 'localhost'; this.path = options.path ?? '/mcp'; - this.walletMode = options.walletMode ?? 'disabled'; + if (!options.appConfig) throw new Error('appConfig is required.'); + this.appConfig = snapshotConfig(options.appConfig); this.maxActiveRequests = options.maxActiveRequests ?? DEFAULT_MAX_STREAMABLE_REQUESTS; - this.serverFactory = dependencies.serverFactory ?? getServer; + this.serverFactory = dependencies.serverFactory ?? (() => getServer(this.appConfig)); this.transportFactory = dependencies.transportFactory ?? (() => @@ -94,7 +96,7 @@ export class StreamableHttpTransport implements McpTransport { } async start(_server?: McpServer): Promise { - validateSecurityConfig(this.mode, this.walletMode); + validateSecurityConfig(this.mode, this.appConfig.walletMode); if (this.host.trim().length === 0) { throw new Error('SERVER_HOST must not be empty.'); } @@ -142,7 +144,7 @@ export class StreamableHttpTransport implements McpTransport { let activeRequest: ActiveRequest | undefined; let requestServer: McpServer | undefined; try { - const server = await this.serverFactory(); + const server = await runWithAppConfig(this.appConfig, () => this.serverFactory()); requestServer = server; const transport = this.transportFactory(); activeRequest = { server, transport, releaseSlot }; @@ -186,8 +188,8 @@ export class StreamableHttpTransport implements McpTransport { return; } - await server.connect(transport); - await transport.handleRequest(req, res, req.body); + await runWithAppConfig(this.appConfig, () => server.connect(transport)); + await runWithAppConfig(this.appConfig, () => transport.handleRequest(req, res, req.body)); } catch (error) { console.error('Error handling MCP request:', sanitizeError(error)); if (!res.headersSent) { diff --git a/packages/mcp-server/src/server/transport/types.ts b/packages/mcp-server/src/server/transport/types.ts index bffc8764..acc9b27d 100644 --- a/packages/mcp-server/src/server/transport/types.ts +++ b/packages/mcp-server/src/server/transport/types.ts @@ -1,5 +1,5 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import type { WalletMode } from '../../core/config.js'; +import type { AppConfigSnapshot, WalletMode } from '../../core/config.js'; export type TransportMode = 'stdio' | 'streamable-http' | 'http-sse'; @@ -11,7 +11,7 @@ export interface McpTransport { export interface TransportConfig { mode: TransportMode; - walletMode: WalletMode; + appConfig: AppConfigSnapshot; port: number; // Required for HTTP-based transports host: string; // Required for HTTP-based transports path: string; // Required for HTTP-based transports diff --git a/packages/mcp-server/src/tests/core/config.test.ts b/packages/mcp-server/src/tests/core/config.test.ts index 5581e091..1579fdb4 100644 --- a/packages/mcp-server/src/tests/core/config.test.ts +++ b/packages/mcp-server/src/tests/core/config.test.ts @@ -1,13 +1,19 @@ -import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, jest, test } from 'bun:test'; +import type { AppConfigSnapshot } from '../../core/config.js'; import { config, formatPrivateKey, getPrivateKeyAsHex, + getRuntimeConfig, + getScopedAppConfig, getWalletMode, initializeConfig, isValidPrivateKey, isWalletEnabled, - loadConfig + loadConfig, + runWithAppConfig, + snapshotConfig, + wrapWithAppConfig } from '../../core/config.js'; describe('Config Module - Actual Implementation', () => { @@ -85,6 +91,95 @@ describe('Config Module - Actual Implementation', () => { walletApiKey: undefined }); }); + + test('snapshots freeze a copy that later initializeConfig mutations cannot change', () => { + const snapshot = snapshotConfig(); + expect(snapshot).toMatchObject({ + privateKey: undefined, + walletMode: 'disabled', + walletApiKey: undefined + }); + expect(snapshot).not.toBe(config); + expect(Object.isFrozen(snapshot)).toBe(true); + expect(snapshotConfig(snapshot)).toBe(snapshot); + + const privateKey = '3'.repeat(64); + initializeConfig({ WALLET_MODE: 'private-key', PRIVATE_KEY: privateKey }); + expect(snapshot.walletMode).toBe('disabled'); + expect(snapshot.privateKey).toBeUndefined(); + expect(config.walletMode).toBe('private-key'); + }); + + test('runWithAppConfig rejects mutable configuration objects', () => { + expect(() => + runWithAppConfig( + { + privateKey: undefined, + walletMode: 'disabled', + walletApiKey: undefined + } as unknown as AppConfigSnapshot, + () => undefined + ) + ).toThrow('runWithAppConfig requires a frozen AppConfig snapshot.'); + }); + + test('runWithAppConfig makes getters read the snapshot instead of the process singleton', () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + config.walletMode = 'private-key'; + config.privateKey = '0xabcdef'; + const snapshot = snapshotConfig({ + privateKey: undefined, + walletMode: 'disabled', + walletApiKey: undefined + }); + + expect(isWalletEnabled()).toBe(true); + runWithAppConfig(snapshot, () => { + expect(isWalletEnabled()).toBe(false); + expect(getWalletMode()).toBe('disabled'); + expect(getPrivateKeyAsHex()).toBeUndefined(); + expect(getRuntimeConfig()).toBe(snapshot); + expect(getScopedAppConfig()).toBe(snapshot); + }); + expect(isWalletEnabled()).toBe(true); + expect(getPrivateKeyAsHex()).toBe('0xabcdef'); + expect(getScopedAppConfig()).toBeUndefined(); + consoleErrorSpy.mockRestore(); + }); + + test('warns once per initialization for reads outside a runtime scope', () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + initializeConfig({ WALLET_MODE: 'private-key', PRIVATE_KEY: '5'.repeat(64) }); + + expect(getWalletMode()).toBe('private-key'); + expect(isWalletEnabled()).toBe(true); + expect(consoleErrorSpy).toHaveBeenCalledTimes(1); + + initializeConfig({ WALLET_MODE: 'disabled' }); + expect(getWalletMode()).toBe('disabled'); + expect(isWalletEnabled()).toBe(false); + expect(consoleErrorSpy).toHaveBeenCalledTimes(2); + expect(consoleErrorSpy).toHaveBeenCalledWith('Wallet configuration was read outside an MCP runtime scope; using the mutable process configuration.'); + consoleErrorSpy.mockRestore(); + }); + + test('wrapWithAppConfig keeps later callbacks on the snapshot', () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + config.walletMode = 'private-key'; + const snapshot = snapshotConfig({ + privateKey: undefined, + walletMode: 'disabled', + walletApiKey: undefined + }); + const readMode = wrapWithAppConfig(snapshot, () => getWalletMode()); + + expect(getWalletMode()).toBe('private-key'); + expect(readMode()).toBe('disabled'); + initializeConfig({ WALLET_MODE: 'private-key', PRIVATE_KEY: '4'.repeat(64) }); + expect(getWalletMode()).toBe('private-key'); + expect(readMode()).toBe('disabled'); + consoleErrorSpy.mockRestore(); + }); }); describe('getPrivateKeyAsHex', () => { diff --git a/packages/mcp-server/src/tests/core/wallet/index.test.ts b/packages/mcp-server/src/tests/core/wallet/index.test.ts index 42308bab..8ef39063 100644 --- a/packages/mcp-server/src/tests/core/wallet/index.test.ts +++ b/packages/mcp-server/src/tests/core/wallet/index.test.ts @@ -5,9 +5,15 @@ import { PrivateKeyWalletProvider } from '../../../core/wallet/providers/private import type { WalletProvider } from '../../../core/wallet/types.js'; // Mock dependencies -jest.mock('../../../core/config.js', () => ({ - getWalletMode: jest.fn() -})); +jest.mock('../../../core/config.js', () => { + const config = { privateKey: undefined as string | undefined, walletMode: 'disabled', walletApiKey: undefined }; + return { + config, + getRuntimeConfig: jest.fn(() => config), + getScopedAppConfig: jest.fn(), + snapshotConfig: jest.fn((source = config) => Object.freeze({ ...source })) + }; +}); jest.mock('../../../core/wallet/providers/private-key.js', () => ({ PrivateKeyWalletProvider: jest.fn() @@ -17,7 +23,7 @@ jest.mock('../../../core/wallet/providers/disabled.js', () => ({ DisabledWalletProvider: jest.fn() })); -import { getWalletMode } from '../../../core/config.js'; +import { getRuntimeConfig, getScopedAppConfig, config as processConfig, snapshotConfig } from '../../../core/config.js'; describe('Wallet Provider', () => { const mockPrivateKeyProvider: WalletProvider = { @@ -37,12 +43,14 @@ describe('Wallet Provider', () => { }; beforeEach(() => { - // Reset wallet provider instance before each test - resetWalletProvider(); - // Reset all mocks jest.resetAllMocks(); + Object.assign(processConfig, { privateKey: undefined, walletMode: 'disabled', walletApiKey: undefined }); + (getRuntimeConfig as jest.Mock).mockReturnValue(processConfig); + (getScopedAppConfig as jest.Mock).mockReturnValue(undefined); + (snapshotConfig as jest.Mock).mockImplementation((source = processConfig) => Object.freeze({ ...source })); + // Setup default mock implementations (PrivateKeyWalletProvider as unknown as jest.Mock).mockImplementation(() => mockPrivateKeyProvider); (DisabledWalletProvider as unknown as jest.Mock).mockImplementation(() => mockDisabledProvider); @@ -50,86 +58,66 @@ describe('Wallet Provider', () => { describe('getWalletProvider', () => { test('should create and return PrivateKeyWalletProvider for private-key mode', () => { - (getWalletMode as jest.Mock).mockReturnValue('private-key'); + Object.assign(processConfig, { privateKey: '0xabc', walletMode: 'private-key' }); const provider = getWalletProvider(); - expect(getWalletMode).toHaveBeenCalled(); - expect(PrivateKeyWalletProvider).toHaveBeenCalled(); + expect(PrivateKeyWalletProvider).toHaveBeenCalledWith({ privateKey: '0xabc' }); expect(provider).toBe(mockPrivateKeyProvider); }); test('should create and return DisabledWalletProvider for disabled mode', () => { - (getWalletMode as jest.Mock).mockReturnValue('disabled'); - const provider = getWalletProvider(); - expect(getWalletMode).toHaveBeenCalled(); expect(DisabledWalletProvider).toHaveBeenCalled(); expect(provider).toBe(mockDisabledProvider); }); - test('should return cached provider on subsequent calls', () => { - (getWalletMode as jest.Mock).mockReturnValue('private-key'); + test('should not memoize providers outside a runtime scope', () => { + Object.assign(processConfig, { privateKey: '0xabc', walletMode: 'private-key' }); + const provider2: WalletProvider = { ...mockPrivateKeyProvider }; + (PrivateKeyWalletProvider as unknown as jest.Mock).mockImplementationOnce(() => mockPrivateKeyProvider).mockImplementationOnce(() => provider2); - // First call const provider1 = getWalletProvider(); + const nextProvider = getWalletProvider(); - // Reset mocks to verify they aren't called again - jest.clearAllMocks(); - - // Second call should return cached provider - const provider2 = getWalletProvider(); - - expect(getWalletMode).not.toHaveBeenCalled(); - expect(PrivateKeyWalletProvider).not.toHaveBeenCalled(); - expect(provider2).toBe(provider1); + expect(PrivateKeyWalletProvider).toHaveBeenCalledTimes(2); + expect(nextProvider).toBe(provider2); + expect(nextProvider).not.toBe(provider1); }); test('should throw error for unknown wallet mode', () => { - (getWalletMode as jest.Mock).mockReturnValue('unknown-mode'); + processConfig.walletMode = 'unknown-mode' as never; expect(() => getWalletProvider()).toThrow('Unknown wallet mode: unknown-mode'); }); - }); - describe('resetWalletProvider', () => { - test('should reset the wallet provider instance', () => { - (getWalletMode as jest.Mock).mockReturnValue('private-key'); - - // Create separate mock instances for each call - const mockProvider1: WalletProvider = { - getName: () => 'private-key', - isAvailable: () => true, - getAddress: jest.fn(), - signTransaction: jest.fn(), - getWalletClient: jest.fn() - }; - const mockProvider2: WalletProvider = { - getName: () => 'private-key', - isAvailable: () => true, - getAddress: jest.fn(), - signTransaction: jest.fn(), - getWalletClient: jest.fn() - }; - - // Mock constructor to return different instances on each call - (PrivateKeyWalletProvider as unknown as jest.Mock).mockImplementationOnce(() => mockProvider1).mockImplementationOnce(() => mockProvider2); - - // Create provider instance + test('returns the cached provider for a runtime snapshot', () => { + const snapshot = snapshotConfig({ privateKey: '0xabc', walletMode: 'private-key', walletApiKey: undefined }); + (getScopedAppConfig as jest.Mock).mockReturnValue(snapshot); + const provider1 = getWalletProvider(); - expect(provider1).toBe(mockProvider1); + expect(PrivateKeyWalletProvider).toHaveBeenCalledWith({ privateKey: '0xabc' }); + expect(provider1).toBe(mockPrivateKeyProvider); - // Reset the provider - resetWalletProvider(); + jest.clearAllMocks(); + (getScopedAppConfig as jest.Mock).mockReturnValue(snapshot); - // Create new provider instance const provider2 = getWalletProvider(); - expect(provider2).toBe(mockProvider2); + expect(provider2).toBe(provider1); + expect(PrivateKeyWalletProvider).not.toHaveBeenCalled(); + }); + + test('recreates only the provider for a reset snapshot', () => { + const snapshot = snapshotConfig({ privateKey: '0xabc', walletMode: 'private-key', walletApiKey: undefined }); + const providerAfterReset = { ...mockPrivateKeyProvider }; + (getScopedAppConfig as jest.Mock).mockReturnValue(snapshot); + (PrivateKeyWalletProvider as unknown as jest.Mock).mockImplementationOnce(() => mockPrivateKeyProvider).mockImplementationOnce(() => providerAfterReset); - // Should have created a new instance + expect(getWalletProvider()).toBe(mockPrivateKeyProvider); + resetWalletProvider(snapshot); + expect(getWalletProvider()).toBe(providerAfterReset); expect(PrivateKeyWalletProvider).toHaveBeenCalledTimes(2); - expect(provider2).not.toBe(provider1); }); }); }); diff --git a/packages/mcp-server/src/tests/core/wallet/providers/private-key.test.ts b/packages/mcp-server/src/tests/core/wallet/providers/private-key.test.ts index 247646f4..5b1def58 100644 --- a/packages/mcp-server/src/tests/core/wallet/providers/private-key.test.ts +++ b/packages/mcp-server/src/tests/core/wallet/providers/private-key.test.ts @@ -63,6 +63,15 @@ describe('PrivateKeyWalletProvider', () => { expect(provider.isAvailable()).toBe(false); }); + + test('does not read the process singleton when constructed with explicit options', () => { + (getPrivateKeyAsHex as jest.Mock).mockReturnValue(mockPrivateKey); + + const provider = new PrivateKeyWalletProvider({ privateKey: undefined }); + + expect(provider.isAvailable()).toBe(false); + expect(getPrivateKeyAsHex).not.toHaveBeenCalled(); + }); }); describe('getName', () => { diff --git a/packages/mcp-server/src/tests/index.test.ts b/packages/mcp-server/src/tests/index.test.ts index 4b759e6f..238309a5 100644 --- a/packages/mcp-server/src/tests/index.test.ts +++ b/packages/mcp-server/src/tests/index.test.ts @@ -9,8 +9,8 @@ jest.mock('../server/transport/index.js', () => ({ createTransport: jest.fn() })); -jest.mock('../core/config.js', () => ({ - isWalletEnabled: jest.fn() +jest.mock('../core/wallet/index.js', () => ({ + resetWalletProvider: jest.fn() })); jest.mock('../server/args.js', () => ({ @@ -20,10 +20,10 @@ jest.mock('../server/args.js', () => ({ describe('index', () => { let mockGetServer: jest.MockedFunction<() => Promise>; let mockCreateTransport: jest.MockedFunction<(config: unknown) => unknown>; - let mockIsWalletEnabled: jest.MockedFunction<() => boolean>; let mockParseArgs: jest.MockedFunction<() => unknown>; let mockTransport: { start: jest.Mock; stop: jest.Mock }; let mockServer: { close: jest.Mock }; + let mockAppConfig: Readonly<{ privateKey: string | undefined; walletMode: 'private-key' | 'disabled'; walletApiKey: string | undefined }>; let consoleErrorSpy: jest.SpyInstance; let processExitSpy: jest.SpyInstance; let originalExitCode: number | string | null | undefined; @@ -35,12 +35,10 @@ describe('index', () => { // Import mocked modules const serverModule = await import('../server/server.js'); const transportModule = await import('../server/transport/index.js'); - const configModule = await import('../core/config.js'); const argsModule = await import('../server/args.js'); mockGetServer = serverModule.getServer as jest.MockedFunction<() => Promise>; mockCreateTransport = transportModule.createTransport as jest.MockedFunction<(config: unknown) => unknown>; - mockIsWalletEnabled = configModule.isWalletEnabled as jest.MockedFunction<() => boolean>; mockParseArgs = argsModule.parseArgs as jest.MockedFunction<() => unknown>; // Setup mock objects @@ -53,10 +51,10 @@ describe('index', () => { }; // Setup default mock implementations - mockParseArgs.mockReturnValue({ mode: 'stdio' }); + mockAppConfig = Object.freeze({ privateKey: '0xabc', walletMode: 'private-key', walletApiKey: undefined }); + mockParseArgs.mockReturnValue({ mode: 'stdio', appConfig: mockAppConfig }); mockGetServer.mockResolvedValue(mockServer); mockCreateTransport.mockReturnValue(mockTransport); - mockIsWalletEnabled.mockReturnValue(true); // Spy on console and process consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); @@ -85,13 +83,13 @@ describe('index', () => { expect(mockGetServer).toHaveBeenCalled(); expect(mockCreateTransport).toHaveBeenCalled(); expect(mockTransport.start).toHaveBeenCalledWith(mockServer); - expect(mockIsWalletEnabled).toHaveBeenCalled(); expect(consoleErrorSpy).not.toHaveBeenCalled(); await runtime?.stop(); }); it('should log warning when wallet is disabled', async () => { - mockIsWalletEnabled.mockReturnValue(false); + const appConfig = Object.freeze({ privateKey: undefined, walletMode: 'disabled' as const, walletApiKey: undefined }); + mockParseArgs.mockReturnValue({ mode: 'stdio', appConfig }); const indexModule = await import('../index.js'); const runtime = await indexModule.main(); @@ -144,7 +142,8 @@ describe('index', () => { }); it('does not construct an unused bootstrap server for HTTP transports', async () => { - mockParseArgs.mockReturnValue({ mode: 'http-sse' }); + const appConfig = Object.freeze({ privateKey: undefined, walletMode: 'disabled' as const, walletApiKey: undefined }); + mockParseArgs.mockReturnValue({ mode: 'http-sse', appConfig }); const indexModule = await import('../index.js'); const runtime = await indexModule.main(); @@ -176,6 +175,8 @@ describe('index', () => { await expect(runtime?.stop()).rejects.toBeInstanceOf(AggregateError); expect(mockTransport.stop).toHaveBeenCalledTimes(1); expect(mockServer.close).toHaveBeenCalledTimes(1); + const walletModule = await import('../core/wallet/index.js'); + expect(walletModule.resetWalletProvider).toHaveBeenCalledWith(mockAppConfig); }); it('runs graceful shutdown once and allows signal handlers to be removed', async () => { diff --git a/packages/mcp-server/src/tests/server/args.test.ts b/packages/mcp-server/src/tests/server/args.test.ts index 75c17170..a3cf4f22 100644 --- a/packages/mcp-server/src/tests/server/args.test.ts +++ b/packages/mcp-server/src/tests/server/args.test.ts @@ -112,10 +112,15 @@ describe('Args Module', () => { port: 8080, host: 'localhost', path: '/mcp', - walletMode: 'disabled', + appConfig: { + privateKey: undefined, + walletMode: 'disabled', + walletApiKey: undefined + }, maxSseSessions: 100, maxStreamableRequests: 100 }); + expect(Object.isFrozen(result.appConfig)).toBe(true); }); it('should load configuration from environment variables', () => { @@ -133,7 +138,11 @@ describe('Args Module', () => { port: 3001, host: '0.0.0.0', path: '/api/mcp', - walletMode: 'private-key', + appConfig: { + privateKey: `0x${'1'.repeat(64)}`, + walletMode: 'private-key', + walletApiKey: undefined + }, maxSseSessions: 100, maxStreamableRequests: 100 }); @@ -357,14 +366,14 @@ describe('Args Module', () => { expect(result).toHaveProperty('port'); expect(result).toHaveProperty('host'); expect(result).toHaveProperty('path'); - expect(result).toHaveProperty('walletMode'); + expect(result).toHaveProperty('appConfig'); // Verify types expect(typeof result.mode).toBe('string'); expect(typeof result.port).toBe('number'); expect(typeof result.host).toBe('string'); expect(typeof result.path).toBe('string'); - expect(typeof result.walletMode).toBe('string'); + expect(typeof result.appConfig.walletMode).toBe('string'); }); it('should handle getPackageInfo errors gracefully', () => { @@ -398,7 +407,11 @@ describe('Args Module', () => { port: 9000, host: 'localhost', path: '/mcp', - walletMode: 'private-key', + appConfig: { + privateKey: `0x${'1'.repeat(64)}`, + walletMode: 'private-key', + walletApiKey: undefined + }, maxSseSessions: 100, maxStreamableRequests: 100 }); diff --git a/packages/mcp-server/src/tests/server/server.test.ts b/packages/mcp-server/src/tests/server/server.test.ts index 8dfaecaf..e8fb2653 100644 --- a/packages/mcp-server/src/tests/server/server.test.ts +++ b/packages/mcp-server/src/tests/server/server.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, jest, test } from 'bun:test'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { type AppConfigSnapshot, getScopedAppConfig, snapshotConfig } from '../../core/config.js'; // Mock all dependencies jest.mock('@modelcontextprotocol/sdk/server/mcp.js', () => ({ @@ -31,7 +32,8 @@ jest.mock('../../core/chains.js', () => ({ rpcUrlMap: {} })); -type GetServerFunction = () => Promise; +type GetServerFunction = (appConfig: AppConfigSnapshot) => Promise; +const APP_CONFIG = snapshotConfig({ privateKey: undefined, walletMode: 'disabled', walletApiKey: undefined }); describe('Server Module', () => { let getServer: GetServerFunction; @@ -52,7 +54,8 @@ describe('Server Module', () => { // Create mock server instance mockServerInstance = { name: '@sei-js/mcp-server', - version: '1.0.0' + version: '1.0.0', + connect: jest.fn().mockResolvedValue(undefined) }; // Import mocked functions first @@ -100,7 +103,7 @@ describe('Server Module', () => { describe('getServer', () => { it('should call all initialization functions', async () => { - await getServer(); + await getServer(APP_CONFIG); expect(mockGetPackageInfo).toHaveBeenCalled(); expect(mockRegisterEVMResources).toHaveBeenCalled(); @@ -116,18 +119,37 @@ describe('Server Module', () => { it('should log supported networks', async () => { const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); - await getServer(); + await getServer(APP_CONFIG); expect(consoleErrorSpy).toHaveBeenCalledWith('Supported networks:', 'sei, sei-testnet'); }); + it('binds legacy and current registration callbacks to the runtime snapshot', async () => { + await getServer(APP_CONFIG); + const scopedServer = mockRegisterEVMTools.mock.calls[0][0] as McpServer; + const seenConfigs: unknown[] = []; + + for (const method of ['tool', 'resource', 'prompt', 'registerTool', 'registerResource', 'registerPrompt'] as const) { + let registeredCallback: (() => void) | undefined; + mockServerInstance[method] = jest.fn((...args: unknown[]) => { + registeredCallback = args.at(-1) as () => void; + }); + (scopedServer[method] as (...args: unknown[]) => unknown)('name', 'description', {}, () => { + seenConfigs.push(getScopedAppConfig()); + }); + registeredCallback?.(); + } + + expect(seenConfigs).toEqual([APP_CONFIG, APP_CONFIG, APP_CONFIG, APP_CONFIG, APP_CONFIG, APP_CONFIG]); + }); + it('should sanitize and propagate server initialization errors', async () => { const testError = new Error('Initialization failed'); mockGetPackageInfo.mockImplementation(() => { throw testError; }); - await expect(getServer()).rejects.toThrow('Initialization failed'); + await expect(getServer(APP_CONFIG)).rejects.toThrow('Initialization failed'); expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to initialize server:', 'Initialization failed'); expect(processExitSpy).not.toHaveBeenCalled(); diff --git a/packages/mcp-server/src/tests/server/transport/factory.test.ts b/packages/mcp-server/src/tests/server/transport/factory.test.ts index 229dbe5a..b40866e5 100644 --- a/packages/mcp-server/src/tests/server/transport/factory.test.ts +++ b/packages/mcp-server/src/tests/server/transport/factory.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, jest, test } from 'bun:test'; +import { snapshotConfig } from '../../../core/config.js'; import type { TransportConfig } from '../../../server/transport/types.js'; // Mock transport classes @@ -14,6 +15,9 @@ jest.mock('../../../server/transport/http-sse.js', () => ({ HttpSseTransport: jest.fn() })); +const DISABLED_APP_CONFIG = snapshotConfig({ privateKey: undefined, walletMode: 'disabled', walletApiKey: undefined }); +const PRIVATE_KEY_APP_CONFIG = snapshotConfig({ privateKey: `0x${'1'.repeat(64)}`, walletMode: 'private-key', walletApiKey: undefined }); + describe('Transport Factory', () => { let createTransport: any; let StdioTransport: jest.MockedClass; @@ -44,7 +48,7 @@ describe('Transport Factory', () => { it('should create StdioTransport for stdio mode', () => { const config: TransportConfig = { mode: 'stdio', - walletMode: 'disabled', + appConfig: DISABLED_APP_CONFIG, port: 3000, host: 'localhost', path: '/mcp' @@ -55,14 +59,14 @@ describe('Transport Factory', () => { const transport = createTransport(config); - expect(StdioTransport).toHaveBeenCalledWith(); + expect(StdioTransport).toHaveBeenCalledWith(DISABLED_APP_CONFIG); expect(transport).toBe(mockStdioInstance); }); it('should create StreamableHttpTransport for streamable-http mode', () => { const config: TransportConfig = { mode: 'streamable-http', - walletMode: 'private-key', + appConfig: PRIVATE_KEY_APP_CONFIG, port: 8080, host: '0.0.0.0', path: '/api/mcp' @@ -77,8 +81,8 @@ describe('Transport Factory', () => { port: 8080, host: '0.0.0.0', path: '/api/mcp', - walletMode: 'private-key', - maxActiveRequests: undefined + maxActiveRequests: undefined, + appConfig: PRIVATE_KEY_APP_CONFIG }); expect(transport).toBe(mockStreamableInstance); }); @@ -86,7 +90,7 @@ describe('Transport Factory', () => { it('should create HttpSseTransport for http-sse mode', () => { const config: TransportConfig = { mode: 'http-sse', - walletMode: 'disabled', + appConfig: DISABLED_APP_CONFIG, port: 9000, host: '127.0.0.1', path: '/sse', @@ -102,16 +106,62 @@ describe('Transport Factory', () => { port: 9000, host: '127.0.0.1', path: '/sse', - walletMode: 'disabled', - maxSessions: 25 + maxSessions: 25, + appConfig: DISABLED_APP_CONFIG }); expect(transport).toBe(mockSseInstance); }); + it('forwards a frozen appConfig snapshot to stdio transport', () => { + const appConfig = snapshotConfig({ + privateKey: `0x${'1'.repeat(64)}`, + walletMode: 'private-key' as const, + walletApiKey: undefined + }); + const config: TransportConfig = { + mode: 'stdio', + appConfig, + port: 3000, + host: 'localhost', + path: '/mcp' + }; + StdioTransport.mockImplementation(() => ({ mode: 'stdio' })); + + createTransport(config); + + expect(StdioTransport).toHaveBeenCalledWith(appConfig); + }); + + it('forwards a frozen appConfig snapshot to HTTP transports', () => { + const appConfig = snapshotConfig({ + privateKey: undefined, + walletMode: 'disabled' as const, + walletApiKey: undefined + }); + const config: TransportConfig = { + mode: 'streamable-http', + appConfig, + port: 8080, + host: 'localhost', + path: '/mcp' + }; + StreamableHttpTransport.mockImplementation(() => ({ mode: 'streamable-http' })); + + createTransport(config); + + expect(StreamableHttpTransport).toHaveBeenCalledWith({ + port: 8080, + host: 'localhost', + path: '/mcp', + maxActiveRequests: undefined, + appConfig + }); + }); + it('should throw error for unsupported transport mode', () => { const config = { mode: 'unsupported-mode', - walletMode: 'disabled', + appConfig: DISABLED_APP_CONFIG, port: 3000, host: 'localhost', path: '/mcp' @@ -130,7 +180,7 @@ describe('Transport Factory', () => { configs.forEach((params, index) => { const config: TransportConfig = { mode: 'streamable-http', - walletMode: 'disabled', + appConfig: DISABLED_APP_CONFIG, ...params }; @@ -141,8 +191,8 @@ describe('Transport Factory', () => { expect(StreamableHttpTransport).toHaveBeenCalledWith({ ...params, - walletMode: 'disabled', - maxActiveRequests: undefined + maxActiveRequests: undefined, + appConfig: DISABLED_APP_CONFIG }); expect(transport).toBe(mockInstance); @@ -154,7 +204,7 @@ describe('Transport Factory', () => { // Test with minimal path const config1: TransportConfig = { mode: 'http-sse', - walletMode: 'private-key', + appConfig: PRIVATE_KEY_APP_CONFIG, port: 1, host: '::1', // IPv6 localhost path: '/' @@ -169,8 +219,8 @@ describe('Transport Factory', () => { port: 1, host: '::1', path: '/', - walletMode: 'private-key', - maxSessions: undefined + maxSessions: undefined, + appConfig: PRIVATE_KEY_APP_CONFIG }); expect(transport1).toBe(mockInstance1); @@ -179,7 +229,7 @@ describe('Transport Factory', () => { // Test with maximum port number const config2: TransportConfig = { mode: 'streamable-http', - walletMode: 'disabled', + appConfig: DISABLED_APP_CONFIG, port: 65535, host: '0.0.0.0', path: '/very/long/path/to/test/edge/cases' @@ -194,8 +244,8 @@ describe('Transport Factory', () => { port: 65535, host: '0.0.0.0', path: '/very/long/path/to/test/edge/cases', - walletMode: 'disabled', - maxActiveRequests: undefined + maxActiveRequests: undefined, + appConfig: DISABLED_APP_CONFIG }); expect(transport2).toBe(mockInstance2); }); @@ -206,7 +256,7 @@ describe('Transport Factory', () => { // This test ensures TypeScript compilation catches invalid configs const validConfig: TransportConfig = { mode: 'stdio', - walletMode: 'disabled', + appConfig: DISABLED_APP_CONFIG, port: 3000, host: 'localhost', path: '/mcp' @@ -218,7 +268,7 @@ describe('Transport Factory', () => { it('should return McpTransport interface', () => { const config: TransportConfig = { mode: 'stdio', - walletMode: 'disabled', + appConfig: DISABLED_APP_CONFIG, port: 3000, host: 'localhost', path: '/mcp' @@ -244,7 +294,7 @@ describe('Transport Factory', () => { it('should handle transport constructor errors', () => { const config: TransportConfig = { mode: 'stdio', - walletMode: 'disabled', + appConfig: DISABLED_APP_CONFIG, port: 3000, host: 'localhost', path: '/mcp' @@ -264,7 +314,7 @@ describe('Transport Factory', () => { it('should handle config with missing mode', () => { const invalidConfig = { - walletMode: 'disabled', + appConfig: DISABLED_APP_CONFIG, port: 3000, host: 'localhost', path: '/mcp' @@ -277,9 +327,9 @@ describe('Transport Factory', () => { describe('integration scenarios', () => { it('should create different transport types in sequence', () => { const configs: TransportConfig[] = [ - { mode: 'stdio', walletMode: 'disabled', port: 3000, host: 'localhost', path: '/mcp' }, - { mode: 'streamable-http', walletMode: 'private-key', port: 8080, host: '0.0.0.0', path: '/api' }, - { mode: 'http-sse', walletMode: 'disabled', port: 9000, host: '127.0.0.1', path: '/sse' } + { mode: 'stdio', appConfig: DISABLED_APP_CONFIG, port: 3000, host: 'localhost', path: '/mcp' }, + { mode: 'streamable-http', appConfig: PRIVATE_KEY_APP_CONFIG, port: 8080, host: '0.0.0.0', path: '/api' }, + { mode: 'http-sse', appConfig: DISABLED_APP_CONFIG, port: 9000, host: '127.0.0.1', path: '/sse' } ]; const mockInstances = [{ mode: 'stdio' }, { mode: 'streamable-http' }, { mode: 'http-sse' }]; @@ -303,7 +353,7 @@ describe('Transport Factory', () => { it('should handle repeated creation of same transport type', () => { const config: TransportConfig = { mode: 'streamable-http', - walletMode: 'disabled', + appConfig: DISABLED_APP_CONFIG, port: 3000, host: 'localhost', path: '/mcp' diff --git a/packages/mcp-server/src/tests/server/transport/http-sse.test.ts b/packages/mcp-server/src/tests/server/transport/http-sse.test.ts index cb353317..9c979bc0 100644 --- a/packages/mcp-server/src/tests/server/transport/http-sse.test.ts +++ b/packages/mcp-server/src/tests/server/transport/http-sse.test.ts @@ -6,10 +6,13 @@ import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; import type { Response } from 'express'; +import { snapshotConfig } from '../../../core/config.js'; import { HttpSseTransport } from '../../../server/transport/http-sse.js'; const HOST = '127.0.0.1'; const PATH = '/mcp'; +const DISABLED_APP_CONFIG = snapshotConfig({ privateKey: undefined, walletMode: 'disabled', walletApiKey: undefined }); +const PRIVATE_KEY_APP_CONFIG = snapshotConfig({ privateKey: `0x${'1'.repeat(64)}`, walletMode: 'private-key', walletApiKey: undefined }); function textOf(result: Awaited>): string { if (!('content' in result)) throw new Error('Expected an immediate tool result'); @@ -60,7 +63,7 @@ describe('HttpSseTransport', () => { throw new Error(`process.exit called with code ${code}`); }); const listenFactory = jest.fn(); - const transport = new HttpSseTransport({ port: 8080, host: HOST, path: PATH, walletMode: 'private-key' }, { listenFactory }); + const transport = new HttpSseTransport({ port: 8080, host: HOST, path: PATH, appConfig: PRIVATE_KEY_APP_CONFIG }, { listenFactory }); await expect(transport.start()).rejects.toThrow('process.exit called with code 1'); expect(processExit).toHaveBeenCalledWith(1); @@ -75,7 +78,7 @@ describe('HttpSseTransport', () => { let nextSession = 0; const transport = new HttpSseTransport( - { port: 0, host: HOST, path: PATH }, + { port: 0, host: HOST, path: PATH, appConfig: DISABLED_APP_CONFIG }, { serverFactory: async () => { const sessionIndex = nextSession++; @@ -149,7 +152,7 @@ describe('HttpSseTransport', () => { consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); let serverCount = 0; const transport = new HttpSseTransport( - { port: 0, host: HOST, path: PATH, maxSessions: 1 }, + { port: 0, host: HOST, path: PATH, appConfig: DISABLED_APP_CONFIG, maxSessions: 1 }, { serverFactory: async () => { serverCount++; @@ -189,7 +192,7 @@ describe('HttpSseTransport', () => { let serverCloseCount = 0; let transportCloseCount = 0; const transport = new HttpSseTransport( - { port: 0, host: HOST, path: PATH }, + { port: 0, host: HOST, path: PATH, appConfig: DISABLED_APP_CONFIG }, { serverFactory: async () => { const server = new McpServer({ name: 'disconnect-session', version: '1.0.0' }); @@ -246,7 +249,7 @@ describe('HttpSseTransport', () => { } as unknown as McpServer; const sessionTransportClose = jest.fn().mockRejectedValue(new Error('simulated transport cleanup failure')); const transport = new HttpSseTransport( - { port: 0, host: HOST, path: PATH }, + { port: 0, host: HOST, path: PATH, appConfig: DISABLED_APP_CONFIG }, { serverFactory: async () => sessionServer, transportFactory: (endpoint, sessionResponse) => { @@ -291,7 +294,7 @@ describe('HttpSseTransport', () => { }) as typeof delayedServer.close; let listenCount = 0; const transport = new HttpSseTransport( - { port: 0, host: HOST, path: PATH }, + { port: 0, host: HOST, path: PATH, appConfig: DISABLED_APP_CONFIG }, { serverFactory: async () => new McpServer({ name: 'session', version: '1.0.0' }), transportFactory: (endpoint, sessionResponse) => new SSEServerTransport(endpoint, sessionResponse), @@ -323,13 +326,13 @@ describe('HttpSseTransport', () => { const address = occupied.address(); if (!address || typeof address === 'string') throw new Error('Expected an occupied TCP port'); - const blocked = new HttpSseTransport({ port: address.port, host: HOST, path: PATH }); + const blocked = new HttpSseTransport({ port: address.port, host: HOST, path: PATH, appConfig: DISABLED_APP_CONFIG }); const bootstrap = new McpServer({ name: 'bootstrap', version: '1.0.0' }); bootstrapServers.push(bootstrap); await expect(blocked.start(bootstrap)).rejects.toMatchObject({ code: 'EADDRINUSE' }); await new Promise((resolve, reject) => occupied.close((error) => (error ? reject(error) : resolve()))); - const transport = new HttpSseTransport({ port: 0, host: HOST, path: PATH }); + const transport = new HttpSseTransport({ port: 0, host: HOST, path: PATH, appConfig: DISABLED_APP_CONFIG }); transports.push(transport); await transport.start(bootstrap); const health = await fetch(`http://${HOST}:${transport.getListeningPort()}/health`); @@ -342,7 +345,7 @@ describe('HttpSseTransport', () => { const transportFactory = jest.fn(() => { throw new Error('simulated SSE constructor failure'); }); - const transport = new HttpSseTransport({ port: 0, host: HOST, path: PATH, maxSessions: 1 }, { transportFactory }); + const transport = new HttpSseTransport({ port: 0, host: HOST, path: PATH, appConfig: DISABLED_APP_CONFIG, maxSessions: 1 }, { transportFactory }); transports.push(transport); const bootstrap = new McpServer({ name: 'bootstrap', version: '1.0.0' }); bootstrapServers.push(bootstrap); @@ -363,7 +366,7 @@ describe('HttpSseTransport', () => { } as unknown as McpServer; const sessionTransportClose = jest.fn().mockRejectedValue(new Error('transport close failed')); const transport = new HttpSseTransport( - { port: 0, host: HOST, path: PATH }, + { port: 0, host: HOST, path: PATH, appConfig: DISABLED_APP_CONFIG }, { serverFactory: async () => sessionServer, transportFactory: (endpoint, response) => { @@ -402,14 +405,14 @@ describe('HttpSseTransport', () => { it('rejects messages without a valid session and validates a nonempty host', async () => { consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); - const invalidHost = new HttpSseTransport({ port: 8080, host: ' ', path: PATH }); + const invalidHost = new HttpSseTransport({ port: 8080, host: ' ', path: PATH, appConfig: DISABLED_APP_CONFIG }); const unused = new McpServer({ name: 'unused', version: '1.0.0' }); bootstrapServers.push(unused); await expect(invalidHost.start(unused)).rejects.toThrow('SERVER_HOST must not be empty'); - const invalidLimit = new HttpSseTransport({ port: 8080, host: HOST, path: PATH, maxSessions: 0 }); + const invalidLimit = new HttpSseTransport({ port: 8080, host: HOST, path: PATH, appConfig: DISABLED_APP_CONFIG, maxSessions: 0 }); await expect(invalidLimit.start(unused)).rejects.toThrow('SSE_MAX_SESSIONS must be a positive integer'); - const transport = new HttpSseTransport({ port: 0, host: HOST, path: PATH }); + const transport = new HttpSseTransport({ port: 0, host: HOST, path: PATH, appConfig: DISABLED_APP_CONFIG }); transports.push(transport); const bootstrap = new McpServer({ name: 'bootstrap', version: '1.0.0' }); bootstrapServers.push(bootstrap); diff --git a/packages/mcp-server/src/tests/server/transport/stdio.test.ts b/packages/mcp-server/src/tests/server/transport/stdio.test.ts index 0469603c..6608b266 100644 --- a/packages/mcp-server/src/tests/server/transport/stdio.test.ts +++ b/packages/mcp-server/src/tests/server/transport/stdio.test.ts @@ -1,10 +1,13 @@ import { afterEach, beforeEach, describe, expect, it, jest, test } from 'bun:test'; +import { snapshotConfig } from '../../../core/config.js'; // Mock dependencies jest.mock('@modelcontextprotocol/sdk/server/stdio.js', () => ({ StdioServerTransport: jest.fn() })); +const APP_CONFIG = snapshotConfig({ privateKey: undefined, walletMode: 'disabled', walletApiKey: undefined }); + describe('StdioTransport', () => { let StdioTransport: any; let mockStdioServerTransport: jest.MockedClass; @@ -37,14 +40,18 @@ describe('StdioTransport', () => { describe('Constructor', () => { it('should initialize with stdio mode', () => { - const transport = new StdioTransport(); + const transport = new StdioTransport(APP_CONFIG); expect(transport.mode).toBe('stdio'); }); it('should not have transport initially', () => { - const transport = new StdioTransport(); + const transport = new StdioTransport(APP_CONFIG); expect((transport as any).transport).toBeUndefined(); }); + + it('requires an appConfig snapshot', () => { + expect(() => new StdioTransport()).toThrow('appConfig is required.'); + }); }); describe('start', () => { @@ -54,7 +61,7 @@ describe('StdioTransport', () => { }; mockStdioServerTransport.mockImplementation(() => mockTransportInstance); - const transport = new StdioTransport(); + const transport = new StdioTransport(APP_CONFIG); await transport.start(mockServer); expect(mockStdioServerTransport).toHaveBeenCalledWith(); @@ -66,7 +73,7 @@ describe('StdioTransport', () => { const mockTransportInstance = {}; mockStdioServerTransport.mockImplementation(() => mockTransportInstance); - const transport = new StdioTransport(); + const transport = new StdioTransport(APP_CONFIG); await transport.start(mockServer); expect(consoleErrorSpy).toHaveBeenCalledWith('MCP Server ready (stdio transport)'); @@ -79,7 +86,7 @@ describe('StdioTransport', () => { const connectionError = new Error('Connection failed'); mockServer.connect.mockRejectedValue(connectionError); - const transport = new StdioTransport(); + const transport = new StdioTransport(APP_CONFIG); await expect(transport.start(mockServer)).rejects.toThrow('Connection failed'); expect(mockStdioServerTransport).toHaveBeenCalledWith(); @@ -92,7 +99,7 @@ describe('StdioTransport', () => { throw constructorError; }); - const transport = new StdioTransport(); + const transport = new StdioTransport(APP_CONFIG); await expect(transport.start(mockServer)).rejects.toThrow('Transport creation failed'); expect(mockServer.connect).not.toHaveBeenCalled(); @@ -101,7 +108,7 @@ describe('StdioTransport', () => { describe('stop', () => { it('should set transport to undefined', async () => { - const transport = new StdioTransport(); + const transport = new StdioTransport(APP_CONFIG); // Set up transport first const mockTransportInstance = {}; @@ -118,7 +125,7 @@ describe('StdioTransport', () => { }); it('should resolve immediately if no transport exists', async () => { - const transport = new StdioTransport(); + const transport = new StdioTransport(APP_CONFIG); // Don't start transport, just stop await expect(transport.stop()).resolves.toBeUndefined(); @@ -126,7 +133,7 @@ describe('StdioTransport', () => { }); it('should not throw errors during stop', async () => { - const transport = new StdioTransport(); + const transport = new StdioTransport(APP_CONFIG); // Start and then stop multiple times const mockTransportInstance = {}; @@ -141,7 +148,7 @@ describe('StdioTransport', () => { describe('mode property', () => { it('should always return stdio', () => { - const transport = new StdioTransport(); + const transport = new StdioTransport(APP_CONFIG); expect(transport.mode).toBe('stdio'); // Verify it's readonly - TypeScript prevents assignment but doesn't throw at runtime @@ -155,7 +162,7 @@ describe('StdioTransport', () => { const mockTransportInstance = {}; mockStdioServerTransport.mockImplementation(() => mockTransportInstance); - const transport = new StdioTransport(); + const transport = new StdioTransport(APP_CONFIG); // Start await transport.start(mockServer); @@ -178,7 +185,7 @@ describe('StdioTransport', () => { return callCount === 1 ? mockTransportInstance1 : mockTransportInstance2; }); - const transport = new StdioTransport(); + const transport = new StdioTransport(APP_CONFIG); // First start await transport.start(mockServer); diff --git a/packages/mcp-server/src/tests/server/transport/streamable-http.test.ts b/packages/mcp-server/src/tests/server/transport/streamable-http.test.ts index 06e8d293..aed28f17 100644 --- a/packages/mcp-server/src/tests/server/transport/streamable-http.test.ts +++ b/packages/mcp-server/src/tests/server/transport/streamable-http.test.ts @@ -5,10 +5,13 @@ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import type { Request, Response } from 'express'; +import { snapshotConfig } from '../../../core/config.js'; import { StreamableHttpTransport } from '../../../server/transport/streamable-http.js'; const HOST = '127.0.0.1'; const PATH = '/mcp'; +const DISABLED_APP_CONFIG = snapshotConfig({ privateKey: undefined, walletMode: 'disabled', walletApiKey: undefined }); +const PRIVATE_KEY_APP_CONFIG = snapshotConfig({ privateKey: `0x${'1'.repeat(64)}`, walletMode: 'private-key', walletApiKey: undefined }); async function listenOnRandomPort(server: Server): Promise { await new Promise((resolve, reject) => { @@ -60,7 +63,7 @@ describe('StreamableHttpTransport', () => { throw new Error(`process.exit called with code ${code}`); }); const listenFactory = jest.fn(); - const transport = new StreamableHttpTransport({ port: 8080, host: HOST, path: PATH, walletMode: 'private-key' }, { listenFactory }); + const transport = new StreamableHttpTransport({ port: 8080, host: HOST, path: PATH, appConfig: PRIVATE_KEY_APP_CONFIG }, { listenFactory }); await expect(transport.start()).rejects.toThrow('process.exit called with code 1'); expect(processExit).toHaveBeenCalledWith(1); @@ -71,7 +74,7 @@ describe('StreamableHttpTransport', () => { it('resolves start only after listening and rejects an empty host', async () => { consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); const bootstrap = { close: jest.fn() } as unknown as McpServer; - const transport = new StreamableHttpTransport({ port: 0, host: HOST, path: PATH }); + const transport = new StreamableHttpTransport({ port: 0, host: HOST, path: PATH, appConfig: DISABLED_APP_CONFIG }); transports.push(transport); await transport.start(bootstrap); @@ -81,16 +84,16 @@ describe('StreamableHttpTransport', () => { expect(health.status).toBe(200); expect(await health.json()).toMatchObject({ status: 'ok' }); - const invalidHost = new StreamableHttpTransport({ port: 8080, host: ' ', path: PATH }); + const invalidHost = new StreamableHttpTransport({ port: 8080, host: ' ', path: PATH, appConfig: DISABLED_APP_CONFIG }); await expect(invalidHost.start(bootstrap)).rejects.toThrow('SERVER_HOST must not be empty'); - const invalidLimit = new StreamableHttpTransport({ port: 8080, host: HOST, path: PATH, maxActiveRequests: 0 }); + const invalidLimit = new StreamableHttpTransport({ port: 8080, host: HOST, path: PATH, appConfig: DISABLED_APP_CONFIG, maxActiveRequests: 0 }); await expect(invalidLimit.start(bootstrap)).rejects.toThrow('STREAMABLE_HTTP_MAX_REQUESTS must be a positive integer'); }); it('serves a real local MCP client request', async () => { consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); const transport = new StreamableHttpTransport( - { port: 0, host: HOST, path: PATH }, + { port: 0, host: HOST, path: PATH, appConfig: DISABLED_APP_CONFIG }, { serverFactory: async () => { const server = new McpServer({ name: 'request-server', version: '1.0.0' }); @@ -141,7 +144,10 @@ describe('StreamableHttpTransport', () => { }) }) as unknown as StreamableHTTPServerTransport ); - const transport = new StreamableHttpTransport({ port: 0, host: HOST, path: PATH, maxActiveRequests: 1 }, { serverFactory, transportFactory }); + const transport = new StreamableHttpTransport( + { port: 0, host: HOST, path: PATH, appConfig: DISABLED_APP_CONFIG, maxActiveRequests: 1 }, + { serverFactory, transportFactory } + ); transports.push(transport); await transport.start(); const url = `http://${HOST}:${transport.getListeningPort()}${PATH}`; @@ -171,7 +177,7 @@ describe('StreamableHttpTransport', () => { const occupied = createServer(); occupiedServers.push(occupied); const port = await listenOnRandomPort(occupied); - const transport = new StreamableHttpTransport({ port, host: HOST, path: PATH }); + const transport = new StreamableHttpTransport({ port, host: HOST, path: PATH, appConfig: DISABLED_APP_CONFIG }); transports.push(transport); try { @@ -190,7 +196,10 @@ describe('StreamableHttpTransport', () => { const transportFactory = jest.fn(() => { throw new Error('simulated request transport constructor failure'); }); - const transport = new StreamableHttpTransport({ port: 0, host: HOST, path: PATH, maxActiveRequests: 1 }, { serverFactory, transportFactory }); + const transport = new StreamableHttpTransport( + { port: 0, host: HOST, path: PATH, appConfig: DISABLED_APP_CONFIG, maxActiveRequests: 1 }, + { serverFactory, transportFactory } + ); transports.push(transport); await transport.start(); const url = `http://${HOST}:${transport.getListeningPort()}${PATH}`; @@ -231,7 +240,7 @@ describe('StreamableHttpTransport', () => { } as unknown as StreamableHTTPServerTransport; const transport = new StreamableHttpTransport( - { port: 0, host: HOST, path: PATH }, + { port: 0, host: HOST, path: PATH, appConfig: DISABLED_APP_CONFIG }, { serverFactory: async () => requestServer, transportFactory: () => requestTransport @@ -280,7 +289,7 @@ describe('StreamableHttpTransport', () => { }) } as unknown as StreamableHTTPServerTransport; const transport = new StreamableHttpTransport( - { port: 0, host: HOST, path: PATH }, + { port: 0, host: HOST, path: PATH, appConfig: DISABLED_APP_CONFIG }, { serverFactory: async () => requestServer, transportFactory: () => requestTransport @@ -323,7 +332,7 @@ describe('StreamableHttpTransport', () => { }) as typeof delayedServer.close; let listenCount = 0; const transport = new StreamableHttpTransport( - { port: 0, host: HOST, path: PATH }, + { port: 0, host: HOST, path: PATH, appConfig: DISABLED_APP_CONFIG }, { serverFactory: async () => new McpServer({ name: 'request', version: '1.0.0' }), transportFactory: () => ({ close: jest.fn() }) as unknown as StreamableHTTPServerTransport, @@ -363,7 +372,7 @@ describe('StreamableHttpTransport', () => { }) } as unknown as StreamableHTTPServerTransport; const transport = new StreamableHttpTransport( - { port: 0, host: HOST, path: PATH }, + { port: 0, host: HOST, path: PATH, appConfig: DISABLED_APP_CONFIG }, { serverFactory: async () => requestServer, transportFactory: () => requestTransport diff --git a/packages/mcp-server/src/tests/server/transport/wallet-isolation.test.ts b/packages/mcp-server/src/tests/server/transport/wallet-isolation.test.ts new file mode 100644 index 00000000..bd7dbf49 --- /dev/null +++ b/packages/mcp-server/src/tests/server/transport/wallet-isolation.test.ts @@ -0,0 +1,214 @@ +import { afterEach, describe, expect, it, jest } from 'bun:test'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { privateKeyToAccount } from 'viem/accounts'; +import { type AppConfigSnapshot, config, initializeConfig, isWalletEnabled, runWithAppConfig, snapshotConfig } from '../../../core/config.js'; +import { getWalletProvider, resetWalletProvider } from '../../../core/wallet/index.js'; +import { getServer } from '../../../server/server.js'; +import { HttpSseTransport } from '../../../server/transport/http-sse.js'; +import { StreamableHttpTransport } from '../../../server/transport/streamable-http.js'; + +const HOST = '127.0.0.1'; +const PATH = '/mcp'; +const PRIVATE_KEY = '1'.repeat(64); +// Intentionally mirrors the EVM read-only policy plus search_docs without +// importing its implementation, so additions require explicit review here. +const EXPECTED_HTTP_TOOL_NAMES = [ + 'check_nft_ownership', + 'estimate_gas', + 'get_balance', + 'get_block_by_number', + 'get_chain_info', + 'get_erc1155_balance', + 'get_erc1155_token_uri', + 'get_erc20_balance', + 'get_latest_block', + 'get_nft_balance', + 'get_nft_info', + 'get_supported_networks', + 'get_token_balance', + 'get_token_balance_erc20', + 'get_token_info', + 'get_transaction', + 'get_transaction_receipt', + 'is_contract', + 'read_contract', + 'search_docs' +].sort(); + +type HttpMode = 'http-sse' | 'streamable-http'; + +async function listTools(mode: HttpMode, url: URL): Promise { + const client = new Client({ name: `wallet-isolation-${mode}`, version: '1.0.0' }); + const transport = mode === 'http-sse' ? new SSEClientTransport(url) : new StreamableHTTPClientTransport(url); + await client.connect(transport); + try { + const listed = await client.listTools(); + return listed.tools.map((tool) => tool.name).sort(); + } finally { + await client.close(); + } +} + +async function deriveAddressFromPrivateKey(client: Client): Promise { + const result = await client.callTool({ name: 'get_address_from_private_key', arguments: {} }); + expect(result.isError).toBeFalsy(); + if (!('content' in result)) throw new Error('Expected an immediate tool result'); + const content = (result as { content: Array<{ type: string; text?: string }> }).content[0]; + if (content?.type !== 'text' || content.text === undefined) throw new Error('Expected a text tool result'); + return JSON.parse(content.text).address; +} + +describe('HTTP wallet isolation across later starts', () => { + const transports: Array = []; + let consoleErrorSpy: jest.SpiedFunction; + const originalConfig = { ...config }; + + afterEach(async () => { + await Promise.allSettled(transports.splice(0).map((transport) => transport.stop())); + consoleErrorSpy?.mockRestore(); + Object.assign(config, originalConfig); + delete process.env.WALLET_MODE; + delete process.env.PRIVATE_KEY; + }); + + it.each(['http-sse', 'streamable-http'] as const)('keeps %s sessions read-only after a later private-key initializeConfig', async (mode) => { + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + const appConfig = snapshotConfig({ + privateKey: undefined, + walletMode: 'disabled', + walletApiKey: undefined + }); + const transport = + mode === 'http-sse' + ? new HttpSseTransport({ port: 0, host: HOST, path: PATH, appConfig }) + : new StreamableHttpTransport({ port: 0, host: HOST, path: PATH, appConfig }); + transports.push(transport); + await transport.start(); + + const url = new URL(`http://${HOST}:${transport.getListeningPort()}${PATH}`); + expect(await listTools(mode, url)).toEqual(EXPECTED_HTTP_TOOL_NAMES); + + process.env.WALLET_MODE = 'private-key'; + process.env.PRIVATE_KEY = PRIVATE_KEY; + expect(await listTools(mode, url)).toEqual(EXPECTED_HTTP_TOOL_NAMES); + expect(isWalletEnabled()).toBe(false); + + initializeConfig({ WALLET_MODE: 'private-key', PRIVATE_KEY: PRIVATE_KEY }); + process.env.WALLET_MODE = 'disabled'; + delete process.env.PRIVATE_KEY; + expect(isWalletEnabled()).toBe(true); + expect(await listTools(mode, url)).toEqual(EXPECTED_HTTP_TOOL_NAMES); + }); + + it.each(['http-sse', 'streamable-http'] as const)('snapshots a mutable %s appConfig at construction', async (mode) => { + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + const appConfig = { privateKey: undefined as string | undefined, walletMode: 'disabled' as 'disabled' | 'private-key', walletApiKey: undefined }; + const transport = + mode === 'http-sse' + ? new HttpSseTransport({ port: 0, host: HOST, path: PATH, appConfig: appConfig as unknown as AppConfigSnapshot }) + : new StreamableHttpTransport({ port: 0, host: HOST, path: PATH, appConfig: appConfig as unknown as AppConfigSnapshot }); + transports.push(transport); + await transport.start(); + + appConfig.walletMode = 'private-key'; + appConfig.privateKey = `0x${PRIVATE_KEY}`; + const url = new URL(`http://${HOST}:${transport.getListeningPort()}${PATH}`); + expect(await listTools(mode, url)).toEqual(EXPECTED_HTTP_TOOL_NAMES); + }); + + it('still terminates a direct private-key HTTP start before listen', async () => { + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + const processExit = jest.spyOn(process, 'exit').mockImplementation((code) => { + throw new Error(`process.exit called with code ${code}`); + }); + const listenFactory = jest.fn(); + const appConfig = snapshotConfig({ privateKey: `0x${PRIVATE_KEY}`, walletMode: 'private-key', walletApiKey: undefined }); + const streamable = new StreamableHttpTransport({ port: 8080, host: HOST, path: PATH, appConfig }, { listenFactory }); + const sse = new HttpSseTransport({ port: 8080, host: HOST, path: PATH, appConfig }, { listenFactory }); + + await expect(streamable.start()).rejects.toThrow('process.exit called with code 1'); + await expect(sse.start()).rejects.toThrow('process.exit called with code 1'); + expect(processExit).toHaveBeenCalledWith(1); + expect(listenFactory).not.toHaveBeenCalled(); + processExit.mockRestore(); + }); + + it.each(['http-sse', 'streamable-http'] as const)('rejects a wallet-enabled %s appConfig before listen', async (mode) => { + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + const processExit = jest.spyOn(process, 'exit').mockImplementation((code) => { + throw new Error(`process.exit called with code ${code}`); + }); + const listenFactory = jest.fn(); + const appConfig = snapshotConfig({ + privateKey: `0x${PRIVATE_KEY}`, + walletMode: 'private-key', + walletApiKey: undefined + }); + const options = { port: 8080, host: HOST, path: PATH, appConfig }; + const transport = mode === 'http-sse' ? new HttpSseTransport(options, { listenFactory }) : new StreamableHttpTransport(options, { listenFactory }); + + await expect(transport.start()).rejects.toThrow('process.exit called with code 1'); + expect(processExit).toHaveBeenCalledWith(1); + expect(listenFactory).not.toHaveBeenCalled(); + processExit.mockRestore(); + }); + + it.each(['http-sse', 'streamable-http'] as const)('requires an explicit appConfig for %s', (mode) => { + const options = { port: 8080, host: HOST, path: PATH }; + + expect(() => (mode === 'http-sse' ? new HttpSseTransport(options as never) : new StreamableHttpTransport(options as never))).toThrow( + 'appConfig is required.' + ); + }); +}); + +describe('stdio wallet isolation after another runtime stop', () => { + let consoleErrorSpy: jest.SpiedFunction; + const originalConfig = { ...config }; + + afterEach(() => { + consoleErrorSpy?.mockRestore(); + Object.assign(config, originalConfig); + delete process.env.WALLET_MODE; + delete process.env.PRIVATE_KEY; + }); + + it('keeps signing with the stdio snapshot after resetWalletProvider and a later initializeConfig', async () => { + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + const keyA = '1'.repeat(64); + const keyB = '2'.repeat(64); + const expectedAddress = privateKeyToAccount(`0x${keyA}`).address; + const otherAddress = privateKeyToAccount(`0x${keyB}`).address; + + initializeConfig({ WALLET_MODE: 'private-key', PRIVATE_KEY: keyA }); + const appConfig = snapshotConfig(); + const server = await getServer(appConfig); + // The real SDK transport verifies that registered handlers carry the + // snapshot without relying on transport.onmessage interception. + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: 'stdio-wallet-isolation', version: '1.0.0' }); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + try { + const listed = await client.listTools(); + expect(listed.tools.map((tool) => tool.name)).toContain('get_address_from_private_key'); + expect(await deriveAddressFromPrivateKey(client)).toBe(expectedAddress); + + initializeConfig({ WALLET_MODE: 'private-key', PRIVATE_KEY: keyB }); + const otherAppConfig = snapshotConfig(); + runWithAppConfig(otherAppConfig, () => getWalletProvider()); + resetWalletProvider(otherAppConfig); + expect(isWalletEnabled()).toBe(true); + expect(config.privateKey).toBe(`0x${keyB}`); + + expect(await deriveAddressFromPrivateKey(client)).toBe(expectedAddress); + expect(otherAddress).not.toBe(expectedAddress); + } finally { + await client.close(); + await server.close(); + } + }); +});