From a5cf7180f99c740ea16d21a5cfd2ff7013265bce Mon Sep 17 00:00:00 2001 From: kugouming Date: Wed, 2 Sep 2026 20:15:52 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix:=20=E4=BC=9A=E8=AF=9D=E8=BF=87=E6=9C=9F?= =?UTF-8?q?=E5=90=8E=E9=80=8F=E6=98=8E=E9=87=8D=E5=BB=BA=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=EF=BC=8C=E5=B7=A5=E5=85=B7=E8=B0=83=E7=94=A8?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E9=87=8D=E8=AF=95=E6=81=A2=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tool-router 的 callTool 遇到 -32001 会话过期错误时作废连接并重试(最多 maxConnections+1 次),非会话错误与工具级错误仍快速失败 - server-mode 将失效的 mcp-session-id 视为句柄按原 id 重建会话,不再返回 404/-32001;会话清理空闲超时调整为 30 分钟 - session-manager 支持 createSession 传入显式 id 重建会话 - 补充 tool-router / session-manager 单元测试及集成测试,改用全局 fetch mock 替代 node-fetch --- .gitignore | 1 + src/routing/tool-router.ts | 128 ++++---- src/server-mode.ts | 138 +++++---- src/session/session-manager.ts | 6 +- .../backend-session-expiry.test.ts | 89 +++++- tests/integration/server-mode.test.ts | 57 +++- .../sse-reconnect-recovery.test.ts | 24 +- tests/unit/routing/tool-router.test.ts | 280 +++++++++++++++++- tests/unit/session/session-manager.test.ts | 69 +++++ 9 files changed, 662 insertions(+), 130 deletions(-) create mode 100644 tests/unit/session/session-manager.test.ts diff --git a/.gitignore b/.gitignore index 2d6dfca..314284a 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ build/ .vscode/ .idea/ .kiro/ +.claude/ *.swp *.swo *~ diff --git a/src/routing/tool-router.ts b/src/routing/tool-router.ts index fa2ccde..e14551d 100644 --- a/src/routing/tool-router.ts +++ b/src/routing/tool-router.ts @@ -728,6 +728,9 @@ export class ToolRouter extends EventEmitter { // re-establishes it. Any other connection failure (backend down, // timeout) would just fail again on a fresh connection, so fail fast. if (this.isSessionExpiryError(error) && attempt + 1 < maxAttempts) { + log.warn( + `[${service.name}] Backend session expired (tools/list), invalidating connection ${connection.id} and retrying` + ); continue; } throw error; @@ -962,62 +965,87 @@ export class ToolRouter extends EventEmitter { // Validate parameters against tool schema (Requirement 5.2) this.validateToolParameters(tool, params, context); - let connection: Connection; - try { - connection = await pool.acquire(); - } catch (error) { - throw this.createToolError( - ErrorCode.CONNECTION_POOL_EXHAUSTED, - `Failed to acquire connection for service: ${serviceName}`, - context, - { serviceName, toolName }, - error as Error - ); - } + // A backend session (SSE/HTTP) can expire after idle, failing the call + // with a JSON-RPC -32001 error while the transport itself still looks + // healthy (isConnected stays true). On such a failure we invalidate the + // stale connection and retry so the call transparently re-initializes the + // backend session instead of surfacing an error to the client. Allow up to + // maxConnections invalidations plus one final attempt that forces a fresh + // connection. Non-session connection failures (backend down, timeout) and + // tool-level errors still fail fast — a fresh connection won't help. + const maxAttempts = Math.max(2, pool.maxConnections + 1); + for (let attempt = 0; ; attempt++) { + let connection: Connection; + try { + connection = await pool.acquire(); + } catch (error) { + throw this.createToolError( + ErrorCode.CONNECTION_POOL_EXHAUSTED, + `Failed to acquire connection for service: ${serviceName}`, + context, + { serviceName, toolName }, + error as Error + ); + } - let connectionHandled = false; - try { - const result = await this.executeToolCall(connection, toolName, params, context); + let connectionHandled = false; + try { + const result = await this.executeToolCall(connection, toolName, params, context); - this.emit('toolCallSuccess', { - namespacedName, - serviceName, - toolName, - context, - }); + this.emit('toolCallSuccess', { + namespacedName, + serviceName, + toolName, + context, + }); - return result; - } catch (error) { - this.emit('toolCallError', { - namespacedName, - serviceName, - toolName, - context, - error, - }); + return result; + } catch (error) { + if (this.isConnectionLevelError(error)) { + await pool.markConnectionFailed( + connection, + error instanceof Error ? error : new Error(String(error)) + ); + connectionHandled = true; + // Retry only for a stale backend session: a fresh connection + // re-establishes it. Any other connection failure (backend down, + // timeout) would just fail again on a fresh connection, so fail fast. + if (this.isSessionExpiryError(error) && attempt + 1 < maxAttempts) { + log.warn( + `[${actualServiceName}] Backend session expired (tools/call ${toolName}), invalidating connection ${connection.id} and retrying` + ); + continue; + } + } else { + // Tool-level failure (bad params, backend-reported tool error): the + // connection itself is fine, release it instead of invalidating. + pool.release(connection); + connectionHandled = true; + } - if (this.isConnectionLevelError(error)) { - await pool.markConnectionFailed( - connection, - error instanceof Error ? error : new Error(String(error)) - ); - connectionHandled = true; - } + this.emit('toolCallError', { + namespacedName, + serviceName, + toolName, + context, + error, + }); - if (error instanceof Error && 'code' in error) { - throw error; - } + if (error instanceof Error && 'code' in error) { + throw error; + } - throw this.createToolError( - ErrorCode.INTERNAL_ERROR, - `Tool execution failed: ${(error as Error).message}`, - context, - { serviceName, toolName }, - error as Error - ); - } finally { - if (!connectionHandled) { - pool.release(connection); + throw this.createToolError( + ErrorCode.INTERNAL_ERROR, + `Tool execution failed: ${(error as Error).message}`, + context, + { serviceName, toolName }, + error as Error + ); + } finally { + if (!connectionHandled) { + pool.release(connection); + } } } } diff --git a/src/server-mode.ts b/src/server-mode.ts index dd82c9c..39a10ba 100644 --- a/src/server-mode.ts +++ b/src/server-mode.ts @@ -18,7 +18,7 @@ import { HealthMonitor } from './health/health-monitor.js'; import { ToolRouter } from './routing/tool-router.js'; import { ConnectionPool } from './pool/connection-pool.js'; import { getPackageVersion } from './utils/package-version.js'; -import { SessionManager, type SessionContext } from './session/session-manager.js'; +import { SessionManager, type Session, type SessionContext } from './session/session-manager.js'; import { MetricsService } from './metrics/service.js'; import type { ConfigProvider } from './types/config.js'; import type { RequestContext } from './types/context.js'; @@ -213,63 +213,17 @@ export class ServerModeRunner { const suppliedMcpSessionId = typeof request.headers['mcp-session-id'] === 'string'; if (!session && suppliedMcpSessionId) { - void reply.code(404).send({ - jsonrpc: '2.0', - id: null, - error: { - code: -32001, - message: 'MCP session not found', - }, - }); - return; + // The client's session was evicted (idle TTL) or lost (server restart). + // Treat the session id as a handle, not a living resource: recreate it + // under the same id so clients that don't re-initialize on 404 (MCP + // Inspector, some Claude Code versions) keep working transparently. + log.warn(`Client session ${sessionId} not found, recreating transparently`); + session = this.createSessionFromRequest(request, sessionId); } if (!session) { // Create new session for this client - const agentId = this.getAgentId(request); - - // Parse tag filter from HTTP header (X-MCP-Tags: "tag1,tag2,tag3") - let tagFilter: TagFilter | undefined; - const tagsHeader = request.headers['x-mcp-tags']; - if (tagsHeader && typeof tagsHeader === 'string') { - const tags = tagsHeader - .split(',') - .map((t) => t.trim()) - .filter((t) => t.length > 0); - if (tags.length > 0) { - tagFilter = { tags, logic: 'OR' }; - log.info(`Tag filter from header: ${tags.join(', ')} (OR logic)`); - } - } - - // Parse smart discovery override from HTTP header - // X-MCP-Smart-Discovery: false → disable smart discovery for this session - // X-MCP-Smart-Discovery: true → enable smart discovery for this session - // (absent) → use server default (--smart-discovery flag or default: disabled) - let sessionSmartDiscovery: boolean | undefined; - const smartDiscoveryHeader = request.headers['x-mcp-smart-discovery']; - if (typeof smartDiscoveryHeader === 'string') { - const val = smartDiscoveryHeader.trim().toLowerCase(); - if (val === 'false' || val === '0' || val === 'off') { - sessionSmartDiscovery = false; - } else if (val === 'true' || val === '1' || val === 'on') { - sessionSmartDiscovery = true; - } - if (sessionSmartDiscovery !== undefined) { - log.info( - `Smart discovery from header: ${sessionSmartDiscovery ? 'enabled' : 'disabled'}` - ); - } - } - - const sessionContext: SessionContext = {}; - if (tagFilter) { - sessionContext.tagFilter = tagFilter; - } - if (sessionSmartDiscovery !== undefined) { - sessionContext.smartDiscovery = sessionSmartDiscovery; - } - session = this.sessionManager.createSession(agentId, sessionContext); + session = this.createSessionFromRequest(request); } // Parse request body @@ -595,6 +549,78 @@ export class ServerModeRunner { return request.ip || 'unknown'; } + /** + * Create a session for a client request, parsing per-session header overrides. + * + * When sessionId is given (a client-presented handle whose stored session was + * evicted), the session is recreated under the same id. The recreated session + * is marked initialized unless this request is itself an initialize — a client + * re-initializing on a stale handle must be able to complete the handshake. + */ + private createSessionFromRequest(request: FastifyRequest, sessionId?: string): Session { + const agentId = this.getAgentId(request); + + // Parse tag filter from HTTP header (X-MCP-Tags: "tag1,tag2,tag3") + let tagFilter: TagFilter | undefined; + const tagsHeader = request.headers['x-mcp-tags']; + if (tagsHeader && typeof tagsHeader === 'string') { + const tags = tagsHeader + .split(',') + .map((t) => t.trim()) + .filter((t) => t.length > 0); + if (tags.length > 0) { + tagFilter = { tags, logic: 'OR' }; + log.info(`Tag filter from header: ${tags.join(', ')} (OR logic)`); + } + } + + // Parse smart discovery override from HTTP header + // X-MCP-Smart-Discovery: false → disable smart discovery for this session + // X-MCP-Smart-Discovery: true → enable smart discovery for this session + // (absent) → use server default (--smart-discovery flag or default: disabled) + let sessionSmartDiscovery: boolean | undefined; + const smartDiscoveryHeader = request.headers['x-mcp-smart-discovery']; + if (typeof smartDiscoveryHeader === 'string') { + const val = smartDiscoveryHeader.trim().toLowerCase(); + if (val === 'false' || val === '0' || val === 'off') { + sessionSmartDiscovery = false; + } else if (val === 'true' || val === '1' || val === 'on') { + sessionSmartDiscovery = true; + } + if (sessionSmartDiscovery !== undefined) { + log.info(`Smart discovery from header: ${sessionSmartDiscovery ? 'enabled' : 'disabled'}`); + } + } + + const sessionContext: SessionContext = {}; + if (tagFilter) { + sessionContext.tagFilter = tagFilter; + } + if (sessionSmartDiscovery !== undefined) { + sessionContext.smartDiscovery = sessionSmartDiscovery; + } + if (sessionId) { + sessionContext.initialized = !this.isInitializeRequest(request); + } + return this.sessionManager.createSession(agentId, sessionContext, sessionId); + } + + /** + * Whether the request body is a JSON-RPC initialize request. + */ + private isInitializeRequest(request: FastifyRequest): boolean { + try { + const body = request.body; + const parsed = + typeof body === 'string' + ? (JSON.parse(body) as { method?: unknown }) + : (body as { method?: unknown } | null | undefined); + return parsed?.method === 'initialize'; + } catch { + return false; + } + } + /** * Start the Server mode runner * @@ -641,7 +667,9 @@ export class ServerModeRunner { } // Start session cleanup - void this.sessionManager.startAutoCleanup(60000, 300000); // Cleanup every minute, 5 min timeout + // Idle GC only — a stale handle is transparently recreated on use, so + // eviction is a memory-hygiene concern, not a correctness one. + void this.sessionManager.startAutoCleanup(60000, 1800000); // Cleanup every minute, 30 min timeout this.unwatchConfig = this.configProvider.watch((newConfig) => { log.info('Configuration change detected, reloading...'); diff --git a/src/session/session-manager.ts b/src/session/session-manager.ts index 9cb6ce5..ddf4be0 100644 --- a/src/session/session-manager.ts +++ b/src/session/session-manager.ts @@ -59,11 +59,13 @@ export class SessionManager { * * @param agentId - Identifier for the AI Agent * @param context - Session-specific context + * @param id - Optional explicit session id (used to recreate an evicted + * session under the same handle; callers must ensure the id is free) * @returns The created session */ - createSession(agentId: string, context: SessionContext = {}): Session { + createSession(agentId: string, context: SessionContext = {}, id?: string): Session { const session: Session = { - id: randomUUID(), + id: id ?? randomUUID(), agentId, createdAt: new Date(), lastActivity: new Date(), diff --git a/tests/integration/backend-session-expiry.test.ts b/tests/integration/backend-session-expiry.test.ts index 9b74b62..5bd9de8 100644 --- a/tests/integration/backend-session-expiry.test.ts +++ b/tests/integration/backend-session-expiry.test.ts @@ -57,11 +57,12 @@ function createMockConfigProvider(): ConfigProvider { /** * Start a minimal Streamable HTTP MCP backend. * - * Each session may serve exactly one non-initialize request before it is - * reported as expired (`-32001 Session not found or expired`), modelling the - * idle-timeout behaviour of the real jymcp backend without wall-clock timing. + * Each session may serve exactly `requestsPerSession` non-initialize request(s) + * before it is reported as expired (`-32001 Session not found or expired`), + * modelling the idle-timeout behaviour of the real jymcp backend without + * wall-clock timing. */ -function startMockBackend(): Promise<{ +function startMockBackend(requestsPerSession = 1): Promise<{ url: string; close: () => Promise; initializeCount: () => number; @@ -102,7 +103,7 @@ function startMockBackend(): Promise<{ if (msg['method'] === 'initialize') { initializeCount++; const sessionId = `sess-${++sidCounter}`; - sessions.set(sessionId, 1); + sessions.set(sessionId, requestsPerSession); sendJson( 200, { @@ -129,7 +130,11 @@ function startMockBackend(): Promise<{ if (msg['method'] === 'tools/list') { sendJson(200, { jsonrpc: '2.0', id: msg['id'], result: { tools: TOOLS } }); } else { - sendJson(200, { jsonrpc: '2.0', id: msg['id'], result: {} }); + sendJson(200, { + jsonrpc: '2.0', + id: msg['id'], + result: { content: [{ type: 'text', text: 'ok' }] }, + }); } return; } @@ -216,4 +221,76 @@ describe('Backend session expiry recovery (integration)', () => { await pool.closeAll().catch(() => {}); } }, 30000); + + it('recovers tools/call after the backend session expires', async () => { + // Two requests per session: one for callTool's internal tools/list lookup + // (findTool queries the backend live) and one for the actual tools/call. + backend = await startMockBackend(2); + + const service: ServiceDefinition = { + name: 'mock-http', + enabled: true, + tags: [], + transport: 'http', + url: backend.url, + connectionPool: { maxConnections: 2, idleTimeout: 60000, connectionTimeout: 10000 }, + } as ServiceDefinition; + const poolConfig: ConnectionPoolConfig = { + maxConnections: 2, + idleTimeout: 60000, + connectionTimeout: 10000, + }; + + const configProvider = createMockConfigProvider(); + const serviceRegistry = new ServiceRegistry(configProvider); + await serviceRegistry.initialize(); + await serviceRegistry.register(service); + + const namespaceManager = new NamespaceManager(); + const healthMonitor = new HealthMonitor(serviceRegistry); + const toolRouter = new ToolRouter(serviceRegistry, namespaceManager, healthMonitor); + + const pool = new ConnectionPool(service, poolConfig); + pool.on('error', () => {}); + toolRouter.registerConnectionPool(service.name, pool); + + try { + // Discovery uses up the session's one allowed non-initialize request, so + // the pooled connection now holds an expired session. + const tools = await toolRouter.discoverTools(); + expect(tools.map((t) => t.name)).toEqual(['alpha', 'beta']); + + // The first tools/call hits the expired session (-32001). The router must + // invalidate the stale connection, re-initialize a fresh backend session + // and succeed transparently — the caller just sees the tool result. + const result = (await toolRouter.callTool( + 'mock-http__alpha', + {}, + { + requestId: 'req-1', + correlationId: 'corr-1', + timestamp: new Date(), + } + )) as { content: Array<{ type: string; text: string }> }; + expect(result.content[0]?.text).toBe('ok'); + + // Exactly two sessions were created: the original + one re-initialized. + expect(backend.initializeCount()).toBe(2); + + // The rebuilt session stays usable for a subsequent call on the same + // pooled connection. + const result2 = (await toolRouter.callTool( + 'mock-http__beta', + {}, + { + requestId: 'req-2', + correlationId: 'corr-2', + timestamp: new Date(), + } + )) as { content: Array<{ type: string; text: string }> }; + expect(result2.content[0]?.text).toBe('ok'); + } finally { + await pool.closeAll().catch(() => {}); + } + }, 30000); }); diff --git a/tests/integration/server-mode.test.ts b/tests/integration/server-mode.test.ts index ab60fc4..571cafd 100644 --- a/tests/integration/server-mode.test.ts +++ b/tests/integration/server-mode.test.ts @@ -543,7 +543,7 @@ describe('Server Mode Integration Tests', () => { expect(Array.isArray(diagData.sessions.list)).toBe(true); }); - it('should reject a deleted standard MCP session ID', async () => { + it('recreates a deleted session ID transparently when it is reused', async () => { await runner.start(); const initializeResponse = await fetch(`http://localhost:${testPort}/mcp`, { @@ -565,6 +565,9 @@ describe('Server Mode Integration Tests', () => { }); expect(deleteResponse.status).toBe(200); + // The session id is a handle: reusing a deleted/evicted id transparently + // recreates the session, so clients that don't re-initialize on 404 keep + // working instead of seeing "MCP session not found". const reuseResponse = await fetch(`http://localhost:${testPort}/mcp`, { method: 'POST', headers: { @@ -573,9 +576,55 @@ describe('Server Mode Integration Tests', () => { }, body: JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }), }); - expect(reuseResponse.status).toBe(404); - const responseBody = (await reuseResponse.json()) as { error?: { code?: number } }; - expect(responseBody.error?.code).toBe(-32001); + expect(reuseResponse.status).toBe(200); + const responseBody = (await reuseResponse.json()) as { error?: unknown; result?: unknown }; + expect(responseBody.error).toBeUndefined(); + expect(responseBody.result).toBeDefined(); + }); + + it('lets a client re-initialize on a deleted session ID (same handle echoed back)', async () => { + await runner.start(); + + const initializeResponse = await fetch(`http://localhost:${testPort}/mcp`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: '2024-11-05' }, + }), + }); + const sessionId = initializeResponse.headers.get('mcp-session-id'); + expect(sessionId).not.toBeNull(); + + const deleteResponse = await fetch(`http://localhost:${testPort}/mcp`, { + method: 'DELETE', + headers: { 'mcp-session-id': sessionId ?? '' }, + }); + expect(deleteResponse.status).toBe(200); + + // Spec-compliant recovery: the client re-initializes on the stale handle. + // The recreated session starts uninitialized so the handshake completes, + // and the same handle is echoed back. + const reinitResponse = await fetch(`http://localhost:${testPort}/mcp`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'mcp-session-id': sessionId ?? '', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 2, + method: 'initialize', + params: { protocolVersion: '2024-11-05' }, + }), + }); + expect(reinitResponse.status).toBe(200); + const reinitBody = (await reinitResponse.json()) as { error?: unknown; result?: unknown }; + expect(reinitBody.error).toBeUndefined(); + expect(reinitBody.result).toBeDefined(); + expect(reinitResponse.headers.get('mcp-session-id')).toBe(sessionId); }); }); diff --git a/tests/integration/sse-reconnect-recovery.test.ts b/tests/integration/sse-reconnect-recovery.test.ts index 36c1acc..510d1ad 100644 --- a/tests/integration/sse-reconnect-recovery.test.ts +++ b/tests/integration/sse-reconnect-recovery.test.ts @@ -12,7 +12,7 @@ * or returns a clear degraded error (not a stale dead-connection response) * * HttpTransport and ConnectionPool are real; only the network primitives - * (eventsource, node-fetch) are mocked so the test can drive SSE connectivity + * (eventsource, global fetch) are mocked so the test can drive SSE connectivity * deterministically. This verifies the real chain: * SSE errors → handleSSEError reconnects → max attempts → handleError * → transport state ERROR + 'error' emitted @@ -24,14 +24,12 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { EventEmitter } from 'events'; import EventSource from 'eventsource'; -import fetch from 'node-fetch'; import { ConnectionPool } from '../../src/pool/connection-pool.js'; import { TransportState } from '../../src/transport/base.js'; import type { ServiceDefinition, ConnectionPoolConfig } from '../../src/types/service.js'; import type { JsonRpcMessage } from '../../src/types/jsonrpc.js'; vi.mock('eventsource'); -vi.mock('node-fetch'); const POOL_CONFIG: ConnectionPoolConfig = { maxConnections: 3, @@ -108,13 +106,18 @@ describe('Backend death does not permanently break tool discovery', () => { return es as unknown as EventSource; }); - vi.mocked(fetch).mockResolvedValue({ - ok: true, - status: 200, - statusText: 'OK', - headers: { get: () => null }, - text: () => Promise.resolve(''), - } as never); + // HttpTransport POSTs via the global fetch (node-fetch was removed from + // the deps); stub it so the dummy backend URL never sees real traffic. + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ + ok: true, + status: 200, + statusText: 'OK', + headers: { get: () => null }, + text: () => Promise.resolve(''), + })) + ); const service: ServiceDefinition = { name: 'mock-sse', @@ -132,6 +135,7 @@ describe('Backend death does not permanently break tool discovery', () => { if (pool) { await pool.closeAll(); } + vi.unstubAllGlobals(); vi.useRealTimers(); vi.restoreAllMocks(); }); diff --git a/tests/unit/routing/tool-router.test.ts b/tests/unit/routing/tool-router.test.ts index 6d2fad7..264b9b9 100644 --- a/tests/unit/routing/tool-router.test.ts +++ b/tests/unit/routing/tool-router.test.ts @@ -2162,8 +2162,9 @@ describe('ToolRouter', () => { lastUsed: new Date(), createdAt: new Date(), }; + const acquire = vi.fn().mockResolvedValue(mockConnection); const mockPool = { - acquire: vi.fn().mockResolvedValue(mockConnection), + acquire, release: vi.fn(), markConnectionFailed: vi.fn().mockResolvedValue(undefined), maxConnections: 1, @@ -2180,6 +2181,11 @@ describe('ToolRouter', () => { }; const findToolSpy = vi.spyOn(toolRouter as any, 'findTool').mockResolvedValue(mockTool); + const errorSpy = vi.fn(); + const successSpy = vi.fn(); + toolRouter.on('toolCallError', errorSpy); + toolRouter.on('toolCallSuccess', successSpy); + const context: RequestContext = { requestId: 'req-1', correlationId: 'corr-1', @@ -2190,9 +2196,277 @@ describe('ToolRouter', () => { 'Session not found' ); - // The stale session connection must be dropped so the next call reconnects. - expect(mockPool.markConnectionFailed).toHaveBeenCalledTimes(1); + // Every acquire hands back the same stale connection, so the bounded + // retry (max(2, maxConnections + 1) = 2 attempts) invalidates it twice + // before surfacing the expiry error to the client. + expect(acquire).toHaveBeenCalledTimes(2); + expect(mockPool.markConnectionFailed).toHaveBeenCalledTimes(2); expect(mockPool.markConnectionFailed).toHaveBeenCalledWith(mockConnection, expect.any(Error)); + expect(mockPool.release).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(successSpy).not.toHaveBeenCalled(); + + findToolSpy.mockRestore(); + }); + + it('should transparently retry a tool call after the backend session expires', async () => { + const service: ServiceDefinition = { + name: 'test-service', + enabled: true, + tags: [], + transport: 'http', + url: 'http://127.0.0.1:9999/mcp', + connectionPool: { maxConnections: 5, idleTimeout: 60000, connectionTimeout: 30000 }, + }; + await serviceRegistry.register(service); + + const makeTransport = (expired: boolean) => { + let requestId = ''; + return { + send: vi.fn(async (request: { id?: string | number }) => { + requestId = String(request.id ?? ''); + }), + receive: vi.fn().mockReturnValue({ + next: vi.fn().mockImplementation(async () => ({ + done: false as const, + value: expired + ? { + jsonrpc: '2.0', + id: requestId, + error: { + code: -32001, + message: 'Session not found or expired. Please send initialize again.', + }, + } + : { + jsonrpc: '2.0', + id: requestId, + result: { content: [{ type: 'text', text: 'ok' }] }, + }, + })), + return: vi.fn().mockResolvedValue({ done: true }), + }), + close: vi.fn().mockResolvedValue(undefined), + getType: vi.fn().mockReturnValue('http'), + isConnected: vi.fn().mockReturnValue(true), + }; + }; + + const staleTransport = makeTransport(true); + const freshTransport = makeTransport(false); + const staleConnection = { + id: 'conn-stale', + transport: staleTransport, + state: 'idle' as const, + lastUsed: new Date(), + createdAt: new Date(), + }; + const freshConnection = { + id: 'conn-fresh', + transport: freshTransport, + state: 'idle' as const, + lastUsed: new Date(), + createdAt: new Date(), + }; + + const acquire = vi + .fn() + .mockResolvedValueOnce(staleConnection) + .mockResolvedValueOnce(freshConnection); + const mockPool = { + acquire, + release: vi.fn(), + markConnectionFailed: vi.fn().mockResolvedValue(undefined), + maxConnections: 1, + } as any; + toolRouter.registerConnectionPool('test-service', mockPool); + + const mockTool: Tool = { + name: 'test_tool', + namespacedName: 'test-service__test_tool', + serviceName: 'test-service', + description: 'Test tool', + inputSchema: { type: 'object', properties: {} }, + enabled: true, + }; + const findToolSpy = vi.spyOn(toolRouter as any, 'findTool').mockResolvedValue(mockTool); + + const successSpy = vi.fn(); + const errorSpy = vi.fn(); + toolRouter.on('toolCallSuccess', successSpy); + toolRouter.on('toolCallError', errorSpy); + + const context: RequestContext = { + requestId: 'req-1', + correlationId: 'corr-1', + timestamp: new Date(), + }; + + const result = (await toolRouter.callTool('test-service__test_tool', {}, context)) as { + content: Array<{ text: string }>; + }; + + // The call recovers on the fresh connection instead of surfacing the error. + expect(result.content[0]?.text).toBe('ok'); + expect(acquire).toHaveBeenCalledTimes(2); + expect(mockPool.markConnectionFailed).toHaveBeenCalledTimes(1); + expect(mockPool.markConnectionFailed).toHaveBeenCalledWith( + staleConnection, + expect.any(Error) + ); + expect(mockPool.release).not.toHaveBeenCalledWith(staleConnection); + expect(mockPool.release).toHaveBeenCalledWith(freshConnection); + + // The same request is replayed on the new connection (same JSON-RPC id). + expect(staleTransport.send).toHaveBeenCalledTimes(1); + expect(freshTransport.send).toHaveBeenCalledTimes(1); + expect(staleTransport.send.mock.calls[0]?.[0]).toMatchObject({ id: 'req-1' }); + expect(freshTransport.send.mock.calls[0]?.[0]).toMatchObject({ id: 'req-1' }); + + // Transparent recovery: success is reported once, no phantom failure. + expect(successSpy).toHaveBeenCalledTimes(1); + expect(errorSpy).not.toHaveBeenCalled(); + + findToolSpy.mockRestore(); + }); + + it('should fail fast (no retry) when a tool call hits a non-session connection error', async () => { + const service: ServiceDefinition = { + name: 'test-service', + enabled: true, + tags: [], + transport: 'http', + url: 'http://127.0.0.1:9999/mcp', + connectionPool: { maxConnections: 5, idleTimeout: 60000, connectionTimeout: 30000 }, + }; + await serviceRegistry.register(service); + + const mockTransport = { + send: vi.fn().mockRejectedValue(new TransportError('Response timeout', 'RESPONSE_TIMEOUT')), + receive: vi.fn(), + close: vi.fn(), + getType: vi.fn().mockReturnValue('http'), + isConnected: vi.fn().mockReturnValue(true), + }; + const mockConnection = { + id: 'conn-1', + transport: mockTransport, + state: 'idle' as const, + lastUsed: new Date(), + createdAt: new Date(), + }; + + const acquire = vi.fn().mockResolvedValue(mockConnection); + const mockPool = { + acquire, + release: vi.fn(), + markConnectionFailed: vi.fn().mockResolvedValue(undefined), + maxConnections: 1, + } as any; + toolRouter.registerConnectionPool('test-service', mockPool); + + const mockTool: Tool = { + name: 'test_tool', + namespacedName: 'test-service__test_tool', + serviceName: 'test-service', + description: 'Test tool', + inputSchema: { type: 'object', properties: {} }, + enabled: true, + }; + const findToolSpy = vi.spyOn(toolRouter as any, 'findTool').mockResolvedValue(mockTool); + + const errorSpy = vi.fn(); + toolRouter.on('toolCallError', errorSpy); + + const context: RequestContext = { + requestId: 'req-1', + correlationId: 'corr-1', + timestamp: new Date(), + }; + + // A backend down / timeout failure cannot be fixed by a fresh connection. + await expect(toolRouter.callTool('test-service__test_tool', {}, context)).rejects.toThrow( + 'Response timeout' + ); + expect(acquire).toHaveBeenCalledTimes(1); + expect(mockPool.markConnectionFailed).toHaveBeenCalledTimes(1); + expect(errorSpy).toHaveBeenCalledTimes(1); + + findToolSpy.mockRestore(); + }); + + it('should release the connection and fail fast on a tool-level error', async () => { + const service: ServiceDefinition = { + name: 'test-service', + enabled: true, + tags: [], + transport: 'http', + url: 'http://127.0.0.1:9999/mcp', + connectionPool: { maxConnections: 5, idleTimeout: 60000, connectionTimeout: 30000 }, + }; + await serviceRegistry.register(service); + + let requestId = ''; + const mockTransport = { + send: vi.fn(async (request: { id?: string | number }) => { + requestId = String(request.id ?? ''); + }), + receive: vi.fn().mockReturnValue({ + next: vi.fn().mockImplementation(async () => ({ + done: false as const, + value: { + jsonrpc: '2.0', + id: requestId, + error: { code: -32602, message: 'Invalid tool arguments' }, + }, + })), + return: vi.fn().mockResolvedValue({ done: true }), + }), + close: vi.fn().mockResolvedValue(undefined), + getType: vi.fn().mockReturnValue('http'), + isConnected: vi.fn().mockReturnValue(true), + }; + const mockConnection = { + id: 'conn-1', + transport: mockTransport, + state: 'idle' as const, + lastUsed: new Date(), + createdAt: new Date(), + }; + + const acquire = vi.fn().mockResolvedValue(mockConnection); + const mockPool = { + acquire, + release: vi.fn(), + markConnectionFailed: vi.fn().mockResolvedValue(undefined), + maxConnections: 1, + } as any; + toolRouter.registerConnectionPool('test-service', mockPool); + + const mockTool: Tool = { + name: 'test_tool', + namespacedName: 'test-service__test_tool', + serviceName: 'test-service', + description: 'Test tool', + inputSchema: { type: 'object', properties: {} }, + enabled: true, + }; + const findToolSpy = vi.spyOn(toolRouter as any, 'findTool').mockResolvedValue(mockTool); + + const context: RequestContext = { + requestId: 'req-1', + correlationId: 'corr-1', + timestamp: new Date(), + }; + + // A backend-reported tool error is not a connection problem: the + // connection must go back to the pool intact and no retry may happen. + await expect(toolRouter.callTool('test-service__test_tool', {}, context)).rejects.toThrow( + 'Invalid tool arguments' + ); + expect(acquire).toHaveBeenCalledTimes(1); + expect(mockPool.markConnectionFailed).not.toHaveBeenCalled(); + expect(mockPool.release).toHaveBeenCalledWith(mockConnection); findToolSpy.mockRestore(); }); diff --git a/tests/unit/session/session-manager.test.ts b/tests/unit/session/session-manager.test.ts new file mode 100644 index 0000000..2dab434 --- /dev/null +++ b/tests/unit/session/session-manager.test.ts @@ -0,0 +1,69 @@ +/** + * Unit tests for SessionManager + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { SessionManager } from '../../../src/session/session-manager'; + +describe('SessionManager', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + describe('createSession', () => { + it('generates a random id when none is given', () => { + const manager = new SessionManager(); + const s1 = manager.createSession('agent-1'); + const s2 = manager.createSession('agent-1'); + + expect(s1.id).toBeTruthy(); + expect(s2.id).toBeTruthy(); + expect(s1.id).not.toBe(s2.id); + expect(manager.getSession(s1.id)?.agentId).toBe('agent-1'); + }); + + it('recreates a session under an explicit id (handle semantics)', () => { + const manager = new SessionManager(); + const session = manager.createSession('agent-1', { initialized: true }, 'fixed-id'); + + expect(session.id).toBe('fixed-id'); + expect(session.context.initialized).toBe(true); + expect(manager.getSession('fixed-id')).toBe(session); + }); + + it('allows recreating an evicted session with the same id and fresh context', () => { + vi.useFakeTimers(); + const manager = new SessionManager(); + manager.createSession('agent-1', { initialized: true }, 'fixed-id'); + + // Simulate eviction + vi.advanceTimersByTime(1000); + manager.cleanupExpiredSessions(0); + + expect(manager.getSession('fixed-id')).toBeUndefined(); + + const recreated = manager.createSession('agent-1', { initialized: true }, 'fixed-id'); + expect(recreated.id).toBe('fixed-id'); + expect(recreated.context.initialized).toBe(true); + expect(recreated.activeRequests).toBe(0); + }); + }); + + describe('cleanupExpiredSessions', () => { + it('only evicts sessions idle beyond the timeout with no active requests', () => { + vi.useFakeTimers(); + const manager = new SessionManager(); + const idle = manager.createSession('agent-1'); + const busy = manager.createSession('agent-1'); + manager.incrementActiveRequests(busy.id); + + vi.advanceTimersByTime(10 * 60 * 1000); + + manager.cleanupExpiredSessions(5 * 60 * 1000); + + // Idle session evicted; busy session survives until its requests finish. + expect(manager.getSession(idle.id)).toBeUndefined(); + expect(manager.getSession(busy.id)).toBeDefined(); + }); + }); +}); From 7cea1fbe3de857be75b022cab8b0541f6a1ba5e6 Mon Sep 17 00:00:00 2001 From: kugouming Date: Thu, 3 Sep 2026 00:39:42 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(routing):=20=E6=89=A9=E5=B1=95=E5=8F=AF?= =?UTF-8?q?=E9=87=8D=E8=BF=9E=E9=94=99=E8=AF=AF=E5=88=86=E7=B1=BB=E4=B8=8E?= =?UTF-8?q?=20404=20=E8=BF=87=E6=9C=9F=E8=AF=86=E5=88=AB=EF=BC=8CfindTool?= =?UTF-8?q?=20=E5=A4=8D=E7=94=A8=E5=8F=91=E7=8E=B0=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 src/routing/session-error.ts:统一会话过期(-32001/规范型 HTTP 404) 与可重连连接错误的分类,供路由与 TUI 共用 - callTool 与发现路径重试条件扩展至可重连错误(stdio 崩溃、SSE 断流、 RESPONSE_STREAM_ENDED),首调即透明恢复;超时/网络不可达保持快速失败 - 修正 404 过期识别:doSend 会将 HTTP_REQUEST_FAILED 二次包装为 HTTP_SEND_FAILED,按真实传播形态匹配 - findTool 复用发现缓存(60s TTL),每次调用减少一次后端 tools/list 往返 - TUI discovery-worker:会话过期自动重试(懒重建)+ HTTP 404 识别 - SessionManager 会话数上限护栏(10k,最旧空闲驱逐) - 新增单测 13 个、集成用例 3 个(404 恢复 / discovery-worker 三场景) --- src/routing/session-error.ts | 99 ++++++++++ src/routing/tool-router.ts | 93 +++++----- src/session/session-manager.ts | 31 ++++ src/tui/discovery-worker.ts | 27 +++ .../backend-session-expiry.test.ts | 84 ++++++++- .../discovery-worker-session-expiry.test.ts | 168 +++++++++++++++++ tests/integration/fixtures/mock-stdio-mcp.cjs | 8 + tests/unit/routing/session-error.test.ts | 120 +++++++++++++ tests/unit/routing/tool-router.test.ts | 170 ++++++++++++++++++ tests/unit/session/session-manager.test.ts | 29 +++ 10 files changed, 781 insertions(+), 48 deletions(-) create mode 100644 src/routing/session-error.ts create mode 100644 tests/integration/discovery-worker-session-expiry.test.ts create mode 100644 tests/unit/routing/session-error.test.ts diff --git a/src/routing/session-error.ts b/src/routing/session-error.ts new file mode 100644 index 0000000..1612cb0 --- /dev/null +++ b/src/routing/session-error.ts @@ -0,0 +1,99 @@ +/** + * Shared error classification for backend connection recovery. + * + * Used by the ToolRouter retry loops and the TUI discovery worker to decide + * whether a failure can be transparently recovered on a fresh connection. + */ + +import { TransportError } from '../transport/base.js'; + +/** + * Transport error codes meaning "this connection object is dead but the + * backend itself is reachable again on a fresh connection" (stdio process + * exit, SSE stream drop, ...). + * + * RESPONSE_STREAM_ENDED is included deliberately: the receive stream ended, + * which means the transport is definitively dead, and the error surfaces + * immediately (unlike RESPONSE_TIMEOUT, where retrying would double a 60s + * wait). Deliberately excluded: HTTP_TIMEOUT / RESPONSE_TIMEOUT / + * RESPONSE_MISMATCH (router-level waits — retrying would double the latency), + * network-unreachable errors (HTTP_SEND_FAILED — a fresh connection hits the + * same network), and acquire-phase errors (CONNECTION_FAILED, + * PROCESS_START_FAILED). + */ +const RECOVERABLE_CONNECTION_CODES = new Set([ + 'PROCESS_EXITED', + 'PROCESS_ERROR', + 'STDIN_UNAVAILABLE', + 'STDIN_DESTROYED', + 'STDIN_WRITE_FAILED', + 'TRANSPORT_CLOSED', + 'TRANSPORT_CLOSING', + 'TRANSPORT_ERROR', + 'SEND_FAILED', + 'SSE_CONNECTION_FAILED', + 'SSE_NOT_CONNECTED', + 'SSE_INIT_FAILED', + 'RESPONSE_STREAM_ENDED', +]); + +/** + * Whether an error indicates the backend session has expired or is no longer valid. + * + * SSE/HTTP backends often report an expired session as a JSON-RPC *error* + * (HTTP 200, code `-32001` or a "session ... not found/expired" message) rather + * than dropping the connection. Spec-conformant Streamable HTTP backends + * instead answer a stale `Mcp-Session-Id` with HTTP 404, which surfaces as a + * TransportError with code `HTTP_REQUEST_FAILED` and "status 404" in the + * message. In all these cases the connection must be invalidated and + * re-initialized — releasing it back into the pool would keep reusing the + * stale session and fail every subsequent tools/list / tools/call. + */ +export function isSessionExpiryError(error: unknown): boolean { + if (error instanceof TransportError && error.code === 'SESSION_EXPIRED') { + return true; + } + + if (error && typeof error === 'object' && 'code' in error) { + const code = (error as { code?: unknown }).code; + if (code === -32001) { + return true; + } + } + + if (error instanceof Error) { + // Ordered match only: "session" must precede the expiry signal so an + // unrelated error like "tool X not found in session Y" isn't misclassified. + if (/session\b.*\b(not found|expired)/i.test(error.message)) { + return true; + } + + // Spec-conformant backends answer a stale Mcp-Session-Id with HTTP 404. + // doSend surfaces non-2xx responses as HTTP_SEND_FAILED wrapping the inner + // HTTP_REQUEST_FAILED message ("...status 404: ..."), so match both codes. + // The SSE GET listen stream never produces these codes, so a 404 here + // cannot be a normal listen-stream 404 (which must not trigger a rebuild). + if ( + error instanceof TransportError && + (error.code === 'HTTP_SEND_FAILED' || error.code === 'HTTP_REQUEST_FAILED') && + /\bstatus 404\b/.test(error.message) + ) { + return true; + } + } + + return false; +} + +/** + * Whether an error means the connection object died but the backend is + * reachable again on a fresh connection (stdio respawn, SSE reconnect). + * Such failures are safe to retry once on a new connection. + */ +export function isRecoverableConnectionError(error: unknown): boolean { + return ( + error instanceof TransportError && + typeof error.code === 'string' && + RECOVERABLE_CONNECTION_CODES.has(error.code) + ); +} diff --git a/src/routing/tool-router.ts b/src/routing/tool-router.ts index e14551d..8dac346 100644 --- a/src/routing/tool-router.ts +++ b/src/routing/tool-router.ts @@ -23,6 +23,7 @@ import type { } from '../types/jsonrpc.js'; import { ErrorCode } from '../types/jsonrpc.js'; import { TransportError } from '../transport/base.js'; +import { isRecoverableConnectionError, isSessionExpiryError } from './session-error.js'; import { enhanceDescription } from '../protocol/description-enhancer.js'; import Ajv from 'ajv'; import { EventEmitter } from 'events'; @@ -217,21 +218,15 @@ export class ToolRouter extends EventEmitter { // For unfiltered queries, check per-service cache (Requirement 2.3) if (!tagFilter) { const services = this.serviceRegistry.list().filter((s) => s.enabled); - const now = Date.now(); const allCached: Tool[] = []; let allHit = services.length > 0; for (const service of services) { - const entry = this.serviceToolCache.get(service.name); - if (!entry) { + const cached = this.getFreshServiceCache(service.name); + if (!cached) { allHit = false; break; } - const ageMs = now - entry.timestamp.getTime(); - if (CACHE_TTL_MS !== 0 && ageMs >= CACHE_TTL_MS) { - allHit = false; - break; - } - allCached.push(...entry.tools); + allCached.push(...cached); } if (allHit) { return allCached; @@ -522,33 +517,11 @@ export class ToolRouter extends EventEmitter { /** * Whether an error indicates the backend session has expired or is no longer valid. * - * SSE/HTTP backends often report an expired session as a JSON-RPC *error* - * (HTTP 200, code `-32001` or a "session ... not found/expired" message) rather - * than dropping the connection. A connection whose backend session is gone must - * be invalidated and re-initialized — releasing it back into the pool would keep - * reusing the stale session and fail every subsequent tools/list / tools/call. + * Delegates to the shared classifier in session-error.ts (also used by the + * TUI discovery worker). */ private isSessionExpiryError(error: unknown): boolean { - if (error instanceof TransportError && error.code === 'SESSION_EXPIRED') { - return true; - } - - if (error && typeof error === 'object' && 'code' in error) { - const code = (error as { code?: unknown }).code; - if (code === -32001) { - return true; - } - } - - if (error instanceof Error) { - // Ordered match only: "session" must precede the expiry signal so an - // unrelated error like "tool X not found in session Y" isn't misclassified. - if (/session\b.*\b(not found|expired)/i.test(error.message)) { - return true; - } - } - - return false; + return isSessionExpiryError(error); } /** @@ -724,12 +697,16 @@ export class ToolRouter extends EventEmitter { error instanceof Error ? error : new Error(String(error)) ); connectionHandled = true; - // Retry only for a stale backend session: a fresh connection - // re-establishes it. Any other connection failure (backend down, - // timeout) would just fail again on a fresh connection, so fail fast. - if (this.isSessionExpiryError(error) && attempt + 1 < maxAttempts) { + // Retry when a fresh connection plausibly helps: an expired backend + // session (transparently re-initialized) or a dead-but-reconnectable + // transport (stdio respawn, SSE reconnect). Anything else (backend + // down, timeout) would just fail again on a fresh connection — fail fast. + if ( + (this.isSessionExpiryError(error) || isRecoverableConnectionError(error)) && + attempt + 1 < maxAttempts + ) { log.warn( - `[${service.name}] Backend session expired (tools/list), invalidating connection ${connection.id} and retrying` + `[${service.name}] Recoverable connection failure (tools/list), invalidating connection ${connection.id} and retrying` ); continue; } @@ -1007,12 +984,16 @@ export class ToolRouter extends EventEmitter { error instanceof Error ? error : new Error(String(error)) ); connectionHandled = true; - // Retry only for a stale backend session: a fresh connection - // re-establishes it. Any other connection failure (backend down, - // timeout) would just fail again on a fresh connection, so fail fast. - if (this.isSessionExpiryError(error) && attempt + 1 < maxAttempts) { + // Retry when a fresh connection plausibly helps: an expired backend + // session (transparently re-initialized) or a dead-but-reconnectable + // transport (stdio respawn, SSE reconnect). Anything else (backend + // down, timeout) would just fail again on a fresh connection — fail fast. + if ( + (this.isSessionExpiryError(error) || isRecoverableConnectionError(error)) && + attempt + 1 < maxAttempts + ) { log.warn( - `[${actualServiceName}] Backend session expired (tools/call ${toolName}), invalidating connection ${connection.id} and retrying` + `[${actualServiceName}] Recoverable connection failure (tools/call ${toolName}), invalidating connection ${connection.id} and retrying` ); continue; } @@ -1072,6 +1053,15 @@ export class ToolRouter extends EventEmitter { return null; } + // Serve from the discovery cache when fresh: callTool would otherwise pay + // a live tools/list round-trip on every invocation. Misses fall through to + // the live query below (the client's tools/list typically pre-populated + // the cache; invalidation hooks keep it current on health/config changes). + const cached = this.getFreshServiceCache(serviceName); + if (cached) { + return cached.find((t) => t.name === toolName) ?? null; + } + // Query tools from the service try { const tools = await this.queryServiceTools(service, pool); @@ -1081,6 +1071,21 @@ export class ToolRouter extends EventEmitter { } } + /** + * Fresh (non-expired) cached tool list for a single service, if any. + */ + private getFreshServiceCache(serviceName: string): Tool[] | undefined { + const entry = this.serviceToolCache.get(serviceName); + if (!entry) { + return undefined; + } + const ageMs = Date.now() - entry.timestamp.getTime(); + if (CACHE_TTL_MS !== 0 && ageMs >= CACHE_TTL_MS) { + return undefined; + } + return entry.tools; + } + /** * Validate tool parameters against the tool's input schema * diff --git a/src/session/session-manager.ts b/src/session/session-manager.ts index ddf4be0..9f4a982 100644 --- a/src/session/session-manager.ts +++ b/src/session/session-manager.ts @@ -51,6 +51,13 @@ export interface Session { * - Ensures session isolation */ export class SessionManager { + /** + * Upper bound on live sessions. Session handles are recreated transparently + * on use, so a buggy or hostile client presenting fresh ids per request + * would otherwise grow this map without bound until the idle TTL cleans it. + */ + private static readonly MAX_SESSIONS = 10_000; + private sessions: Map = new Map(); private cleanupInterval: NodeJS.Timeout | null = null; @@ -64,6 +71,10 @@ export class SessionManager { * @returns The created session */ createSession(agentId: string, context: SessionContext = {}, id?: string): Session { + if (this.sessions.size >= SessionManager.MAX_SESSIONS && !this.sessions.has(id ?? '')) { + this.evictOldestIdleSessions(); + } + const session: Session = { id: id ?? randomUUID(), agentId, @@ -77,6 +88,26 @@ export class SessionManager { return session; } + /** + * Evict the least-recently-active sessions without in-flight requests, + * oldest first, until the map is at most half full. Sessions that are + * evicted are transparently recreated on their next request (handle + * semantics), so eviction is safe. + */ + private evictOldestIdleSessions(): void { + const evictable = [...this.sessions.values()] + .filter((s) => s.activeRequests === 0) + .sort((a, b) => a.lastActivity.getTime() - b.lastActivity.getTime()); + + const target = Math.floor(SessionManager.MAX_SESSIONS / 2); + for (const session of evictable) { + if (this.sessions.size <= target) { + break; + } + this.sessions.delete(session.id); + } + } + /** * Get a session by ID * diff --git a/src/tui/discovery-worker.ts b/src/tui/discovery-worker.ts index 229b88a..ca595a7 100644 --- a/src/tui/discovery-worker.ts +++ b/src/tui/discovery-worker.ts @@ -5,6 +5,7 @@ import EventSource from 'eventsource'; import { StdioTransport } from '../transport/stdio.js'; +import { isSessionExpiryError } from '../routing/session-error.js'; import { getPackageVersion } from '../utils/package-version.js'; import type { ServiceDefinition } from '../types/service.js'; import type { Tool } from '../types/tool.js'; @@ -533,6 +534,16 @@ async function discoverToolsViaHttp(service: ServiceDefinition, timeout: number) }), }); + if (!toolsResponse.ok) { + // Per the MCP Streamable HTTP spec, a 404 on a request carrying + // Mcp-Session-Id means the backend terminated the session — word it so + // the session-expiry recovery in fetchServiceTools retries it. + if (toolsResponse.status === 404) { + throw new Error('tools/list failed: HTTP 404, session not found or expired'); + } + throw new Error(`tools/list failed: HTTP ${toolsResponse.status}`); + } + const toolsText = await toolsResponse.text(); let toolsData: | { @@ -609,11 +620,27 @@ async function discoverToolsViaHttp(service: ServiceDefinition, timeout: number) /** * Fetch all tools for a service, returning full tool objects. * Used by ServiceTools view to display tool details. + * + * Each attempt opens a one-shot connection (initialize → tools/list → close), + * so a session-expiry failure (-32001 / HTTP 404) is recovered by simply + * retrying once: the fresh attempt establishes a brand-new backend session + * (lazy rebuild), matching the ToolRouter's recovery semantics. */ export async function fetchServiceTools( service: ServiceDefinition, timeout: number ): Promise { + try { + return await fetchServiceToolsOnce(service, timeout); + } catch (err) { + if (isSessionExpiryError(err)) { + return await fetchServiceToolsOnce(service, timeout); + } + throw err; + } +} + +async function fetchServiceToolsOnce(service: ServiceDefinition, timeout: number): Promise { if (service.transport === 'stdio') { return discoverToolsViaStdio(service, timeout); } else if (service.transport === 'sse') { diff --git a/tests/integration/backend-session-expiry.test.ts b/tests/integration/backend-session-expiry.test.ts index 5bd9de8..5b179a2 100644 --- a/tests/integration/backend-session-expiry.test.ts +++ b/tests/integration/backend-session-expiry.test.ts @@ -58,11 +58,15 @@ function createMockConfigProvider(): ConfigProvider { * Start a minimal Streamable HTTP MCP backend. * * Each session may serve exactly `requestsPerSession` non-initialize request(s) - * before it is reported as expired (`-32001 Session not found or expired`), - * modelling the idle-timeout behaviour of the real jymcp backend without - * wall-clock timing. + * before it is reported as expired, modelling the idle-timeout behaviour of the + * real jymcp backend without wall-clock timing. `staleMode` selects how the + * expiry is reported: a JSON-RPC `-32001` error (jymcp style, HTTP 200) or a + * spec-conformant HTTP 404 on the stale Mcp-Session-Id. */ -function startMockBackend(requestsPerSession = 1): Promise<{ +function startMockBackend( + requestsPerSession = 1, + staleMode: 'jsonrpc' | 'http404' = 'jsonrpc' +): Promise<{ url: string; close: () => Promise; initializeCount: () => number; @@ -139,6 +143,12 @@ function startMockBackend(requestsPerSession = 1): Promise<{ return; } + if (staleMode === 'http404') { + // Spec-conformant expiry signal: HTTP 404 on the stale Mcp-Session-Id. + res.writeHead(404).end('Not Found'); + return; + } + sendJson(200, { jsonrpc: '2.0', id: msg['id'], @@ -260,6 +270,11 @@ describe('Backend session expiry recovery (integration)', () => { const tools = await toolRouter.discoverTools(); expect(tools.map((t) => t.name)).toEqual(['alpha', 'beta']); + // findTool serves from the discovery cache now — drop it so callTool's + // internal tools/list lookup goes live and consumes the session's second + // request, making the actual tools/call hit the expired session. + toolRouter.invalidateServiceCache(service.name); + // The first tools/call hits the expired session (-32001). The router must // invalidate the stale connection, re-initialize a fresh backend session // and succeed transparently — the caller just sees the tool result. @@ -293,4 +308,65 @@ describe('Backend session expiry recovery (integration)', () => { await pool.closeAll().catch(() => {}); } }, 30000); + + it('recovers tools/call when a spec-conformant backend answers HTTP 404 on the stale session', async () => { + // One request per session: discovery consumes session A's only request, so + // the pooled connection is stale by the time the tool is called. The stale + // session is reported as HTTP 404 (the MCP-spec expiry signal) instead of + // a JSON-RPC -32001 error body. + backend = await startMockBackend(1, 'http404'); + + const service: ServiceDefinition = { + name: 'mock-http', + enabled: true, + tags: [], + transport: 'http', + url: backend.url, + connectionPool: { maxConnections: 2, idleTimeout: 60000, connectionTimeout: 10000 }, + } as ServiceDefinition; + const poolConfig: ConnectionPoolConfig = { + maxConnections: 2, + idleTimeout: 60000, + connectionTimeout: 10000, + }; + + const configProvider = createMockConfigProvider(); + const serviceRegistry = new ServiceRegistry(configProvider); + await serviceRegistry.initialize(); + await serviceRegistry.register(service); + + const namespaceManager = new NamespaceManager(); + const healthMonitor = new HealthMonitor(serviceRegistry); + const toolRouter = new ToolRouter(serviceRegistry, namespaceManager, healthMonitor); + + const pool = new ConnectionPool(service, poolConfig); + pool.on('error', () => {}); + toolRouter.registerConnectionPool(service.name, pool); + + try { + // Populates the discovery cache (findTool will serve from it, matching + // the common client flow) and uses up session A's single request. + const tools = await toolRouter.discoverTools(); + expect(tools.map((t) => t.name)).toEqual(['alpha', 'beta']); + + // The tools/call hits the stale session and receives HTTP 404. The + // router must classify it as session expiry, invalidate the connection, + // re-initialize and succeed transparently. + const result = (await toolRouter.callTool( + 'mock-http__alpha', + {}, + { + requestId: 'req-1', + correlationId: 'corr-1', + timestamp: new Date(), + } + )) as { content: Array<{ type: string; text: string }> }; + expect(result.content[0]?.text).toBe('ok'); + + // Exactly two sessions: the original + one re-initialized on the 404. + expect(backend.initializeCount()).toBe(2); + } finally { + await pool.closeAll().catch(() => {}); + } + }, 30000); }); diff --git a/tests/integration/discovery-worker-session-expiry.test.ts b/tests/integration/discovery-worker-session-expiry.test.ts new file mode 100644 index 0000000..14fa9ca --- /dev/null +++ b/tests/integration/discovery-worker-session-expiry.test.ts @@ -0,0 +1,168 @@ +/** + * Integration tests: TUI discovery-worker must recover from backend session expiry. + * + * fetchServiceTools opens a one-shot connection per attempt; a session-expiry + * failure (-32001 JSON-RPC error, or a spec-conformant HTTP 404) must be + * retried once so the fresh attempt establishes a new backend session + * (lazy rebuild) instead of surfacing an error to the TUI. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import http from 'node:http'; +import { AddressInfo } from 'node:net'; +import { fetchServiceTools } from '../../src/tui/discovery-worker.js'; +import type { ServiceDefinition } from '../../src/types/service.js'; + +const TOOLS = [ + { name: 'alpha', description: 'Alpha', inputSchema: { type: 'object', properties: {} } }, +]; + +type Mode = 'first-session-expired' | 'first-session-http404' | 'always-expired'; + +function startMockBackend(mode: Mode): Promise<{ + url: string; + close: () => Promise; + stats: () => { initializes: number; expiredErrors: number }; +}> { + const sessions = new Map(); + let sidCounter = 0; + let initializeCount = 0; + let expiredErrors = 0; + + const server = http.createServer((req, res) => { + let raw = ''; + req.on('data', (c) => (raw += c)); + req.on('end', () => { + let msg: Record; + try { + msg = JSON.parse(raw) as Record; + } catch { + res.writeHead(400).end(); + return; + } + const sendJson = (status: number, body: unknown) => { + const json = JSON.stringify(body); + res.writeHead(status, { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(json), + }); + res.end(json); + }; + + if (msg['method'] === 'initialize') { + initializeCount++; + const sessionId = `sess-${++sidCounter}`; + // The first session is already expired; every later session is healthy. + sessions.set(sessionId, sidCounter === 1 ? 0 : 5); + const json = JSON.stringify({ + jsonrpc: '2.0', + id: msg['id'], + result: { + protocolVersion: '2024-11-05', + capabilities: { tools: {} }, + serverInfo: { name: 'mock-backend', version: '1.0.0' }, + }, + }); + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(json), + 'mcp-session-id': sessionId, + }); + res.end(json); + return; + } + + const sessionId = req.headers['mcp-session-id']; + const remaining = typeof sessionId === 'string' ? sessions.get(sessionId) : undefined; + + if (msg['method'] === 'tools/list') { + const expired = mode === 'always-expired' || remaining === undefined || remaining <= 0; + if (expired) { + expiredErrors++; + // Spec-conformant backends answer a stale Mcp-Session-Id with HTTP 404. + if (mode === 'first-session-http404') { + res.writeHead(404).end('Not Found'); + return; + } + sendJson(200, { + jsonrpc: '2.0', + id: msg['id'], + error: { + code: -32001, + message: 'Session not found or expired. Please send initialize again.', + }, + }); + return; + } + sessions.set(sessionId as string, (remaining as number) - 1); + sendJson(200, { jsonrpc: '2.0', id: msg['id'], result: { tools: TOOLS } }); + return; + } + + // notifications/initialized and anything else + sendJson(200, { jsonrpc: '2.0', id: msg['id'] ?? 'notification', result: {} }); + }); + }); + + return new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + const port = (server.address() as AddressInfo).port; + resolve({ + url: `http://127.0.0.1:${port}/mcp`, + close: () => new Promise((r) => server.close(() => r())), + stats: () => ({ initializes: initializeCount, expiredErrors }), + }); + }); + }); +} + +function makeService(url: string): ServiceDefinition { + return { + name: 'mock-http', + enabled: true, + tags: [], + transport: 'http', + url, + connectionPool: { maxConnections: 1, idleTimeout: 60000, connectionTimeout: 10000 }, + } as ServiceDefinition; +} + +describe('discovery-worker session expiry recovery', () => { + let backend: Awaited> | undefined; + + afterEach(async () => { + if (backend) { + await backend.close().catch(() => {}); + backend = undefined; + } + }); + + it('recovers from a JSON-RPC -32001 expired session via one retry', async () => { + backend = await startMockBackend('first-session-expired'); + + const tools = await fetchServiceTools(makeService(backend.url), 5000); + + expect(tools.map((t) => t.name)).toEqual(['alpha']); + // The retry established a brand-new backend session (lazy rebuild). + expect(backend.stats()).toEqual({ initializes: 2, expiredErrors: 1 }); + }); + + it('recovers from a spec-conformant HTTP 404 session expiry via one retry', async () => { + backend = await startMockBackend('first-session-http404'); + + const tools = await fetchServiceTools(makeService(backend.url), 5000); + + expect(tools.map((t) => t.name)).toEqual(['alpha']); + expect(backend.stats()).toEqual({ initializes: 2, expiredErrors: 1 }); + }); + + it('fails after the single retry when every session is expired (bounded)', async () => { + backend = await startMockBackend('always-expired'); + + await expect(fetchServiceTools(makeService(backend.url), 5000)).rejects.toThrow( + /Session not found/ + ); + // Exactly two attempts (initial + one lazy rebuild), then the error surfaces. + expect(backend.stats()).toEqual({ initializes: 2, expiredErrors: 2 }); + }); +}); diff --git a/tests/integration/fixtures/mock-stdio-mcp.cjs b/tests/integration/fixtures/mock-stdio-mcp.cjs index a1e722b..9da1228 100644 --- a/tests/integration/fixtures/mock-stdio-mcp.cjs +++ b/tests/integration/fixtures/mock-stdio-mcp.cjs @@ -37,6 +37,9 @@ const TOOLS = [ ]; const EXIT_AFTER_LIST = process.env.ONEMCP_FIXTURE_EXIT_AFTER_LIST === '1'; +// Exit after responding to the Nth tools/call (deterministic mid-conversation death) +const EXIT_AFTER_CALLS = parseInt(process.env.ONEMCP_FIXTURE_EXIT_AFTER_CALLS || '', 10); +let toolsCallCount = 0; function send(message) { process.stdout.write(JSON.stringify(message) + '\n'); @@ -83,6 +86,11 @@ rl.on('line', (line) => { id: request.id, result: { content: [{ type: 'text', text: 'ok' }] }, }); + toolsCallCount += 1; + if (Number.isFinite(EXIT_AFTER_CALLS) && toolsCallCount >= EXIT_AFTER_CALLS) { + rl.close(); + setTimeout(() => process.exit(0), 50); + } break; default: if (request.id !== undefined && request.id !== null) { diff --git a/tests/unit/routing/session-error.test.ts b/tests/unit/routing/session-error.test.ts new file mode 100644 index 0000000..37a2c64 --- /dev/null +++ b/tests/unit/routing/session-error.test.ts @@ -0,0 +1,120 @@ +/** + * Unit tests for the shared backend error classifiers (session-error.ts) + */ + +import { describe, it, expect } from 'vitest'; +import { TransportError } from '../../../src/transport/base'; +import { + isRecoverableConnectionError, + isSessionExpiryError, +} from '../../../src/routing/session-error'; + +describe('isSessionExpiryError', () => { + it('recognizes a JSON-RPC -32001 error object', () => { + const error = Object.assign(new Error('Session not found or expired'), { code: -32001 }); + expect(isSessionExpiryError(error)).toBe(true); + }); + + it('recognizes session expiry from the message alone (ordered match)', () => { + expect(isSessionExpiryError(new Error('Session not found or expired. Please re-init'))).toBe( + true + ); + expect(isSessionExpiryError(new Error('the session has expired'))).toBe(true); + }); + + it('does not misclassify "tool not found in session" errors', () => { + expect(isSessionExpiryError(new Error("Tool 'foo' not found in session local"))).toBe(false); + }); + + it('recognizes a spec-conformant HTTP 404 session expiry', () => { + // Real propagation shape: doSend re-wraps the inner HTTP_REQUEST_FAILED + // into HTTP_SEND_FAILED with the status message embedded. + expect( + isSessionExpiryError( + new TransportError( + 'Failed to send HTTP request: HTTP request failed with status 404: Not Found', + 'HTTP_SEND_FAILED' + ) + ) + ).toBe(true); + // In case the inner error ever propagates unwrapped. + expect( + isSessionExpiryError( + new TransportError('HTTP request failed with status 404: Not Found', 'HTTP_REQUEST_FAILED') + ) + ).toBe(true); + }); + + it('does not treat non-404 send failures as session expiry', () => { + expect( + isSessionExpiryError( + new TransportError( + 'Failed to send HTTP request: HTTP request failed with status 500: Oops', + 'HTTP_SEND_FAILED' + ) + ) + ).toBe(false); + }); + + it('requires the TransportError type for the status-404 branch (no side-effect misfires)', () => { + // A plain Error (e.g. a tool's business error message) mentioning 404 must + // not be classified as session expiry — retrying it could repeat side effects. + expect(isSessionExpiryError(new Error('upstream returned status 404'))).toBe(false); + expect( + isSessionExpiryError( + new TransportError('HTTP request failed with status 4041: Nope', 'HTTP_REQUEST_FAILED') + ) + ).toBe(false); + }); + + it('returns false for unrelated errors', () => { + expect(isSessionExpiryError(null)).toBe(false); + expect(isSessionExpiryError(new Error('boom'))).toBe(false); + expect(isSessionExpiryError(new TransportError('Response timeout', 'RESPONSE_TIMEOUT'))).toBe( + false + ); + }); +}); + +describe('isRecoverableConnectionError', () => { + it('recognizes dead-but-reconnectable transport failures', () => { + expect( + isRecoverableConnectionError( + new TransportError('Process exited with code 0', 'PROCESS_EXITED') + ) + ).toBe(true); + expect( + isRecoverableConnectionError( + new TransportError('SSE connection lost', 'SSE_CONNECTION_FAILED') + ) + ).toBe(true); + expect(isRecoverableConnectionError(new TransportError('closed', 'TRANSPORT_CLOSED'))).toBe( + true + ); + }); + + it('excludes timeouts and network-unreachable errors (fail fast)', () => { + expect( + isRecoverableConnectionError(new TransportError('Response timeout', 'RESPONSE_TIMEOUT')) + ).toBe(false); + expect( + isRecoverableConnectionError(new TransportError('fetch failed', 'HTTP_SEND_FAILED')) + ).toBe(false); + expect( + isRecoverableConnectionError(new TransportError('request failed', 'HTTP_REQUEST_FAILED')) + ).toBe(false); + }); + + it('treats an ended receive stream as recoverable (immediate error, dead transport)', () => { + expect( + isRecoverableConnectionError( + new TransportError('transport stream ended', 'RESPONSE_STREAM_ENDED') + ) + ).toBe(true); + }); + + it('returns false for non-transport errors', () => { + expect(isRecoverableConnectionError(new Error('PROCESS_EXITED'))).toBe(false); + expect(isRecoverableConnectionError(null)).toBe(false); + }); +}); diff --git a/tests/unit/routing/tool-router.test.ts b/tests/unit/routing/tool-router.test.ts index 264b9b9..ebfc3f7 100644 --- a/tests/unit/routing/tool-router.test.ts +++ b/tests/unit/routing/tool-router.test.ts @@ -1839,6 +1839,77 @@ describe('ToolRouter', () => { }); }); + describe('findTool discovery cache', () => { + it('serves findTool from a fresh cache without a live backend query', async () => { + const service: ServiceDefinition = { + name: 'test-service', + enabled: true, + tags: [], + transport: 'http', + url: 'http://127.0.0.1:9999/mcp', + connectionPool: { maxConnections: 1, idleTimeout: 60000, connectionTimeout: 30000 }, + }; + await serviceRegistry.register(service); + + const mockTool = { + name: 'real_tool', + description: 'Real tool', + inputSchema: { type: 'object' as const, properties: {} }, + }; + let requestId = ''; + const mockTransport = { + send: vi.fn(async (request: { id?: string | number }) => { + requestId = String(request.id ?? ''); + }), + receive: vi.fn().mockReturnValue({ + next: vi.fn().mockImplementation(async () => ({ + done: false as const, + value: { jsonrpc: '2.0', id: requestId, result: { tools: [mockTool] } }, + })), + return: vi.fn().mockResolvedValue({ done: true }), + }), + close: vi.fn().mockResolvedValue(undefined), + getType: vi.fn().mockReturnValue('http'), + isConnected: vi.fn().mockReturnValue(true), + }; + const connection = { + id: 'conn-1', + transport: mockTransport, + state: 'idle' as const, + lastUsed: new Date(), + createdAt: new Date(), + }; + const mockPool = { + acquire: vi.fn().mockResolvedValue(connection), + release: vi.fn(), + markConnectionFailed: vi.fn().mockResolvedValue(undefined), + maxConnections: 1, + } as any; + toolRouter.registerConnectionPool('test-service', mockPool); + + // Populate the cache via a real discovery (one live tools/list). + await toolRouter.discoverTools(); + expect(mockTransport.send).toHaveBeenCalledTimes(1); + + const liveQuerySpy = vi.spyOn(toolRouter as any, 'queryServiceTools'); + + // Cache hit: findTool must not pay another backend round-trip. + const tool = await (toolRouter as any).findTool('test-service', 'real_tool', mockPool); + expect(tool?.name).toBe('real_tool'); + expect(liveQuerySpy).not.toHaveBeenCalled(); + expect(mockTransport.send).toHaveBeenCalledTimes(1); + + // TTL expiry: findTool falls back to the live query. + vi.useFakeTimers({ toFake: ['Date'] }); + vi.advanceTimersByTime(61_000); + const tool2 = await (toolRouter as any).findTool('test-service', 'real_tool', mockPool); + expect(tool2?.name).toBe('real_tool'); + expect(liveQuerySpy).toHaveBeenCalledTimes(1); + expect(mockTransport.send).toHaveBeenCalledTimes(2); + vi.useRealTimers(); + }); + }); + describe('session expiry recovery', () => { it('should re-establish the backend session when discovery hits an expired session', async () => { // Register an enabled service @@ -2470,6 +2541,105 @@ describe('ToolRouter', () => { findToolSpy.mockRestore(); }); + + it('should transparently retry a tool call on a dead-but-reconnectable transport (stdio crash)', async () => { + const service: ServiceDefinition = { + name: 'test-service', + enabled: true, + tags: [], + transport: 'stdio', + command: 'test', + connectionPool: { maxConnections: 5, idleTimeout: 60000, connectionTimeout: 30000 }, + }; + await serviceRegistry.register(service); + + const deadTransport = { + send: vi + .fn() + .mockRejectedValue(new TransportError('Process exited with code 0', 'PROCESS_EXITED')), + receive: vi.fn(), + close: vi.fn(), + getType: vi.fn().mockReturnValue('stdio'), + isConnected: vi.fn().mockReturnValue(false), + }; + const liveTransport = (() => { + let requestId = ''; + return { + send: vi.fn(async (request: { id?: string | number }) => { + requestId = String(request.id ?? ''); + }), + receive: vi.fn().mockReturnValue({ + next: vi.fn().mockImplementation(async () => ({ + done: false as const, + value: { + jsonrpc: '2.0', + id: requestId, + result: { content: [{ type: 'text', text: 'ok' }] }, + }, + })), + return: vi.fn().mockResolvedValue({ done: true }), + }), + close: vi.fn().mockResolvedValue(undefined), + getType: vi.fn().mockReturnValue('stdio'), + isConnected: vi.fn().mockReturnValue(true), + }; + })(); + const deadConnection = { + id: 'conn-dead', + transport: deadTransport, + state: 'idle' as const, + lastUsed: new Date(), + createdAt: new Date(), + }; + const liveConnection = { + id: 'conn-live', + transport: liveTransport, + state: 'idle' as const, + lastUsed: new Date(), + createdAt: new Date(), + }; + + const acquire = vi + .fn() + .mockResolvedValueOnce(deadConnection) + .mockResolvedValueOnce(liveConnection); + const mockPool = { + acquire, + release: vi.fn(), + markConnectionFailed: vi.fn().mockResolvedValue(undefined), + maxConnections: 1, + } as any; + toolRouter.registerConnectionPool('test-service', mockPool); + + const mockTool: Tool = { + name: 'test_tool', + namespacedName: 'test-service__test_tool', + serviceName: 'test-service', + description: 'Test tool', + inputSchema: { type: 'object', properties: {} }, + enabled: true, + }; + const findToolSpy = vi.spyOn(toolRouter as any, 'findTool').mockResolvedValue(mockTool); + + const context: RequestContext = { + requestId: 'req-1', + correlationId: 'corr-1', + timestamp: new Date(), + }; + + // A stdio backend crash mid-call is recovered by respawning: the dead + // connection is invalidated and the retry succeeds on the fresh one. + const result = (await toolRouter.callTool('test-service__test_tool', {}, context)) as { + content: Array<{ text: string }>; + }; + expect(result.content[0]?.text).toBe('ok'); + expect(acquire).toHaveBeenCalledTimes(2); + expect(mockPool.markConnectionFailed).toHaveBeenCalledTimes(1); + expect(mockPool.markConnectionFailed).toHaveBeenCalledWith(deadConnection, expect.any(Error)); + expect(mockPool.release).not.toHaveBeenCalledWith(deadConnection); + + findToolSpy.mockRestore(); + }); }); describe('discoverTools - cache invalidation events', () => { diff --git a/tests/unit/session/session-manager.test.ts b/tests/unit/session/session-manager.test.ts index 2dab434..3a38aad 100644 --- a/tests/unit/session/session-manager.test.ts +++ b/tests/unit/session/session-manager.test.ts @@ -66,4 +66,33 @@ describe('SessionManager', () => { expect(manager.getSession(busy.id)).toBeDefined(); }); }); + + describe('session cap', () => { + it('evicts oldest idle sessions when the cap is exceeded (safe under handle semantics)', () => { + const manager = new SessionManager(); + for (let i = 0; i <= 10_000; i++) { + manager.createSession('agent-1', {}, `s-${i}`); + } + + // A buggy/hostile client presenting fresh ids per request cannot grow + // the map without bound: the oldest idle sessions are evicted first. + expect(manager.getSession('s-0')).toBeUndefined(); + expect(manager.getSession('s-10000')).toBeDefined(); + expect((manager as any).sessions.size).toBeLessThanOrEqual(10_001); + }); + + it('never evicts sessions with in-flight requests first', () => { + const manager = new SessionManager(); + const busy = manager.createSession('agent-1', {}, 'busy-one'); + manager.incrementActiveRequests(busy.id); + + for (let i = 0; i <= 10_000; i++) { + manager.createSession('agent-1', {}, `s-${i}`); + } + + // The busy session may still be alive even though it is the oldest. + expect(manager.getSession('busy-one')).toBeDefined(); + manager.decrementActiveRequests('busy-one'); + }); + }); }); From ad8f6fa5ff0e34db693f5876c56418321d2ab1cb Mon Sep 17 00:00:00 2001 From: kugouming Date: Thu, 3 Sep 2026 00:42:13 +0800 Subject: [PATCH 3/4] =?UTF-8?q?feat(scripts):=20=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E9=83=A8=E7=BD=B2=E4=B8=8E=E7=AB=AF=E5=88=B0=E7=AB=AF=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=E5=B7=A5=E5=85=B7=E9=93=BE=EF=BC=8C=E8=A7=84=E8=8C=83?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E6=94=B6=E6=95=9B=E8=87=B3=20CLAUDE.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - deploy:local:clean+build → npm pack → tarball 全局真实安装(install-shape 硬校验,防软链式假安装)→ 安全重启 :5625 daemon → initialize 冒烟 - verify:local:自包含 E2E 回归,10 场景 15 断言 - 正常 N1-N6:HTTP 连接复用 / stdio / SSE / 标签过滤 / ping+DELETE / 诊断端点 - 恢复 F1-F4:HTTP -32001 与 404 过期重建(/__expire 控制端点触发)、 stdio 崩溃 respawn、前端会话句柄重启重建 - 共享安装管道 scripts/lib/install-local.mjs(构建产物新鲜度断言) - build 脚本显式前置 clean,杜绝历史产物残留 - 文档:CLAUDE.md 收敛为唯一规范源并新增 E2E 场景回归规则;删除 AGENTS.md; README 更新场景清单 --- .gitignore | 1 + AGENTS.md | 167 --------- CLAUDE.md | 330 ++++++++++++----- README.md | 34 ++ package.json | 4 +- scripts/deploy-local.mjs | 189 ++++++++++ scripts/e2e-local.mjs | 675 ++++++++++++++++++++++++++++++++++ scripts/lib/install-local.mjs | 81 ++++ 8 files changed, 1231 insertions(+), 250 deletions(-) delete mode 100644 AGENTS.md create mode 100644 scripts/deploy-local.mjs create mode 100644 scripts/e2e-local.mjs create mode 100644 scripts/lib/install-local.mjs diff --git a/.gitignore b/.gitignore index 314284a..7d8febf 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ pnpm-lock.yaml dist/ build/ *.tsbuildinfo +*.tgz # IDE .vscode/ diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 359ebae..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,167 +0,0 @@ -# OneMCP Dev Rules - -## Commands - -| Command | Description | -|---------|-------------| -| `npm run build` | Build with tsup | -| `npm run dev` | Watch mode | -| `npm test` | Run all tests | -| `npm run test:watch` | Watch mode tests | -| `npm run test:coverage` | Coverage report (thresholds: 80% lines/fn/stmt, 75% branches) | -| `npm run test:property` | Property-based tests (fast-check) | -| `npm run lint` / `lint:fix` | ESLint | -| `npm run format` / `format:check` | Prettier | -| `npm run typecheck` | TypeScript check only | -| `npx vitest run ` | Single test file | -| `npx vitest run -t ""` | Single test by name | - ---- - -## Constraints (Hard Rules) - -These are enforced by ESLint and will cause CI failure if violated: - -- **NO `any`** — use proper types; `@typescript-eslint/no-explicit-any: error` -- **NO `!`** — no non-null assertions; use explicit null checks or optional chaining -- **NO `console.log/warn/error`** — use `process.stdout.write()` / `process.stderr.write()`; only `console.log` in CLI help/version output with `// eslint-disable-next-line no-console` -- **Always handle promises** — `await` or `void`; floating promises are errors -- **No implicit `any`** — all parameters and return types must be inferrable or explicit -- **No `!` index access** — `noUncheckedIndexedAccess` is enabled; check array/map access results - ---- - -## TypeScript - -- Target: ES2022, Module: ESNext (ESM), strict mode enabled -- `exactOptionalPropertyTypes` enabled — don't assign `undefined` to optional fields explicitly -- Use `readonly` for fields that don't change after construction -- Use `type` keyword for type-only imports: `import type { Foo } from './foo.js'` -- Explicit return types required on all public methods -- Use type inference only when the type is obvious from the right-hand side - ---- - -## Naming - -| Element | Convention | Example | -|---------|------------|---------| -| Classes / Interfaces / Types | PascalCase | `ToolRouter`, `ServiceDefinition` | -| Functions / Variables | camelCase | `discoverTools`, `toolCache` | -| Constants | UPPER_SNAKE_CASE | `DEFAULT_TIMEOUT_MS`, `MAX_RETRIES` | -| Private members | `private` keyword (or `_` prefix) | `private readonly _cache` | -| Files | kebab-case | `tool-router.ts`, `connection-pool.ts` | - ---- - -## Imports - -- Relative imports must use explicit `.js` extensions (ESM requirement) -- Group order: external packages → internal modules → types -- Use `import type` for type-only imports - -```typescript -import Ajv from 'ajv'; -import { ToolRouter } from './tool-router.js'; -import type { ServiceDefinition } from '../types/service.js'; -``` - ---- - -## Error Handling - -- Always use `instanceof Error` guard before accessing `.message` -- Use `??` for defaults, `?.` for safe access — never `!` -- Use `void` for fire-and-forget promise calls -- Wrap errors with context (correlationId, requestId, sessionId) via `ErrorBuilder` - -```typescript -try { - return await configProvider.load(); -} catch (error) { - process.stderr.write(`Failed: ${error instanceof Error ? error.message : String(error)}\n`); - return null; -} - -process.on('SIGINT', () => void shutdown('SIGINT')); -``` - ---- - -## Class Structure - -```typescript -export class MyService extends EventEmitter { - private readonly cache: Map = new Map(); - - constructor( - private readonly registry: ServiceRegistry, - private readonly monitor: HealthMonitor - ) { - super(); - } - - /** Brief description of what this method does. */ - public async doWork(input: string): Promise { - // implementation - } - - private handleError(error: Error): void { - this.emit('error', error); - } -} -``` - ---- - -## JSDoc - -Add JSDoc to all public methods. Keep it brief — describe *what* and *why*, not *how*. - -```typescript -/** - * Resolves config directory using priority: - * 1. CLI arg (--config-dir) - * 2. Env var (ONEMCP_CONFIG_DIR) - * 3. Default (~/.onemcp) - */ -function resolveConfigDir(args: CliArgs): string {} -``` - ---- - -## Project Structure - -``` -src/ -├── cli.ts / tui.ts / index.ts # Entry points -├── cli-mode.ts / server-mode.ts # Mode runners -├── config/ # Config providers (FileConfigProvider) -├── errors/ # ErrorBuilder, recovery, timeout handler -├── health/ # HealthMonitor -├── logging/ # Pino logger, audit logger, data masker -├── metrics/ # Metrics collector and service -├── namespace/ # NamespaceManager (__-separated tool names) -├── pool/ # ConnectionPool -├── protocol/ # JSON-RPC parser, serializer, MCP handler -├── registry/ # ServiceRegistry -├── routing/ # ToolRouter -├── session/ # Session management -├── storage/ # File / memory adapters -├── transport/ # stdio, HTTP transports -├── tui/ # Ink/React TUI components -├── types/ # All TypeScript types (re-exported from index.ts) -└── utils/ # Shared utilities -``` - ---- - -## Testing - -- Unit tests: `tests/unit//.test.ts` (mirrors src structure) -- Property tests: `tests/property/.property.test.ts` using fast-check -- Integration tests: `tests/integration/` -- Use factory helpers (`createTestService()`, `createMockConfigProvider()`) — don't repeat setup inline -- Mock with `vi.fn()` — avoid real I/O in unit tests -- Property tests must include arbitraries for each type; test invariants not just happy paths -- Coverage thresholds enforced: 80% lines/functions/statements, 75% branches diff --git a/CLAUDE.md b/CLAUDE.md index 48a066b..499292b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,35 +1,69 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Quick Commands - -```bash -# Development -npm run dev # Watch mode with auto-rebuild -npm run build # Build TypeScript to dist/ -npm run clean # Remove dist/ directory - -# Testing -npm test # Run all tests (unit + property) -npm run test:watch # Watch mode for tests -npm run test:coverage # Generate coverage report -npm run test:property # Property-based tests only -npm run test -- # Run specific test file - -# Code Quality -npm run lint # Check code with ESLint -npm run lint:fix # Auto-fix ESLint issues -npm run format # Format code with Prettier -npm run format:check # Check formatting without changing -npm run typecheck # TypeScript type checking - -# Running -npx tsx src/cli.ts --help # See CLI options -npx tsx src/cli.ts # CLI mode (stdio) -npx tsx src/cli.ts --mode server # Server mode (HTTP) -npx tsx src/cli.ts --mode tui # TUI mode (interactive) -``` +# OneMCP AI Agent Guide(单一规范源) + +本文件是所有 AI Agent 在本仓库工作的**唯一规范来源**, +由「架构与设计模式」「命令与验证」「编码约定」三部分组成。请勿在其他文件中重复维护规范内容。 + +--- + +## Commands + +| Command | Description | +|---------|-------------| +| `npm run build` | Clean dist/ + build with tsup(保证产物不含历史残留) | +| `npm run dev` | Watch mode | +| `npm test` | Run all tests(unit + property) | +| `npm run test:watch` | Watch mode tests | +| `npm run test:coverage` | Coverage report (thresholds: 80% lines/fn/stmt, 75% branches) | +| `npm run test:property` | Property-based tests (fast-check) | +| `npm run deploy:local` | 编译 → npm 打包真实 tarball → 全局安装(完整替代旧 onemcp 命令)→ 重启 ~/.onemcp daemon → 冒烟 | +| `npm run verify:local` | 端到端回归:重新编译安装后,以独立实例(随机端口)跑全部场景 case | +| `npm run lint` / `lint:fix` | ESLint | +| `npm run format` / `format:check` | Prettier | +| `npm run typecheck` | TypeScript check only | +| `npx vitest run ` | Single test file | +| `npx vitest run -t ""` | Single test by name | + +--- + +## E2E 场景回归规则(必须遵守) + +端到端场景统一维护在 **`scripts/e2e-local.mjs`**(正常场景 `N*` + 故障恢复场景 `F*`)。 +该脚本自包含"编译 → npm pack → 全局真实安装(tarball + 安装形态校验)",随后以随机端口 + +独立临时配置的独立实例运行全部场景,不影响 :5625 正在运行的 daemon。 + +### 规则 + +1. **新增功能或修复缺陷时,必须同步在 `scripts/e2e-local.mjs` 增加/更新对应场景 case**: + 修复类问题放入 `F*`(故障恢复),新功能/正常操作放入 `N*`(正常场景),编号顺延。 + 确保每个问题都能在端到端层面复现与验证,迭代过程始终可用全局 case 回归。 +2. **每次修改代码后的标准验证链**(全绿才算完成): + `npm test` → `npm run deploy:local` → `npm run verify:local` +3. **场景编写约定**: + - 随机空闲端口 + `mkdtemp` 独立临时配置,绝不触碰 :5625 运行实例 + - mock 后端自带请求级日志与 `/__stats`、`/__expire`(HTTP 过期触发)控制端点; + stdio 崩溃用 fixture 的 `ONEMCP_FIXTURE_EXIT_AFTER_CALLS` 确定性触发 + - 断言用相对式(如 `expiredErrors >= 1`),不依赖连接预热/启动时序的精确计数 + - 恢复类场景需用日志特征确认走了恢复路径 + (后端 `Recoverable connection failure ...`、前端 `Client session ... recreating transparently`) + - 每条断言独立报告(✓/✗ 汇总),失败时转储实例 stderr,退出码供 CI 使用 +4. **文档同步**:新增/调整场景后,同步更新 README「本地部署与端到端验证」小节的场景清单。 + +### 当前场景清单(以 scripts/e2e-local.mjs 为准) + +- **N1** HTTP 正常链路与连接复用(零过期零重建,后端请求计数精确匹配) +- **N2** stdio 正常链路(spawn → initialize → tools/call) +- **N3** SSE 正常链路(legacy 两阶段握手) +- **N4** 标签过滤(X-MCP-Tags 在会话创建时解析,需带标签头 initialize) +- **N5** ping + DELETE 会话终止 + 终止后句柄透明重建 +- **N6** /health 与 /diagnostics 端点 +- **F1** HTTP 后端会话过期(jymcp 型 -32001,经 /__expire 触发)→ 透明重建 +- **F2** HTTP 后端规范型会话过期(HTTP 404)→ 透明重建 +- **F3** stdio 后端进程崩溃 → 自动 respawn 重放 +- **F4** 前端客户端会话句柄失效 → 重启实例后旧 Mcp-Session-Id 透明重建 +- TUI:交互式界面需 PTY,不纳入脚本;其恢复逻辑由 + `tests/integration/discovery-worker-session-expiry.test.ts` 覆盖 + +--- ## Architecture Overview @@ -37,72 +71,45 @@ npx tsx src/cli.ts --mode tui # TUI mode (interactive) ### Core Layers (Bottom to Top) -1. **Storage Layer** (`src/storage/`) - - Adapters: `FileStorageAdapter`, `MemoryStorageAdapter` - - Persists configuration and runtime state - -2. **Config Layer** (`src/config/`) - - `FileConfigProvider`: Loads/validates/watches config files - - Validates service definitions and system settings - -3. **Service Registry** (`src/registry/`) - - Registers, tracks, and manages MCP backend services - - Discovers tools from registered services - - Tag-based filtering - -4. **Connection Pool** (`src/pool/`) - - Manages connections to backend MCP servers - - Handles connection lifecycle, idle timeouts, health checks - - Uses `child_process` to spawn MCP servers - -5. **Protocol Layer** (`src/protocol/`) - - JSON-RPC 2.0 parsing and serialization - - Message validation and error formatting - -6. **Transport Layer** (`src/transport/`) - - **StdioTransport**: CLI mode (stdin/stdout) - - **HttpTransport**: Server mode (HTTP/SSE) - -7. **Routing Layer** (`src/routing/`) - - Tool routing and namespace management - - Tool state management (enabled/disabled) - - Batch tool invocation - - Tag filtering +1. **Storage Layer** (`src/storage/`) — `FileStorageAdapter`, `MemoryStorageAdapter`; persists configuration and runtime state +2. **Config Layer** (`src/config/`) — `FileConfigProvider`: loads/validates/watches config files +3. **Service Registry** (`src/registry/`) — registers services, discovers tools, tag-based filtering +4. **Connection Pool** (`src/pool/`) — connection lifecycle, idle timeouts, health checks; spawns stdio servers +5. **Protocol Layer** (`src/protocol/`) — JSON-RPC 2.0 parsing/serialization, MCP handler, smart discovery +6. **Transport Layer** (`src/transport/`) — `StdioTransport` (CLI mode), `HttpTransport` (Streamable HTTP / SSE client) +7. **Routing Layer** (`src/routing/`) — tool routing, namespacing, tool states, discovery cache, `session-error.ts` error classification ### Application Entry Points -- **CLI Mode** (`src/cli-mode.ts`): Stdio-based communication for use as MCP server -- **Server Mode** (`src/server-mode.ts`): HTTP server for remote clients -- **TUI Mode** (`src/tui.ts` + `src/tui/components/`): Interactive React-based UI for config management -- **Daemon Mode** (`src/daemon/`): Background server management (start/stop/logs/status) +- **CLI Mode** (`src/cli-mode.ts`): stdio communication for use as an MCP server +- **Server Mode** (`src/server-mode.ts`): HTTP server (Streamable HTTP) for remote clients, client session handles +- **TUI Mode** (`src/tui.ts` + `src/tui/`): interactive React/Ink config management +- **Daemon Mode** (`src/daemon/`): background server management (start/stop/logs/status) ### Key Cross-Cutting Concerns -- **Logging** (`src/logging/`): Pino-based with masking support -- **Health Monitoring** (`src/health/`): Service health tracking and auto-unload -- **Session Management** (`src/session/`): Multi-client session isolation -- **Audit Logging** (`src/logging/audit-logger.ts`): Request/response tracking -- **Metrics** (`src/metrics/`): System metrics collection and reporting +- **Logging** (`src/logging/`): Pino-based with masking; audit logger +- **Health Monitoring** (`src/health/`): service health tracking +- **Session Management** (`src/session/`): client session lifecycle (server mode) +- **Metrics** (`src/metrics/`): metrics collection and reporting ## Important Design Patterns -**Tool Namespacing**: Tools are exposed as `{serviceName}___{toolName}` to avoid collisions between services. +**Tool Namespacing**: Tools are exposed as `{serviceName}__{toolName}` (double underscore, `NamespaceManager.DELIMITER`) to avoid collisions between services. **Smart Tool Discovery**: By default, `tools/list` returns only a search tool (`search_tools`). Clients search for tools on-demand rather than receiving the full list upfront. Disable with `--no-smart-discovery`. -**Tag Filtering**: Services can have tags (e.g., "production", "api"). Clients filter which services to load via CLI `--tag` or HTTP `X-MCP-Tags` header. +**Tag Filtering**: Services can have tags (e.g., "production", "api"). Clients filter which services to load via CLI `--tag` or HTTP `X-MCP-Tags` header (parsed at session creation). **Connection Pooling**: Each service gets its own pool with configurable max connections, idle timeout, and connection timeout. Prevents resource exhaustion and improves performance through connection reuse. -**Configuration Hot-Reload**: Config file changes are detected and services are reloaded without restarting the entire system. +**Backend Session-Expiry Recovery**: Backends may expire idle sessions and report it as a JSON-RPC `-32001` error (HTTP 200) or a spec-conformant HTTP 404 — signals invisible to the transport layer. `src/routing/session-error.ts` classifies such errors (plus dead-but-reconnectable transport failures like stdio process exit / SSE drop / ended receive streams). Both the discovery path (`queryServiceTools`) and `callTool` run a bounded retry loop (`maxConnections + 1` attempts): invalidate the stale connection via `markConnectionFailed`, acquire a fresh one and replay the request transparently. Timeouts and network-unreachable errors fail fast on purpose (retrying would double latency or repeat side effects). -## Testing Strategy +**Client Session Handles** (server mode): A client's `Mcp-Session-Id` is a handle, not a living resource. If a request presents an unknown/evicted id, the session is transparently recreated under the same id (`createSessionFromRequest` in `src/server-mode.ts`) so clients that don't re-initialize keep working; an `initialize` on a stale handle starts fresh per spec. Idle sessions are garbage-collected after 30 min and the map is capped (oldest-idle eviction). -- **Unit Tests** (`tests/unit/`): Test individual components with specific examples -- **Property-Based Tests** (`tests/property/`): Use `fast-check` to verify correctness properties hold across random inputs -- **Integration Tests** (`tests/integration/`): End-to-end tests with mock MCP servers +**Discovery Cache Reuse**: `findTool` serves tool lookups from the per-service discovery cache (60s TTL, same cache as `discoverTools`); misses fall back to a live backend query. Cache invalidation hooks: service register/unregister, health events, `setToolState`, config hot-reload. -Run tests early and often during development. Property tests are especially valuable for complex logic like routing and connection pooling. +**Configuration Hot-Reload**: Config file changes are detected and services are reloaded without restarting the entire system. ## Configuration Structure @@ -120,9 +127,168 @@ See README.md for example configurations. **Adding a new config provider**: Implement `IConfigProvider` interface in `src/config/`. -**Debugging service connections**: Set `logLevel: 'DEBUG'` in config. Check `src/pool/connection.ts` and `src/routing/` for detailed logs. +**Debugging service connections**: Set `logLevel: 'INFO'` or `DEBUG` in config. Recovery actions log WARN lines: `Recoverable connection failure (tools/list|tools/call ...), invalidating connection ... and retrying` (backend side) and `Client session ... recreating transparently` (front side). + +**Troubleshooting tool routing**: Namespace parsing happens in `src/routing/`. Check that tool names follow `{serviceName}__{toolName}` format (double underscore). + +--- + +## Constraints (Hard Rules) + +These are enforced by ESLint and will cause CI failure if violated: + +- **NO `any`** — use proper types; `@typescript-eslint/no-explicit-any: error` +- **NO `!`** — no non-null assertions; use explicit null checks or optional chaining +- **NO `console.log/warn/error`** — use `process.stdout.write()` / `process.stderr.write()`; only `console.log` in CLI help/version output with `// eslint-disable-next-line no-console` +- **Always handle promises** — `await` or `void`; floating promises are errors +- **No implicit `any`** — all parameters and return types must be inferrable or explicit +- **No `!` index access** — `noUncheckedIndexedAccess` is enabled; check array/map access results + +--- + +## TypeScript + +- Target: ES2022, Module: ESNext (ESM), strict mode enabled +- `exactOptionalPropertyTypes` enabled — don't assign `undefined` to optional fields explicitly +- Use `readonly` for fields that don't change after construction +- Use `type` keyword for type-only imports: `import type { Foo } from './foo.js'` +- Explicit return types required on all public methods +- Use type inference only when the type is obvious from the right-hand side + +--- + +## Naming + +| Element | Convention | Example | +|---------|------------|---------| +| Classes / Interfaces / Types | PascalCase | `ToolRouter`, `ServiceDefinition` | +| Functions / Variables | camelCase | `discoverTools`, `toolCache` | +| Constants | UPPER_SNAKE_CASE | `DEFAULT_TIMEOUT_MS`, `MAX_RETRIES` | +| Private members | `private` keyword (or `_` prefix) | `private readonly _cache` | +| Files | kebab-case | `tool-router.ts`, `connection-pool.ts` | + +--- + +## Imports + +- Relative imports must use explicit `.js` extensions (ESM requirement) +- Group order: external packages → internal modules → types +- Use `import type` for type-only imports + +```typescript +import Ajv from 'ajv'; +import { ToolRouter } from './tool-router.js'; +import type { ServiceDefinition } from '../types/service.js'; +``` + +--- + +## Error Handling + +- Always use `instanceof Error` guard before accessing `.message` +- Use `??` for defaults, `?.` for safe access — never `!` +- Use `void` for fire-and-forget promise calls +- Wrap errors with context (correlationId, requestId, sessionId) via `ErrorBuilder` + +```typescript +try { + return await configProvider.load(); +} catch (error) { + process.stderr.write(`Failed: ${error instanceof Error ? error.message : String(error)}\n`); + return null; +} + +process.on('SIGINT', () => void shutdown('SIGINT')); +``` + +--- + +## Class Structure + +```typescript +export class MyService extends EventEmitter { + private readonly cache: Map = new Map(); + + constructor( + private readonly registry: ServiceRegistry, + private readonly monitor: HealthMonitor + ) { + super(); + } + + /** Brief description of what this method does. */ + public async doWork(input: string): Promise { + // implementation + } + + private handleError(error: Error): void { + this.emit('error', error); + } +} +``` + +--- + +## JSDoc + +Add JSDoc to all public methods. Keep it brief — describe *what* and *why*, not *how*. + +```typescript +/** + * Resolves config directory using priority: + * 1. CLI arg (--config-dir) + * 2. Env var (ONEMCP_CONFIG_DIR) + * 3. Default (~/.onemcp) + */ +function resolveConfigDir(args: CliArgs): string {} +``` + +--- + +## Project Structure + +``` +src/ +├── cli.ts / tui.ts / index.ts # Entry points +├── cli-mode.ts / server-mode.ts # Mode runners +├── config/ # Config providers (FileConfigProvider) +├── errors/ # ErrorBuilder, recovery, timeout handler +├── health/ # HealthMonitor +├── logging/ # Pino logger, audit logger, data masker +├── metrics/ # Metrics collector and service +├── namespace/ # NamespaceManager (__-separated tool names) +├── pool/ # ConnectionPool +├── protocol/ # JSON-RPC parser, serializer, MCP handler +├── registry/ # ServiceRegistry +├── routing/ # ToolRouter, session-error.ts (error classification) +├── session/ # Client session management (server mode) +├── storage/ # File / memory adapters +├── transport/ # stdio, HTTP transports +├── tui/ # Ink/React TUI components +├── types/ # All TypeScript types (re-exported from index.ts) +└── utils/ # Shared utilities +scripts/ +├── deploy-local.mjs # npm run deploy:local +├── e2e-local.mjs # npm run verify:local(E2E 场景 case 维护在此) +└── lib/install-local.mjs # 共享"编译→打包→安装"管道 +``` + +--- + +## Testing + +- Unit tests: `tests/unit//.test.ts` (mirrors src structure) +- Property tests: `tests/property/.property.test.ts` using fast-check +- Integration tests: `tests/integration/`(真实 HTTP/stdio mock 后端) +- E2E scenarios: `scripts/e2e-local.mjs`(见「E2E 场景回归规则」) +- Use factory helpers (`createTestService()`, `createMockConfigProvider()`) — don't repeat setup inline +- Mock with `vi.fn()` — avoid real I/O in unit tests +- Property tests must include arbitraries for each type; test invariants not just happy paths +- Coverage thresholds enforced: 80% lines/functions/statements, 75% branches + +Run tests early and often during development. Property tests are especially valuable for complex logic like routing and connection pooling. -**Troubleshooting tool routing**: Namespace parsing happens in `src/routing/`. Check that tool names follow `{serviceName}___{toolName}` format. +--- ## Notes diff --git a/README.md b/README.md index 889b299..7d3aba6 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ OneMCP 是一个基于 Node.js 的智能路由中间件,用于聚合和管理 - 🌐 **多协议支持** - 支持 stdio、SSE 和 Streamable HTTP 三种传输协议 - 👥 **多会话隔离** - 支持多个 AI Agent 并发连接,确保会话间完全隔离 - 💚 **健康监控** - 自动检测服务健康状态,实现工具的自动加载/卸载 +- 🔁 **会话过期自愈** - 后端会话过期(JSON-RPC `-32001` 或规范型 HTTP 404)与连接死亡(stdio 进程退出、SSE 断流)自动失效重建并重放请求,客户端零感知;Server 模式下客户端会话句柄失效同样透明重建 - 🎨 **交互式 TUI** - 提供友好的终端界面进行配置管理 - 🔧 **可扩展架构** - 支持自定义配置提供者和存储适配器 - 📊 **审计日志** - 详细记录所有请求和操作,便于追踪和调试 @@ -497,6 +498,7 @@ Server 模式支持以下自定义 HTTP 请求头,用于每个客户端连接 | 请求头 | 说明 | 示例值 | |--------|------|--------| +| `Mcp-Session-Id` | MCP 会话标识(`initialize` 响应返回,后续请求携带)。会话闲置 30 分钟后回收;**携带已失效的 id 请求会被透明重建(同一 id),客户端无需重新握手**;主动携带该 id 调用 `initialize` 则按规范重新握手 | `9c021dad-...` | | `X-MCP-Tags` | 按标签过滤服务和工具(逗号分隔,OR 逻辑) | `production,api` | | `X-MCP-Smart-Discovery` | 控制智能工具发现开关 | `true` / `false` | @@ -600,6 +602,7 @@ npm run dev ### 构建 ```bash +# 清理 dist/ 后全新构建(保证产物不含历史残留) npm run build ``` @@ -609,6 +612,37 @@ npm run build npm run clean ``` +### 本地部署与端到端验证 + +一键把当前代码打包成真实 npm 包(tarball)、全局安装(完整替代旧的全局 `onemcp` 命令)、重启本地 daemon 并做就绪冒烟: + +```bash +npm run deploy:local [-- --port 5625 --log-level INFO] +``` + +针对**已安装产物**的端到端回归。脚本自身完成"编译 → npm pack → 全局真实安装(tarball + 安装形态校验)",然后以随机端口 + 独立临时配置的独立实例(不影响正在运行的 daemon,注册 HTTP×2 + SSE + stdio×2 共 5 个 mock 后端)覆盖正常与故障恢复两类共 10 个场景,每条断言独立报告、退出码可供 CI 使用: + +```bash +npm run verify:local +``` + +正常操作场景: + +- **N1 HTTP 正常链路与连接复用**:tools/list + 连续 3 次 tools/call 全部成功,零过期零重建(连接复用生效),后端请求计数精确匹配 +- **N2 stdio 正常链路**:spawn → initialize → 连续 tools/call +- **N3 SSE 正常链路**:legacy SSE 两阶段握手 + tools/call +- **N4 标签过滤**:X-MCP-Tags 头在会话创建时生效,tools/list 只返回匹配服务的工具 +- **N5 ping + 会话终止**:DELETE /mcp 返回 200;终止后同句柄调用透明重建 +- **N6 诊断端点**:/diagnostics 暴露各服务连接池状态 + +故障恢复场景(对应历史问题,防回归): + +- **F1 后端会话过期(HTTP,jymcp 型 -32001)**:tools/call 透明重建,客户端零感知 +- **F2 后端规范型会话过期(HTTP 404)**:同上,验证 MCP 规范的过期信号 +- **F3 stdio 后端进程崩溃**:进程运行中退出,调用路径自动 respawn 并重放请求 +- **F4 前端会话句柄失效**:重启 onemcp 实例后客户端携带旧 `Mcp-Session-Id` 重放,会话句柄透明重建 +- TUI:交互式界面(需 PTY)不在本脚本内;其恢复逻辑由 `tests/integration/discovery-worker-session-expiry.test.ts` 覆盖 + ### 运行测试 ```bash diff --git a/package.json b/package.json index cdbf9ee..fd8a712 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "README.md" ], "scripts": { - "build": "tsup", + "build": "npm run clean && tsup", "dev": "tsup --watch", "test": "vitest --run", "test:watch": "vitest", @@ -31,6 +31,8 @@ "format": "prettier --write \"src/**/*.ts\" \"tests/**/*.ts\"", "format:check": "prettier --check \"src/**/*.ts\" \"tests/**/*.ts\"", "typecheck": "tsc --noEmit", + "deploy:local": "npm run build && node scripts/deploy-local.mjs", + "verify:local": "node scripts/e2e-local.mjs", "clean": "node -e \"require('fs').rmSync('dist', {recursive:true, force:true})\"", "prepublishOnly": "npm run clean && npm run build", "prepare": "husky" diff --git a/scripts/deploy-local.mjs b/scripts/deploy-local.mjs new file mode 100644 index 0000000..08f95e7 --- /dev/null +++ b/scripts/deploy-local.mjs @@ -0,0 +1,189 @@ +#!/usr/bin/env node +/** + * deploy-local.mjs — 一键本地部署:编译打包 → 全局安装 → 重启 daemon → 冒烟验证。 + * + * 用法: + * npm run deploy:local [-- --port 5625 --log-level INFO] + * + * 编译打包安装部分与 verify:local 共用 scripts/lib/install-local.mjs: + * 1. npm run build(内置 clean,dist 全新)+ 产物新鲜度断言 + * 2. npm pack 打真实 tarball(遵循 files 字段过滤) + * 3. npm install -g (全局真实副本,完整替代旧 onemcp 命令, + * 与从 registry 安装同语义)+ 安装形态硬校验(防软链式假安装) + * 然后本脚本: + * 4. 安全停止旧 daemon:读 pidfile → SIGTERM → 等待退出 → 必要时 SIGKILL → 清理 pidfile + * 5. onemcp -m server -d 后台启动新 daemon + * 6. 轮询 initialize 直至就绪(避免 pidfile 竞态与半启动状态) + */ +import fs from 'node:fs'; +import http from 'node:http'; +import net from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawn } from 'node:child_process'; +import { buildPackAndInstall } from './lib/install-local.mjs'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const args = process.argv.slice(2); +const getArg = (name, fallback) => { + const i = args.indexOf(`--${name}`); + return i >= 0 && args[i + 1] !== undefined ? args[i + 1] : fallback; +}; + +const PORT = Number(getArg('port', 5625)); +const LOG_LEVEL = getArg('log-level', 'INFO'); +const ONEMCP_DIR = path.join(os.homedir(), '.onemcp'); +const PID_FILE = path.join(ONEMCP_DIR, 'server.pid'); +const LOG_FILE = path.join(ONEMCP_DIR, 'logs', 'server.log'); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +function log(msg) { + console.log(`[deploy] ${msg}`); +} + +function isProcessAlive(pid) { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function portInUse(port) { + return new Promise((resolve) => { + const socket = net.connect({ host: '127.0.0.1', port }); + socket.once('connect', () => { + socket.destroy(); + resolve(true); + }); + socket.once('error', () => resolve(false)); + socket.setTimeout(1000, () => { + socket.destroy(); + resolve(false); + }); + }); +} + +async function stopDaemon() { + let pid = null; + try { + pid = parseInt(fs.readFileSync(PID_FILE, 'utf8').trim(), 10); + } catch { + /* no pidfile */ + } + + if (pid !== null && Number.isFinite(pid) && isProcessAlive(pid)) { + log(`stopping old daemon (pid ${pid})...`); + try { + process.kill(pid, 'SIGTERM'); + } catch { + /* already gone */ + } + const deadline = Date.now() + 10_000; + while (Date.now() < deadline && isProcessAlive(pid)) { + await sleep(200); + } + if (isProcessAlive(pid)) { + log(`daemon (pid ${pid}) did not exit after SIGTERM, sending SIGKILL`); + try { + process.kill(pid, 'SIGKILL'); + } catch { + /* already gone */ + } + await sleep(500); + } + } else if (await portInUse(PORT)) { + // No usable pidfile but something is listening (e.g. a manually started + // instance) — refuse to guess which process to kill. + throw new Error( + `port ${PORT} is in use but no daemon pidfile exists at ${PID_FILE}. ` + + `Stop the existing instance manually, then re-run.` + ); + } + + fs.rmSync(PID_FILE, { force: true }); + if (await portInUse(PORT)) { + throw new Error(`port ${PORT} is still in use after stopping the daemon`); + } + log('old daemon stopped'); +} + +function postInitialize(port, timeoutMs = 10_000) { + return new Promise((resolve, reject) => { + const body = JSON.stringify({ + jsonrpc: '2.0', + id: 'deploy-check', + method: 'initialize', + params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'deploy', version: '0' } }, + }); + const req = http.request( + { + host: '127.0.0.1', + port, + path: '/mcp', + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }, + timeout: timeoutMs, + }, + (res) => { + res.resume(); + res.on('end', () => + resolve({ status: res.statusCode, sessionId: res.headers['mcp-session-id'] }) + ); + } + ); + req.on('error', reject); + req.on('timeout', () => req.destroy(new Error('timeout'))); + req.end(body); + }); +} + +async function waitReady(port, timeoutMs = 60_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const r = await postInitialize(port); + if (r.status === 200 && r.sessionId) return r; + } catch { + /* not ready yet */ + } + await sleep(500); + } + throw new Error(`daemon did not become ready within ${timeoutMs / 1000}s (log: ${LOG_FILE})`); +} + +async function main() { + await buildPackAndInstall(ROOT, (msg) => log(msg)); + + await stopDaemon(); + + log(`starting daemon: onemcp -m server -p ${PORT} -l ${LOG_LEVEL} -d`); + const code = await new Promise((resolve) => { + const child = spawn('onemcp', ['-m', 'server', '-p', String(PORT), '-l', LOG_LEVEL, '-d'], { + stdio: 'ignore', + }); + child.on('exit', resolve); + child.on('error', () => resolve(1)); + }); + if (code !== 0) { + const tail = fs.existsSync(LOG_FILE) + ? fs.readFileSync(LOG_FILE, 'utf8').trimEnd().split('\n').slice(-5).join('\n') + : '(no log file)'; + throw new Error(`daemon failed to start (exit ${code}). Last log lines:\n${tail}`); + } + + log('waiting for readiness (initialize round-trip)...'); + await waitReady(PORT); + + console.log(''); + console.log(`✓ deploy:local PASSED — daemon on http://127.0.0.1:${PORT}/mcp`); + console.log(` log: ${LOG_FILE}`); +} + +main().catch((err) => { + console.error(`✗ deploy:local FAILED: ${err.message}`); + process.exit(1); +}); diff --git a/scripts/e2e-local.mjs b/scripts/e2e-local.mjs new file mode 100644 index 0000000..d6d8058 --- /dev/null +++ b/scripts/e2e-local.mjs @@ -0,0 +1,675 @@ +#!/usr/bin/env node +/** + * e2e-local.mjs — 针对本地已安装 onemcp 的端到端回归。 + * + * 完整链路:本脚本自身执行"编译 → npm pack → 全局真实安装(tarball + 形态校验)", + * 然后用随机端口 + 独立临时配置的独立实例模拟各场景(不影响 :5625 daemon)。 + * + * 场景分两类,先验证正常操作,再验证故障恢复(对应历史问题,防回归): + * + * 【正常场景】 + * N1 HTTP 正常链路与连接复用:tools/list + 连续多次 tools/call 全部成功, + * 零过期/零重建(连接复用生效),后端侧请求计数精确匹配 + * N2 stdio 正常链路:initialize + tools/call 正常 + * N3 SSE 正常链路:legacy SSE 两阶段握手 + tools/call 正常 + * N4 标签过滤:X-MCP-Tags 头只返回匹配服务的工具 + * N5 ping + 会话终止(DELETE /mcp)+ 终止后句柄透明重建 + * N6 /health 与 /diagnostics 端点 + * + * 【故障恢复场景】 + * F1 HTTP 后端会话过期(jymcp 型 -32001,经 /__expire 控制端点触发) + * → 透明重建,客户端零感知 + * F2 HTTP 后端规范型会话过期(HTTP 404)→ 同上 + * F3 stdio 后端进程崩溃(fixture 第 2 次 tools/call 后自杀)→ 自动 respawn 重放 + * F4 前端客户端会话句柄失效 → 重启 onemcp 实例后旧 Mcp-Session-Id 透明重建 + * + * TUI:交互式界面需 PTY,不纳入本脚本;其恢复逻辑由 + * tests/integration/discovery-worker-session-expiry.test.ts 覆盖。 + * + * 用法:npm run verify:local + */ +import { execSync, spawn } from 'node:child_process'; +import fs from 'node:fs'; +import http from 'node:http'; +import net from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { buildPackAndInstall } from './lib/install-local.mjs'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const STDIO_FIXTURE = path.join(ROOT, 'tests/integration/fixtures/mock-stdio-mcp.cjs'); +const SESSION_QUOTA = 100; // 正常场景配额充足;过期场景经 /__expire 控制端点显式触发 + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +const freePort = () => + new Promise((resolve, reject) => { + const srv = net.createServer(); + srv.once('error', reject); + srv.listen(0, '127.0.0.1', () => { + const port = srv.address().port; + srv.close(() => resolve(port)); + }); + }); + +function resolveOnemcpCommand() { + try { + const bin = execSync('command -v onemcp', { encoding: 'utf8' }).trim(); + return { cmd: bin, args: [] }; + } catch { + const dist = path.join(ROOT, 'dist', 'cli.js'); + if (fs.existsSync(dist)) { + return { cmd: process.execPath, args: [dist], note: '(未找到全局 onemcp,回退使用 dist/cli.js)' }; + } + throw new Error('未找到 onemcp 可执行文件,请先运行 npm run deploy:local'); + } +} + +// ---------- mock Streamable HTTP 后端(支持 /__expire 控制端点) ---------- +function startHttpBackend(port, staleMode = 'jsonrpc') { + const sessions = new Map(); // sid -> remaining allowed requests + let sidCounter = 0; + const stats = { initializes: 0, toolsList: 0, toolsCall: 0, expiredErrors: 0, log: [] }; + const t0 = Date.now(); + const trace = (entry) => { + if (stats.log.length < 120) stats.log.push(`${Date.now() - t0}ms ${entry}`); + }; + + const server = http.createServer((req, res) => { + if (req.method === 'GET' && req.url === '/__stats') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(stats)); + return; + } + if (req.method === 'POST' && req.url === '/__expire') { + // 模拟后端空闲回收:立即作废所有已发会话 + for (const sid of sessions.keys()) sessions.set(sid, 0); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ expired: sessions.size })); + return; + } + let raw = ''; + req.on('data', (c) => (raw += c)); + req.on('end', () => { + let msg; + try { + msg = JSON.parse(raw); + } catch { + res.writeHead(400).end(); + return; + } + const sendJson = (status, body) => { + const json = JSON.stringify(body); + res.writeHead(status, { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(json), + }); + res.end(json); + }; + if (msg.id === undefined || msg.id === null) { + res.writeHead(202).end(); + return; + } + if (msg.method === 'initialize') { + stats.initializes++; + const sid = `sess-${++sidCounter}`; + sessions.set(sid, SESSION_QUOTA); + trace(`initialize -> ${sid}`); + const json = JSON.stringify({ + jsonrpc: '2.0', + id: msg.id, + result: { + protocolVersion: '2024-11-05', + capabilities: { tools: { listChanged: true } }, + serverInfo: { name: 'mock-backend', version: '1.0.0' }, + }, + }); + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(json), + 'mcp-session-id': sid, + }); + res.end(json); + return; + } + const sid = req.headers['mcp-session-id']; + const remaining = typeof sid === 'string' ? sessions.get(sid) : undefined; + trace(`${msg.method} sid=${sid ?? '-'} remaining=${remaining ?? 'unknown'}`); + if (remaining !== undefined && remaining > 0) { + sessions.set(sid, remaining - 1); + if (msg.method === 'tools/list') { + stats.toolsList++; + sendJson(200, { + jsonrpc: '2.0', + id: msg.id, + result: { + tools: [ + { name: 'alpha', description: 'Alpha', inputSchema: { type: 'object', properties: {} } }, + { name: 'beta', description: 'Beta', inputSchema: { type: 'object', properties: {} } }, + ], + }, + }); + } else { + stats.toolsCall++; + sendJson(200, { + jsonrpc: '2.0', + id: msg.id, + result: { content: [{ type: 'text', text: 'ok' }] }, + }); + } + return; + } + stats.expiredErrors++; + if (staleMode === 'http404') { + // 规范型过期信号:HTTP 404 on stale Mcp-Session-Id + res.writeHead(404).end('Not Found'); + return; + } + sendJson(200, { + jsonrpc: '2.0', + id: msg.id, + error: { code: -32001, message: 'Session not found or expired. Please send initialize again.' }, + }); + }); + }); + + return { + listen: () => new Promise((r) => server.listen(port, '127.0.0.1', r)), + close: () => new Promise((r) => server.close(r)), + stats: () => stats, + expireAll: () => { + for (const sid of sessions.keys()) sessions.set(sid, 0); + }, + }; +} + +// ---------- mock legacy SSE 后端(两阶段握手:GET /sse + POST /messages) ---------- +function startSseBackend(port) { + let sseRes = null; + const server = http.createServer((req, res) => { + if (req.method === 'GET' && req.url.startsWith('/sse')) { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }); + res.write(': connected\n\n'); + res.write('event: endpoint\ndata: /messages\n\n'); + sseRes = res; + req.on('close', () => { + if (sseRes === res) sseRes = null; + }); + return; + } + if (req.method === 'POST' && req.url.startsWith('/messages')) { + let raw = ''; + req.on('data', (c) => (raw += c)); + req.on('end', () => { + let msg; + try { + msg = JSON.parse(raw); + } catch { + res.writeHead(400).end(); + return; + } + if (msg.id === undefined || msg.id === null) { + res.writeHead(202).end(); + return; + } + let response; + if (msg.method === 'initialize') { + response = { + jsonrpc: '2.0', + id: msg.id, + result: { + protocolVersion: '2024-11-05', + capabilities: { tools: { listChanged: false } }, + serverInfo: { name: 'mock-sse', version: '1.0.0' }, + }, + }; + } else if (msg.method === 'tools/list') { + response = { + jsonrpc: '2.0', + id: msg.id, + result: { + tools: [ + { name: 'alpha', description: 'Alpha', inputSchema: { type: 'object', properties: {} } }, + ], + }, + }; + } else { + response = { + jsonrpc: '2.0', + id: msg.id, + result: { content: [{ type: 'text', text: 'ok' }] }, + }; + } + if (sseRes) { + sseRes.write(`event: message\ndata: ${JSON.stringify(response)}\n\n`); + } + res.writeHead(202).end(); + }); + return; + } + res.writeHead(404).end(); + }); + + return { + listen: () => new Promise((r) => server.listen(port, '127.0.0.1', r)), + close: () => new Promise((r) => server.close(r)), + }; +} + +// ---------- HTTP 客户端 ---------- +function request(port, method, path, body, headers = {}) { + return new Promise((resolve, reject) => { + const data = body === undefined ? null : JSON.stringify(body); + const req = http.request( + { + host: '127.0.0.1', + port, + path, + method, + headers: { + 'Content-Type': 'application/json', + ...(data ? { 'Content-Length': Buffer.byteLength(data) } : {}), + ...headers, + }, + }, + (res) => { + let out = ''; + res.on('data', (c) => (out += c)); + res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: out })); + } + ); + req.on('error', reject); + req.setTimeout(30_000, () => req.destroy(new Error('client request timeout'))); + req.end(data ?? undefined); + }); +} + +const post = (port, body, headers = {}) => request(port, 'POST', '/mcp', body, headers); +const get = (port, p) => request(port, 'GET', p, undefined); + +// ---------- onemcp 实例管理 ---------- +function spawnOnemcp(onemcpCmd, port, configDir, onStderr) { + const child = spawn(onemcpCmd.cmd, [...onemcpCmd.args, '-m', 'server', '-p', String(port), '-l', 'INFO', '-c', configDir], { + cwd: ROOT, + stdio: ['ignore', 'ignore', 'pipe'], + }); + child.stderr.on('data', (d) => onStderr(d.toString())); + return child; +} + +async function stopChild(child, port) { + if (!child || child.exitCode !== null) return; + child.kill('SIGTERM'); + await Promise.race([ + new Promise((r) => child.once('exit', r)), + sleep(10_000).then(() => child.kill('SIGKILL')), + ]); + // 等端口释放,避免下一个实例绑定失败 + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + const inUse = await new Promise((resolve) => { + const s = net.connect({ host: '127.0.0.1', port }); + s.once('connect', () => { + s.destroy(); + resolve(true); + }); + s.once('error', () => resolve(false)); + s.setTimeout(500, () => { + s.destroy(); + resolve(false); + }); + }); + if (!inUse) return; + await sleep(200); + } +} + +async function waitReady(port, timeoutMs = 60_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const r = await post(port, { + jsonrpc: '2.0', + id: 'probe', + method: 'initialize', + params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'e2e', version: '0' } }, + }); + if (r.status === 200 && r.headers['mcp-session-id']) return r.headers['mcp-session-id']; + } catch { + /* not ready */ + } + await sleep(500); + } + throw new Error(`onemcp 未在 ${timeoutMs / 1000}s 内就绪`); +} + +async function callTool(port, session, id, name, args = {}) { + const res = JSON.parse( + ( + await post(port, { jsonrpc: '2.0', id, method: 'tools/call', params: { name, arguments: args } }, { + 'mcp-session-id': session, + }) + ).body + ); + if (res.error) throw new Error(`tools/call(${name}) 把错误暴露给了客户端: ${JSON.stringify(res.error)}`); + const text = res.result?.content?.[0]?.text; + if (text !== 'ok') throw new Error(`tools/call(${name}) 返回异常: ${JSON.stringify(res).slice(0, 300)}`); +} + +// ---------- 主流程 ---------- +async function main() { + console.log('[0/6] 编译当前代码 → npm 打包 → 全局真实安装(与 registry 安装同语义)'); + const { bin } = await buildPackAndInstall(ROOT, (msg) => console.log(` ${msg}`)); + const onemcpCmd = { cmd: bin, args: [] }; + + const httpNormalPort = await freePort(); // N1/F1: -32001 过期 + const http404Port = await freePort(); // F2: 404 过期 + const ssePort = await freePort(); // N3: SSE + const onemcpPort = await freePort(); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'onemcp-e2e-')); + const backendNormal = startHttpBackend(httpNormalPort, 'jsonrpc'); + const backend404 = startHttpBackend(http404Port, 'http404'); + const sseBackend = startSseBackend(ssePort); + const stderrBuf = { text: '' }; + const onStderr = (d) => (stderrBuf.text += d); + + let child = null; + const results = []; + const record = (name, ok, detail = '') => { + results.push({ name, ok }); + console.log(` ${ok ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`); + if (!ok) process.exitCode = 1; + }; + + try { + await backendNormal.listen(); + await backend404.listen(); + await sseBackend.listen(); + fs.writeFileSync( + path.join(tmpDir, 'config.json'), + JSON.stringify({ + mode: 'server', + port: onemcpPort, + logLevel: 'INFO', + configDir: tmpDir, + mcpServers: { + 'http-main': { + enabled: true, + tags: ['grp-http'], + transport: 'http', + url: `http://127.0.0.1:${httpNormalPort}/mcp`, + connectionPool: { maxConnections: 2, idleTimeout: 60000, connectionTimeout: 10000 }, + }, + 'http-404': { + enabled: true, + tags: ['grp-http'], + transport: 'http', + url: `http://127.0.0.1:${http404Port}/mcp`, + connectionPool: { maxConnections: 2, idleTimeout: 60000, connectionTimeout: 10000 }, + }, + 'sse-svc': { + enabled: true, + tags: ['grp-sse'], + transport: 'sse', + url: `http://127.0.0.1:${ssePort}/sse`, + connectionPool: { maxConnections: 2, idleTimeout: 60000, connectionTimeout: 10000 }, + }, + 'stdio-normal': { + enabled: true, + tags: ['grp-stdio'], + transport: 'stdio', + command: process.execPath, + args: [STDIO_FIXTURE], + connectionPool: { maxConnections: 2, idleTimeout: 60000, connectionTimeout: 10000 }, + }, + 'stdio-crash': { + enabled: true, + tags: ['grp-stdio'], + transport: 'stdio', + command: process.execPath, + args: [STDIO_FIXTURE], + env: { ONEMCP_FIXTURE_EXIT_AFTER_CALLS: '2' }, + connectionPool: { maxConnections: 2, idleTimeout: 60000, connectionTimeout: 10000 }, + }, + }, + connectionPool: { maxConnections: 5, idleTimeout: 60000, connectionTimeout: 30000 }, + healthCheck: { enabled: true, interval: 30000, failureThreshold: 3, autoUnload: true }, + audit: { enabled: false, level: 'standard', logInput: false, logOutput: false, retention: { days: 30, maxSize: '1GB' } }, + security: { dataMasking: { enabled: false, patterns: [] } }, + }) + ); + + console.log('\n[准备] 启动 onemcp 实例(HTTP×2 + SSE + stdio×2 共 5 个后端)'); + child = spawnOnemcp(onemcpCmd, onemcpPort, tmpDir, onStderr); + const session = await waitReady(onemcpPort); + record('实例就绪(initialize 往返正常)', true); + + const H = { 'mcp-session-id': session }; + + console.log('\n[N1] HTTP 正常链路与连接复用'); + { + const listRes = JSON.parse((await post(onemcpPort, { jsonrpc: '2.0', id: 'l1', method: 'tools/list', params: {} }, H)).body); + const names = (listRes.result?.tools || []).map((t) => t.name).sort(); + record( + 'N1.1 tools/list 返回全部命名空间工具', + !listRes.error && + ['http-main__alpha', 'http-404__alpha', 'sse-svc__alpha', 'stdio-normal__echo', 'stdio-crash__echo'].every((n) => + names.includes(n) + ), + `[${names.join(', ')}]` + ); + + const before = { ...backendNormal.stats() }; // 按值快照(stats() 返回同一引用) + let n1ok = true; + try { + for (const [i, id] of ['c1', 'c2', 'c3'].entries()) { + await callTool(onemcpPort, session, id, 'http-main__alpha'); + } + } catch (e) { + n1ok = false; + record('N1.2 连续 3 次调用全部成功', false, e.message); + } + if (n1ok) { + const after = backendNormal.stats(); + const reused = + after.initializes === before.initializes && // 零重建:连接复用生效 + after.expiredErrors === before.expiredErrors && // 零过期 + after.toolsCall - before.toolsCall === 3; // 3 次调用精确到达后端 + record( + 'N1.2 连续 3 次调用全部成功', + reused, + `连接复用(initialize ${before.initializes}→${after.initializes})、零过期、后端收到 3 次 tools/call` + ); + } + } + + console.log('\n[N2] stdio 正常链路'); + { + let ok = true; + try { + await callTool(onemcpPort, session, 'n2a', 'stdio-normal__echo', { text: 'hello' }); + await callTool(onemcpPort, session, 'n2b', 'stdio-normal__echo', { text: 'world' }); + } catch (e) { + ok = false; + record('N2.1 stdio 初始化 + 连续调用', false, e.message); + } + if (ok) record('N2.1 stdio 初始化 + 连续调用', true, 'spawn → initialize → tools/call ×2 正常'); + } + + console.log('\n[N3] SSE 正常链路'); + { + let ok = true; + try { + await callTool(onemcpPort, session, 'n3a', 'sse-svc__alpha'); + } catch (e) { + ok = false; + record('N3.1 SSE 两阶段握手 + tools/call', false, e.message); + } + if (ok) record('N3.1 SSE 两阶段握手 + tools/call', true); + } + + console.log('\n[N4] 标签过滤(X-MCP-Tags)'); + { + // 标签在会话创建时解析:规范流程 = 携带 X-MCP-Tags 发起 initialize, + // 会话即带上标签过滤,随后该会话的 tools/list 只返回匹配服务的工具。 + const initRes = await post(onemcpPort, { + jsonrpc: '2.0', + id: 'tag-init', + method: 'initialize', + params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'e2e-tag', version: '0' } }, + }, { 'X-MCP-Tags': 'grp-stdio' }); + const tagSession = initRes.headers['mcp-session-id']; + if (initRes.status !== 200 || !tagSession) { + throw new Error(`带标签 initialize 失败: ${initRes.status}`); + } + await post(onemcpPort, { jsonrpc: '2.0', method: 'notifications/initialized' }, { 'mcp-session-id': tagSession }); + + const res = JSON.parse( + ( + await post(onemcpPort, { jsonrpc: '2.0', id: 'tag1', method: 'tools/list', params: {} }, { + 'mcp-session-id': tagSession, + }) + ).body + ); + const names = (res.result?.tools || []).map((t) => t.name); + const ok = + !res.error && + names.length > 0 && + names.every((n) => n.startsWith('stdio-normal__') || n.startsWith('stdio-crash__')); + record('N4.1 X-MCP-Tags=grp-stdio 只返回 stdio 服务工具', ok, `[${names.join(', ')}]`); + } + + console.log('\n[N5] ping + 会话终止(DELETE)+ 终止后句柄重建'); + { + const pingRes = JSON.parse((await post(onemcpPort, { jsonrpc: '2.0', id: 'p1', method: 'ping' }, H)).body); + record('N5.1 ping 正常响应', !pingRes.error && pingRes.result !== undefined); + + const del = await request(onemcpPort, 'DELETE', '/mcp', undefined, { 'mcp-session-id': session }); + record('N5.2 DELETE /mcp 终止会话返回 200', del.status === 200, `status=${del.status}`); + + // 终止后同句柄重放:会话按句柄语义透明重建,客户端零感知 + let ok = true; + try { + await callTool(onemcpPort, session, 'n5c', 'http-main__alpha'); + } catch (e) { + ok = false; + record('N5.3 终止后同句柄调用透明重建', false, e.message); + } + if (ok) { + const recreated = stderrBuf.text.includes('recreating transparently'); + record('N5.3 终止后同句柄调用透明重建', recreated, recreated ? '日志确认重建' : '日志中未发现重建记录'); + } + } + + console.log('\n[F1] HTTP 后端会话过期(-32001)→ 透明重建'); + { + backendNormal.expireAll(); + let ok = true; + try { + await callTool(onemcpPort, session, 'f1a', 'http-main__beta'); + } catch (e) { + ok = false; + record('F1.1 过期后调用透明恢复', false, e.message); + } + if (ok) { + const s = backendNormal.stats(); + record('F1.1 过期后调用透明恢复', s.expiredErrors >= 1, `后端 ${s.expiredErrors} 次过期,客户端零感知`); + } + } + + console.log('\n[F2] HTTP 后端规范型会话过期(HTTP 404)→ 透明重建'); + { + backend404.expireAll(); + let ok = true; + try { + await callTool(onemcpPort, session, 'f2a', 'http-404__alpha'); + } catch (e) { + ok = false; + record('F2.1 404 过期后调用透明恢复', false, e.message); + } + if (ok) { + const s = backend404.stats(); + record('F2.1 404 过期后调用透明恢复', s.expiredErrors >= 1, `后端 ${s.expiredErrors} 次 404,客户端零感知`); + } + } + + console.log('\n[F3] stdio 后端进程崩溃 → 自动 respawn'); + { + // fixture 设置 EXIT_AFTER_CALLS=2:响应第 2 次 tools/call 后自杀。 + // c1/c2 正常;c2 应答后进程退出 → c3 撞死连接 → 路由恢复(失效+respawn+重放)。 + let ok = true; + try { + await callTool(onemcpPort, session, 'f3a', 'stdio-crash__echo', { text: 'first' }); + await callTool(onemcpPort, session, 'f3b', 'stdio-crash__echo', { text: 'second' }); + await callTool(onemcpPort, session, 'f3c', 'stdio-crash__echo', { text: 'third' }); + } catch (e) { + ok = false; + record('F3.1 崩溃后调用自动恢复', false, e.message); + } + if (ok) { + const recovered = stderrBuf.text.includes('Recoverable connection failure (tools/call echo)'); + record('F3.1 崩溃后调用自动恢复', recovered, recovered ? '3 次调用成功,日志确认走恢复路径' : '3 次调用成功,但日志中未发现恢复记录'); + } + } + + console.log('\n[F4] 前端客户端会话句柄失效 → 透明重建'); + { + await stopChild(child, onemcpPort); + child = spawnOnemcp(onemcpCmd, onemcpPort, tmpDir, onStderr); + await waitReady(onemcpPort); + const health = await get(onemcpPort, '/health'); + record('F4.1 实例重启后 /health 正常', health.status === 200); + + let ok = true; + try { + await callTool(onemcpPort, session, 'f4a', 'http-main__alpha'); + } catch (e) { + ok = false; + record('F4.2 旧 Mcp-Session-Id 重放调用', false, e.message); + } + if (ok) { + const recreated = stderrBuf.text.includes('recreating transparently'); + record('F4.2 旧 Mcp-Session-Id 重放调用', recreated, recreated ? '日志确认会话句柄透明重建' : '日志中未发现重建记录'); + } + } + + console.log('\n[N6] 诊断端点'); + { + const diag = JSON.parse((await get(onemcpPort, '/diagnostics')).body); + const pools = diag.connectionPools || []; + record( + 'N6.1 /diagnostics 暴露连接池状态', + Array.isArray(pools) && pools.some((p) => p.serviceName === 'http-main' && p.stats), + pools.map((p) => `${p.serviceName}:${JSON.stringify(p.stats)}`).join(' ') + ); + } + + const failed = results.filter((r) => !r.ok); + console.log(''); + if (failed.length === 0) { + console.log(`E2E PASSED(${results.length}/${results.length} 项断言通过)`); + } else { + console.error(`E2E FAILED(${failed.length}/${results.length} 项断言失败)`); + if (stderrBuf.text.trim()) { + console.error(`--- onemcp stderr(末尾 2000 字符)---\n${stderrBuf.text.slice(-2000)}`); + } + } + } catch (err) { + console.error(`E2E FAILED: ${err.message}`); + if (stderrBuf.text.trim()) console.error(`--- onemcp stderr(末尾 2000 字符)---\n${stderrBuf.text.slice(-2000)}`); + process.exitCode = 1; + } finally { + await stopChild(child, onemcpPort); + await backendNormal.close().catch(() => {}); + await backend404.close().catch(() => {}); + await sseBackend.close().catch(() => {}); + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +} + +await main(); diff --git a/scripts/lib/install-local.mjs b/scripts/lib/install-local.mjs new file mode 100644 index 0000000..b8f8226 --- /dev/null +++ b/scripts/lib/install-local.mjs @@ -0,0 +1,81 @@ +/** + * install-local.mjs — 共享的"编译 → npm 打包 → 全局安装"管道。 + * + * 被 deploy:local(部署到 :5625 daemon)与 verify:local(独立实例 E2E)共用, + * 保证两者验证的都是"从当前源码打包出来的真实 npm 包": + * 1. npm run build(内置 clean,dist 全新) + * 2. 产物新鲜度断言(dist/cli.js mtime 晚于构建开始时间) + * 3. npm pack 打 tarball(遵循 files 字段过滤) + * 4. npm install -g (全局 node_modules 真实副本,完整替代旧命令) + * 5. 安装形态硬校验(realpath 必须落在 npm root -g 内,防软链式假安装) + */ +import { execSync, spawn } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +export function resolveBin() { + try { + return execSync('command -v onemcp', { encoding: 'utf8' }).trim(); + } catch { + return null; + } +} + +/** + * 构建并断言产物新鲜度,然后打包并全局安装。 + * @returns {{ tarball: string, bin: string }} + */ +export async function buildPackAndInstall(root, log = () => {}) { + const startedAt = new Date(); + log('building (clean + npm run build)...'); + execSync('npm run build', { cwd: root, stdio: 'inherit' }); + + const cliJs = path.join(root, 'dist', 'cli.js'); + if (!fs.existsSync(cliJs)) { + throw new Error('build 未产出 dist/cli.js'); + } + if (fs.statSync(cliJs).mtimeMs < startedAt.getTime() - 1000) { + throw new Error('dist/cli.js 的修改时间早于构建开始时间——疑似部署了历史产物,中止'); + } + log('artifact freshness OK'); + + log('packing (npm pack)...'); + const packDir = fs.mkdtempSync(path.join(os.tmpdir(), 'onemcp-pack-')); + let tarball; + try { + const out = execSync(`npm pack --json --pack-destination "${packDir}"`, { + cwd: root, + encoding: 'utf8', + }); + const packed = JSON.parse(out)[0]; + tarball = path.join(packDir, packed.filename); + if (!fs.existsSync(tarball)) { + throw new Error(`npm pack did not produce ${tarball}`); + } + + log(`installing globally from tarball (npm install -g ${packed.filename})...`); + execSync(`npm install -g "${tarball}"`, { cwd: root, stdio: 'inherit' }); + + const bin = resolveBin(); + if (!bin) { + throw new Error('onemcp binary not found on PATH after global install'); + } + const realBin = fs.realpathSync(bin); + const realGlobalRoot = fs.realpathSync(execSync('npm root -g', { encoding: 'utf8' }).trim()); + if (!realBin.startsWith(realGlobalRoot + path.sep)) { + throw new Error( + `安装形态校验失败:onemcp 解析到 ${realBin},不在全局 node_modules(${realGlobalRoot})内。` + + `全局安装可能仍是仓库软链(npm link 式),请检查。` + ); + } + log(`install shape OK: ${bin} → ${realBin}`); + + // tarball 已装入全局 node_modules,临时目录可清理 + fs.rmSync(packDir, { recursive: true, force: true }); + return { tarball, bin }; + } catch (err) { + fs.rmSync(packDir, { recursive: true, force: true }); + throw err; + } +} From 51d3ffa7f8d61815f8e6d9d5c0b060ba407d5dd3 Mon Sep 17 00:00:00 2001 From: kugouming Date: Thu, 3 Sep 2026 00:59:52 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(ci):=20coverage=20=E6=8E=92=E9=99=A4=20?= =?UTF-8?q?scripts/=20=E7=9B=AE=E5=BD=95=EF=BC=8C=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=85=A8=E5=B1=80=E8=A6=86=E7=9B=96=E7=8E=87=E4=BD=8E=E4=BA=8E?= =?UTF-8?q?=E9=98=88=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- vitest.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/vitest.config.ts b/vitest.config.ts index 23670f0..be25c34 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -13,6 +13,7 @@ export default defineConfig({ 'tests/', '**/*.test.ts', '**/*.config.ts', + 'scripts/', 'src/tui/**', 'src/cli.ts', 'src/cli-mode.ts',