neoagent 2.4.1-beta.9 → 2.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (234) hide show
  1. package/.env.example +33 -3
  2. package/LICENSE +111 -56
  3. package/README.md +8 -3
  4. package/docs/configuration.md +8 -0
  5. package/docs/getting-started.md +9 -3
  6. package/docs/index.md +4 -0
  7. package/extensions/chrome-browser/background.mjs +36 -4
  8. package/extensions/chrome-browser/icons/icon128.png +0 -0
  9. package/extensions/chrome-browser/icons/icon16.png +0 -0
  10. package/extensions/chrome-browser/icons/icon48.png +0 -0
  11. package/extensions/chrome-browser/icons/logo.svg +39 -8
  12. package/extensions/chrome-browser/manifest.json +3 -3
  13. package/extensions/chrome-browser/popup.html +5 -1
  14. package/extensions/chrome-browser/popup.js +13 -0
  15. package/flutter_app/android/app/src/main/AndroidManifest.xml +2 -1
  16. package/flutter_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
  17. package/flutter_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
  18. package/flutter_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
  19. package/flutter_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
  20. package/flutter_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
  21. package/flutter_app/assets/branding/app_icon_1024.png +0 -0
  22. package/flutter_app/assets/branding/app_icon_128.png +0 -0
  23. package/flutter_app/assets/branding/app_icon_192.png +0 -0
  24. package/flutter_app/assets/branding/app_icon_256.png +0 -0
  25. package/flutter_app/assets/branding/app_icon_32.png +0 -0
  26. package/flutter_app/assets/branding/app_icon_512.png +0 -0
  27. package/flutter_app/assets/branding/app_icon_64.png +0 -0
  28. package/flutter_app/assets/branding/app_icon_light_1024.png +0 -0
  29. package/flutter_app/assets/branding/app_icon_light_128.png +0 -0
  30. package/flutter_app/assets/branding/app_icon_light_192.png +0 -0
  31. package/flutter_app/assets/branding/app_icon_light_256.png +0 -0
  32. package/flutter_app/assets/branding/app_icon_light_32.png +0 -0
  33. package/flutter_app/assets/branding/app_icon_light_512.png +0 -0
  34. package/flutter_app/assets/branding/app_icon_light_64.png +0 -0
  35. package/flutter_app/assets/branding/onboarding_intro.mp4 +0 -0
  36. package/flutter_app/assets/branding/tray_icon_light_template.png +0 -0
  37. package/flutter_app/assets/branding/tray_icon_template.png +0 -0
  38. package/flutter_app/lib/features/location/location_service.dart +3 -0
  39. package/flutter_app/lib/features/onboarding/onboarding_chrome.dart +391 -382
  40. package/flutter_app/lib/features/onboarding/onboarding_companion_step.dart +743 -0
  41. package/flutter_app/lib/features/onboarding/onboarding_messaging_step.dart +18 -16
  42. package/flutter_app/lib/features/onboarding/onboarding_model_step.dart +19 -18
  43. package/flutter_app/lib/features/onboarding/onboarding_shell.dart +8 -1
  44. package/flutter_app/lib/features/onboarding/onboarding_video_step.dart +16 -13
  45. package/flutter_app/lib/features/onboarding/onboarding_welcome_step.dart +17 -13
  46. package/flutter_app/lib/main.dart +2 -0
  47. package/flutter_app/lib/main_account_settings.dart +10 -34
  48. package/flutter_app/lib/main_admin.dart +14 -1
  49. package/flutter_app/lib/main_app_shell.dart +377 -340
  50. package/flutter_app/lib/main_chat.dart +707 -227
  51. package/flutter_app/lib/main_controller.dart +197 -42
  52. package/flutter_app/lib/main_devices.dart +436 -4
  53. package/flutter_app/lib/main_integrations.dart +255 -6
  54. package/flutter_app/lib/main_launcher.dart +1 -1
  55. package/flutter_app/lib/main_model_picker.dart +685 -0
  56. package/flutter_app/lib/main_models.dart +212 -0
  57. package/flutter_app/lib/main_navigation.dart +1 -9
  58. package/flutter_app/lib/main_operations.dart +1417 -614
  59. package/flutter_app/lib/main_settings.dart +673 -871
  60. package/flutter_app/lib/main_shared.dart +971 -443
  61. package/flutter_app/lib/main_spacing.dart +4 -4
  62. package/flutter_app/lib/main_theme.dart +22 -14
  63. package/flutter_app/lib/main_unified.dart +5 -72
  64. package/flutter_app/lib/src/android_apk_drop_zone.dart +24 -0
  65. package/flutter_app/lib/src/android_apk_drop_zone_stub.dart +13 -0
  66. package/flutter_app/lib/src/android_apk_drop_zone_web.dart +219 -0
  67. package/flutter_app/lib/src/backend_client.dart +46 -0
  68. package/flutter_app/lib/src/desktop_companion_actions.dart +30 -7
  69. package/flutter_app/lib/src/desktop_companion_io.dart +71 -1
  70. package/flutter_app/lib/src/security/password_strength.dart +84 -0
  71. package/flutter_app/lib/src/stream_renderer.dart +42 -3
  72. package/flutter_app/lib/src/theme/palette.dart +76 -34
  73. package/flutter_app/linux/runner/resources/app_icon.png +0 -0
  74. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png +0 -0
  75. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png +0 -0
  76. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png +0 -0
  77. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png +0 -0
  78. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png +0 -0
  79. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png +0 -0
  80. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png +0 -0
  81. package/flutter_app/pubspec.lock +2 -2
  82. package/flutter_app/pubspec.yaml +7 -1
  83. package/flutter_app/tool/branding_source/neoagent-icon-1024.png +0 -0
  84. package/flutter_app/tool/branding_source/neoagent-icon-128.png +0 -0
  85. package/flutter_app/tool/branding_source/neoagent-icon-16.png +0 -0
  86. package/flutter_app/tool/branding_source/neoagent-icon-180.png +0 -0
  87. package/flutter_app/tool/branding_source/neoagent-icon-192.png +0 -0
  88. package/flutter_app/tool/branding_source/neoagent-icon-256.png +0 -0
  89. package/flutter_app/tool/branding_source/neoagent-icon-32.png +0 -0
  90. package/flutter_app/tool/branding_source/neoagent-icon-48.png +0 -0
  91. package/flutter_app/tool/branding_source/neoagent-icon-512.png +0 -0
  92. package/flutter_app/tool/branding_source/neoagent-icon-64.png +0 -0
  93. package/flutter_app/tool/branding_source/neoagent-icon.svg +43 -0
  94. package/flutter_app/tool/generate_desktop_branding.py +154 -152
  95. package/flutter_app/web/favicon.png +0 -0
  96. package/flutter_app/web/favicon.svg +40 -9
  97. package/flutter_app/web/favicon_light.svg +43 -0
  98. package/flutter_app/web/icons/Icon-192-light.png +0 -0
  99. package/flutter_app/web/icons/Icon-192.png +0 -0
  100. package/flutter_app/web/icons/Icon-512-light.png +0 -0
  101. package/flutter_app/web/icons/Icon-512.png +0 -0
  102. package/flutter_app/web/icons/Icon-maskable-192-light.png +0 -0
  103. package/flutter_app/web/icons/Icon-maskable-192.png +0 -0
  104. package/flutter_app/web/icons/Icon-maskable-512-light.png +0 -0
  105. package/flutter_app/web/icons/Icon-maskable-512.png +0 -0
  106. package/flutter_app/windows/runner/main.cpp +7 -1
  107. package/flutter_app/windows/runner/resources/app_icon.ico +0 -0
  108. package/lib/manager.js +445 -83
  109. package/package.json +17 -3
  110. package/runtime/paths.js +3 -1
  111. package/server/admin/access.js +198 -0
  112. package/server/admin/admin.css +268 -0
  113. package/server/admin/admin.js +348 -0
  114. package/server/admin/analytics.js +128 -0
  115. package/server/admin/index.html +1015 -0
  116. package/server/admin/login.html +290 -0
  117. package/server/admin/logo.svg +43 -0
  118. package/server/admin/sql.js +134 -0
  119. package/server/admin/users.js +147 -0
  120. package/server/config/origins.js +3 -1
  121. package/server/db/database.js +92 -0
  122. package/server/db/ftsQuery.js +27 -0
  123. package/server/http/routes.js +1 -0
  124. package/server/http/static.js +23 -6
  125. package/server/index.js +1 -1
  126. package/server/middleware/adminAuth.js +48 -0
  127. package/server/middleware/auth.js +1 -40
  128. package/server/public/.last_build_id +1 -1
  129. package/server/public/app_icon.png +0 -0
  130. package/server/public/assets/AssetManifest.bin +1 -1
  131. package/server/public/assets/AssetManifest.bin.json +1 -1
  132. package/server/public/assets/assets/branding/app_icon_1024.png +0 -0
  133. package/server/public/assets/assets/branding/app_icon_256.png +0 -0
  134. package/server/public/assets/assets/branding/app_icon_512.png +0 -0
  135. package/server/public/assets/assets/branding/app_icon_light_1024.png +0 -0
  136. package/server/public/assets/assets/branding/app_icon_light_256.png +0 -0
  137. package/server/public/assets/assets/branding/app_icon_light_512.png +0 -0
  138. package/server/public/assets/assets/branding/onboarding_intro.mp4 +0 -0
  139. package/server/public/assets/assets/branding/tray_icon_light_template.png +0 -0
  140. package/server/public/assets/assets/branding/tray_icon_template.png +0 -0
  141. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  142. package/server/public/assets/web/icons/Icon-192.png +0 -0
  143. package/server/public/favicon.png +0 -0
  144. package/server/public/favicon.svg +40 -9
  145. package/server/public/favicon_light.svg +43 -0
  146. package/server/public/flutter_bootstrap.js +2 -2
  147. package/server/public/icons/Icon-192-light.png +0 -0
  148. package/server/public/icons/Icon-192.png +0 -0
  149. package/server/public/icons/Icon-512-light.png +0 -0
  150. package/server/public/icons/Icon-512.png +0 -0
  151. package/server/public/icons/Icon-maskable-192-light.png +0 -0
  152. package/server/public/icons/Icon-maskable-192.png +0 -0
  153. package/server/public/icons/Icon-maskable-512-light.png +0 -0
  154. package/server/public/icons/Icon-maskable-512.png +0 -0
  155. package/server/public/main.dart.js +88537 -85936
  156. package/server/routes/admin.js +632 -0
  157. package/server/routes/agent_profiles.js +3 -0
  158. package/server/routes/agents.js +1 -1
  159. package/server/routes/auth.js +3 -1
  160. package/server/routes/mcp.js +29 -13
  161. package/server/routes/memory.js +23 -1
  162. package/server/routes/messaging.js +2 -0
  163. package/server/routes/screenHistory.js +14 -8
  164. package/server/routes/settings.js +10 -2
  165. package/server/routes/stream.js +1 -1
  166. package/server/routes/triggers.js +4 -4
  167. package/server/routes/voice_assistant.js +36 -1
  168. package/server/routes/workspace.js +86 -0
  169. package/server/services/account/admin_two_factor.js +132 -0
  170. package/server/services/account/password_policy.js +6 -1
  171. package/server/services/ai/completion.js +44 -0
  172. package/server/services/ai/engine.js +321 -500
  173. package/server/services/ai/imageAnalysis.js +9 -5
  174. package/server/services/ai/logFormat.js +46 -0
  175. package/server/services/ai/loopPolicy.js +11 -0
  176. package/server/services/ai/messagingFallback.js +228 -0
  177. package/server/services/ai/models.js +194 -55
  178. package/server/services/ai/providerRetry.js +169 -0
  179. package/server/services/ai/providers/anthropic.js +15 -4
  180. package/server/services/ai/providers/google.js +38 -11
  181. package/server/services/ai/providers/grok.js +19 -68
  182. package/server/services/ai/providers/grokOauth.js +141 -0
  183. package/server/services/ai/providers/nvidia.js +154 -0
  184. package/server/services/ai/providers/ollama.js +76 -39
  185. package/server/services/ai/providers/openai.js +20 -31
  186. package/server/services/ai/providers/openaiCodex.js +6 -2
  187. package/server/services/ai/providers/openaiCompatible.js +70 -0
  188. package/server/services/ai/providers/openrouter.js +162 -0
  189. package/server/services/ai/settings.js +30 -0
  190. package/server/services/ai/systemPrompt.js +51 -29
  191. package/server/services/ai/taskAnalysis.js +60 -1
  192. package/server/services/ai/toolEvidence.js +207 -0
  193. package/server/services/ai/toolSelector.js +8 -1
  194. package/server/services/ai/tools.js +78 -13
  195. package/server/services/browser/extension/registry.js +94 -9
  196. package/server/services/desktop/registry.js +104 -1
  197. package/server/services/desktop/screenRecorder.js +208 -98
  198. package/server/services/desktop/screen_recorder_support.js +46 -0
  199. package/server/services/integrations/google/provider.js +13 -0
  200. package/server/services/integrations/home_assistant/constants.js +88 -0
  201. package/server/services/integrations/home_assistant/network.js +207 -0
  202. package/server/services/integrations/home_assistant/provider.js +249 -0
  203. package/server/services/integrations/home_assistant/snapshot.js +98 -0
  204. package/server/services/integrations/home_assistant/tools.js +101 -0
  205. package/server/services/integrations/manager.js +5 -0
  206. package/server/services/integrations/registry.js +2 -0
  207. package/server/services/integrations/trello/provider.js +8 -2
  208. package/server/services/manager.js +51 -53
  209. package/server/services/mcp/client.js +208 -268
  210. package/server/services/mcp/client_support.js +172 -0
  211. package/server/services/mcp/recovery.js +116 -0
  212. package/server/services/mcp/tool_operations.js +123 -0
  213. package/server/services/memory/ingestion.js +185 -370
  214. package/server/services/memory/ingestion_coverage.js +129 -0
  215. package/server/services/memory/ingestion_documents.js +123 -0
  216. package/server/services/memory/ingestion_support.js +171 -0
  217. package/server/services/memory/intelligence.js +181 -0
  218. package/server/services/memory/manager.js +476 -32
  219. package/server/services/messaging/automation.js +71 -134
  220. package/server/services/messaging/formatting_guides.js +26 -1
  221. package/server/services/messaging/inbound_queue.js +111 -0
  222. package/server/services/messaging/manager.js +25 -8
  223. package/server/services/messaging/typing_keepalive.js +110 -0
  224. package/server/services/streaming/android-stream.js +293 -40
  225. package/server/services/tasks/integration_runtime.js +4 -1
  226. package/server/services/tasks/runtime.js +415 -56
  227. package/server/services/tasks/task_repository.js +48 -6
  228. package/server/services/websocket.js +8 -2
  229. package/server/services/widgets/service.js +17 -1
  230. package/server/services/workspace/manager.js +116 -0
  231. package/server/utils/logger.js +44 -6
  232. package/server/utils/security.js +3 -0
  233. package/server/utils/version.js +29 -19
  234. package/server/services/memory/openhuman_uplift.test.js +0 -98
@@ -1,137 +1,154 @@
1
1
  'use strict';
2
2
 
3
3
  const EventEmitter = require('events');
4
- const crypto = require('crypto');
5
4
  const db = require('../../db/database');
6
5
  const { Client } = require('@modelcontextprotocol/sdk/client/index.js');
7
6
  const { SSEClientTransport } = require('@modelcontextprotocol/sdk/client/sse.js');
8
7
  const { validateRemoteMcpEndpoint } = require('../runtime/mcp');
8
+ const {
9
+ buildTransportOptions,
10
+ extractErrorMessage,
11
+ normalizeServerId,
12
+ } = require('./client_support');
13
+ const recovery = require('./recovery');
14
+ const toolOperations = require('./tool_operations');
15
+
16
+ const DEFAULT_RECONNECT_DELAY_MS = 60_000;
17
+ const DEFAULT_MAX_RECONNECT_DELAY_MS = 15 * 60_000;
18
+ const DEFAULT_OPERATION_TIMEOUT_MS = 30_000;
9
19
 
10
- const CONSECUTIVE_FAIL_LIMIT = 3;
11
- const RECONNECT_DELAY_MS = 60_000;
12
-
13
- class DBAuthProvider {
14
- constructor(serverId, clientId, authServerUrl) {
15
- this.serverId = serverId;
16
- this.clientId = clientId;
17
- this.authServerUrl = authServerUrl;
18
- }
19
-
20
- get redirectUrl() {
21
- const baseUrl = process.env.PUBLIC_URL || `http://localhost:${process.env.PORT || 3333}`;
22
- return `${baseUrl}/api/mcp/oauth/callback`;
23
- }
24
-
25
- get clientMetadata() {
26
- return { client_id: this.clientId };
27
- }
28
-
29
- state() {
30
- return `${this.serverId}::${crypto.randomBytes(16).toString('hex')}`;
31
- }
32
-
33
- clientInformation() {
34
- return { client_id: this.clientId };
20
+ class MCPClient extends EventEmitter {
21
+ constructor(options = {}) {
22
+ super();
23
+ this.servers = new Map();
24
+ this._reconnectTimers = new Map();
25
+ this._reconnectAttempts = new Map();
26
+ this._lifecycleVersions = new Map();
27
+ this._isShuttingDown = false;
28
+ this._reconnectDelayMs = options.reconnectDelayMs ?? DEFAULT_RECONNECT_DELAY_MS;
29
+ this._maxReconnectDelayMs = options.maxReconnectDelayMs ?? DEFAULT_MAX_RECONNECT_DELAY_MS;
30
+ this._operationTimeoutMs = options.operationTimeoutMs ?? DEFAULT_OPERATION_TIMEOUT_MS;
31
+ this._toolFailureThreshold = options.toolFailureThreshold ?? 3;
32
+ this._createTransport = options.createTransport
33
+ || ((endpoint, transportOptions) => new SSEClientTransport(new URL(endpoint), transportOptions));
34
+ this._createClient = options.createClient
35
+ || (() => new Client(
36
+ { name: 'NeoAgent', version: '1.0.0' },
37
+ { capabilities: { tools: {} } },
38
+ ));
35
39
  }
36
40
 
37
- _getConfig() {
38
- const row = db.prepare('SELECT config FROM mcp_servers WHERE id = ?').get(this.serverId);
39
- return row ? JSON.parse(row.config || '{}') : {};
41
+ _nextLifecycleVersion(serverId) {
42
+ serverId = normalizeServerId(serverId);
43
+ const version = (this._lifecycleVersions.get(serverId) || 0) + 1;
44
+ this._lifecycleVersions.set(serverId, version);
45
+ return version;
40
46
  }
41
47
 
42
- _saveConfig(config) {
43
- db.prepare('UPDATE mcp_servers SET config = ? WHERE id = ?').run(JSON.stringify(config), this.serverId);
48
+ _isCurrentLifecycle(serverId, server, version) {
49
+ serverId = normalizeServerId(serverId);
50
+ return (
51
+ !this._isShuttingDown
52
+ && this._lifecycleVersions.get(serverId) === version
53
+ && this.servers.get(serverId) === server
54
+ );
44
55
  }
45
56
 
46
- tokens() {
47
- return this._getConfig().auth?.tokens;
57
+ _cancelReconnect(serverId) {
58
+ serverId = normalizeServerId(serverId);
59
+ const timer = this._reconnectTimers.get(serverId);
60
+ if (!timer) return;
61
+ clearTimeout(timer);
62
+ this._reconnectTimers.delete(serverId);
48
63
  }
49
64
 
50
- saveTokens(tokens) {
51
- const config = this._getConfig();
52
- config.auth = config.auth || {};
53
- config.auth.tokens = tokens;
54
- this._saveConfig(config);
65
+ _persistStatus(serverId, status) {
66
+ serverId = normalizeServerId(serverId);
67
+ db.prepare('UPDATE mcp_servers SET status = ? WHERE id = ?').run(status, serverId);
55
68
  }
56
69
 
57
- redirectToAuthorization(authorizationUrl) {
58
- throw new Error(`OAUTH_REDIRECT:${authorizationUrl.toString()}`);
70
+ _setServerStatus(server, status, extra = {}) {
71
+ if (!server) return;
72
+ server.status = status;
73
+ Object.assign(server, extra);
74
+ this._persistStatus(server.id, status);
75
+ this.emit('server_status', {
76
+ serverId: server.id,
77
+ status,
78
+ error: server.lastError || null,
79
+ consecutiveFails: server.consecutiveFails || 0,
80
+ nextRetryAt: server.nextRetryAt || null,
81
+ });
59
82
  }
60
83
 
61
- saveCodeVerifier(codeVerifier) {
62
- const config = this._getConfig();
63
- config.auth = config.auth || {};
64
- config.auth.codeVerifier = codeVerifier;
65
- this._saveConfig(config);
84
+ async _closeClient(server) {
85
+ if (!server?.client) return;
86
+ try {
87
+ await this._runWithTimeout(
88
+ () => server.client.close(),
89
+ `Closing MCP server "${server.name || server.id}"`,
90
+ );
91
+ } catch (err) {
92
+ console.error(`[MCP] Error closing client ${server.id}:`, extractErrorMessage(err));
93
+ }
66
94
  }
67
95
 
68
- codeVerifier() {
69
- return this._getConfig().auth?.codeVerifier;
96
+ _recordFailure(server, err) {
97
+ const message = extractErrorMessage(err);
98
+ const attempts = (this._reconnectAttempts.get(server.id) || 0) + 1;
99
+ this._reconnectAttempts.set(server.id, attempts);
100
+ server.consecutiveFails = attempts;
101
+ server.lastError = message;
102
+ server.nextRetryAt = null;
103
+ this._setServerStatus(server, err?.requiresAuth ? 'auth_required' : 'error');
104
+ return message;
70
105
  }
71
- }
72
106
 
73
- function extractErrorMessage(err) {
74
- const raw = err?.message || String(err || 'Unknown error');
75
- // Strip HTML bodies (e.g. Cloudflare 530 error pages) keep only the first line
76
- if (raw.includes('<!doctype') || raw.includes('<html') || raw.includes('<!DOCTYPE')) {
77
- const httpMatch = raw.match(/HTTP (\d+)/i);
78
- return httpMatch
79
- ? `Server returned HTTP ${httpMatch[1]} — the MCP endpoint may be down or misconfigured`
80
- : 'Server returned an HTML error page — the MCP endpoint may be down or misconfigured';
81
- }
82
- // ECONNREFUSED: pull out just the host/port
83
- if (err?.code === 'ECONNREFUSED' || raw.includes('ECONNREFUSED')) {
84
- const addrMatch = raw.match(/connect ECONNREFUSED ([^\s,]+)/);
85
- return addrMatch
86
- ? `Connection refused at ${addrMatch[1]} — is the MCP server running?`
87
- : 'Connection refused — the MCP server is not reachable';
88
- }
89
- return raw.split('\n')[0].trim();
90
- }
107
+ async _runWithTimeout(operation, label) {
108
+ let timer;
109
+ const timeout = new Promise((_, reject) => {
110
+ timer = setTimeout(() => {
111
+ const error = new Error(`${label} timed out after ${this._operationTimeoutMs} ms`);
112
+ error.code = 'MCP_OPERATION_TIMEOUT';
113
+ reject(error);
114
+ }, this._operationTimeoutMs);
115
+ });
91
116
 
92
- class MCPClient extends EventEmitter {
93
- constructor() {
94
- super();
95
- this.servers = new Map();
96
- this._reconnectTimers = new Map();
117
+ try {
118
+ return await Promise.race([Promise.resolve().then(operation), timeout]);
119
+ } finally {
120
+ clearTimeout(timer);
121
+ }
97
122
  }
98
123
 
99
124
  async startServer(serverId, url, name = '', userId = null, options = {}) {
100
- if (this.servers.has(serverId)) {
101
- await this.stopServer(serverId);
125
+ serverId = normalizeServerId(serverId);
126
+ if (this._isShuttingDown) {
127
+ throw new Error('MCP client is shutting down');
128
+ }
129
+
130
+ const isReconnect = options._isReconnect === true;
131
+ if (!isReconnect) {
132
+ this._cancelReconnect(serverId);
133
+ this._reconnectAttempts.delete(serverId);
102
134
  }
103
135
 
104
136
  const slug = (name || String(serverId)).toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '');
137
+ const previous = this.servers.get(serverId);
138
+ const lifecycleVersion = this._nextLifecycleVersion(serverId);
139
+ if (previous) {
140
+ await this._closeClient(previous);
141
+ }
142
+
143
+ let serverObj = null;
105
144
  try {
106
145
  const endpoint = validateRemoteMcpEndpoint(url);
107
- const serverRow = db.prepare('SELECT config FROM mcp_servers WHERE id = ?').get(serverId);
108
- let configObj = {};
109
- let authObj = {};
110
- if (serverRow) {
111
- configObj = JSON.parse(serverRow.config || '{}');
112
- authObj = configObj.auth || {};
113
- }
146
+ const transportOpts = buildTransportOptions(serverId);
147
+ const transport = this._createTransport(endpoint, transportOpts);
148
+ const client = this._createClient();
149
+ const previousFails = this._reconnectAttempts.get(serverId) || 0;
114
150
 
115
- const transportOpts = {
116
- requestInit: { headers: {} },
117
- eventSourceInit: { headers: {} },
118
- };
119
-
120
- if (authObj.type === 'bearer' && authObj.token) {
121
- const h = `Bearer ${authObj.token}`;
122
- transportOpts.requestInit.headers['Authorization'] = h;
123
- transportOpts.eventSourceInit.headers['Authorization'] = h;
124
- } else if (authObj.type === 'oauth') {
125
- transportOpts.authProvider = new DBAuthProvider(serverId, authObj.clientId, authObj.authServerUrl);
126
- }
127
-
128
- const transport = new SSEClientTransport(new URL(endpoint), transportOpts);
129
- const client = new Client(
130
- { name: 'NeoAgent', version: '1.0.0' },
131
- { capabilities: { tools: {} } },
132
- );
133
-
134
- const serverObj = {
151
+ serverObj = {
135
152
  id: serverId,
136
153
  userId,
137
154
  agentId: options.agentId || null,
@@ -143,101 +160,123 @@ class MCPClient extends EventEmitter {
143
160
  transport,
144
161
  tools: [],
145
162
  status: 'starting',
146
- consecutiveFails: 0,
163
+ consecutiveFails: previousFails,
147
164
  lastError: null,
165
+ nextRetryAt: null,
148
166
  };
149
167
 
150
168
  this.servers.set(serverId, serverObj);
169
+ this._setServerStatus(serverObj, 'starting');
151
170
 
152
- await client.connect(transport);
171
+ await this._runWithTimeout(
172
+ () => client.connect(transport),
173
+ `Connecting to MCP server "${serverObj.name}"`,
174
+ );
175
+ const response = await this._runWithTimeout(
176
+ () => client.listTools(),
177
+ `Discovering tools from MCP server "${serverObj.name}"`,
178
+ );
153
179
 
154
- const server = this.servers.get(serverId);
155
- if (server) {
156
- server.status = 'running';
157
- server.consecutiveFails = 0;
158
- server.lastError = null;
159
- this.emit('server_status', { serverId, status: 'running' });
180
+ if (!this._isCurrentLifecycle(serverId, serverObj, lifecycleVersion)) {
181
+ await this._closeClient(serverObj);
182
+ const superseded = new Error(`MCP server ${serverId} start was superseded`);
183
+ superseded.code = 'MCP_START_SUPERSEDED';
184
+ throw superseded;
160
185
  }
161
186
 
162
- return { status: 'running' };
187
+ serverObj.tools = response.tools || [];
188
+ serverObj.consecutiveFails = 0;
189
+ serverObj.lastError = null;
190
+ serverObj.nextRetryAt = null;
191
+ this._reconnectAttempts.delete(serverId);
192
+ this._setServerStatus(serverObj, 'running');
193
+
194
+ return { status: 'running', tools: serverObj.tools };
163
195
  } catch (err) {
164
- const message = extractErrorMessage(err);
165
- const server = this.servers.get(serverId);
166
- if (server) {
167
- server.consecutiveFails = (server.consecutiveFails || 0) + 1;
168
- server.lastError = message;
169
- server.status = 'error';
170
- this.emit('server_status', { serverId, status: 'error', error: message });
196
+ if (err?.code === 'MCP_START_SUPERSEDED') {
197
+ throw err;
171
198
  }
172
- const friendlyErr = new Error(message);
173
- friendlyErr.originalError = err;
174
- throw friendlyErr;
199
+ if (serverObj && this.servers.get(serverId) === serverObj) {
200
+ const requiresAuth = String(err?.message || '').startsWith('OAUTH_REDIRECT:');
201
+ if (requiresAuth) err.requiresAuth = true;
202
+ const message = this._recordFailure(serverObj, err);
203
+ if (!requiresAuth) {
204
+ await this._closeClient(serverObj);
205
+ }
206
+ const friendlyErr = new Error(message);
207
+ friendlyErr.originalError = err;
208
+ friendlyErr.requiresAuth = requiresAuth;
209
+ throw friendlyErr;
210
+ }
211
+ if (previous && this.servers.get(serverId) === previous) {
212
+ const message = this._recordFailure(previous, err);
213
+ const friendlyErr = new Error(message);
214
+ friendlyErr.originalError = err;
215
+ throw friendlyErr;
216
+ }
217
+ throw err;
175
218
  }
176
219
  }
177
220
 
178
221
  async finishOAuth(serverId, code) {
222
+ serverId = normalizeServerId(serverId);
179
223
  const server = this.servers.get(serverId);
180
224
  if (!server || !server.transport) {
181
225
  throw new Error(`Server ${serverId} transport not initialized`);
182
226
  }
183
- await server.transport.finishAuth(code);
184
- await server.client.connect(server.transport).catch(() => {});
185
-
186
- server.status = 'running';
187
- server.consecutiveFails = 0;
188
- server.lastError = null;
189
- this.emit('server_status', { serverId, status: 'running' });
227
+ try {
228
+ await this._runWithTimeout(
229
+ () => server.transport.finishAuth(code),
230
+ `Completing OAuth for MCP server "${server.name}"`,
231
+ );
232
+ await this._runWithTimeout(
233
+ () => server.client.connect(server.transport),
234
+ `Connecting to MCP server "${server.name}" after OAuth`,
235
+ );
236
+ const response = await this._runWithTimeout(
237
+ () => server.client.listTools(),
238
+ `Discovering tools from MCP server "${server.name}"`,
239
+ );
240
+ server.tools = response.tools || [];
241
+ server.consecutiveFails = 0;
242
+ server.lastError = null;
243
+ server.nextRetryAt = null;
244
+ this._reconnectAttempts.delete(serverId);
245
+ this._setServerStatus(server, 'running');
246
+ return { status: 'running', tools: server.tools };
247
+ } catch (err) {
248
+ const message = this._recordFailure(server, err);
249
+ const friendlyErr = new Error(message);
250
+ friendlyErr.originalError = err;
251
+ throw friendlyErr;
252
+ }
190
253
  }
191
254
 
192
255
  async stopServer(serverId) {
193
- const timer = this._reconnectTimers.get(serverId);
194
- if (timer) {
195
- clearTimeout(timer);
196
- this._reconnectTimers.delete(serverId);
197
- }
256
+ serverId = normalizeServerId(serverId);
257
+ this._cancelReconnect(serverId);
258
+ this._reconnectAttempts.delete(serverId);
259
+ this._nextLifecycleVersion(serverId);
198
260
 
199
261
  const server = this.servers.get(serverId);
200
- if (!server) return;
201
-
202
- try {
203
- if (server.client) await server.client.close();
204
- } catch (err) {
205
- console.error(`[MCP] Error closing client ${serverId}:`, err.message);
262
+ if (!server) {
263
+ this._persistStatus(serverId, 'stopped');
264
+ return { status: 'stopped' };
206
265
  }
207
266
 
267
+ await this._closeClient(server);
208
268
  this.servers.delete(serverId);
269
+ this._persistStatus(serverId, 'stopped');
209
270
  this.emit('server_status', { serverId, status: 'stopped' });
271
+ return { status: 'stopped' };
210
272
  }
211
273
 
212
274
  _scheduleReconnect(serverId, userId, options) {
213
- if (this._reconnectTimers.has(serverId)) return;
214
-
215
- const timer = setTimeout(async () => {
216
- this._reconnectTimers.delete(serverId);
217
- const server = this.servers.get(serverId);
218
- if (!server || server.status === 'running') return;
219
-
220
- const row = db.prepare('SELECT * FROM mcp_servers WHERE id = ? AND enabled = 1').get(serverId);
221
- if (!row) return;
222
-
223
- try {
224
- await this.startServer(serverId, row.command, row.name, userId, options);
225
- await this.listTools(serverId, userId);
226
- console.log(`[MCP] Reconnected to ${row.name}`);
227
- } catch (err) {
228
- const server = this.servers.get(serverId);
229
- if (server && server.consecutiveFails < CONSECUTIVE_FAIL_LIMIT) {
230
- this._scheduleReconnect(serverId, userId, options);
231
- } else {
232
- console.warn(`[MCP] ${row.name} disabled after ${CONSECUTIVE_FAIL_LIMIT} consecutive failures: ${err.message}`);
233
- }
234
- }
235
- }, RECONNECT_DELAY_MS);
236
-
237
- this._reconnectTimers.set(serverId, timer);
275
+ return recovery.scheduleReconnect(this, serverId, userId, options);
238
276
  }
239
277
 
240
278
  _getOwnedServer(serverId, userId = null) {
279
+ serverId = normalizeServerId(serverId);
241
280
  const server = this.servers.get(serverId);
242
281
  if (!server) return null;
243
282
  if (userId != null && server.userId !== userId) return null;
@@ -245,130 +284,31 @@ class MCPClient extends EventEmitter {
245
284
  }
246
285
 
247
286
  async listTools(serverId, userId = null) {
248
- const server = this._getOwnedServer(serverId, userId);
249
- if (!server || server.status !== 'running') {
250
- throw new Error(`Server ${serverId} not running`);
251
- }
252
-
253
- const response = await server.client.listTools();
254
- server.tools = response.tools || [];
255
- return server.tools;
287
+ return toolOperations.listTools(this, serverId, userId);
256
288
  }
257
289
 
258
290
  async callTool(serverId, toolName, args = {}, userId = null) {
259
- const server = this._getOwnedServer(serverId, userId);
260
- if (!server) throw new Error(`Server ${serverId} not found`);
261
-
262
- if (server.status !== 'running') {
263
- const hint = server.lastError ? ` (${server.lastError})` : '';
264
- throw new Error(`MCP server "${server.name}" is not available${hint}`);
265
- }
266
-
267
- try {
268
- const result = await server.client.callTool({ name: toolName, arguments: args });
269
- server.consecutiveFails = 0;
270
- return result;
271
- } catch (err) {
272
- const message = extractErrorMessage(err);
273
- server.consecutiveFails = (server.consecutiveFails || 0) + 1;
274
- server.lastError = message;
275
-
276
- if (server.consecutiveFails >= CONSECUTIVE_FAIL_LIMIT) {
277
- server.status = 'error';
278
- this.emit('server_status', { serverId, status: 'error', error: message });
279
- this._scheduleReconnect(serverId, server.userId, { agentId: server.agentId });
280
- }
281
-
282
- throw new Error(`MCP tool "${toolName}" failed: ${message}`);
283
- }
291
+ return toolOperations.callTool(this, serverId, toolName, args, userId);
284
292
  }
285
293
 
286
294
  async callToolByName(fullName, args = {}, userId = null, options = {}) {
287
- for (const [serverId, server] of this.servers) {
288
- if (userId != null && server.userId !== userId) continue;
289
- if (options.agentId && server.agentId && server.agentId !== options.agentId) continue;
290
- const prefix = `mcp_${server.slug}_`;
291
- if (!fullName.startsWith(prefix)) continue;
292
-
293
- const originalName = fullName.substring(prefix.length);
294
- return await this.callTool(serverId, originalName, args, userId);
295
- }
296
- return null;
295
+ return toolOperations.callToolByName(this, fullName, args, userId, options);
297
296
  }
298
297
 
299
298
  getAllTools(userId = null, options = {}) {
300
- const allTools = [];
301
- for (const [serverId, server] of this.servers) {
302
- if (userId != null && server.userId !== userId) continue;
303
- if (options.agentId && server.agentId && server.agentId !== options.agentId) continue;
304
- if (server.status !== 'running') continue;
305
- for (const tool of server.tools) {
306
- allTools.push({
307
- ...tool,
308
- name: `mcp_${server.slug}_${tool.name}`,
309
- originalName: tool.name,
310
- parameters: tool.inputSchema || tool.parameters,
311
- serverId,
312
- });
313
- }
314
- }
315
- return allTools;
299
+ return toolOperations.getAllTools(this, userId, options);
316
300
  }
317
301
 
318
302
  getStatus(userId = null, options = {}) {
319
- const statuses = {};
320
- const agentId = options.agentId || options.agent_id || null;
321
- for (const [serverId, server] of this.servers) {
322
- if (userId != null && server.userId !== userId) continue;
323
- if (agentId && server.agentId && server.agentId !== agentId) continue;
324
- statuses[serverId] = {
325
- status: server.status,
326
- command: server.url,
327
- args: [],
328
- toolCount: server.tools.length,
329
- error: server.lastError || null,
330
- consecutiveFails: server.consecutiveFails || 0,
331
- serverInfo: null,
332
- };
333
- }
334
- return statuses;
303
+ return toolOperations.getStatus(this, userId, options);
335
304
  }
336
305
 
337
306
  async loadFromDB(userId) {
338
- const servers = db.prepare('SELECT * FROM mcp_servers WHERE user_id = ? AND enabled = 1').all(userId);
339
- const results = [];
340
-
341
- for (const srv of servers) {
342
- try {
343
- await this.startServer(srv.id, srv.command, srv.name, userId, { agentId: srv.agent_id });
344
- await this.listTools(srv.id, userId);
345
- results.push({ id: srv.id, name: srv.name, status: 'running' });
346
- } catch (err) {
347
- const message = err.message;
348
- console.error(`[MCP] Failed to start "${srv.name}":`, message);
349
- // Schedule a reconnect attempt for transient failures (not auth errors)
350
- const server = this.servers.get(srv.id);
351
- if (server) {
352
- this._scheduleReconnect(srv.id, userId, { agentId: srv.agent_id });
353
- }
354
- results.push({ id: srv.id, name: srv.name, status: 'error', error: message });
355
- }
356
- }
357
-
358
- return results;
307
+ return recovery.loadFromDB(this, userId);
359
308
  }
360
309
 
361
310
  async shutdown() {
362
- for (const timer of this._reconnectTimers.values()) {
363
- clearTimeout(timer);
364
- }
365
- this._reconnectTimers.clear();
366
-
367
- const promises = [];
368
- for (const serverId of this.servers.keys()) {
369
- promises.push(this.stopServer(serverId));
370
- }
371
- await Promise.allSettled(promises);
311
+ return recovery.shutdown(this);
372
312
  }
373
313
  }
374
314