@daniel.stefan/metalink 1.3.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.
- package/README.md +160 -0
- package/package.json +96 -0
- package/packages/cli/dist/bin/cli.d.ts +3 -0
- package/packages/cli/dist/bin/cli.d.ts.map +1 -0
- package/packages/cli/dist/bin/cli.js +4 -0
- package/packages/cli/dist/bin/cli.js.map +1 -0
- package/packages/cli/dist/commands/config/init.d.ts +9 -0
- package/packages/cli/dist/commands/config/init.d.ts.map +1 -0
- package/packages/cli/dist/commands/config/init.js +38 -0
- package/packages/cli/dist/commands/config/init.js.map +1 -0
- package/packages/cli/dist/commands/config/validate.d.ts +9 -0
- package/packages/cli/dist/commands/config/validate.d.ts.map +1 -0
- package/packages/cli/dist/commands/config/validate.js +34 -0
- package/packages/cli/dist/commands/config/validate.js.map +1 -0
- package/packages/cli/dist/commands/daemon/restart.d.ts +15 -0
- package/packages/cli/dist/commands/daemon/restart.d.ts.map +1 -0
- package/packages/cli/dist/commands/daemon/restart.js +184 -0
- package/packages/cli/dist/commands/daemon/restart.js.map +1 -0
- package/packages/cli/dist/commands/daemon/start.d.ts +7 -0
- package/packages/cli/dist/commands/daemon/start.d.ts.map +1 -0
- package/packages/cli/dist/commands/daemon/start.js +85 -0
- package/packages/cli/dist/commands/daemon/start.js.map +1 -0
- package/packages/cli/dist/commands/daemon/status.d.ts +7 -0
- package/packages/cli/dist/commands/daemon/status.d.ts.map +1 -0
- package/packages/cli/dist/commands/daemon/status.js +69 -0
- package/packages/cli/dist/commands/daemon/status.js.map +1 -0
- package/packages/cli/dist/commands/daemon/stop.d.ts +8 -0
- package/packages/cli/dist/commands/daemon/stop.d.ts.map +1 -0
- package/packages/cli/dist/commands/daemon/stop.js +77 -0
- package/packages/cli/dist/commands/daemon/stop.js.map +1 -0
- package/packages/cli/dist/commands/import/mcpm.d.ts +10 -0
- package/packages/cli/dist/commands/import/mcpm.d.ts.map +1 -0
- package/packages/cli/dist/commands/import/mcpm.js +58 -0
- package/packages/cli/dist/commands/import/mcpm.js.map +1 -0
- package/packages/cli/dist/commands/safety/add-risky-pattern.d.ts +16 -0
- package/packages/cli/dist/commands/safety/add-risky-pattern.d.ts.map +1 -0
- package/packages/cli/dist/commands/safety/add-risky-pattern.js +72 -0
- package/packages/cli/dist/commands/safety/add-risky-pattern.js.map +1 -0
- package/packages/cli/dist/commands/safety/add-risky.d.ts +12 -0
- package/packages/cli/dist/commands/safety/add-risky.d.ts.map +1 -0
- package/packages/cli/dist/commands/safety/add-risky.js +52 -0
- package/packages/cli/dist/commands/safety/add-risky.js.map +1 -0
- package/packages/cli/dist/commands/safety/add-safe-pattern.d.ts +16 -0
- package/packages/cli/dist/commands/safety/add-safe-pattern.d.ts.map +1 -0
- package/packages/cli/dist/commands/safety/add-safe-pattern.js +72 -0
- package/packages/cli/dist/commands/safety/add-safe-pattern.js.map +1 -0
- package/packages/cli/dist/commands/safety/add-safe.d.ts +12 -0
- package/packages/cli/dist/commands/safety/add-safe.d.ts.map +1 -0
- package/packages/cli/dist/commands/safety/add-safe.js +52 -0
- package/packages/cli/dist/commands/safety/add-safe.js.map +1 -0
- package/packages/cli/dist/commands/safety/check.d.ts +9 -0
- package/packages/cli/dist/commands/safety/check.d.ts.map +1 -0
- package/packages/cli/dist/commands/safety/check.js +36 -0
- package/packages/cli/dist/commands/safety/check.js.map +1 -0
- package/packages/cli/dist/commands/safety/export.d.ts +10 -0
- package/packages/cli/dist/commands/safety/export.d.ts.map +1 -0
- package/packages/cli/dist/commands/safety/export.js +48 -0
- package/packages/cli/dist/commands/safety/export.js.map +1 -0
- package/packages/cli/dist/commands/safety/import.d.ts +12 -0
- package/packages/cli/dist/commands/safety/import.d.ts.map +1 -0
- package/packages/cli/dist/commands/safety/import.js +78 -0
- package/packages/cli/dist/commands/safety/import.js.map +1 -0
- package/packages/cli/dist/commands/safety/index.d.ts +8 -0
- package/packages/cli/dist/commands/safety/index.d.ts.map +1 -0
- package/packages/cli/dist/commands/safety/index.js +46 -0
- package/packages/cli/dist/commands/safety/index.js.map +1 -0
- package/packages/cli/dist/commands/safety/list.d.ts +6 -0
- package/packages/cli/dist/commands/safety/list.d.ts.map +1 -0
- package/packages/cli/dist/commands/safety/list.js +77 -0
- package/packages/cli/dist/commands/safety/list.js.map +1 -0
- package/packages/cli/dist/commands/safety/remove.d.ts +9 -0
- package/packages/cli/dist/commands/safety/remove.d.ts.map +1 -0
- package/packages/cli/dist/commands/safety/remove.js +46 -0
- package/packages/cli/dist/commands/safety/remove.js.map +1 -0
- package/packages/cli/dist/commands/safety/reset.d.ts +9 -0
- package/packages/cli/dist/commands/safety/reset.d.ts.map +1 -0
- package/packages/cli/dist/commands/safety/reset.js +46 -0
- package/packages/cli/dist/commands/safety/reset.js.map +1 -0
- package/packages/cli/dist/commands/safety/validate.d.ts +9 -0
- package/packages/cli/dist/commands/safety/validate.d.ts.map +1 -0
- package/packages/cli/dist/commands/safety/validate.js +51 -0
- package/packages/cli/dist/commands/safety/validate.js.map +1 -0
- package/packages/cli/dist/commands/secret/get.d.ts +9 -0
- package/packages/cli/dist/commands/secret/get.d.ts.map +1 -0
- package/packages/cli/dist/commands/secret/get.js +26 -0
- package/packages/cli/dist/commands/secret/get.js.map +1 -0
- package/packages/cli/dist/commands/secret/set.d.ts +10 -0
- package/packages/cli/dist/commands/secret/set.d.ts.map +1 -0
- package/packages/cli/dist/commands/secret/set.js +22 -0
- package/packages/cli/dist/commands/secret/set.js.map +1 -0
- package/packages/cli/dist/commands/server/__tests__/server-management-e2e.test.d.ts +2 -0
- package/packages/cli/dist/commands/server/__tests__/server-management-e2e.test.d.ts.map +1 -0
- package/packages/cli/dist/commands/server/__tests__/server-management-e2e.test.js +234 -0
- package/packages/cli/dist/commands/server/__tests__/server-management-e2e.test.js.map +1 -0
- package/packages/cli/dist/commands/server/add.d.ts +14 -0
- package/packages/cli/dist/commands/server/add.d.ts.map +1 -0
- package/packages/cli/dist/commands/server/add.js +86 -0
- package/packages/cli/dist/commands/server/add.js.map +1 -0
- package/packages/cli/dist/commands/server/available.d.ts +10 -0
- package/packages/cli/dist/commands/server/available.d.ts.map +1 -0
- package/packages/cli/dist/commands/server/available.js +62 -0
- package/packages/cli/dist/commands/server/available.js.map +1 -0
- package/packages/cli/dist/commands/server/debug.d.ts +18 -0
- package/packages/cli/dist/commands/server/debug.d.ts.map +1 -0
- package/packages/cli/dist/commands/server/debug.js +165 -0
- package/packages/cli/dist/commands/server/debug.js.map +1 -0
- package/packages/cli/dist/commands/server/info.d.ts +13 -0
- package/packages/cli/dist/commands/server/info.d.ts.map +1 -0
- package/packages/cli/dist/commands/server/info.js +62 -0
- package/packages/cli/dist/commands/server/info.js.map +1 -0
- package/packages/cli/dist/commands/server/list.d.ts +10 -0
- package/packages/cli/dist/commands/server/list.d.ts.map +1 -0
- package/packages/cli/dist/commands/server/list.js +105 -0
- package/packages/cli/dist/commands/server/list.js.map +1 -0
- package/packages/cli/dist/commands/server/refresh-tools.d.ts +13 -0
- package/packages/cli/dist/commands/server/refresh-tools.d.ts.map +1 -0
- package/packages/cli/dist/commands/server/refresh-tools.js +46 -0
- package/packages/cli/dist/commands/server/refresh-tools.js.map +1 -0
- package/packages/cli/dist/commands/server/remove.d.ts +12 -0
- package/packages/cli/dist/commands/server/remove.d.ts.map +1 -0
- package/packages/cli/dist/commands/server/remove.js +39 -0
- package/packages/cli/dist/commands/server/remove.js.map +1 -0
- package/packages/cli/dist/commands/server/restart.d.ts +9 -0
- package/packages/cli/dist/commands/server/restart.d.ts.map +1 -0
- package/packages/cli/dist/commands/server/restart.js +30 -0
- package/packages/cli/dist/commands/server/restart.js.map +1 -0
- package/packages/cli/dist/commands/server/start.d.ts +9 -0
- package/packages/cli/dist/commands/server/start.d.ts.map +1 -0
- package/packages/cli/dist/commands/server/start.js +37 -0
- package/packages/cli/dist/commands/server/start.js.map +1 -0
- package/packages/cli/dist/commands/server/status.d.ts +9 -0
- package/packages/cli/dist/commands/server/status.d.ts.map +1 -0
- package/packages/cli/dist/commands/server/status.js +30 -0
- package/packages/cli/dist/commands/server/status.js.map +1 -0
- package/packages/cli/dist/commands/server/stop.d.ts +9 -0
- package/packages/cli/dist/commands/server/stop.d.ts.map +1 -0
- package/packages/cli/dist/commands/server/stop.js +31 -0
- package/packages/cli/dist/commands/server/stop.js.map +1 -0
- package/packages/cli/dist/commands/server/validate.d.ts +15 -0
- package/packages/cli/dist/commands/server/validate.d.ts.map +1 -0
- package/packages/cli/dist/commands/server/validate.js +87 -0
- package/packages/cli/dist/commands/server/validate.js.map +1 -0
- package/packages/cli/dist/commands/stdio.d.ts +36 -0
- package/packages/cli/dist/commands/stdio.d.ts.map +1 -0
- package/packages/cli/dist/commands/stdio.js +85 -0
- package/packages/cli/dist/commands/stdio.js.map +1 -0
- package/packages/cli/dist/commands/tool/execute-confirm.d.ts +12 -0
- package/packages/cli/dist/commands/tool/execute-confirm.d.ts.map +1 -0
- package/packages/cli/dist/commands/tool/execute-confirm.js +98 -0
- package/packages/cli/dist/commands/tool/execute-confirm.js.map +1 -0
- package/packages/cli/dist/commands/tool/execute.d.ts +12 -0
- package/packages/cli/dist/commands/tool/execute.d.ts.map +1 -0
- package/packages/cli/dist/commands/tool/execute.js +55 -0
- package/packages/cli/dist/commands/tool/execute.js.map +1 -0
- package/packages/cli/dist/commands/tool/list.d.ts +13 -0
- package/packages/cli/dist/commands/tool/list.d.ts.map +1 -0
- package/packages/cli/dist/commands/tool/list.js +91 -0
- package/packages/cli/dist/commands/tool/list.js.map +1 -0
- package/packages/cli/dist/commands/tool/search.d.ts +12 -0
- package/packages/cli/dist/commands/tool/search.d.ts.map +1 -0
- package/packages/cli/dist/commands/tool/search.js +87 -0
- package/packages/cli/dist/commands/tool/search.js.map +1 -0
- package/packages/cli/dist/index.d.ts +2 -0
- package/packages/cli/dist/index.d.ts.map +1 -0
- package/packages/cli/dist/index.js +9 -0
- package/packages/cli/dist/index.js.map +1 -0
- package/packages/cli/dist/utils/daemon-checker.d.ts +18 -0
- package/packages/cli/dist/utils/daemon-checker.d.ts.map +1 -0
- package/packages/cli/dist/utils/daemon-checker.js +69 -0
- package/packages/cli/dist/utils/daemon-checker.js.map +1 -0
- package/packages/cli/dist/utils/daemon-endpoint.d.ts +7 -0
- package/packages/cli/dist/utils/daemon-endpoint.d.ts.map +1 -0
- package/packages/cli/dist/utils/daemon-endpoint.js +13 -0
- package/packages/cli/dist/utils/daemon-endpoint.js.map +1 -0
- package/packages/cli/dist/utils/get-configured-port.d.ts +43 -0
- package/packages/cli/dist/utils/get-configured-port.d.ts.map +1 -0
- package/packages/cli/dist/utils/get-configured-port.js +141 -0
- package/packages/cli/dist/utils/get-configured-port.js.map +1 -0
- package/packages/cli/dist/utils/stdio-bridge.d.ts +48 -0
- package/packages/cli/dist/utils/stdio-bridge.d.ts.map +1 -0
- package/packages/cli/dist/utils/stdio-bridge.js +181 -0
- package/packages/cli/dist/utils/stdio-bridge.js.map +1 -0
- package/packages/cli/package.json +48 -0
- package/packages/core/dist/config/defaults.d.ts +36 -0
- package/packages/core/dist/config/defaults.d.ts.map +1 -0
- package/packages/core/dist/config/defaults.js +324 -0
- package/packages/core/dist/config/defaults.js.map +1 -0
- package/packages/core/dist/config/index.d.ts +9 -0
- package/packages/core/dist/config/index.d.ts.map +1 -0
- package/packages/core/dist/config/index.js +14 -0
- package/packages/core/dist/config/index.js.map +1 -0
- package/packages/core/dist/config/loader.d.ts +269 -0
- package/packages/core/dist/config/loader.d.ts.map +1 -0
- package/packages/core/dist/config/loader.js +777 -0
- package/packages/core/dist/config/loader.js.map +1 -0
- package/packages/core/dist/config/registry.d.ts +212 -0
- package/packages/core/dist/config/registry.d.ts.map +1 -0
- package/packages/core/dist/config/registry.js +754 -0
- package/packages/core/dist/config/registry.js.map +1 -0
- package/packages/core/dist/config/schema.d.ts +4352 -0
- package/packages/core/dist/config/schema.d.ts.map +1 -0
- package/packages/core/dist/config/schema.js +267 -0
- package/packages/core/dist/config/schema.js.map +1 -0
- package/packages/core/dist/daemon.d.ts +7 -0
- package/packages/core/dist/daemon.d.ts.map +1 -0
- package/packages/core/dist/daemon.js +116 -0
- package/packages/core/dist/daemon.js.map +1 -0
- package/packages/core/dist/http-client-retry.d.ts +67 -0
- package/packages/core/dist/http-client-retry.d.ts.map +1 -0
- package/packages/core/dist/http-client-retry.js +133 -0
- package/packages/core/dist/http-client-retry.js.map +1 -0
- package/packages/core/dist/http-client-updated.d.ts +147 -0
- package/packages/core/dist/http-client-updated.d.ts.map +1 -0
- package/packages/core/dist/http-client-updated.js +452 -0
- package/packages/core/dist/http-client-updated.js.map +1 -0
- package/packages/core/dist/http-client.d.ts +207 -0
- package/packages/core/dist/http-client.d.ts.map +1 -0
- package/packages/core/dist/http-client.js +704 -0
- package/packages/core/dist/http-client.js.map +1 -0
- package/packages/core/dist/index.d.ts +13 -0
- package/packages/core/dist/index.d.ts.map +1 -0
- package/packages/core/dist/index.js +23 -0
- package/packages/core/dist/index.js.map +1 -0
- package/packages/core/dist/logging/index.d.ts +46 -0
- package/packages/core/dist/logging/index.d.ts.map +1 -0
- package/packages/core/dist/logging/index.js +74 -0
- package/packages/core/dist/logging/index.js.map +1 -0
- package/packages/core/dist/metrics/index.d.ts +339 -0
- package/packages/core/dist/metrics/index.d.ts.map +1 -0
- package/packages/core/dist/metrics/index.js +792 -0
- package/packages/core/dist/metrics/index.js.map +1 -0
- package/packages/core/dist/plugins/index.d.ts +49 -0
- package/packages/core/dist/plugins/index.d.ts.map +1 -0
- package/packages/core/dist/plugins/index.js +82 -0
- package/packages/core/dist/plugins/index.js.map +1 -0
- package/packages/core/dist/secrets/index.d.ts +6 -0
- package/packages/core/dist/secrets/index.d.ts.map +1 -0
- package/packages/core/dist/secrets/index.js +5 -0
- package/packages/core/dist/secrets/index.js.map +1 -0
- package/packages/core/dist/secrets/keyring.d.ts +54 -0
- package/packages/core/dist/secrets/keyring.d.ts.map +1 -0
- package/packages/core/dist/secrets/keyring.js +141 -0
- package/packages/core/dist/secrets/keyring.js.map +1 -0
- package/packages/core/dist/server/batch-executor.d.ts +83 -0
- package/packages/core/dist/server/batch-executor.d.ts.map +1 -0
- package/packages/core/dist/server/batch-executor.js +291 -0
- package/packages/core/dist/server/batch-executor.js.map +1 -0
- package/packages/core/dist/server/circuit-breaker.d.ts +215 -0
- package/packages/core/dist/server/circuit-breaker.d.ts.map +1 -0
- package/packages/core/dist/server/circuit-breaker.js +330 -0
- package/packages/core/dist/server/circuit-breaker.js.map +1 -0
- package/packages/core/dist/server/client-detection.d.ts +40 -0
- package/packages/core/dist/server/client-detection.d.ts.map +1 -0
- package/packages/core/dist/server/client-detection.js +242 -0
- package/packages/core/dist/server/client-detection.js.map +1 -0
- package/packages/core/dist/server/client-profiles.d.ts +102 -0
- package/packages/core/dist/server/client-profiles.d.ts.map +1 -0
- package/packages/core/dist/server/client-profiles.js +254 -0
- package/packages/core/dist/server/client-profiles.js.map +1 -0
- package/packages/core/dist/server/http.d.ts +386 -0
- package/packages/core/dist/server/http.d.ts.map +1 -0
- package/packages/core/dist/server/http.js +4253 -0
- package/packages/core/dist/server/http.js.map +1 -0
- package/packages/core/dist/server/index.d.ts +7 -0
- package/packages/core/dist/server/index.d.ts.map +1 -0
- package/packages/core/dist/server/index.js +6 -0
- package/packages/core/dist/server/index.js.map +1 -0
- package/packages/core/dist/server/manager.d.ts +458 -0
- package/packages/core/dist/server/manager.d.ts.map +1 -0
- package/packages/core/dist/server/manager.js +3255 -0
- package/packages/core/dist/server/manager.js.map +1 -0
- package/packages/core/dist/server/managers/HttpConnectionManager.d.ts +69 -0
- package/packages/core/dist/server/managers/HttpConnectionManager.d.ts.map +1 -0
- package/packages/core/dist/server/managers/HttpConnectionManager.js +214 -0
- package/packages/core/dist/server/managers/HttpConnectionManager.js.map +1 -0
- package/packages/core/dist/server/managers/ProcessManager.d.ts +128 -0
- package/packages/core/dist/server/managers/ProcessManager.d.ts.map +1 -0
- package/packages/core/dist/server/managers/ProcessManager.js +443 -0
- package/packages/core/dist/server/managers/ProcessManager.js.map +1 -0
- package/packages/core/dist/server/managers/SchemaCacheManager.d.ts +152 -0
- package/packages/core/dist/server/managers/SchemaCacheManager.d.ts.map +1 -0
- package/packages/core/dist/server/managers/SchemaCacheManager.js +426 -0
- package/packages/core/dist/server/managers/SchemaCacheManager.js.map +1 -0
- package/packages/core/dist/server/managers/index.d.ts +9 -0
- package/packages/core/dist/server/managers/index.d.ts.map +1 -0
- package/packages/core/dist/server/managers/index.js +9 -0
- package/packages/core/dist/server/managers/index.js.map +1 -0
- package/packages/core/dist/server/metrics.d.ts +134 -0
- package/packages/core/dist/server/metrics.d.ts.map +1 -0
- package/packages/core/dist/server/metrics.js +273 -0
- package/packages/core/dist/server/metrics.js.map +1 -0
- package/packages/core/dist/server/prompts.d.ts +58 -0
- package/packages/core/dist/server/prompts.d.ts.map +1 -0
- package/packages/core/dist/server/prompts.js +405 -0
- package/packages/core/dist/server/prompts.js.map +1 -0
- package/packages/core/dist/server/protocol-versions.d.ts +49 -0
- package/packages/core/dist/server/protocol-versions.d.ts.map +1 -0
- package/packages/core/dist/server/protocol-versions.js +173 -0
- package/packages/core/dist/server/protocol-versions.js.map +1 -0
- package/packages/core/dist/server/resources.d.ts +64 -0
- package/packages/core/dist/server/resources.d.ts.map +1 -0
- package/packages/core/dist/server/resources.js +243 -0
- package/packages/core/dist/server/resources.js.map +1 -0
- package/packages/core/dist/server/schema-store.d.ts +84 -0
- package/packages/core/dist/server/schema-store.d.ts.map +1 -0
- package/packages/core/dist/server/schema-store.js +234 -0
- package/packages/core/dist/server/schema-store.js.map +1 -0
- package/packages/core/dist/server/schema-validator.d.ts +51 -0
- package/packages/core/dist/server/schema-validator.d.ts.map +1 -0
- package/packages/core/dist/server/schema-validator.js +208 -0
- package/packages/core/dist/server/schema-validator.js.map +1 -0
- package/packages/core/dist/server/token-calculator.d.ts +44 -0
- package/packages/core/dist/server/token-calculator.d.ts.map +1 -0
- package/packages/core/dist/server/token-calculator.js +53 -0
- package/packages/core/dist/server/token-calculator.js.map +1 -0
- package/packages/core/dist/server/types.d.ts +45 -0
- package/packages/core/dist/server/types.d.ts.map +1 -0
- package/packages/core/dist/server/types.js +5 -0
- package/packages/core/dist/server/types.js.map +1 -0
- package/packages/core/dist/utils/file-lock.d.ts +73 -0
- package/packages/core/dist/utils/file-lock.d.ts.map +1 -0
- package/packages/core/dist/utils/file-lock.js +235 -0
- package/packages/core/dist/utils/file-lock.js.map +1 -0
- package/packages/core/package.json +36 -0
- package/packages/dashboard/dist/assets/index-B7hvkCMu.css +1 -0
- package/packages/dashboard/dist/assets/index-yZhLPpzr.js +1 -0
- package/packages/dashboard/dist/index.html +14 -0
- package/packages/dashboard/package.json +24 -0
- package/packages/shared/dist/version.d.ts +2 -0
- package/packages/shared/dist/version.d.ts.map +1 -0
- package/packages/shared/dist/version.js +2 -0
- package/packages/shared/dist/version.js.map +1 -0
- package/packages/shared/package.json +18 -0
|
@@ -0,0 +1,3255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server Manager - Handles spawning and managing MCP server processes and HTTP clients
|
|
3
|
+
*/
|
|
4
|
+
import { spawn, spawnSync } from "child_process";
|
|
5
|
+
import path from "path";
|
|
6
|
+
import { EventEmitter } from "events";
|
|
7
|
+
import { LRUCache } from "lru-cache";
|
|
8
|
+
import { version } from "../index.js";
|
|
9
|
+
import { ProcessManager, HttpConnectionManager, SchemaCacheManager, } from "./managers/index.js";
|
|
10
|
+
import { SchemaStore } from "./schema-store.js";
|
|
11
|
+
import { SchemaValidator } from "./schema-validator.js";
|
|
12
|
+
import { globalMetrics } from "../metrics/index.js";
|
|
13
|
+
import { CircuitBreakerManager } from "./circuit-breaker.js";
|
|
14
|
+
export class ServerManager extends EventEmitter {
|
|
15
|
+
/**
|
|
16
|
+
* Constructor - Initialize with configurable schema cache TTL and persistent storage
|
|
17
|
+
*/
|
|
18
|
+
constructor(config) {
|
|
19
|
+
super();
|
|
20
|
+
this.processes = new Map();
|
|
21
|
+
this.httpClients = new Map(); // Track HTTP clients
|
|
22
|
+
this.state = {};
|
|
23
|
+
this.healthCheckIntervals = new Map();
|
|
24
|
+
this.activeServers = new Map(); // Track server tools
|
|
25
|
+
this.baseServers = [];
|
|
26
|
+
this.baseServersEnabled = false;
|
|
27
|
+
this.schemasCacheTTL = 300000; // Default 5 minutes in ms
|
|
28
|
+
// Persistent schema storage
|
|
29
|
+
this.schemaStore = null;
|
|
30
|
+
this.schemasBackgroundRefresh = true;
|
|
31
|
+
this.backgroundRefreshInterval = null;
|
|
32
|
+
this.BACKGROUND_REFRESH_INTERVAL_MS = 60000; // 1 minute
|
|
33
|
+
// Adaptive TTL configuration
|
|
34
|
+
this.schemaStabilityMetrics = new Map();
|
|
35
|
+
this.ADAPTIVE_TTL_ENABLED = true;
|
|
36
|
+
this.TTL_STABLE = 3600000; // 60 minutes for stable schemas
|
|
37
|
+
this.TTL_NORMAL = 300000; // 5 minutes for normal schemas (default)
|
|
38
|
+
this.TTL_UNSTABLE = 300000; // 5 minutes for unstable schemas (same as normal)
|
|
39
|
+
this.STABILITY_WINDOW = 5; // Look at last 5 refreshes
|
|
40
|
+
this.UNSTABLE_THRESHOLD = 2; // Changed in last 2 refreshes = unstable
|
|
41
|
+
this.discoveredServers = new Set();
|
|
42
|
+
// Discovery TTL properties (v1.4.0)
|
|
43
|
+
this.discoveryTimers = new Map();
|
|
44
|
+
this.discoveryTTL = 600000; // 10 minutes (configurable)
|
|
45
|
+
// v1.3.74: Failed discovery tracking - skip servers that fail repeatedly
|
|
46
|
+
this.failedDiscoveryAttempts = new Map();
|
|
47
|
+
this.MAX_DISCOVERY_FAILURES = 3; // Skip after this many failures
|
|
48
|
+
this.DISCOVERY_FAILURE_BACKOFF_MS = 3600000; // 1 hour backoff
|
|
49
|
+
// v1.1.8: Startup Mutex - Prevents race condition in ensureServerStarted()
|
|
50
|
+
this.startupMutex = new Map();
|
|
51
|
+
// Auto-restart tracking for failed servers
|
|
52
|
+
this.autoRestartConfig = {
|
|
53
|
+
enabled: true,
|
|
54
|
+
maxRetries: 3,
|
|
55
|
+
baseDelay: 5000, // 5 seconds
|
|
56
|
+
maxDelay: 300000, // 5 minutes
|
|
57
|
+
backoffMultiplier: 2,
|
|
58
|
+
};
|
|
59
|
+
this.restartAttempts = new Map();
|
|
60
|
+
// Phase 3: Response router state
|
|
61
|
+
this.stdoutBuffers = new Map(); // Buffer per server
|
|
62
|
+
this.stdoutListeners = new Map(); // Listener references for cleanup
|
|
63
|
+
this.pendingRequests = new Map(); // Pending requests per server
|
|
64
|
+
// v1.1.49: Monotonic request ID counters per server (fixes ID collision bug)
|
|
65
|
+
this.requestIdCounters = new Map();
|
|
66
|
+
// Timeout configuration (in milliseconds)
|
|
67
|
+
this.timeouts = {
|
|
68
|
+
serverInit: 10000, // 10s - Server initialization timeout
|
|
69
|
+
toolFetch: 60000, // 60s - Tool fetch timeout
|
|
70
|
+
toolExecution: 300000, // 5min - Tool execution timeout
|
|
71
|
+
gracefulShutdown: 5000, // 5s - Graceful shutdown timeout
|
|
72
|
+
};
|
|
73
|
+
// Initialize circuit breaker manager with configuration
|
|
74
|
+
this.circuitBreakerManager = new CircuitBreakerManager({
|
|
75
|
+
failureThreshold: config?.circuitBreaker?.failureThreshold ?? 3,
|
|
76
|
+
resetTimeout: config?.circuitBreaker?.resetTimeout ?? 30000,
|
|
77
|
+
halfOpenSuccessThreshold: config?.circuitBreaker?.halfOpenSuccessThreshold ?? 1,
|
|
78
|
+
onStateChange: (oldState, newState, serverName) => {
|
|
79
|
+
console.log(`[CircuitBreaker] ${serverName}: ${oldState} → ${newState}`);
|
|
80
|
+
this.emit("circuit-breaker-state-change", {
|
|
81
|
+
serverName,
|
|
82
|
+
oldState,
|
|
83
|
+
newState,
|
|
84
|
+
});
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
// Phase 1-3: Initialize extracted managers
|
|
88
|
+
this.processManager = new ProcessManager(this.circuitBreakerManager, {
|
|
89
|
+
autoRestart: this.autoRestartConfig,
|
|
90
|
+
});
|
|
91
|
+
this.httpConnectionManager = new HttpConnectionManager({
|
|
92
|
+
timeouts: {
|
|
93
|
+
gracefulShutdown: this.timeouts.gracefulShutdown,
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
this.schemaCacheManager = new SchemaCacheManager({
|
|
97
|
+
cacheTTL: config?.schemasCacheTTL ?? 300000,
|
|
98
|
+
maxCacheEntries: config?.maxSchemaCacheEntries ?? 100,
|
|
99
|
+
backgroundRefresh: config?.schemasBackgroundRefresh ?? true,
|
|
100
|
+
cacheDir: config?.schemasCachePath ?? "~/.config/metalink/schemas",
|
|
101
|
+
});
|
|
102
|
+
// Forward events from managers
|
|
103
|
+
this.processManager.on("server:started", ({ name, pid }) => {
|
|
104
|
+
this.emit("server:started", { name, pid });
|
|
105
|
+
});
|
|
106
|
+
this.processManager.on("server:stopped", ({ name }) => {
|
|
107
|
+
this.emit("server:stopped", { name });
|
|
108
|
+
});
|
|
109
|
+
this.processManager.on("health:check", (result) => {
|
|
110
|
+
this.emit("health:check", result);
|
|
111
|
+
});
|
|
112
|
+
this.httpConnectionManager.on("server:started", ({ name, type, url }) => {
|
|
113
|
+
this.emit("server:started", { name, type, url });
|
|
114
|
+
});
|
|
115
|
+
this.httpConnectionManager.on("server:stopped", ({ name }) => {
|
|
116
|
+
this.emit("server:stopped", { name });
|
|
117
|
+
});
|
|
118
|
+
this.httpConnectionManager.on("health:check", (result) => {
|
|
119
|
+
this.emit("health:check", result);
|
|
120
|
+
});
|
|
121
|
+
// Initialize auto-restart configuration
|
|
122
|
+
if (config?.autoRestart) {
|
|
123
|
+
if (config.autoRestart.enabled !== undefined) {
|
|
124
|
+
this.autoRestartConfig.enabled = config.autoRestart.enabled;
|
|
125
|
+
}
|
|
126
|
+
if (config.autoRestart.maxRetries !== undefined) {
|
|
127
|
+
this.autoRestartConfig.maxRetries = config.autoRestart.maxRetries;
|
|
128
|
+
}
|
|
129
|
+
if (config.autoRestart.baseDelay !== undefined) {
|
|
130
|
+
this.autoRestartConfig.baseDelay = config.autoRestart.baseDelay;
|
|
131
|
+
}
|
|
132
|
+
if (config.autoRestart.maxDelay !== undefined) {
|
|
133
|
+
this.autoRestartConfig.maxDelay = config.autoRestart.maxDelay;
|
|
134
|
+
}
|
|
135
|
+
if (config.autoRestart.backoffMultiplier !== undefined) {
|
|
136
|
+
this.autoRestartConfig.backoffMultiplier =
|
|
137
|
+
config.autoRestart.backoffMultiplier;
|
|
138
|
+
}
|
|
139
|
+
console.log(`[ServerManager] Auto-restart configured:`, this.autoRestartConfig);
|
|
140
|
+
}
|
|
141
|
+
// Load base servers from config (empty array if not configured)
|
|
142
|
+
this.baseServers = config?.base_servers || [];
|
|
143
|
+
if (this.baseServers.length > 0) {
|
|
144
|
+
console.log(`[ServerManager] Base servers configured:`, this.baseServers);
|
|
145
|
+
}
|
|
146
|
+
if (config?.schemasCacheTTL) {
|
|
147
|
+
this.schemasCacheTTL = config.schemasCacheTTL;
|
|
148
|
+
console.log(`[ServerManager] Schema cache TTL configured to ${config.schemasCacheTTL}ms`);
|
|
149
|
+
}
|
|
150
|
+
// Initialize LRU cache for schemas with bounded memory
|
|
151
|
+
const maxCacheEntries = config?.maxSchemaCacheEntries ?? 100;
|
|
152
|
+
this.toolSchemaCache = new LRUCache({
|
|
153
|
+
max: maxCacheEntries,
|
|
154
|
+
dispose: (value, key) => {
|
|
155
|
+
console.log(`[ServerManager] LRU eviction: ${key} (${value.tools.length} tools, age: ${Date.now() - value.timestamp}ms)`);
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
console.log(`[ServerManager] Schema cache initialized with max ${maxCacheEntries} entries`);
|
|
159
|
+
// Initialize persistent schema store
|
|
160
|
+
const cachePath = config?.schemasCachePath || "~/.config/metalink/schemas";
|
|
161
|
+
// Expand ~ to home directory (v1.3.60: global cache across versions)
|
|
162
|
+
const expandedCachePath = cachePath.startsWith("~/")
|
|
163
|
+
? cachePath.replace("~", process.env.HOME || process.env.USERPROFILE || "~")
|
|
164
|
+
: cachePath;
|
|
165
|
+
this.schemaStore = new SchemaStore(expandedCachePath, this.schemasCacheTTL);
|
|
166
|
+
// Load cached schemas from disk on startup
|
|
167
|
+
this.loadSchemasFromDisk();
|
|
168
|
+
// Initialize background refresh setting
|
|
169
|
+
if (config?.schemasBackgroundRefresh !== undefined) {
|
|
170
|
+
this.schemasBackgroundRefresh = config.schemasBackgroundRefresh;
|
|
171
|
+
}
|
|
172
|
+
// Initialize discovery TTL (v1.4.0)
|
|
173
|
+
if (config?.discoveryTTL !== undefined) {
|
|
174
|
+
this.discoveryTTL = config.discoveryTTL;
|
|
175
|
+
console.log(`[ServerManager] Discovery TTL configured to ${config.discoveryTTL}ms`);
|
|
176
|
+
}
|
|
177
|
+
else if (process.env.METALINK_DISCOVERY_TTL) {
|
|
178
|
+
this.discoveryTTL = parseInt(process.env.METALINK_DISCOVERY_TTL);
|
|
179
|
+
console.log(`[ServerManager] Discovery TTL from env: ${this.discoveryTTL}ms`);
|
|
180
|
+
}
|
|
181
|
+
// Initialize timeout configuration
|
|
182
|
+
if (config?.timeouts) {
|
|
183
|
+
if (config.timeouts.serverInit !== undefined) {
|
|
184
|
+
this.timeouts.serverInit = config.timeouts.serverInit;
|
|
185
|
+
}
|
|
186
|
+
if (config.timeouts.toolFetch !== undefined) {
|
|
187
|
+
this.timeouts.toolFetch = config.timeouts.toolFetch;
|
|
188
|
+
}
|
|
189
|
+
if (config.timeouts.toolExecution !== undefined) {
|
|
190
|
+
this.timeouts.toolExecution = config.timeouts.toolExecution;
|
|
191
|
+
}
|
|
192
|
+
if (config.timeouts.gracefulShutdown !== undefined) {
|
|
193
|
+
this.timeouts.gracefulShutdown = config.timeouts.gracefulShutdown;
|
|
194
|
+
}
|
|
195
|
+
console.log(`[ServerManager] Timeouts configured:`, this.timeouts);
|
|
196
|
+
}
|
|
197
|
+
// Override with environment variables if present
|
|
198
|
+
if (process.env.METALINK_TIMEOUT_INIT) {
|
|
199
|
+
this.timeouts.serverInit = parseInt(process.env.METALINK_TIMEOUT_INIT);
|
|
200
|
+
console.log(`[ServerManager] Server init timeout from env: ${this.timeouts.serverInit}ms`);
|
|
201
|
+
}
|
|
202
|
+
if (process.env.METALINK_TIMEOUT_TOOL_FETCH) {
|
|
203
|
+
this.timeouts.toolFetch = parseInt(process.env.METALINK_TIMEOUT_TOOL_FETCH);
|
|
204
|
+
console.log(`[ServerManager] Tool fetch timeout from env: ${this.timeouts.toolFetch}ms`);
|
|
205
|
+
}
|
|
206
|
+
if (process.env.METALINK_TIMEOUT_TOOL_EXECUTION) {
|
|
207
|
+
this.timeouts.toolExecution = parseInt(process.env.METALINK_TIMEOUT_TOOL_EXECUTION);
|
|
208
|
+
console.log(`[ServerManager] Tool execution timeout from env: ${this.timeouts.toolExecution}ms`);
|
|
209
|
+
}
|
|
210
|
+
if (process.env.METALINK_TIMEOUT_SHUTDOWN) {
|
|
211
|
+
this.timeouts.gracefulShutdown = parseInt(process.env.METALINK_TIMEOUT_SHUTDOWN);
|
|
212
|
+
console.log(`[ServerManager] Graceful shutdown timeout from env: ${this.timeouts.gracefulShutdown}ms`);
|
|
213
|
+
}
|
|
214
|
+
// Start background refresh if enabled
|
|
215
|
+
if (this.schemasBackgroundRefresh) {
|
|
216
|
+
this.startBackgroundRefresh();
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Load cached schemas from disk into memory on startup
|
|
221
|
+
* Restores schemas across daemon restarts
|
|
222
|
+
*/
|
|
223
|
+
async loadSchemasFromDisk() {
|
|
224
|
+
if (!this.schemaStore)
|
|
225
|
+
return;
|
|
226
|
+
try {
|
|
227
|
+
const diskSchemas = await this.schemaStore.loadFromDisk();
|
|
228
|
+
let loadedCount = 0;
|
|
229
|
+
for (const [serverName, schema] of diskSchemas) {
|
|
230
|
+
// Load all schemas from disk regardless of TTL (persist across restarts)
|
|
231
|
+
// Background refresh will update expired schemas for running servers
|
|
232
|
+
this.toolSchemaCache.set(serverName, schema);
|
|
233
|
+
this.schemaStore.updateMemoryCache(serverName, schema);
|
|
234
|
+
this.discoveredServers.add(serverName);
|
|
235
|
+
// Restore stability metrics from disk
|
|
236
|
+
if (schema.stability) {
|
|
237
|
+
this.schemaStabilityMetrics.set(serverName, {
|
|
238
|
+
serverName,
|
|
239
|
+
refreshCount: schema.stability.refreshCount,
|
|
240
|
+
changeCount: schema.stability.changeCount,
|
|
241
|
+
lastChangeTimestamp: schema.stability.lastChangeTimestamp,
|
|
242
|
+
currentTTL: schema.stability.currentTTL,
|
|
243
|
+
stability: schema.stability.stability,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
loadedCount++;
|
|
247
|
+
}
|
|
248
|
+
console.log(`[SchemaStore] Loaded ${loadedCount} valid schemas from disk (with stability metrics)`);
|
|
249
|
+
}
|
|
250
|
+
catch (error) {
|
|
251
|
+
console.warn("[SchemaStore] Failed to load schemas from disk:", error);
|
|
252
|
+
// Continue without disk cache - will fetch on demand
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Calculate adaptive TTL based on schema stability
|
|
257
|
+
* Returns TTL in milliseconds
|
|
258
|
+
*/
|
|
259
|
+
calculateAdaptiveTTL(serverName) {
|
|
260
|
+
if (!this.ADAPTIVE_TTL_ENABLED) {
|
|
261
|
+
return this.schemasCacheTTL; // Use default if disabled
|
|
262
|
+
}
|
|
263
|
+
const metrics = this.schemaStabilityMetrics.get(serverName);
|
|
264
|
+
if (!metrics) {
|
|
265
|
+
return this.TTL_NORMAL; // Default for new servers
|
|
266
|
+
}
|
|
267
|
+
// Stable: 0 changes in last 5 refreshes → 60 minute TTL
|
|
268
|
+
if (metrics.refreshCount >= this.STABILITY_WINDOW &&
|
|
269
|
+
metrics.changeCount === 0) {
|
|
270
|
+
return this.TTL_STABLE;
|
|
271
|
+
}
|
|
272
|
+
// Unstable: changed in last 2 refreshes → 5 minute TTL (same as normal)
|
|
273
|
+
if (metrics.changeCount >= this.UNSTABLE_THRESHOLD) {
|
|
274
|
+
return this.TTL_UNSTABLE;
|
|
275
|
+
}
|
|
276
|
+
// Normal: between stable and unstable → 5 minute TTL
|
|
277
|
+
return this.TTL_NORMAL;
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Classify stability level for logging
|
|
281
|
+
*/
|
|
282
|
+
getStabilityLevel(serverName) {
|
|
283
|
+
const metrics = this.schemaStabilityMetrics.get(serverName);
|
|
284
|
+
if (!metrics)
|
|
285
|
+
return "normal";
|
|
286
|
+
if (metrics.refreshCount >= this.STABILITY_WINDOW &&
|
|
287
|
+
metrics.changeCount === 0) {
|
|
288
|
+
return "stable";
|
|
289
|
+
}
|
|
290
|
+
if (metrics.changeCount >= this.UNSTABLE_THRESHOLD) {
|
|
291
|
+
return "unstable";
|
|
292
|
+
}
|
|
293
|
+
return "normal";
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Detect if schema has changed (compare tool names and counts)
|
|
297
|
+
*/
|
|
298
|
+
hasSchemaChanged(oldTools, newTools) {
|
|
299
|
+
if (oldTools.length !== newTools.length) {
|
|
300
|
+
return true;
|
|
301
|
+
}
|
|
302
|
+
// Compare tool names (sorted)
|
|
303
|
+
const oldNames = oldTools.map((t) => t.name).sort();
|
|
304
|
+
const newNames = newTools.map((t) => t.name).sort();
|
|
305
|
+
for (let i = 0; i < oldNames.length; i++) {
|
|
306
|
+
if (oldNames[i] !== newNames[i]) {
|
|
307
|
+
return true;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return false; // No changes detected
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Update stability metrics after refresh
|
|
314
|
+
*/
|
|
315
|
+
updateStabilityMetrics(serverName, schemaChanged) {
|
|
316
|
+
let metrics = this.schemaStabilityMetrics.get(serverName);
|
|
317
|
+
if (!metrics) {
|
|
318
|
+
// Initialize new metrics
|
|
319
|
+
metrics = {
|
|
320
|
+
serverName,
|
|
321
|
+
refreshCount: 0,
|
|
322
|
+
changeCount: 0,
|
|
323
|
+
lastChangeTimestamp: 0,
|
|
324
|
+
currentTTL: this.TTL_NORMAL,
|
|
325
|
+
stability: "normal",
|
|
326
|
+
};
|
|
327
|
+
this.schemaStabilityMetrics.set(serverName, metrics);
|
|
328
|
+
}
|
|
329
|
+
// Update counters
|
|
330
|
+
metrics.refreshCount++;
|
|
331
|
+
if (schemaChanged) {
|
|
332
|
+
metrics.changeCount++;
|
|
333
|
+
metrics.lastChangeTimestamp = Date.now();
|
|
334
|
+
// Reset stability window when schema changes
|
|
335
|
+
// Only keep last STABILITY_WINDOW refreshes
|
|
336
|
+
if (metrics.refreshCount > this.STABILITY_WINDOW) {
|
|
337
|
+
metrics.refreshCount = this.STABILITY_WINDOW;
|
|
338
|
+
metrics.changeCount = Math.min(metrics.changeCount, this.UNSTABLE_THRESHOLD);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
else {
|
|
342
|
+
// Schema stable - decay change count over time
|
|
343
|
+
if (metrics.refreshCount >= this.STABILITY_WINDOW) {
|
|
344
|
+
metrics.changeCount = Math.max(0, metrics.changeCount - 1);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
// Recalculate TTL
|
|
348
|
+
metrics.currentTTL = this.calculateAdaptiveTTL(serverName);
|
|
349
|
+
metrics.stability = this.getStabilityLevel(serverName);
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Start background refresh interval
|
|
353
|
+
* Periodically refreshes schemas for servers near TTL expiration
|
|
354
|
+
*/
|
|
355
|
+
startBackgroundRefresh() {
|
|
356
|
+
if (this.backgroundRefreshInterval) {
|
|
357
|
+
return; // Already running
|
|
358
|
+
}
|
|
359
|
+
this.backgroundRefreshInterval = setInterval(async () => {
|
|
360
|
+
await this.refreshExpiredSchemas();
|
|
361
|
+
// v1.3.72: Also proactively discover schemas for uncached servers
|
|
362
|
+
await this.discoverUncachedServers();
|
|
363
|
+
}, this.BACKGROUND_REFRESH_INTERVAL_MS);
|
|
364
|
+
const adaptiveMsg = this.ADAPTIVE_TTL_ENABLED
|
|
365
|
+
? ` (adaptive TTL: stable=${this.TTL_STABLE}ms, normal=${this.TTL_NORMAL}ms)`
|
|
366
|
+
: "";
|
|
367
|
+
console.log(`[SchemaStore] Background refresh enabled (interval: 1min, refresh at 80% TTL${adaptiveMsg})`);
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Refresh schemas that are expiring soon (>80% of adaptive TTL)
|
|
371
|
+
* Only refreshes servers that are already running
|
|
372
|
+
*/
|
|
373
|
+
async refreshExpiredSchemas() {
|
|
374
|
+
const now = Date.now();
|
|
375
|
+
for (const [serverName, schema] of this.toolSchemaCache) {
|
|
376
|
+
// Use adaptive TTL per server
|
|
377
|
+
const serverTTL = this.calculateAdaptiveTTL(serverName);
|
|
378
|
+
const expireThreshold = serverTTL * 0.8; // 80% of adaptive TTL
|
|
379
|
+
const age = now - schema.timestamp;
|
|
380
|
+
// Skip if not near expiration
|
|
381
|
+
if (age < expireThreshold) {
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
// Skip if server not running (no need to refresh)
|
|
385
|
+
if (!this.isServerActive(serverName)) {
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
try {
|
|
389
|
+
const oldTools = schema.tools;
|
|
390
|
+
const process = this.getProcess(serverName);
|
|
391
|
+
if (!process)
|
|
392
|
+
continue;
|
|
393
|
+
const tools = await this.fetchToolsFromProcess(process, serverName);
|
|
394
|
+
// Detect schema change
|
|
395
|
+
const schemaChanged = this.hasSchemaChanged(oldTools, tools);
|
|
396
|
+
// Update stability metrics
|
|
397
|
+
this.updateStabilityMetrics(serverName, schemaChanged);
|
|
398
|
+
// Get updated metrics
|
|
399
|
+
const metrics = this.schemaStabilityMetrics.get(serverName);
|
|
400
|
+
// Log refresh with stability info
|
|
401
|
+
const changeMsg = schemaChanged ? " (schema changed)" : " (no changes)";
|
|
402
|
+
const stabilityMsg = metrics
|
|
403
|
+
? ` [${metrics.stability}, TTL: ${metrics.currentTTL}ms]`
|
|
404
|
+
: "";
|
|
405
|
+
console.log(`[SchemaStore] Background refresh for ${serverName}${changeMsg}${stabilityMsg}`);
|
|
406
|
+
// Prepare new schema with stability metrics
|
|
407
|
+
const newSchema = {
|
|
408
|
+
tools,
|
|
409
|
+
timestamp: now,
|
|
410
|
+
stability: metrics
|
|
411
|
+
? {
|
|
412
|
+
refreshCount: metrics.refreshCount,
|
|
413
|
+
changeCount: metrics.changeCount,
|
|
414
|
+
lastChangeTimestamp: metrics.lastChangeTimestamp,
|
|
415
|
+
currentTTL: metrics.currentTTL,
|
|
416
|
+
stability: metrics.stability,
|
|
417
|
+
}
|
|
418
|
+
: undefined,
|
|
419
|
+
};
|
|
420
|
+
// Update memory cache
|
|
421
|
+
this.toolSchemaCache.set(serverName, { tools, timestamp: now });
|
|
422
|
+
// Persist to disk (non-blocking)
|
|
423
|
+
if (this.schemaStore) {
|
|
424
|
+
this.schemaStore.saveToDisk(serverName, newSchema).catch((error) => {
|
|
425
|
+
console.warn(`[SchemaStore] Failed to persist refreshed schema for ${serverName}:`, error);
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
catch (error) {
|
|
430
|
+
console.warn(`[SchemaStore] Background refresh failed for ${serverName}:`, error);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* v1.3.72: Proactively discover schemas for servers without cache
|
|
436
|
+
* Starts servers one at a time to avoid overload, fetches schemas, then stops them
|
|
437
|
+
* Only discovers ONE server per refresh cycle to avoid overwhelming the system
|
|
438
|
+
*/
|
|
439
|
+
async discoverUncachedServers() {
|
|
440
|
+
if (!this.serverListProvider) {
|
|
441
|
+
return; // No provider set, can't discover
|
|
442
|
+
}
|
|
443
|
+
try {
|
|
444
|
+
const allServers = this.serverListProvider();
|
|
445
|
+
// Find first server without cached schema (disk or memory)
|
|
446
|
+
for (const config of allServers) {
|
|
447
|
+
const serverName = config.name;
|
|
448
|
+
// Skip if already has cached schema in memory
|
|
449
|
+
if (this.toolSchemaCache.has(serverName)) {
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
// Skip if already has cached schema on disk
|
|
453
|
+
if (this.schemaStore) {
|
|
454
|
+
const diskCached = this.schemaStore.getSyncFromDisk(serverName);
|
|
455
|
+
if (diskCached && diskCached.length > 0) {
|
|
456
|
+
// Also populate memory cache
|
|
457
|
+
this.toolSchemaCache.set(serverName, {
|
|
458
|
+
tools: diskCached,
|
|
459
|
+
timestamp: Date.now(),
|
|
460
|
+
});
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
// Skip if already running (will be discovered via normal flow)
|
|
465
|
+
if (this.isServerActive(serverName)) {
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
// v1.3.74: Skip if server has failed too many times recently
|
|
469
|
+
const failedAttempt = this.failedDiscoveryAttempts.get(serverName);
|
|
470
|
+
if (failedAttempt) {
|
|
471
|
+
const timeSinceLastAttempt = Date.now() - failedAttempt.lastAttempt;
|
|
472
|
+
if (failedAttempt.count >= this.MAX_DISCOVERY_FAILURES &&
|
|
473
|
+
timeSinceLastAttempt < this.DISCOVERY_FAILURE_BACKOFF_MS) {
|
|
474
|
+
// Skip this server, still in backoff period
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
// Reset if backoff period has passed
|
|
478
|
+
if (timeSinceLastAttempt >= this.DISCOVERY_FAILURE_BACKOFF_MS) {
|
|
479
|
+
this.failedDiscoveryAttempts.delete(serverName);
|
|
480
|
+
console.log(`[SchemaStore] Proactive discovery: ${serverName} backoff expired, will retry`);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
// Discover this server's schema
|
|
484
|
+
console.log(`[SchemaStore] Proactive discovery: starting ${serverName} to fetch schemas...`);
|
|
485
|
+
try {
|
|
486
|
+
// Start server
|
|
487
|
+
await this.startServer(config);
|
|
488
|
+
let tools = [];
|
|
489
|
+
// Check if HTTP or stdio server
|
|
490
|
+
const isHttpServer = this.httpClients.has(serverName);
|
|
491
|
+
if (isHttpServer) {
|
|
492
|
+
const client = this.httpClients.get(serverName);
|
|
493
|
+
const response = await client.call("tools/list", {});
|
|
494
|
+
if (response.result &&
|
|
495
|
+
typeof response.result === "object" &&
|
|
496
|
+
"tools" in response.result) {
|
|
497
|
+
tools = response.result.tools;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
else {
|
|
501
|
+
const process = this.getProcess(serverName);
|
|
502
|
+
if (process) {
|
|
503
|
+
tools = await this.fetchToolsFromProcess(process, serverName);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
// Cache the schema
|
|
507
|
+
const schema = { tools, timestamp: Date.now() };
|
|
508
|
+
this.toolSchemaCache.set(serverName, schema);
|
|
509
|
+
// Persist to disk
|
|
510
|
+
if (this.schemaStore) {
|
|
511
|
+
await this.schemaStore.saveToDisk(serverName, schema);
|
|
512
|
+
}
|
|
513
|
+
console.log(`[SchemaStore] Proactive discovery: ${serverName} cached (${tools.length} tools)`);
|
|
514
|
+
// v1.1.33: Don't kill servers after caching - let them stay running
|
|
515
|
+
// This prevents "process has been killed" errors and improves latency
|
|
516
|
+
// await this.stopServer(serverName);
|
|
517
|
+
// console.log(`[SchemaStore] Proactive discovery: ${serverName} stopped after caching`);
|
|
518
|
+
// Only discover ONE server per cycle to avoid overwhelming the system
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
catch (error) {
|
|
522
|
+
console.warn(`[SchemaStore] Proactive discovery failed for ${serverName}:`, error);
|
|
523
|
+
// v1.3.74: Track failure for backoff
|
|
524
|
+
const existing = this.failedDiscoveryAttempts.get(serverName);
|
|
525
|
+
const failureCount = (existing?.count || 0) + 1;
|
|
526
|
+
this.failedDiscoveryAttempts.set(serverName, {
|
|
527
|
+
count: failureCount,
|
|
528
|
+
lastAttempt: Date.now(),
|
|
529
|
+
});
|
|
530
|
+
if (failureCount >= this.MAX_DISCOVERY_FAILURES) {
|
|
531
|
+
console.log(`[SchemaStore] Proactive discovery: ${serverName} failed ${failureCount} times, skipping for 1 hour`);
|
|
532
|
+
}
|
|
533
|
+
// Try to stop if it was started
|
|
534
|
+
try {
|
|
535
|
+
await this.stopServer(serverName);
|
|
536
|
+
}
|
|
537
|
+
catch {
|
|
538
|
+
// Ignore stop errors
|
|
539
|
+
}
|
|
540
|
+
// Continue to next server in next cycle
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
catch (error) {
|
|
546
|
+
console.warn("[SchemaStore] Proactive discovery error:", error);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* Start a server process (stdio or HTTP)
|
|
551
|
+
*/
|
|
552
|
+
async startServer(config) {
|
|
553
|
+
if (this.processes.has(config.name) || this.httpClients.has(config.name)) {
|
|
554
|
+
throw new Error(`Server ${config.name} is already running`);
|
|
555
|
+
}
|
|
556
|
+
// Route to appropriate handler based on transport
|
|
557
|
+
if (config.transport === "stdio" || config.transport === undefined) {
|
|
558
|
+
return this.startStdioServer(config);
|
|
559
|
+
}
|
|
560
|
+
else {
|
|
561
|
+
return this.startHttpServer(config);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
/**
|
|
565
|
+
* Start a stdio-based server (spawns process)
|
|
566
|
+
*/
|
|
567
|
+
async startStdioServer(config) {
|
|
568
|
+
try {
|
|
569
|
+
// Helper to expand env var syntax: ${VAR} and ~/path
|
|
570
|
+
const expandEnvVar = (value, env) => {
|
|
571
|
+
// Expand ${VAR_NAME} syntax
|
|
572
|
+
let expanded = value.replace(/\$\{([^}]+)\}/g, (_, key) => env[key] || value);
|
|
573
|
+
// Expand ~ to home directory
|
|
574
|
+
if (expanded.startsWith("~")) {
|
|
575
|
+
expanded = expanded.replace(/^~/, env.HOME || "/root");
|
|
576
|
+
}
|
|
577
|
+
return expanded;
|
|
578
|
+
};
|
|
579
|
+
// Replace environment variables in args and env values
|
|
580
|
+
const processEnv = {
|
|
581
|
+
...process.env,
|
|
582
|
+
};
|
|
583
|
+
// Expand and add config.env
|
|
584
|
+
if (config.env) {
|
|
585
|
+
for (const [key, value] of Object.entries(config.env)) {
|
|
586
|
+
processEnv[key] = expandEnvVar(value, processEnv);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
// Resolve env vars in args
|
|
590
|
+
const args = (config.args || []).map((arg) => expandEnvVar(arg, processEnv));
|
|
591
|
+
// Prepare spawn options with optional cwd
|
|
592
|
+
const spawnOptions = {
|
|
593
|
+
env: processEnv,
|
|
594
|
+
stdio: "pipe",
|
|
595
|
+
};
|
|
596
|
+
// Add cwd if configured
|
|
597
|
+
if (config.cwd) {
|
|
598
|
+
// Expand ~ and env variables in cwd path
|
|
599
|
+
let cwdPath = config.cwd;
|
|
600
|
+
if (cwdPath.startsWith("~")) {
|
|
601
|
+
cwdPath = cwdPath.replace(/^~/, processEnv.HOME || "/root");
|
|
602
|
+
}
|
|
603
|
+
// Resolve to absolute path
|
|
604
|
+
spawnOptions.cwd = path.resolve(cwdPath);
|
|
605
|
+
console.log(`[${config.name}] Starting with cwd: ${spawnOptions.cwd}`);
|
|
606
|
+
}
|
|
607
|
+
// v1.1.52: Robust orphan process cleanup with security fixes
|
|
608
|
+
// Addresses: command injection, false positives, cross-user matching, parent process exclusion
|
|
609
|
+
// Step 1: Kill any in-memory tracked process (from this ServerManager instance)
|
|
610
|
+
const existingProcess = this.processes.get(config.name);
|
|
611
|
+
if (existingProcess && !existingProcess.killed) {
|
|
612
|
+
console.log(`[${config.name}] Killing tracked orphan process (PID: ${existingProcess.pid}) before starting new instance`);
|
|
613
|
+
try {
|
|
614
|
+
existingProcess.kill("SIGTERM");
|
|
615
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
616
|
+
if (!existingProcess.killed) {
|
|
617
|
+
existingProcess.kill("SIGKILL");
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
catch (killError) {
|
|
621
|
+
console.warn(`[${config.name}] Failed to kill tracked process:`, killError);
|
|
622
|
+
}
|
|
623
|
+
this.processes.delete(config.name);
|
|
624
|
+
this.activeServers.delete(config.name);
|
|
625
|
+
}
|
|
626
|
+
// Step 2: Kill system orphan processes using METALINK_SERVER_NAME env var
|
|
627
|
+
// This is the unique identifier we inject into spawned processes
|
|
628
|
+
const serverNamePattern = `METALINK_SERVER_NAME=${config.name}`;
|
|
629
|
+
// Also create fallback pattern from args (for processes from older versions)
|
|
630
|
+
// Use scoped package names (@org/pkg) which are unique, avoid generic patterns
|
|
631
|
+
const packageIdentifier = args.find((arg) => arg.includes("@") && arg.includes("/")) ||
|
|
632
|
+
(config.command !== "npx" && config.command !== "uvx"
|
|
633
|
+
? config.command
|
|
634
|
+
: null);
|
|
635
|
+
// Get PIDs to exclude: current process + parent + grandparent
|
|
636
|
+
const excludePids = new Set();
|
|
637
|
+
excludePids.add(String(process.pid));
|
|
638
|
+
try {
|
|
639
|
+
const ppidResult = spawnSync("ps", ["-o", "ppid=", "-p", String(process.pid)], { encoding: "utf8", timeout: 2000 });
|
|
640
|
+
const ppid = ppidResult.stdout?.trim();
|
|
641
|
+
if (ppid) {
|
|
642
|
+
excludePids.add(ppid);
|
|
643
|
+
// Get grandparent too
|
|
644
|
+
const gppidResult = spawnSync("ps", ["-o", "ppid=", "-p", ppid], {
|
|
645
|
+
encoding: "utf8",
|
|
646
|
+
timeout: 2000,
|
|
647
|
+
});
|
|
648
|
+
const gppid = gppidResult.stdout?.trim();
|
|
649
|
+
if (gppid)
|
|
650
|
+
excludePids.add(gppid);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
catch {
|
|
654
|
+
// Non-fatal - continue with just current PID excluded
|
|
655
|
+
}
|
|
656
|
+
// Get current user ID for filtering
|
|
657
|
+
let currentUid = "";
|
|
658
|
+
try {
|
|
659
|
+
const idResult = spawnSync("id", ["-u"], {
|
|
660
|
+
encoding: "utf8",
|
|
661
|
+
timeout: 2000,
|
|
662
|
+
});
|
|
663
|
+
currentUid = idResult.stdout?.trim() || "";
|
|
664
|
+
}
|
|
665
|
+
catch {
|
|
666
|
+
// Non-fatal - will skip user filtering
|
|
667
|
+
}
|
|
668
|
+
// Search for orphans using server name pattern (primary) or package identifier (fallback)
|
|
669
|
+
const patternsToSearch = [serverNamePattern];
|
|
670
|
+
if (packageIdentifier) {
|
|
671
|
+
patternsToSearch.push(packageIdentifier);
|
|
672
|
+
}
|
|
673
|
+
const orphanPids = new Set();
|
|
674
|
+
for (const pattern of patternsToSearch) {
|
|
675
|
+
try {
|
|
676
|
+
// Use spawnSync to avoid shell injection - no string interpolation
|
|
677
|
+
const pgrepArgs = currentUid
|
|
678
|
+
? ["-f", "-u", currentUid, pattern]
|
|
679
|
+
: ["-f", pattern];
|
|
680
|
+
const pgrepResult = spawnSync("pgrep", pgrepArgs, {
|
|
681
|
+
encoding: "utf8",
|
|
682
|
+
timeout: 5000,
|
|
683
|
+
});
|
|
684
|
+
// pgrep returns exit code 1 if no matches (not an error)
|
|
685
|
+
const pids = (pgrepResult.stdout || "")
|
|
686
|
+
.trim()
|
|
687
|
+
.split("\n")
|
|
688
|
+
.filter((pid) => pid && !excludePids.has(pid));
|
|
689
|
+
pids.forEach((pid) => orphanPids.add(pid));
|
|
690
|
+
}
|
|
691
|
+
catch (pgrepError) {
|
|
692
|
+
const errMsg = pgrepError instanceof Error
|
|
693
|
+
? pgrepError.message
|
|
694
|
+
: String(pgrepError);
|
|
695
|
+
if (errMsg.includes("command not found") ||
|
|
696
|
+
errMsg.includes("ENOENT")) {
|
|
697
|
+
console.error(`[${config.name}] pgrep not found - cannot detect orphan processes`);
|
|
698
|
+
}
|
|
699
|
+
// Other errors are non-fatal
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
if (orphanPids.size > 0) {
|
|
703
|
+
const pidList = Array.from(orphanPids);
|
|
704
|
+
console.log(`[${config.name}] Found ${pidList.length} orphan process(es): PIDs ${pidList.join(", ")}`);
|
|
705
|
+
// Send SIGTERM to all orphans (using spawnSync for safety)
|
|
706
|
+
for (const pid of pidList) {
|
|
707
|
+
try {
|
|
708
|
+
const killResult = spawnSync("kill", ["-TERM", pid], {
|
|
709
|
+
timeout: 1000,
|
|
710
|
+
});
|
|
711
|
+
if (killResult.status === 0) {
|
|
712
|
+
console.log(`[${config.name}] Sent SIGTERM to orphan PID: ${pid}`);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
catch {
|
|
716
|
+
// Process may have already exited
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
// Wait for graceful termination
|
|
720
|
+
await new Promise((resolve) => setTimeout(resolve, 800));
|
|
721
|
+
// Force kill any remaining with SIGKILL
|
|
722
|
+
for (const pid of pidList) {
|
|
723
|
+
try {
|
|
724
|
+
// Check if still alive
|
|
725
|
+
const checkResult = spawnSync("kill", ["-0", pid], {
|
|
726
|
+
timeout: 500,
|
|
727
|
+
});
|
|
728
|
+
if (checkResult.status === 0) {
|
|
729
|
+
// Still alive - force kill
|
|
730
|
+
spawnSync("kill", ["-KILL", pid], { timeout: 1000 });
|
|
731
|
+
console.log(`[${config.name}] Sent SIGKILL to orphan PID: ${pid}`);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
catch {
|
|
735
|
+
// Process already dead
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
// Inject METALINK_SERVER_NAME into process env for future orphan detection
|
|
740
|
+
processEnv.METALINK_SERVER_NAME = config.name;
|
|
741
|
+
processEnv.METALINK_VERSION = version;
|
|
742
|
+
const childProcess = spawn(config.command, args, spawnOptions);
|
|
743
|
+
this.processes.set(config.name, childProcess);
|
|
744
|
+
this.state[config.name] = {
|
|
745
|
+
pid: childProcess.pid || 0,
|
|
746
|
+
status: "running",
|
|
747
|
+
uptime: Date.now(),
|
|
748
|
+
lastHealthCheck: Date.now(),
|
|
749
|
+
errorCount: 0,
|
|
750
|
+
};
|
|
751
|
+
// Track server start in metrics (Phase 4 - v1.4.0)
|
|
752
|
+
globalMetrics.recordServerStart(config.name);
|
|
753
|
+
// Capture stdout and stderr for debugging
|
|
754
|
+
let stdoutData = "";
|
|
755
|
+
let stderrData = "";
|
|
756
|
+
const debugMode = process.env.METALINK_DEBUG === "true";
|
|
757
|
+
if (childProcess.stdout) {
|
|
758
|
+
childProcess.stdout.on("data", (chunk) => {
|
|
759
|
+
const str = chunk.toString();
|
|
760
|
+
stdoutData += str;
|
|
761
|
+
if (debugMode) {
|
|
762
|
+
console.log(`[DIAG] [${config.name}] Listener #1 (startStdioServer debug) received data:`, str.substring(0, 100));
|
|
763
|
+
}
|
|
764
|
+
console.log(`[${config.name}] stdout: ${str}`);
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
if (childProcess.stderr) {
|
|
768
|
+
childProcess.stderr.on("data", (chunk) => {
|
|
769
|
+
const str = chunk.toString();
|
|
770
|
+
stderrData += str;
|
|
771
|
+
console.error(`[${config.name}] stderr: ${str}`);
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
// Handle process errors
|
|
775
|
+
childProcess.on("error", (error) => {
|
|
776
|
+
this.handleServerError(config.name, error);
|
|
777
|
+
});
|
|
778
|
+
childProcess.on("exit", (code) => {
|
|
779
|
+
if (code !== 0 && (stdoutData || stderrData)) {
|
|
780
|
+
console.error(`[MetaLink] Server '${config.name}' output on crash:`);
|
|
781
|
+
if (stdoutData)
|
|
782
|
+
console.error(` stdout: ${stdoutData}`);
|
|
783
|
+
if (stderrData)
|
|
784
|
+
console.error(` stderr: ${stderrData}`);
|
|
785
|
+
}
|
|
786
|
+
this.handleServerExit(config.name, code);
|
|
787
|
+
});
|
|
788
|
+
// Start health checks if enabled
|
|
789
|
+
if (config.healthCheck?.enabled) {
|
|
790
|
+
this.startHealthCheck(config.name, config.healthCheck.interval || 30000);
|
|
791
|
+
}
|
|
792
|
+
this.emit("server:started", { name: config.name, pid: childProcess.pid });
|
|
793
|
+
return this.state[config.name];
|
|
794
|
+
}
|
|
795
|
+
catch (error) {
|
|
796
|
+
throw new Error(`Failed to start stdio server ${config.name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
/**
|
|
800
|
+
* Start an HTTP-based server (connects via HTTP client)
|
|
801
|
+
*/
|
|
802
|
+
async startHttpServer(config) {
|
|
803
|
+
try {
|
|
804
|
+
// Phase 6: Delegate to HttpConnectionManager
|
|
805
|
+
const serverProcess = await this.httpConnectionManager.startHttpServer(config);
|
|
806
|
+
// Keep local state for backward compatibility
|
|
807
|
+
const client = this.httpConnectionManager.getClient(config.name);
|
|
808
|
+
if (client) {
|
|
809
|
+
this.httpClients.set(config.name, client);
|
|
810
|
+
}
|
|
811
|
+
// Update local state
|
|
812
|
+
this.state[config.name] = serverProcess;
|
|
813
|
+
// Track server start in metrics (Phase 4 - v1.4.0)
|
|
814
|
+
globalMetrics.recordServerStart(config.name);
|
|
815
|
+
// Forward events
|
|
816
|
+
this.httpConnectionManager.on("server:started", ({ name, type, url }) => {
|
|
817
|
+
this.emit("server:started", { name, type, url });
|
|
818
|
+
});
|
|
819
|
+
return serverProcess;
|
|
820
|
+
}
|
|
821
|
+
catch (error) {
|
|
822
|
+
throw new Error(`Failed to start HTTP server ${config.name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
/**
|
|
826
|
+
* Stop a server process (stdio or HTTP)
|
|
827
|
+
*/
|
|
828
|
+
async stopServer(serverName) {
|
|
829
|
+
// Phase 5: Check ProcessManager first, then fall back to local maps
|
|
830
|
+
const isStdioInManager = this.processManager.isProcessRunning(serverName);
|
|
831
|
+
const isStdioInLocal = this.processes.has(serverName);
|
|
832
|
+
const isHttp = this.httpClients.has(serverName);
|
|
833
|
+
if (!isStdioInManager && !isStdioInLocal && !isHttp) {
|
|
834
|
+
throw new Error(`Server ${serverName} is not running`);
|
|
835
|
+
}
|
|
836
|
+
// Stop health checks
|
|
837
|
+
const healthCheckInterval = this.healthCheckIntervals.get(serverName);
|
|
838
|
+
if (healthCheckInterval) {
|
|
839
|
+
clearInterval(healthCheckInterval);
|
|
840
|
+
this.healthCheckIntervals.delete(serverName);
|
|
841
|
+
}
|
|
842
|
+
if (isStdioInManager) {
|
|
843
|
+
// Phase 5: Delegate to ProcessManager
|
|
844
|
+
await this.processManager.stopServer(serverName);
|
|
845
|
+
// Also clean up local map if present
|
|
846
|
+
if (isStdioInLocal) {
|
|
847
|
+
this.processes.delete(serverName);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
else if (isStdioInLocal) {
|
|
851
|
+
// Legacy: Direct process termination
|
|
852
|
+
const childProcess = this.processes.get(serverName);
|
|
853
|
+
// v1.1.27: Clean up all event listeners to prevent memory leaks
|
|
854
|
+
if (childProcess.stdout) {
|
|
855
|
+
childProcess.stdout.removeAllListeners();
|
|
856
|
+
}
|
|
857
|
+
if (childProcess.stderr) {
|
|
858
|
+
childProcess.stderr.removeAllListeners();
|
|
859
|
+
}
|
|
860
|
+
childProcess.removeAllListeners();
|
|
861
|
+
childProcess.kill("SIGTERM");
|
|
862
|
+
// Wait for graceful shutdown
|
|
863
|
+
await new Promise((resolve) => {
|
|
864
|
+
const timeout = setTimeout(() => {
|
|
865
|
+
childProcess.kill("SIGKILL");
|
|
866
|
+
resolve();
|
|
867
|
+
}, this.timeouts.gracefulShutdown);
|
|
868
|
+
childProcess.once("exit", () => {
|
|
869
|
+
clearTimeout(timeout);
|
|
870
|
+
resolve();
|
|
871
|
+
});
|
|
872
|
+
});
|
|
873
|
+
this.processes.delete(serverName);
|
|
874
|
+
}
|
|
875
|
+
else if (isHttp) {
|
|
876
|
+
// Phase 6: Delegate to HttpConnectionManager
|
|
877
|
+
if (this.httpConnectionManager.hasClient(serverName)) {
|
|
878
|
+
await this.httpConnectionManager.stopServer(serverName);
|
|
879
|
+
}
|
|
880
|
+
// Clean up local map
|
|
881
|
+
this.httpClients.delete(serverName);
|
|
882
|
+
}
|
|
883
|
+
if (this.state[serverName]) {
|
|
884
|
+
this.state[serverName].status = "stopped";
|
|
885
|
+
}
|
|
886
|
+
this.emit("server:stopped", { name: serverName });
|
|
887
|
+
}
|
|
888
|
+
/**
|
|
889
|
+
* Remove a server from the manager
|
|
890
|
+
* Stops the process if running and cleans up all associated state
|
|
891
|
+
* Called when a server is removed from the registry
|
|
892
|
+
*/
|
|
893
|
+
async removeServer(serverName) {
|
|
894
|
+
// 1. Stop process if running
|
|
895
|
+
try {
|
|
896
|
+
await this.stopServer(serverName);
|
|
897
|
+
}
|
|
898
|
+
catch (error) {
|
|
899
|
+
// Server might not be running, continue cleanup anyway
|
|
900
|
+
}
|
|
901
|
+
// 2. Clean up active servers tracking
|
|
902
|
+
this.activeServers.delete(serverName);
|
|
903
|
+
// 3. Clean up response router listeners
|
|
904
|
+
const listener = this.stdoutListeners.get(serverName);
|
|
905
|
+
if (listener) {
|
|
906
|
+
// Remove listeners from the process if it still exists
|
|
907
|
+
const process = this.processes.get(serverName);
|
|
908
|
+
if (process && process.stdout) {
|
|
909
|
+
process.stdout.removeListener("data", listener.onStdoutData);
|
|
910
|
+
process.removeListener("error", listener.onError);
|
|
911
|
+
process.removeListener("exit", listener.onExit);
|
|
912
|
+
}
|
|
913
|
+
this.stdoutListeners.delete(serverName);
|
|
914
|
+
}
|
|
915
|
+
// 4. Clear pending requests for this server
|
|
916
|
+
const pendingRequests = this.pendingRequests.get(serverName);
|
|
917
|
+
if (pendingRequests) {
|
|
918
|
+
// Reject all pending requests with informative error
|
|
919
|
+
for (const [requestId, request] of pendingRequests) {
|
|
920
|
+
clearTimeout(request.timeout);
|
|
921
|
+
request.reject(new Error(`Server '${serverName}' was removed from registry (pending request ${requestId})`));
|
|
922
|
+
}
|
|
923
|
+
this.pendingRequests.delete(serverName);
|
|
924
|
+
}
|
|
925
|
+
// 5. Clear stdout buffers
|
|
926
|
+
this.stdoutBuffers.delete(serverName);
|
|
927
|
+
// 6. Delete state
|
|
928
|
+
delete this.state[serverName];
|
|
929
|
+
// 7. Ensure process is fully removed
|
|
930
|
+
this.processes.delete(serverName);
|
|
931
|
+
this.emit("server:removed", { name: serverName });
|
|
932
|
+
}
|
|
933
|
+
/**
|
|
934
|
+
* Restart a server
|
|
935
|
+
* v1.4.2: Clears schema cache to ensure fresh tool discovery
|
|
936
|
+
*/
|
|
937
|
+
async restartServer(config) {
|
|
938
|
+
try {
|
|
939
|
+
await this.stopServer(config.name);
|
|
940
|
+
}
|
|
941
|
+
catch {
|
|
942
|
+
// Server might not be running, continue anyway
|
|
943
|
+
}
|
|
944
|
+
// v1.4.2: Clear schema cache to force fresh discovery on restart
|
|
945
|
+
this.invalidateServerSchema(config.name);
|
|
946
|
+
return this.startServer(config);
|
|
947
|
+
}
|
|
948
|
+
/**
|
|
949
|
+
* Invalidate schema cache for a server
|
|
950
|
+
* Forces fresh discovery on next access
|
|
951
|
+
* v1.4.2: Added to support force-refresh of tool lists
|
|
952
|
+
*/
|
|
953
|
+
invalidateServerSchema(serverName) {
|
|
954
|
+
// Phase 7: Delegate to SchemaCacheManager
|
|
955
|
+
this.schemaCacheManager.invalidate(serverName);
|
|
956
|
+
// Legacy: Keep local cache invalidation for backward compatibility
|
|
957
|
+
const hadCache = this.toolSchemaCache.has(serverName);
|
|
958
|
+
this.toolSchemaCache.delete(serverName);
|
|
959
|
+
// Clear from discovered servers set
|
|
960
|
+
this.discoveredServers.delete(serverName);
|
|
961
|
+
// Clear discovery timer if exists
|
|
962
|
+
const timer = this.discoveryTimers.get(serverName);
|
|
963
|
+
if (timer) {
|
|
964
|
+
clearTimeout(timer);
|
|
965
|
+
this.discoveryTimers.delete(serverName);
|
|
966
|
+
}
|
|
967
|
+
// Clear stability metrics
|
|
968
|
+
this.schemaStabilityMetrics.delete(serverName);
|
|
969
|
+
if (hadCache) {
|
|
970
|
+
console.log(`[ServerManager] Schema cache invalidated for ${serverName}`);
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
/**
|
|
974
|
+
* Force refresh schema for a server
|
|
975
|
+
* Invalidates cache and re-discovers tools from running server
|
|
976
|
+
* v1.4.2: Added to support force-refresh of tool lists
|
|
977
|
+
*/
|
|
978
|
+
async forceRefreshSchema(serverName, config) {
|
|
979
|
+
console.log(`[ServerManager] Force refreshing schema for ${serverName}...`);
|
|
980
|
+
// 1. Invalidate all caches
|
|
981
|
+
this.invalidateServerSchema(serverName);
|
|
982
|
+
// 2. Also delete from disk cache
|
|
983
|
+
if (this.schemaStore) {
|
|
984
|
+
try {
|
|
985
|
+
const schemaPath = `${this.schemaStore["cacheDir"]}/${serverName}.json`;
|
|
986
|
+
const fs = await import("fs/promises");
|
|
987
|
+
await fs.unlink(schemaPath).catch(() => { }); // Ignore if not exists
|
|
988
|
+
console.log(`[ServerManager] Deleted disk cache for ${serverName}`);
|
|
989
|
+
}
|
|
990
|
+
catch (error) {
|
|
991
|
+
// Disk cache deletion is best-effort
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
// 3. If server is running, fetch tools directly
|
|
995
|
+
if (this.isServerActive(serverName)) {
|
|
996
|
+
const process = this.getProcess(serverName);
|
|
997
|
+
if (process) {
|
|
998
|
+
try {
|
|
999
|
+
const tools = await this.fetchToolsFromProcess(process, serverName);
|
|
1000
|
+
const schema = { tools, timestamp: Date.now() };
|
|
1001
|
+
// Update cache
|
|
1002
|
+
this.toolSchemaCache.set(serverName, schema);
|
|
1003
|
+
this.discoveredServers.add(serverName);
|
|
1004
|
+
// Persist to disk
|
|
1005
|
+
if (this.schemaStore) {
|
|
1006
|
+
await this.schemaStore.saveToDisk(serverName, schema);
|
|
1007
|
+
}
|
|
1008
|
+
console.log(`[ServerManager] Force refreshed ${serverName}: ${tools.length} tools`);
|
|
1009
|
+
return tools;
|
|
1010
|
+
}
|
|
1011
|
+
catch (error) {
|
|
1012
|
+
console.warn(`[ServerManager] Failed to fetch tools from running server ${serverName}:`, error);
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
// 4. If not running or fetch failed, do full discovery
|
|
1017
|
+
return this.discoverToolSchemas(serverName, config);
|
|
1018
|
+
}
|
|
1019
|
+
/**
|
|
1020
|
+
* Get server status
|
|
1021
|
+
*/
|
|
1022
|
+
getServerStatus(serverName) {
|
|
1023
|
+
return this.state[serverName];
|
|
1024
|
+
}
|
|
1025
|
+
/**
|
|
1026
|
+
* Get all server statuses
|
|
1027
|
+
*/
|
|
1028
|
+
getAllServers() {
|
|
1029
|
+
return { ...this.state };
|
|
1030
|
+
}
|
|
1031
|
+
/**
|
|
1032
|
+
* Start health check for a server
|
|
1033
|
+
*/
|
|
1034
|
+
startHealthCheck(serverName, interval) {
|
|
1035
|
+
const healthCheckInterval = setInterval(async () => {
|
|
1036
|
+
const result = await this.healthCheck(serverName);
|
|
1037
|
+
this.emit("health:check", result);
|
|
1038
|
+
if (!result.healthy && this.state[serverName]) {
|
|
1039
|
+
this.state[serverName].errorCount++;
|
|
1040
|
+
}
|
|
1041
|
+
}, interval);
|
|
1042
|
+
this.healthCheckIntervals.set(serverName, healthCheckInterval);
|
|
1043
|
+
}
|
|
1044
|
+
/**
|
|
1045
|
+
* Perform health check on a server
|
|
1046
|
+
*/
|
|
1047
|
+
async healthCheck(serverName) {
|
|
1048
|
+
const childProcess = this.processes.get(serverName);
|
|
1049
|
+
const state = this.state[serverName];
|
|
1050
|
+
if (!childProcess || !state) {
|
|
1051
|
+
return {
|
|
1052
|
+
serverName,
|
|
1053
|
+
healthy: false,
|
|
1054
|
+
error: "Server not found",
|
|
1055
|
+
timestamp: Date.now(),
|
|
1056
|
+
};
|
|
1057
|
+
}
|
|
1058
|
+
// Simple health check: check if process is still running
|
|
1059
|
+
const healthy = !childProcess.killed;
|
|
1060
|
+
if (state) {
|
|
1061
|
+
state.lastHealthCheck = Date.now();
|
|
1062
|
+
}
|
|
1063
|
+
return {
|
|
1064
|
+
serverName,
|
|
1065
|
+
healthy,
|
|
1066
|
+
timestamp: Date.now(),
|
|
1067
|
+
};
|
|
1068
|
+
}
|
|
1069
|
+
/**
|
|
1070
|
+
* Start health checks for HTTP server
|
|
1071
|
+
*/
|
|
1072
|
+
startHttpHealthCheck(serverName, interval) {
|
|
1073
|
+
const healthCheckInterval = setInterval(async () => {
|
|
1074
|
+
const result = await this.httpHealthCheck(serverName);
|
|
1075
|
+
this.emit("health:check", result);
|
|
1076
|
+
if (!result.healthy && this.state[serverName]) {
|
|
1077
|
+
this.state[serverName].errorCount++;
|
|
1078
|
+
}
|
|
1079
|
+
}, interval);
|
|
1080
|
+
this.healthCheckIntervals.set(serverName, healthCheckInterval);
|
|
1081
|
+
}
|
|
1082
|
+
/**
|
|
1083
|
+
* Perform health check on an HTTP server
|
|
1084
|
+
*/
|
|
1085
|
+
async httpHealthCheck(serverName) {
|
|
1086
|
+
const client = this.httpClients.get(serverName);
|
|
1087
|
+
const state = this.state[serverName];
|
|
1088
|
+
if (!client || !state) {
|
|
1089
|
+
return {
|
|
1090
|
+
serverName,
|
|
1091
|
+
healthy: false,
|
|
1092
|
+
error: "HTTP client not found",
|
|
1093
|
+
timestamp: Date.now(),
|
|
1094
|
+
};
|
|
1095
|
+
}
|
|
1096
|
+
try {
|
|
1097
|
+
// Use HTTP client's built-in health check
|
|
1098
|
+
const healthy = await client.healthCheck();
|
|
1099
|
+
if (state) {
|
|
1100
|
+
state.lastHealthCheck = Date.now();
|
|
1101
|
+
}
|
|
1102
|
+
return {
|
|
1103
|
+
serverName,
|
|
1104
|
+
healthy,
|
|
1105
|
+
timestamp: Date.now(),
|
|
1106
|
+
};
|
|
1107
|
+
}
|
|
1108
|
+
catch (error) {
|
|
1109
|
+
return {
|
|
1110
|
+
serverName,
|
|
1111
|
+
healthy: false,
|
|
1112
|
+
error: error instanceof Error ? error.message : "Unknown error",
|
|
1113
|
+
timestamp: Date.now(),
|
|
1114
|
+
};
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
/**
|
|
1118
|
+
* Handle server errors
|
|
1119
|
+
*/
|
|
1120
|
+
handleServerError(serverName, error) {
|
|
1121
|
+
console.error(`[MetaLink] Server '${serverName}' error:`, error.message);
|
|
1122
|
+
if (this.state[serverName]) {
|
|
1123
|
+
this.state[serverName].status = "crashed";
|
|
1124
|
+
this.state[serverName].errorCount++;
|
|
1125
|
+
}
|
|
1126
|
+
// Track server error in metrics with classification (Phase 4 - v1.4.0)
|
|
1127
|
+
const errorType = globalMetrics.classifyErrorType(error);
|
|
1128
|
+
globalMetrics.recordServerError(serverName, error.message, errorType);
|
|
1129
|
+
this.emit("server:error", { name: serverName, error: error.message });
|
|
1130
|
+
}
|
|
1131
|
+
/**
|
|
1132
|
+
* Handle server exit
|
|
1133
|
+
*/
|
|
1134
|
+
handleServerExit(serverName, code) {
|
|
1135
|
+
this.processes.delete(serverName);
|
|
1136
|
+
this.activeServers.delete(serverName); // v1.1.33: Clean up stale server data to prevent "process killed" errors
|
|
1137
|
+
if (code !== 0) {
|
|
1138
|
+
console.error(`[MetaLink] Server '${serverName}' exited with code ${code}`);
|
|
1139
|
+
}
|
|
1140
|
+
if (this.state[serverName]) {
|
|
1141
|
+
this.state[serverName].status = code === 0 ? "stopped" : "crashed";
|
|
1142
|
+
}
|
|
1143
|
+
this.emit("server:exited", { name: serverName, code });
|
|
1144
|
+
// Trigger auto-restart if server crashed (non-zero exit code)
|
|
1145
|
+
if (code !== 0) {
|
|
1146
|
+
this.scheduleServerRestart(serverName);
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
/**
|
|
1150
|
+
* Get tools for a server
|
|
1151
|
+
* v1.3.60: Check both active servers AND schema cache (for stopped servers)
|
|
1152
|
+
* v1.3.71: Also check disk cache for tools that persist across daemon restarts
|
|
1153
|
+
*/
|
|
1154
|
+
getServerTools(serverName) {
|
|
1155
|
+
// Phase 7: Delegate to SchemaCacheManager first
|
|
1156
|
+
const cachedTools = this.schemaCacheManager.getCachedTools(serverName);
|
|
1157
|
+
if (cachedTools && cachedTools.length > 0) {
|
|
1158
|
+
return cachedTools;
|
|
1159
|
+
}
|
|
1160
|
+
// Fallback to active servers (running)
|
|
1161
|
+
const serverData = this.activeServers.get(serverName);
|
|
1162
|
+
if (serverData?.tools && serverData.tools.length > 0) {
|
|
1163
|
+
return serverData.tools;
|
|
1164
|
+
}
|
|
1165
|
+
// Legacy fallback to local caches (will be removed in Phase 8)
|
|
1166
|
+
const cached = this.toolSchemaCache.get(serverName);
|
|
1167
|
+
if (cached?.tools && cached.tools.length > 0) {
|
|
1168
|
+
return cached.tools;
|
|
1169
|
+
}
|
|
1170
|
+
// Fallback to disk cache via SchemaStore
|
|
1171
|
+
if (this.schemaStore) {
|
|
1172
|
+
const diskCached = this.schemaStore.getSyncFromDisk(serverName);
|
|
1173
|
+
if (diskCached && diskCached.length > 0) {
|
|
1174
|
+
// Also populate memory cache for faster subsequent lookups
|
|
1175
|
+
this.toolSchemaCache.set(serverName, {
|
|
1176
|
+
tools: diskCached,
|
|
1177
|
+
timestamp: Date.now(),
|
|
1178
|
+
});
|
|
1179
|
+
return diskCached;
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
return [];
|
|
1183
|
+
}
|
|
1184
|
+
/**
|
|
1185
|
+
* Set tools for a server
|
|
1186
|
+
* v1.3.62: Persist schemas to disk whenever tools are updated
|
|
1187
|
+
*/
|
|
1188
|
+
setServerTools(serverName, tools, process) {
|
|
1189
|
+
let serverData = this.activeServers.get(serverName);
|
|
1190
|
+
if (!serverData) {
|
|
1191
|
+
serverData = {
|
|
1192
|
+
process: process || this.processes.get(serverName),
|
|
1193
|
+
tools: [],
|
|
1194
|
+
toolsReady: false,
|
|
1195
|
+
};
|
|
1196
|
+
this.activeServers.set(serverName, serverData);
|
|
1197
|
+
}
|
|
1198
|
+
else if (process) {
|
|
1199
|
+
// Update process if provided
|
|
1200
|
+
serverData.process = process;
|
|
1201
|
+
}
|
|
1202
|
+
else if (!serverData.process) {
|
|
1203
|
+
// Try to retrieve from processes map if not already set
|
|
1204
|
+
serverData.process = this.processes.get(serverName);
|
|
1205
|
+
}
|
|
1206
|
+
// Phase 2: Enrich tools with safety annotations
|
|
1207
|
+
const enrichedTools = this.enrichToolsWithAnnotations(serverName, tools);
|
|
1208
|
+
serverData.tools = enrichedTools;
|
|
1209
|
+
serverData.toolsReady = true;
|
|
1210
|
+
// v1.3.62: Persist schemas to disk (global cache across versions)
|
|
1211
|
+
// This ensures schemas are saved regardless of how server started:
|
|
1212
|
+
// - Base server initialization
|
|
1213
|
+
// - Direct tool execution (auto-start)
|
|
1214
|
+
// - search_tools (when server already running)
|
|
1215
|
+
// - discover_tools (explicit discovery)
|
|
1216
|
+
const schema = { tools: enrichedTools, timestamp: Date.now() };
|
|
1217
|
+
// Phase 7: Delegate to SchemaCacheManager
|
|
1218
|
+
this.schemaCacheManager.setCachedTools(serverName, enrichedTools, "discovery");
|
|
1219
|
+
// Legacy: Keep local caches for backward compatibility (will be removed in Phase 8)
|
|
1220
|
+
this.toolSchemaCache.set(serverName, schema);
|
|
1221
|
+
if (this.schemaStore) {
|
|
1222
|
+
this.schemaStore.saveToDisk(serverName, schema).catch((error) => {
|
|
1223
|
+
console.warn(`[ServerManager] Failed to persist schema for ${serverName}:`, error);
|
|
1224
|
+
});
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
/**
|
|
1228
|
+
* Get all active servers with their tools
|
|
1229
|
+
*/
|
|
1230
|
+
getActiveServers() {
|
|
1231
|
+
return new Map(this.activeServers);
|
|
1232
|
+
}
|
|
1233
|
+
/**
|
|
1234
|
+
* Get the process for a specific server
|
|
1235
|
+
* Phase 5: Delegate to ProcessManager first, fallback to local map
|
|
1236
|
+
*/
|
|
1237
|
+
getProcess(serverName) {
|
|
1238
|
+
// Try ProcessManager first (Phase 5 delegation)
|
|
1239
|
+
const managerProcess = this.processManager.getProcess(serverName);
|
|
1240
|
+
if (managerProcess) {
|
|
1241
|
+
return managerProcess;
|
|
1242
|
+
}
|
|
1243
|
+
// Legacy fallback to local map
|
|
1244
|
+
return this.processes.get(serverName);
|
|
1245
|
+
}
|
|
1246
|
+
/**
|
|
1247
|
+
* Check if a server is currently active (running and tools loaded)
|
|
1248
|
+
*/
|
|
1249
|
+
isServerActive(serverName) {
|
|
1250
|
+
// v1.1.36: Check process.killed flag to ensure dead processes trigger auto-restart
|
|
1251
|
+
const process = this.processes.get(serverName);
|
|
1252
|
+
const hasValidProcess = process && !process.killed;
|
|
1253
|
+
const hasHttpClient = this.httpClients.has(serverName);
|
|
1254
|
+
const hasTools = this.activeServers.has(serverName);
|
|
1255
|
+
return (hasValidProcess || hasHttpClient) && hasTools;
|
|
1256
|
+
}
|
|
1257
|
+
/**
|
|
1258
|
+
* Wait for MCP initialization handshake to complete (without fetching tools)
|
|
1259
|
+
*/
|
|
1260
|
+
async waitForInitialization(childProcess, serverName) {
|
|
1261
|
+
return new Promise((resolve, reject) => {
|
|
1262
|
+
const debugMode = process.env.METALINK_DEBUG === "true";
|
|
1263
|
+
let settled = false; // Guard against double-rejection
|
|
1264
|
+
const timeout = setTimeout(() => {
|
|
1265
|
+
if (settled)
|
|
1266
|
+
return; // Already resolved/rejected, ignore timeout
|
|
1267
|
+
settled = true;
|
|
1268
|
+
const serverInfo = serverName ? ` for ${serverName}` : "";
|
|
1269
|
+
console.error(`[MetaLink] Server initialization timeout after ${this.timeouts.serverInit}ms${serverInfo}`);
|
|
1270
|
+
if (debugMode) {
|
|
1271
|
+
console.log(`[DIAG] waitForInitialization${serverInfo}: TIMEOUT (no initialize response in ${this.timeouts.serverInit}ms)`);
|
|
1272
|
+
}
|
|
1273
|
+
childProcess.stdout?.removeListener("data", onData);
|
|
1274
|
+
reject(new Error(`Server initialization timeout after ${this.timeouts.serverInit}ms${serverInfo}`));
|
|
1275
|
+
}, this.timeouts.serverInit);
|
|
1276
|
+
let buffer = "";
|
|
1277
|
+
let initialized = false;
|
|
1278
|
+
const handleLines = () => {
|
|
1279
|
+
const lines = buffer.split("\n");
|
|
1280
|
+
for (let i = 0; i < lines.length - 1; i++) {
|
|
1281
|
+
const line = lines[i].trim();
|
|
1282
|
+
if (!line || !line.startsWith("{"))
|
|
1283
|
+
continue;
|
|
1284
|
+
let response;
|
|
1285
|
+
try {
|
|
1286
|
+
response = JSON.parse(line);
|
|
1287
|
+
}
|
|
1288
|
+
catch (parseError) {
|
|
1289
|
+
continue;
|
|
1290
|
+
}
|
|
1291
|
+
// Wait for initialize response only
|
|
1292
|
+
if (!initialized &&
|
|
1293
|
+
(response.result || response.error) &&
|
|
1294
|
+
response.id === 0) {
|
|
1295
|
+
if (settled)
|
|
1296
|
+
return; // Already timed out or settled
|
|
1297
|
+
settled = true;
|
|
1298
|
+
if (debugMode) {
|
|
1299
|
+
console.log(`[DIAG] waitForInitialization: Detected initialize response (id=0), success=${!!response.result}`);
|
|
1300
|
+
}
|
|
1301
|
+
clearTimeout(timeout);
|
|
1302
|
+
childProcess.stdout?.removeListener("data", onData);
|
|
1303
|
+
if (response.error) {
|
|
1304
|
+
reject(new Error(`Server initialization failed: ${JSON.stringify(response.error)}`));
|
|
1305
|
+
}
|
|
1306
|
+
else {
|
|
1307
|
+
console.log(`[MetaLink] Server ${serverName || "unknown"} initialized successfully`);
|
|
1308
|
+
// Send initialized notification (MCP spec requirement)
|
|
1309
|
+
const initializedNotif = JSON.stringify({
|
|
1310
|
+
jsonrpc: "2.0",
|
|
1311
|
+
method: "notifications/initialized",
|
|
1312
|
+
params: {},
|
|
1313
|
+
});
|
|
1314
|
+
childProcess.stdin?.write(initializedNotif + "\n");
|
|
1315
|
+
resolve();
|
|
1316
|
+
}
|
|
1317
|
+
return;
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
buffer = lines[lines.length - 1];
|
|
1321
|
+
};
|
|
1322
|
+
const onData = (data) => {
|
|
1323
|
+
buffer += data.toString();
|
|
1324
|
+
handleLines();
|
|
1325
|
+
};
|
|
1326
|
+
childProcess.stdout?.on("data", onData);
|
|
1327
|
+
// Send initialize request
|
|
1328
|
+
const initReq = JSON.stringify({
|
|
1329
|
+
jsonrpc: "2.0",
|
|
1330
|
+
id: 0,
|
|
1331
|
+
method: "initialize",
|
|
1332
|
+
params: {
|
|
1333
|
+
protocolVersion: "2025-06-18",
|
|
1334
|
+
capabilities: { roots: { listChanged: true } },
|
|
1335
|
+
clientInfo: { name: "metalink", version: "1.3.49" },
|
|
1336
|
+
},
|
|
1337
|
+
});
|
|
1338
|
+
childProcess.stdin?.write(initReq + "\n");
|
|
1339
|
+
});
|
|
1340
|
+
}
|
|
1341
|
+
/**
|
|
1342
|
+
* Fetch tools from a spawned MCP server via JSON-RPC (PUBLIC)
|
|
1343
|
+
*/
|
|
1344
|
+
async fetchToolsFromProcess(childProcess, serverName) {
|
|
1345
|
+
return new Promise((resolve, reject) => {
|
|
1346
|
+
const debugMode = process.env.METALINK_DEBUG === "true";
|
|
1347
|
+
let settled = false; // CRITICAL FIX: Guard against double-rejection
|
|
1348
|
+
// Capture stderr for debugging (especially for npm-based servers)
|
|
1349
|
+
let stderrOutput = "";
|
|
1350
|
+
if (childProcess.stderr) {
|
|
1351
|
+
childProcess.stderr.on("data", (chunk) => {
|
|
1352
|
+
stderrOutput += chunk.toString();
|
|
1353
|
+
});
|
|
1354
|
+
}
|
|
1355
|
+
// v1.1.27: Track SIGKILL timeout to prevent race condition
|
|
1356
|
+
let killTimeout = null;
|
|
1357
|
+
// Timeout: 60s for npm-based servers (download + install + startup)
|
|
1358
|
+
const timeout = setTimeout(() => {
|
|
1359
|
+
if (settled)
|
|
1360
|
+
return; // CRITICAL FIX: Already resolved/rejected, ignore timeout
|
|
1361
|
+
settled = true;
|
|
1362
|
+
const serverInfo = serverName ? ` for ${serverName}` : "";
|
|
1363
|
+
console.error(`[MetaLink] Server initialization timeout after 60s${serverInfo}`);
|
|
1364
|
+
if (debugMode) {
|
|
1365
|
+
console.log(`[DIAG] fetchToolsFromProcess${serverInfo}: TIMEOUT (no tools/list response in 60s)`);
|
|
1366
|
+
}
|
|
1367
|
+
if (stderrOutput) {
|
|
1368
|
+
console.error(`[MetaLink] Server stderr${serverInfo}:\n${stderrOutput}`);
|
|
1369
|
+
}
|
|
1370
|
+
try {
|
|
1371
|
+
childProcess.kill("SIGTERM");
|
|
1372
|
+
// v1.1.27: Store SIGKILL timeout reference
|
|
1373
|
+
killTimeout = setTimeout(() => {
|
|
1374
|
+
if (!childProcess.killed) {
|
|
1375
|
+
childProcess.kill("SIGKILL");
|
|
1376
|
+
}
|
|
1377
|
+
}, 2000);
|
|
1378
|
+
}
|
|
1379
|
+
catch (killError) {
|
|
1380
|
+
console.error(`[MetaLink] Error killing timed-out process: ${killError instanceof Error ? killError.message : String(killError)}`);
|
|
1381
|
+
}
|
|
1382
|
+
reject(new Error(`Server initialization timeout after ${this.timeouts.toolFetch}ms${serverInfo}`));
|
|
1383
|
+
}, this.timeouts.toolFetch);
|
|
1384
|
+
// v1.1.27: Helper to clear all timeouts
|
|
1385
|
+
const clearAllTimeouts = () => {
|
|
1386
|
+
clearTimeout(timeout);
|
|
1387
|
+
if (killTimeout) {
|
|
1388
|
+
clearTimeout(killTimeout);
|
|
1389
|
+
killTimeout = null;
|
|
1390
|
+
}
|
|
1391
|
+
};
|
|
1392
|
+
let buffer = "";
|
|
1393
|
+
let initialized = false;
|
|
1394
|
+
const handleLines = () => {
|
|
1395
|
+
const lines = buffer.split("\n");
|
|
1396
|
+
for (let i = 0; i < lines.length - 1; i++) {
|
|
1397
|
+
const line = lines[i].trim();
|
|
1398
|
+
if (!line || !line.startsWith("{"))
|
|
1399
|
+
continue;
|
|
1400
|
+
let response;
|
|
1401
|
+
try {
|
|
1402
|
+
response = JSON.parse(line);
|
|
1403
|
+
}
|
|
1404
|
+
catch (parseError) {
|
|
1405
|
+
console.error(`[MetaLink] Failed to parse JSON: ${parseError instanceof Error ? parseError.message : String(parseError)}`);
|
|
1406
|
+
continue;
|
|
1407
|
+
}
|
|
1408
|
+
// Wait for initialize response first
|
|
1409
|
+
if (!initialized &&
|
|
1410
|
+
(response.result || response.error) &&
|
|
1411
|
+
response.id === 0) {
|
|
1412
|
+
if (response.error) {
|
|
1413
|
+
if (settled)
|
|
1414
|
+
return; // CRITICAL FIX: Already resolved/rejected
|
|
1415
|
+
settled = true;
|
|
1416
|
+
clearAllTimeouts(); // v1.1.27: Use helper to clear all timeouts
|
|
1417
|
+
childProcess.stdout?.removeListener("data", onData);
|
|
1418
|
+
reject(new Error(`Server initialization failed: ${JSON.stringify(response.error)}`));
|
|
1419
|
+
return;
|
|
1420
|
+
}
|
|
1421
|
+
else {
|
|
1422
|
+
initialized = true;
|
|
1423
|
+
console.error(`[MetaLink] Server initialized successfully, requesting tools...`);
|
|
1424
|
+
// Send initialized notification (MCP spec requirement)
|
|
1425
|
+
const initializedNotif = JSON.stringify({
|
|
1426
|
+
jsonrpc: "2.0",
|
|
1427
|
+
method: "notifications/initialized",
|
|
1428
|
+
params: {},
|
|
1429
|
+
});
|
|
1430
|
+
childProcess.stdin?.write(initializedNotif + "\n");
|
|
1431
|
+
// Request tools/list
|
|
1432
|
+
const listReq = JSON.stringify({
|
|
1433
|
+
jsonrpc: "2.0",
|
|
1434
|
+
id: 1,
|
|
1435
|
+
method: "tools/list",
|
|
1436
|
+
params: {},
|
|
1437
|
+
});
|
|
1438
|
+
childProcess.stdin?.write(listReq + "\n");
|
|
1439
|
+
continue;
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
// Handle tools/list response
|
|
1443
|
+
if (response.id === 1) {
|
|
1444
|
+
if (debugMode) {
|
|
1445
|
+
console.log(`[DIAG] fetchToolsFromProcess: Detected tools/list response (id=1), has_error=${!!response.error}, has_result=${!!response.result}`);
|
|
1446
|
+
}
|
|
1447
|
+
if (response.error) {
|
|
1448
|
+
if (settled)
|
|
1449
|
+
return; // CRITICAL FIX: Already resolved/rejected
|
|
1450
|
+
settled = true;
|
|
1451
|
+
clearAllTimeouts(); // v1.1.27: Use helper to clear all timeouts
|
|
1452
|
+
childProcess.stdout?.removeListener("data", onData);
|
|
1453
|
+
reject(new Error(`Failed to get tools list: ${JSON.stringify(response.error)}`));
|
|
1454
|
+
return;
|
|
1455
|
+
}
|
|
1456
|
+
const resultTools = response.result
|
|
1457
|
+
?.tools;
|
|
1458
|
+
if (resultTools && Array.isArray(resultTools)) {
|
|
1459
|
+
if (settled)
|
|
1460
|
+
return; // CRITICAL FIX: Already resolved/rejected
|
|
1461
|
+
settled = true;
|
|
1462
|
+
clearAllTimeouts(); // v1.1.27: Use helper to clear all timeouts
|
|
1463
|
+
childProcess.stdout?.removeListener("data", onData);
|
|
1464
|
+
console.error(`[MetaLink] Successfully received ${resultTools.length} tools`);
|
|
1465
|
+
if (debugMode) {
|
|
1466
|
+
console.log(`[DIAG] fetchToolsFromProcess: Returning ${resultTools.length} tools to caller`);
|
|
1467
|
+
}
|
|
1468
|
+
// v1.3.58: Health check - warn if server returns 0 tools
|
|
1469
|
+
if (resultTools.length === 0) {
|
|
1470
|
+
console.error(`[MetaLink] ⚠️ WARNING: Server returned 0 tools!`);
|
|
1471
|
+
console.error(`[MetaLink] This may indicate:`);
|
|
1472
|
+
console.error(`[MetaLink] • Server still initializing (may need more time)`);
|
|
1473
|
+
console.error(`[MetaLink] • Backend service connection failed`);
|
|
1474
|
+
console.error(`[MetaLink] • Environment variables (API keys, URLs) incorrect`);
|
|
1475
|
+
console.error(`[MetaLink] • Server does not implement tools/list correctly`);
|
|
1476
|
+
console.error(`[MetaLink] Debug with: metalink server debug <server-name>`);
|
|
1477
|
+
}
|
|
1478
|
+
// v1.4.0: Schema validation
|
|
1479
|
+
this.validateToolSchemas(resultTools, serverName);
|
|
1480
|
+
resolve(resultTools);
|
|
1481
|
+
return;
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
buffer = lines[lines.length - 1];
|
|
1486
|
+
};
|
|
1487
|
+
const onData = (data) => {
|
|
1488
|
+
buffer += data.toString();
|
|
1489
|
+
handleLines();
|
|
1490
|
+
};
|
|
1491
|
+
childProcess.stdout?.on("data", onData);
|
|
1492
|
+
childProcess.stderr?.on("data", (data) => {
|
|
1493
|
+
const msg = data.toString().trim();
|
|
1494
|
+
if (msg)
|
|
1495
|
+
console.error(`[MetaLink] [${childProcess.pid}] stderr: ${msg}`);
|
|
1496
|
+
});
|
|
1497
|
+
// Send initialize request
|
|
1498
|
+
const initReq = JSON.stringify({
|
|
1499
|
+
jsonrpc: "2.0",
|
|
1500
|
+
id: 0,
|
|
1501
|
+
method: "initialize",
|
|
1502
|
+
params: {
|
|
1503
|
+
protocolVersion: "2025-06-18",
|
|
1504
|
+
capabilities: { roots: { listChanged: true } },
|
|
1505
|
+
clientInfo: { name: "metalink", version },
|
|
1506
|
+
},
|
|
1507
|
+
});
|
|
1508
|
+
childProcess.stdin?.write(initReq + "\n");
|
|
1509
|
+
});
|
|
1510
|
+
}
|
|
1511
|
+
/**
|
|
1512
|
+
* Initialize base servers before daemon startup
|
|
1513
|
+
* Starts all configured base servers unconditionally for immediate availability
|
|
1514
|
+
*/
|
|
1515
|
+
async initializeBaseServers(configLoader) {
|
|
1516
|
+
if (this.baseServersEnabled) {
|
|
1517
|
+
console.log("[MetaLink] Base servers already initialized");
|
|
1518
|
+
return;
|
|
1519
|
+
}
|
|
1520
|
+
if (this.baseServers.length === 0) {
|
|
1521
|
+
console.log("[MetaLink] No base servers configured, skipping initialization");
|
|
1522
|
+
this.baseServersEnabled = true;
|
|
1523
|
+
return;
|
|
1524
|
+
}
|
|
1525
|
+
console.log(`[MetaLink] Initializing base servers: ${this.baseServers.join(", ")}`);
|
|
1526
|
+
// Get all available servers (config + registry)
|
|
1527
|
+
const allServers = configLoader.getAllServers();
|
|
1528
|
+
const serverConfigs = new Map(allServers.map((s) => [s.name, s]));
|
|
1529
|
+
// Start base servers in parallel for faster startup (80% improvement: 5-15s → <2s)
|
|
1530
|
+
const startResults = await Promise.allSettled(this.baseServers.map(async (baseServerName) => {
|
|
1531
|
+
const config = serverConfigs.get(baseServerName);
|
|
1532
|
+
if (!config) {
|
|
1533
|
+
console.warn(`[MetaLink] Base server '${baseServerName}' not found in available servers, skipping`);
|
|
1534
|
+
return { server: baseServerName, status: "skipped" };
|
|
1535
|
+
}
|
|
1536
|
+
try {
|
|
1537
|
+
console.log(`[MetaLink] Starting base server: ${baseServerName}`);
|
|
1538
|
+
await this.startServer(config);
|
|
1539
|
+
// Fetch tools from the started server
|
|
1540
|
+
const serverProcess = this.processes.get(baseServerName);
|
|
1541
|
+
if (serverProcess) {
|
|
1542
|
+
const tools = await this.fetchToolsFromProcess(serverProcess, baseServerName);
|
|
1543
|
+
this.setServerTools(baseServerName, tools);
|
|
1544
|
+
console.log(`[MetaLink] Base server '${baseServerName}' ready with ${tools.length} tools`);
|
|
1545
|
+
return {
|
|
1546
|
+
server: baseServerName,
|
|
1547
|
+
status: "success",
|
|
1548
|
+
toolCount: tools.length,
|
|
1549
|
+
};
|
|
1550
|
+
}
|
|
1551
|
+
return { server: baseServerName, status: "no-process" };
|
|
1552
|
+
}
|
|
1553
|
+
catch (error) {
|
|
1554
|
+
console.error(`[MetaLink] Failed to start base server '${baseServerName}': ${error instanceof Error ? error.message : String(error)}`);
|
|
1555
|
+
return {
|
|
1556
|
+
server: baseServerName,
|
|
1557
|
+
status: "failed",
|
|
1558
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1559
|
+
};
|
|
1560
|
+
}
|
|
1561
|
+
}));
|
|
1562
|
+
// Count successful starts
|
|
1563
|
+
const startedCount = startResults.filter((r) => r.status === "fulfilled" && r.value?.status === "success").length;
|
|
1564
|
+
this.baseServersEnabled = true;
|
|
1565
|
+
console.log(`[MetaLink] Base servers initialization complete (${startedCount}/${this.baseServers.length} started)`);
|
|
1566
|
+
}
|
|
1567
|
+
/**
|
|
1568
|
+
* Check if base servers are enabled
|
|
1569
|
+
*/
|
|
1570
|
+
areBaseServersEnabled() {
|
|
1571
|
+
return this.baseServersEnabled;
|
|
1572
|
+
}
|
|
1573
|
+
/**
|
|
1574
|
+
* Background population of missing schemas (v1.3.62)
|
|
1575
|
+
* Compares registry servers with cached schemas and populates missing ones
|
|
1576
|
+
* Runs in background, one server at a time, non-blocking
|
|
1577
|
+
*/
|
|
1578
|
+
async populateMissingSchemas(configLoader) {
|
|
1579
|
+
console.log("[SchemaPopulation] Starting background schema population...");
|
|
1580
|
+
// Get all servers from registry
|
|
1581
|
+
const allServers = configLoader.getAllServers();
|
|
1582
|
+
const serverNames = Array.from(allServers.keys());
|
|
1583
|
+
// Find servers missing from cache
|
|
1584
|
+
const missingServers = serverNames.filter((name) => !this.toolSchemaCache.has(name));
|
|
1585
|
+
if (missingServers.length === 0) {
|
|
1586
|
+
console.log("[SchemaPopulation] All servers already have cached schemas");
|
|
1587
|
+
return;
|
|
1588
|
+
}
|
|
1589
|
+
console.log(`[SchemaPopulation] Found ${missingServers.length} servers with missing schemas:`, missingServers);
|
|
1590
|
+
// Start servers one by one in background
|
|
1591
|
+
let populated = 0;
|
|
1592
|
+
for (const serverName of missingServers) {
|
|
1593
|
+
try {
|
|
1594
|
+
const config = allServers.get(serverName);
|
|
1595
|
+
if (!config)
|
|
1596
|
+
continue;
|
|
1597
|
+
console.log(`[SchemaPopulation] [${populated + 1}/${missingServers.length}] Populating schema for ${serverName}...`);
|
|
1598
|
+
// Start server and fetch tools
|
|
1599
|
+
await this.ensureServerStarted(serverName, config);
|
|
1600
|
+
const tools = this.getServerTools(serverName);
|
|
1601
|
+
if (tools.length > 0) {
|
|
1602
|
+
console.log(`[SchemaPopulation] ✅ Cached ${tools.length} tools from ${serverName}`);
|
|
1603
|
+
populated++;
|
|
1604
|
+
// v1.1.33: Don't kill servers after caching - improves reliability
|
|
1605
|
+
// Previously would stop server here to save resources, but this caused
|
|
1606
|
+
// "process has been killed" errors when tools were called
|
|
1607
|
+
// this.stopServer(serverName);
|
|
1608
|
+
// console.log(`[SchemaPopulation] Stopped ${serverName} (schema cached)`);
|
|
1609
|
+
}
|
|
1610
|
+
else {
|
|
1611
|
+
console.warn(`[SchemaPopulation] ⚠️ ${serverName} returned 0 tools`);
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
catch (error) {
|
|
1615
|
+
console.error(`[SchemaPopulation] Failed to populate ${serverName}:`, error instanceof Error ? error.message : String(error));
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
console.log(`[SchemaPopulation] Complete: ${populated}/${missingServers.length} schemas populated`);
|
|
1619
|
+
}
|
|
1620
|
+
/**
|
|
1621
|
+
* Get schema for a specific tool
|
|
1622
|
+
* v1.1.47: Add disk fallback to match getServerTools() behavior
|
|
1623
|
+
* This fixes the bug where CLI shows tools but MCP can't find them
|
|
1624
|
+
*/
|
|
1625
|
+
getToolSchema(serverName, toolName) {
|
|
1626
|
+
// First check active servers (running)
|
|
1627
|
+
const serverData = this.activeServers.get(serverName);
|
|
1628
|
+
if (serverData?.tools) {
|
|
1629
|
+
const tool = serverData.tools.find((t) => t.name === toolName);
|
|
1630
|
+
if (tool)
|
|
1631
|
+
return tool;
|
|
1632
|
+
}
|
|
1633
|
+
// Fallback to schema cache (persistent cache for stopped servers)
|
|
1634
|
+
const cached = this.toolSchemaCache.get(serverName);
|
|
1635
|
+
if (cached?.tools) {
|
|
1636
|
+
const tool = cached.tools.find((t) => t.name === toolName);
|
|
1637
|
+
if (tool)
|
|
1638
|
+
return tool;
|
|
1639
|
+
}
|
|
1640
|
+
// v1.1.47: Fallback to disk cache (persists across daemon restarts)
|
|
1641
|
+
// This ensures getToolSchema() has same fallback chain as getServerTools()
|
|
1642
|
+
if (this.schemaStore) {
|
|
1643
|
+
const diskCached = this.schemaStore.getSyncFromDisk(serverName);
|
|
1644
|
+
if (diskCached && diskCached.length > 0) {
|
|
1645
|
+
// Populate memory cache for faster subsequent lookups
|
|
1646
|
+
this.toolSchemaCache.set(serverName, {
|
|
1647
|
+
tools: diskCached,
|
|
1648
|
+
timestamp: Date.now(),
|
|
1649
|
+
});
|
|
1650
|
+
const tool = diskCached.find((t) => t.name === toolName);
|
|
1651
|
+
if (tool)
|
|
1652
|
+
return tool;
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
return null;
|
|
1656
|
+
}
|
|
1657
|
+
/**
|
|
1658
|
+
* Detect if arguments are missing when tool requires them
|
|
1659
|
+
*/
|
|
1660
|
+
detectMissingArguments(args, toolSchema) {
|
|
1661
|
+
if (!args || typeof args !== "object") {
|
|
1662
|
+
return false;
|
|
1663
|
+
}
|
|
1664
|
+
const hasArguments = "arguments" in args;
|
|
1665
|
+
const argumentsValue = args.arguments;
|
|
1666
|
+
const inputSchema = toolSchema?.inputSchema;
|
|
1667
|
+
const requiresArguments = inputSchema?.required && inputSchema.required.length > 0;
|
|
1668
|
+
if (!hasArguments && requiresArguments && inputSchema?.required) {
|
|
1669
|
+
console.error(`[MetaLink] Missing 'arguments' parameter. Tool: ${args.server_name || "unknown"}-${args.tool_name || "unknown"}, ` +
|
|
1670
|
+
`Required params: ${inputSchema.required.join(", ")}`);
|
|
1671
|
+
return true;
|
|
1672
|
+
}
|
|
1673
|
+
if (hasArguments &&
|
|
1674
|
+
(argumentsValue === null || argumentsValue === undefined) &&
|
|
1675
|
+
requiresArguments &&
|
|
1676
|
+
inputSchema?.required) {
|
|
1677
|
+
console.error(`[MetaLink] 'arguments' parameter is null/undefined. Tool: ${args.server_name || "unknown"}-${args.tool_name || "unknown"}, ` +
|
|
1678
|
+
`Required params: ${inputSchema.required.join(", ")}`);
|
|
1679
|
+
return true;
|
|
1680
|
+
}
|
|
1681
|
+
return false;
|
|
1682
|
+
}
|
|
1683
|
+
/**
|
|
1684
|
+
* Detect and fix Raycast's malformed argument format
|
|
1685
|
+
* Raycast sometimes wraps tool_name and server_name inside the arguments object
|
|
1686
|
+
*/
|
|
1687
|
+
detectAndFixRaycastFormat(args) {
|
|
1688
|
+
if (!args || typeof args !== "object" || Array.isArray(args)) {
|
|
1689
|
+
return args;
|
|
1690
|
+
}
|
|
1691
|
+
// Check if args.arguments exists and contains tool_name or server_name (malformed)
|
|
1692
|
+
const argObj = args.arguments;
|
|
1693
|
+
if (argObj && typeof argObj === "object" && !Array.isArray(argObj)) {
|
|
1694
|
+
const hasToolName = "tool_name" in argObj;
|
|
1695
|
+
const hasServerName = "server_name" in argObj;
|
|
1696
|
+
if (hasToolName || hasServerName) {
|
|
1697
|
+
// This is Raycast's malformed format - fix it
|
|
1698
|
+
const toolName = argObj.tool_name;
|
|
1699
|
+
const serverName = argObj.server_name;
|
|
1700
|
+
// Extract tool parameters (anything that's not tool_name or server_name)
|
|
1701
|
+
const toolParams = {};
|
|
1702
|
+
for (const [key, value] of Object.entries(argObj)) {
|
|
1703
|
+
if (key !== "tool_name" && key !== "server_name") {
|
|
1704
|
+
toolParams[key] = value;
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
// Determine final arguments - unwrap if toolParams contains nested 'arguments'
|
|
1708
|
+
let finalArguments;
|
|
1709
|
+
if ("arguments" in toolParams &&
|
|
1710
|
+
typeof toolParams.arguments === "object") {
|
|
1711
|
+
// Raycast sent nested arguments - unwrap it
|
|
1712
|
+
finalArguments = toolParams.arguments;
|
|
1713
|
+
}
|
|
1714
|
+
else if (Object.keys(toolParams).length > 0) {
|
|
1715
|
+
// Tool params at this level - use them directly
|
|
1716
|
+
finalArguments = toolParams;
|
|
1717
|
+
}
|
|
1718
|
+
else {
|
|
1719
|
+
// No params found - use empty object
|
|
1720
|
+
finalArguments = {};
|
|
1721
|
+
}
|
|
1722
|
+
// Reconstruct correct structure
|
|
1723
|
+
const fixedArgs = {
|
|
1724
|
+
...args,
|
|
1725
|
+
server_name: serverName || args.server_name,
|
|
1726
|
+
tool_name: toolName || args.tool_name,
|
|
1727
|
+
arguments: finalArguments,
|
|
1728
|
+
};
|
|
1729
|
+
console.error(`[MetaLink] [Raycast Workaround] Fixed malformed argument format for ${fixedArgs.server_name}-${fixedArgs.tool_name}` +
|
|
1730
|
+
` | Unwrapped nested arguments: ${Object.keys(finalArguments).length} params`);
|
|
1731
|
+
if (Object.keys(finalArguments).length === 0) {
|
|
1732
|
+
console.error(`[MetaLink] [Raycast Issue] WARNING: No tool parameters found after fixing malformed format. ` +
|
|
1733
|
+
`Tool: ${fixedArgs.server_name}-${fixedArgs.tool_name} | ` +
|
|
1734
|
+
`This may indicate Raycast failed to populate arguments from describe_tool response.`);
|
|
1735
|
+
}
|
|
1736
|
+
return fixedArgs;
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
// Case B: Check if tool params are at TOP level instead of in arguments
|
|
1740
|
+
// This happens when Raycast passes: {"server_name": "X", "tool_name": "Y", "cql": "..."}
|
|
1741
|
+
// instead of: {"server_name": "X", "tool_name": "Y", "arguments": {"cql": "..."}}
|
|
1742
|
+
if (!args.arguments) {
|
|
1743
|
+
// Extract tool parameters (anything that's not server_name, tool_name, or arguments)
|
|
1744
|
+
const topLevelParams = {};
|
|
1745
|
+
for (const [key, value] of Object.entries(args)) {
|
|
1746
|
+
if (key !== "tool_name" &&
|
|
1747
|
+
key !== "server_name" &&
|
|
1748
|
+
key !== "arguments") {
|
|
1749
|
+
topLevelParams[key] = value;
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
// If we found top-level params, wrap them in arguments
|
|
1753
|
+
if (Object.keys(topLevelParams).length > 0) {
|
|
1754
|
+
console.error(`[MetaLink] [Raycast Workaround] Fixed flat argument format for ${args.server_name}-${args.tool_name}. ` +
|
|
1755
|
+
`Moved top-level params to arguments: ${Object.keys(topLevelParams).join(", ")}`);
|
|
1756
|
+
return {
|
|
1757
|
+
server_name: args.server_name,
|
|
1758
|
+
tool_name: args.tool_name,
|
|
1759
|
+
arguments: topLevelParams,
|
|
1760
|
+
};
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
return args;
|
|
1764
|
+
}
|
|
1765
|
+
/**
|
|
1766
|
+
* Phase 3: Set up response router for a server
|
|
1767
|
+
* Listens to stdout, parses JSON-RPC responses, and matches them to pending requests
|
|
1768
|
+
*/
|
|
1769
|
+
setupServerResponseRouter(serverName) {
|
|
1770
|
+
const serverData = this.activeServers.get(serverName);
|
|
1771
|
+
if (!serverData || !serverData.process) {
|
|
1772
|
+
return; // No process to set up router for
|
|
1773
|
+
}
|
|
1774
|
+
// Check if router already exists - but verify it's for the CURRENT process
|
|
1775
|
+
// After server restart, old router points to dead process and must be reset
|
|
1776
|
+
if (this.stdoutListeners.has(serverName)) {
|
|
1777
|
+
const currentPid = serverData.process.pid;
|
|
1778
|
+
const storedPid = this.stdoutListeners.get(serverName)?.processPid;
|
|
1779
|
+
if (storedPid === currentPid) {
|
|
1780
|
+
console.log(`[MetaLink] Response router already exists for ${serverName} (pid=${currentPid})`);
|
|
1781
|
+
return;
|
|
1782
|
+
}
|
|
1783
|
+
// Process changed - clean up stale router and set up fresh one
|
|
1784
|
+
console.log(`[MetaLink] Response router stale for ${serverName} (old pid=${storedPid}, new pid=${currentPid}), resetting...`);
|
|
1785
|
+
this.cleanupServerResponseRouter(serverName);
|
|
1786
|
+
}
|
|
1787
|
+
// Initialize pending requests map for this server
|
|
1788
|
+
if (!this.pendingRequests.has(serverName)) {
|
|
1789
|
+
this.pendingRequests.set(serverName, new Map());
|
|
1790
|
+
}
|
|
1791
|
+
// Initialize buffer for this server
|
|
1792
|
+
if (!this.stdoutBuffers.has(serverName)) {
|
|
1793
|
+
this.stdoutBuffers.set(serverName, "");
|
|
1794
|
+
}
|
|
1795
|
+
const maxBufferSize = 10 * 1024 * 1024; // 10MB limit per server (increased from 1MB for large results like read_graph)
|
|
1796
|
+
// Create shared listener function
|
|
1797
|
+
const onStdoutData = (data) => {
|
|
1798
|
+
let buffer = this.stdoutBuffers.get(serverName) || "";
|
|
1799
|
+
buffer += data.toString();
|
|
1800
|
+
// Warn if buffer is getting large (50% of max)
|
|
1801
|
+
const warnThreshold = maxBufferSize * 0.5;
|
|
1802
|
+
if (buffer.length > warnThreshold && buffer.length <= maxBufferSize) {
|
|
1803
|
+
console.warn(`[MetaLink] Large buffer for ${serverName}: ${(buffer.length / 1024 / 1024).toFixed(2)}MB (${((buffer.length / maxBufferSize) * 100).toFixed(0)}% of max). Consider pagination for large results.`);
|
|
1804
|
+
}
|
|
1805
|
+
// Check buffer size limit
|
|
1806
|
+
if (buffer.length > maxBufferSize) {
|
|
1807
|
+
console.error(`[MetaLink] Buffer exceeded max size (${(buffer.length / 1024 / 1024).toFixed(2)}MB > ${(maxBufferSize / 1024 / 1024).toFixed(2)}MB) for ${serverName}, rejecting pending requests`);
|
|
1808
|
+
const pending = this.pendingRequests.get(serverName);
|
|
1809
|
+
if (pending) {
|
|
1810
|
+
for (const [_requestId, requestInfo] of pending) {
|
|
1811
|
+
clearTimeout(requestInfo.timeout);
|
|
1812
|
+
requestInfo.reject(new Error(`Buffer exceeded max size: ${(buffer.length / 1024 / 1024).toFixed(2)}MB > ${(maxBufferSize / 1024 / 1024).toFixed(2)}MB. Consider using pagination or filtering for large results.`));
|
|
1813
|
+
}
|
|
1814
|
+
pending.clear();
|
|
1815
|
+
}
|
|
1816
|
+
this.stdoutBuffers.set(serverName, "");
|
|
1817
|
+
this.cleanupServerResponseRouter(serverName);
|
|
1818
|
+
return;
|
|
1819
|
+
}
|
|
1820
|
+
// Extract complete JSON objects using brace-depth tracking
|
|
1821
|
+
// This handles both single-line and multiline JSON-RPC responses
|
|
1822
|
+
const extractCompleteJsonObjects = (buf) => {
|
|
1823
|
+
const objects = [];
|
|
1824
|
+
let position = 0;
|
|
1825
|
+
while (position < buf.length) {
|
|
1826
|
+
// Skip whitespace
|
|
1827
|
+
while (position < buf.length && /\s/.test(buf[position])) {
|
|
1828
|
+
position++;
|
|
1829
|
+
}
|
|
1830
|
+
if (position >= buf.length)
|
|
1831
|
+
break;
|
|
1832
|
+
// Must start with '{'
|
|
1833
|
+
if (buf[position] !== "{") {
|
|
1834
|
+
// Skip non-JSON content (e.g., log messages from server)
|
|
1835
|
+
const nextBrace = buf.indexOf("{", position);
|
|
1836
|
+
if (nextBrace === -1) {
|
|
1837
|
+
// No more JSON objects, keep remainder
|
|
1838
|
+
break;
|
|
1839
|
+
}
|
|
1840
|
+
position = nextBrace;
|
|
1841
|
+
continue;
|
|
1842
|
+
}
|
|
1843
|
+
// Track brace depth and extract complete object
|
|
1844
|
+
let braceDepth = 0;
|
|
1845
|
+
let inString = false;
|
|
1846
|
+
let escaped = false;
|
|
1847
|
+
const objectStart = position;
|
|
1848
|
+
while (position < buf.length) {
|
|
1849
|
+
const char = buf[position];
|
|
1850
|
+
// Handle escape sequences
|
|
1851
|
+
if (escaped) {
|
|
1852
|
+
escaped = false;
|
|
1853
|
+
position++;
|
|
1854
|
+
continue;
|
|
1855
|
+
}
|
|
1856
|
+
if (char === "\\" && inString) {
|
|
1857
|
+
escaped = true;
|
|
1858
|
+
position++;
|
|
1859
|
+
continue;
|
|
1860
|
+
}
|
|
1861
|
+
// Track string state
|
|
1862
|
+
if (char === '"' && !escaped) {
|
|
1863
|
+
inString = !inString;
|
|
1864
|
+
position++;
|
|
1865
|
+
continue;
|
|
1866
|
+
}
|
|
1867
|
+
if (inString) {
|
|
1868
|
+
position++;
|
|
1869
|
+
continue;
|
|
1870
|
+
}
|
|
1871
|
+
// Track brace depth (outside strings)
|
|
1872
|
+
if (char === "{") {
|
|
1873
|
+
braceDepth++;
|
|
1874
|
+
}
|
|
1875
|
+
else if (char === "}") {
|
|
1876
|
+
braceDepth--;
|
|
1877
|
+
}
|
|
1878
|
+
position++;
|
|
1879
|
+
// Complete object found
|
|
1880
|
+
if (braceDepth === 0) {
|
|
1881
|
+
const jsonStr = buf.substring(objectStart, position).trim();
|
|
1882
|
+
try {
|
|
1883
|
+
const obj = JSON.parse(jsonStr);
|
|
1884
|
+
if (obj.id !== undefined ||
|
|
1885
|
+
obj.error !== undefined ||
|
|
1886
|
+
obj.result !== undefined) {
|
|
1887
|
+
objects.push(obj);
|
|
1888
|
+
console.log(`[MetaLink] Extracted complete JSON object for ${serverName}: id=${obj.id}`);
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1891
|
+
catch (parseError) {
|
|
1892
|
+
console.error(`[MetaLink] Failed to parse JSON object in ${serverName}:`, `Position ${objectStart}-${position}:`, jsonStr.substring(0, 100) +
|
|
1893
|
+
(jsonStr.length > 100 ? "..." : ""), parseError instanceof Error
|
|
1894
|
+
? parseError.message
|
|
1895
|
+
: String(parseError));
|
|
1896
|
+
}
|
|
1897
|
+
break;
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
// If we didn't complete the object, break and keep remainder in buffer
|
|
1901
|
+
if (braceDepth > 0) {
|
|
1902
|
+
position = objectStart; // Reset to start of incomplete object
|
|
1903
|
+
break;
|
|
1904
|
+
}
|
|
1905
|
+
}
|
|
1906
|
+
return {
|
|
1907
|
+
objects,
|
|
1908
|
+
remaining: buf.substring(position).trim(),
|
|
1909
|
+
};
|
|
1910
|
+
};
|
|
1911
|
+
// Extract complete JSON objects from buffer
|
|
1912
|
+
const { objects, remaining } = extractCompleteJsonObjects(buffer);
|
|
1913
|
+
this.stdoutBuffers.set(serverName, remaining);
|
|
1914
|
+
const pending = this.pendingRequests.get(serverName);
|
|
1915
|
+
if (!pending || pending.size === 0) {
|
|
1916
|
+
return; // No pending requests
|
|
1917
|
+
}
|
|
1918
|
+
// Process extracted JSON objects
|
|
1919
|
+
for (const response of objects) {
|
|
1920
|
+
const requestId = response.id;
|
|
1921
|
+
// Check if this response matches any pending request
|
|
1922
|
+
if (requestId !== undefined && pending.has(requestId)) {
|
|
1923
|
+
const requestInfo = pending.get(requestId);
|
|
1924
|
+
if (requestInfo) {
|
|
1925
|
+
clearTimeout(requestInfo.timeout);
|
|
1926
|
+
pending.delete(requestId);
|
|
1927
|
+
console.log(`[MetaLink] Response router: matched requestId=${requestId} for ${serverName}-${requestInfo.toolName} (${pending.size} remaining)`);
|
|
1928
|
+
// Route response to the correct promise
|
|
1929
|
+
if (response.error) {
|
|
1930
|
+
requestInfo.reject(new Error(JSON.stringify(response.error)));
|
|
1931
|
+
}
|
|
1932
|
+
else {
|
|
1933
|
+
requestInfo.resolve({
|
|
1934
|
+
content: [
|
|
1935
|
+
{
|
|
1936
|
+
type: "text",
|
|
1937
|
+
text: JSON.stringify(response.result),
|
|
1938
|
+
},
|
|
1939
|
+
],
|
|
1940
|
+
});
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
};
|
|
1946
|
+
// Set up error and exit handlers
|
|
1947
|
+
const onError = (error) => {
|
|
1948
|
+
console.error(`[MetaLink] Process error for ${serverName}: ${error.message}`);
|
|
1949
|
+
const pending = this.pendingRequests.get(serverName);
|
|
1950
|
+
if (pending) {
|
|
1951
|
+
for (const [_requestId, requestInfo] of pending) {
|
|
1952
|
+
clearTimeout(requestInfo.timeout);
|
|
1953
|
+
requestInfo.reject(new Error(`Server error: ${error.message}`));
|
|
1954
|
+
}
|
|
1955
|
+
pending.clear();
|
|
1956
|
+
}
|
|
1957
|
+
this.cleanupServerResponseRouter(serverName);
|
|
1958
|
+
};
|
|
1959
|
+
const onExit = (code) => {
|
|
1960
|
+
console.error(`[MetaLink] Process exited for ${serverName} (code=${code})`);
|
|
1961
|
+
const pending = this.pendingRequests.get(serverName);
|
|
1962
|
+
if (pending) {
|
|
1963
|
+
for (const [_requestId, requestInfo] of pending) {
|
|
1964
|
+
clearTimeout(requestInfo.timeout);
|
|
1965
|
+
requestInfo.reject(new Error(`Server exited unexpectedly (code=${code})`));
|
|
1966
|
+
}
|
|
1967
|
+
pending.clear();
|
|
1968
|
+
}
|
|
1969
|
+
this.activeServers.delete(serverName);
|
|
1970
|
+
this.cleanupServerResponseRouter(serverName);
|
|
1971
|
+
};
|
|
1972
|
+
// Attach listeners
|
|
1973
|
+
serverData.process.stdout?.on("data", onStdoutData);
|
|
1974
|
+
serverData.process.on("error", onError);
|
|
1975
|
+
serverData.process.on("exit", onExit);
|
|
1976
|
+
// Store listener references for cleanup (including PID to detect stale routers)
|
|
1977
|
+
this.stdoutListeners.set(serverName, {
|
|
1978
|
+
onStdoutData,
|
|
1979
|
+
onError,
|
|
1980
|
+
onExit,
|
|
1981
|
+
processPid: serverData.process.pid,
|
|
1982
|
+
});
|
|
1983
|
+
console.log(`[MetaLink] Response router set up for ${serverName} (using JSON boundary detection)`);
|
|
1984
|
+
}
|
|
1985
|
+
/**
|
|
1986
|
+
* Phase 3: Clean up response router for a server
|
|
1987
|
+
*/
|
|
1988
|
+
cleanupServerResponseRouter(serverName) {
|
|
1989
|
+
const serverData = this.activeServers.get(serverName);
|
|
1990
|
+
const listenerInfo = this.stdoutListeners.get(serverName);
|
|
1991
|
+
if (listenerInfo && serverData?.process) {
|
|
1992
|
+
// Remove listeners
|
|
1993
|
+
serverData.process.stdout?.removeListener("data", listenerInfo.onStdoutData);
|
|
1994
|
+
serverData.process.removeListener("error", listenerInfo.onError);
|
|
1995
|
+
serverData.process.removeListener("exit", listenerInfo.onExit);
|
|
1996
|
+
console.log(`[MetaLink] Response router cleaned up for ${serverName}`);
|
|
1997
|
+
}
|
|
1998
|
+
// Clear state
|
|
1999
|
+
this.stdoutListeners.delete(serverName);
|
|
2000
|
+
this.stdoutBuffers.delete(serverName);
|
|
2001
|
+
}
|
|
2002
|
+
/**
|
|
2003
|
+
* Phase 3: Call a tool on a server and wait for response
|
|
2004
|
+
* Supports both stdio (process) and HTTP (httpClient) servers
|
|
2005
|
+
*/
|
|
2006
|
+
async callTool(serverName, toolName, toolArgs) {
|
|
2007
|
+
if (!serverName || !toolName) {
|
|
2008
|
+
throw new Error(`Invalid tool call: server_name='${serverName}', tool_name='${toolName}'`);
|
|
2009
|
+
}
|
|
2010
|
+
// Check circuit breaker before attempting tool call
|
|
2011
|
+
const breaker = this.circuitBreakerManager.getBreaker(serverName);
|
|
2012
|
+
if (!breaker.canExecute()) {
|
|
2013
|
+
const metrics = breaker.getMetrics();
|
|
2014
|
+
const timeUntilReset = metrics.lastFailureTime
|
|
2015
|
+
? Math.max(0, 30000 - (Date.now() - metrics.lastFailureTime))
|
|
2016
|
+
: 0;
|
|
2017
|
+
throw new Error(`Circuit breaker is OPEN for ${serverName}. ` +
|
|
2018
|
+
`Server has failed ${metrics.failures} consecutive times. ` +
|
|
2019
|
+
`Retry in ${Math.ceil(timeUntilReset / 1000)}s.`);
|
|
2020
|
+
}
|
|
2021
|
+
let serverData = this.activeServers.get(serverName);
|
|
2022
|
+
// v1.1.56: State recovery - if server process exists but not in activeServers, attempt recovery
|
|
2023
|
+
if (!serverData) {
|
|
2024
|
+
const process = this.processes.get(serverName);
|
|
2025
|
+
const httpClient = this.httpClients.get(serverName);
|
|
2026
|
+
// Check if process/client exists but activeServers is out of sync
|
|
2027
|
+
if (process && !process.killed) {
|
|
2028
|
+
console.warn(`[MetaLink] State corruption detected: ${serverName} has process (PID ${process.pid}) but not in activeServers. Attempting recovery...`);
|
|
2029
|
+
// Try to recover from schema cache
|
|
2030
|
+
const cached = this.toolSchemaCache.get(serverName);
|
|
2031
|
+
if (cached?.tools && cached.tools.length > 0) {
|
|
2032
|
+
this.setServerTools(serverName, cached.tools, process);
|
|
2033
|
+
serverData = this.activeServers.get(serverName);
|
|
2034
|
+
console.log(`[MetaLink] State recovered for ${serverName}: ${cached.tools.length} tools restored`);
|
|
2035
|
+
}
|
|
2036
|
+
else {
|
|
2037
|
+
console.error(`[MetaLink] Cannot recover ${serverName}: no cached schema. Fetching tools...`);
|
|
2038
|
+
try {
|
|
2039
|
+
const tools = await this.fetchToolsFromProcess(process, serverName);
|
|
2040
|
+
this.setServerTools(serverName, tools, process);
|
|
2041
|
+
serverData = this.activeServers.get(serverName);
|
|
2042
|
+
console.log(`[MetaLink] State recovered for ${serverName}: fetched ${tools.length} tools`);
|
|
2043
|
+
}
|
|
2044
|
+
catch (error) {
|
|
2045
|
+
console.error(`[MetaLink] Failed to fetch tools for recovery:`, error);
|
|
2046
|
+
throw new Error(`Server '${serverName}' state corrupted and recovery failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
2049
|
+
}
|
|
2050
|
+
else if (httpClient) {
|
|
2051
|
+
console.warn(`[MetaLink] State corruption detected: ${serverName} has HTTP client but not in activeServers. Attempting recovery...`);
|
|
2052
|
+
// Try to recover from schema cache for HTTP servers
|
|
2053
|
+
const cached = this.toolSchemaCache.get(serverName);
|
|
2054
|
+
if (cached?.tools && cached.tools.length > 0) {
|
|
2055
|
+
this.setServerTools(serverName, cached.tools);
|
|
2056
|
+
serverData = this.activeServers.get(serverName);
|
|
2057
|
+
console.log(`[MetaLink] State recovered for ${serverName}: ${cached.tools.length} tools restored`);
|
|
2058
|
+
}
|
|
2059
|
+
else {
|
|
2060
|
+
console.error(`[MetaLink] Cannot recover ${serverName}: no cached schema for HTTP server`);
|
|
2061
|
+
throw new Error(`Server '${serverName}' HTTP client state corrupted and no cached schema available`);
|
|
2062
|
+
}
|
|
2063
|
+
}
|
|
2064
|
+
else {
|
|
2065
|
+
// No process or client exists - server truly not active
|
|
2066
|
+
const activeList = Array.from(this.activeServers.keys()).join(", ") || "none";
|
|
2067
|
+
throw new Error(`Server '${serverName}' is not active. Active servers: ${activeList}. Enable it first with enable_server.`);
|
|
2068
|
+
}
|
|
2069
|
+
}
|
|
2070
|
+
// Final type assertion after recovery
|
|
2071
|
+
if (!serverData) {
|
|
2072
|
+
throw new Error(`Server '${serverName}' state recovery failed - activeServers is still empty`);
|
|
2073
|
+
}
|
|
2074
|
+
// Check if this is an HTTP server
|
|
2075
|
+
const httpClient = this.httpClients.get(serverName);
|
|
2076
|
+
if (httpClient) {
|
|
2077
|
+
// HTTP server: use httpClient.call()
|
|
2078
|
+
console.log(`[MetaLink] Calling tool '${toolName}' on HTTP server '${serverName}'`);
|
|
2079
|
+
try {
|
|
2080
|
+
const response = await httpClient.call("tools/call", {
|
|
2081
|
+
name: toolName,
|
|
2082
|
+
arguments: toolArgs,
|
|
2083
|
+
});
|
|
2084
|
+
if (response.error) {
|
|
2085
|
+
breaker.recordFailure();
|
|
2086
|
+
throw new Error(`Tool error: ${response.error.message}`);
|
|
2087
|
+
}
|
|
2088
|
+
breaker.recordSuccess();
|
|
2089
|
+
// Reset restart tracking on successful tool execution
|
|
2090
|
+
this.resetRestartTracking(serverName);
|
|
2091
|
+
return response.result;
|
|
2092
|
+
}
|
|
2093
|
+
catch (error) {
|
|
2094
|
+
breaker.recordFailure();
|
|
2095
|
+
throw new Error(`HTTP tool call failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
2096
|
+
}
|
|
2097
|
+
}
|
|
2098
|
+
// stdio server: use process stdin/stdout
|
|
2099
|
+
if (!serverData.process) {
|
|
2100
|
+
throw new Error(`Server '${serverName}' has no process associated`);
|
|
2101
|
+
}
|
|
2102
|
+
// v1.4.1: Auto-restart killed servers instead of throwing
|
|
2103
|
+
// This fixes "process has been killed" errors when server crashes mid-session
|
|
2104
|
+
if (serverData.process.killed) {
|
|
2105
|
+
console.log(`[MetaLink] Server '${serverName}' process was killed, auto-restarting...`);
|
|
2106
|
+
// Get server config for restart
|
|
2107
|
+
if (!this.serverListProvider) {
|
|
2108
|
+
throw new Error(`Server '${serverName}' process has been killed and cannot auto-restart (no config provider)`);
|
|
2109
|
+
}
|
|
2110
|
+
const allConfigs = this.serverListProvider();
|
|
2111
|
+
const config = allConfigs.find((c) => c.name === serverName);
|
|
2112
|
+
if (!config) {
|
|
2113
|
+
throw new Error(`Server '${serverName}' process has been killed and cannot auto-restart (config not found)`);
|
|
2114
|
+
}
|
|
2115
|
+
// Clean up dead process state
|
|
2116
|
+
this.processes.delete(serverName);
|
|
2117
|
+
this.activeServers.delete(serverName);
|
|
2118
|
+
// Restart the server synchronously before tool call
|
|
2119
|
+
await this.ensureServerStarted(serverName, config);
|
|
2120
|
+
// Get refreshed server data after restart
|
|
2121
|
+
serverData = this.activeServers.get(serverName);
|
|
2122
|
+
if (!serverData?.process || serverData.process.killed) {
|
|
2123
|
+
throw new Error(`Server '${serverName}' failed to restart`);
|
|
2124
|
+
}
|
|
2125
|
+
console.log(`[MetaLink] Server '${serverName}' auto-restarted successfully`);
|
|
2126
|
+
}
|
|
2127
|
+
console.log(`[MetaLink] Calling tool '${toolName}' on server '${serverName}'`);
|
|
2128
|
+
// Ensure response router is set up for this server
|
|
2129
|
+
this.setupServerResponseRouter(serverName);
|
|
2130
|
+
// Generate unique request ID using monotonic counter (v1.1.49: fixes collision bug)
|
|
2131
|
+
const counter = (this.requestIdCounters.get(serverName) || 0) + 1;
|
|
2132
|
+
this.requestIdCounters.set(serverName, counter);
|
|
2133
|
+
const requestId = counter;
|
|
2134
|
+
return new Promise((resolve, reject) => {
|
|
2135
|
+
// Set up timeout (5 minutes)
|
|
2136
|
+
const timeout = setTimeout(() => {
|
|
2137
|
+
const pending = this.pendingRequests.get(serverName);
|
|
2138
|
+
if (pending && pending.has(requestId)) {
|
|
2139
|
+
pending.delete(requestId);
|
|
2140
|
+
console.error(`[MetaLink] Tool call timeout after ${this.timeouts.toolExecution}ms: ${serverName}-${toolName} (requestId=${requestId})`);
|
|
2141
|
+
}
|
|
2142
|
+
breaker.recordFailure();
|
|
2143
|
+
reject(new Error(`Tool call timeout after ${this.timeouts.toolExecution}ms: ${serverName}-${toolName}`));
|
|
2144
|
+
}, this.timeouts.toolExecution);
|
|
2145
|
+
// Register this request in pendingRequests
|
|
2146
|
+
if (!this.pendingRequests.has(serverName)) {
|
|
2147
|
+
this.pendingRequests.set(serverName, new Map());
|
|
2148
|
+
}
|
|
2149
|
+
const pending = this.pendingRequests.get(serverName);
|
|
2150
|
+
// Create wrapped reject that cleans up and records failure
|
|
2151
|
+
const wrappedReject = (error) => {
|
|
2152
|
+
clearTimeout(timeout);
|
|
2153
|
+
if (pending.has(requestId)) {
|
|
2154
|
+
pending.delete(requestId);
|
|
2155
|
+
console.log(`[MetaLink] Request ${requestId} rejected for ${serverName}-${toolName}`);
|
|
2156
|
+
}
|
|
2157
|
+
breaker.recordFailure();
|
|
2158
|
+
reject(error);
|
|
2159
|
+
};
|
|
2160
|
+
// Create wrapped resolve that records success
|
|
2161
|
+
const wrappedResolve = (result) => {
|
|
2162
|
+
clearTimeout(timeout);
|
|
2163
|
+
if (pending.has(requestId)) {
|
|
2164
|
+
pending.delete(requestId);
|
|
2165
|
+
}
|
|
2166
|
+
breaker.recordSuccess();
|
|
2167
|
+
// Reset restart tracking on successful tool execution
|
|
2168
|
+
this.resetRestartTracking(serverName);
|
|
2169
|
+
resolve(result);
|
|
2170
|
+
};
|
|
2171
|
+
pending.set(requestId, {
|
|
2172
|
+
resolve: wrappedResolve,
|
|
2173
|
+
reject: wrappedReject,
|
|
2174
|
+
timeout,
|
|
2175
|
+
toolName,
|
|
2176
|
+
startTime: Date.now(),
|
|
2177
|
+
});
|
|
2178
|
+
console.log(`[MetaLink] Registered request ${requestId} for ${serverName}-${toolName} (${pending.size} total pending)`);
|
|
2179
|
+
// Send the request
|
|
2180
|
+
const request = JSON.stringify({
|
|
2181
|
+
jsonrpc: "2.0",
|
|
2182
|
+
id: requestId,
|
|
2183
|
+
method: "tools/call",
|
|
2184
|
+
params: {
|
|
2185
|
+
name: toolName,
|
|
2186
|
+
arguments: toolArgs,
|
|
2187
|
+
},
|
|
2188
|
+
});
|
|
2189
|
+
try {
|
|
2190
|
+
console.log(`[MetaLink] Writing tool call request (id=${requestId}) to ${serverName}-${toolName}`);
|
|
2191
|
+
if (!serverData.process) {
|
|
2192
|
+
throw new Error(`Server process not available for ${serverName}`);
|
|
2193
|
+
}
|
|
2194
|
+
serverData.process.stdin?.write(request + "\n");
|
|
2195
|
+
}
|
|
2196
|
+
catch (writeError) {
|
|
2197
|
+
// Clean up on write error
|
|
2198
|
+
if (pending.has(requestId)) {
|
|
2199
|
+
clearTimeout(timeout);
|
|
2200
|
+
pending.delete(requestId);
|
|
2201
|
+
}
|
|
2202
|
+
breaker.recordFailure();
|
|
2203
|
+
reject(new Error(`Failed to write tool call request: ${writeError instanceof Error ? writeError.message : String(writeError)}`));
|
|
2204
|
+
}
|
|
2205
|
+
});
|
|
2206
|
+
}
|
|
2207
|
+
/**
|
|
2208
|
+
* Discover tool schemas from a server with auto-start capability
|
|
2209
|
+
* Uses cache (in-memory first, then disk) to avoid repeated restarts
|
|
2210
|
+
* Auto-starts servers on first discovery (transparent to caller)
|
|
2211
|
+
* Persists schemas to disk for faster discovery across daemon restarts
|
|
2212
|
+
*/
|
|
2213
|
+
async discoverToolSchemas(serverName, config) {
|
|
2214
|
+
// 1. Check in-memory cache first (fastest)
|
|
2215
|
+
const memCached = this.toolSchemaCache.get(serverName);
|
|
2216
|
+
if (memCached && Date.now() - memCached.timestamp < this.schemasCacheTTL) {
|
|
2217
|
+
console.log(`[Discovery] Using in-memory cache for ${serverName} (${memCached.tools.length} tools, TTL: ${this.schemasCacheTTL}ms)`);
|
|
2218
|
+
this.discoveredServers.add(serverName);
|
|
2219
|
+
this.resetDiscoveryTimer(serverName); // v1.4.0: Start/reset expiration timer
|
|
2220
|
+
// Trigger background refresh if nearing expiration
|
|
2221
|
+
if (this.schemasBackgroundRefresh &&
|
|
2222
|
+
Date.now() - memCached.timestamp > this.schemasCacheTTL * 0.8) {
|
|
2223
|
+
this.refreshSchemaForServer(serverName).catch((error) => {
|
|
2224
|
+
console.warn(`[Discovery] Background refresh failed for ${serverName}:`, error);
|
|
2225
|
+
});
|
|
2226
|
+
}
|
|
2227
|
+
// CRITICAL FIX: Ensure annotations are present in cached tools
|
|
2228
|
+
// The cache should already have enriched tools, but verify
|
|
2229
|
+
const hasAnnotations = memCached.tools.length > 0 && memCached.tools[0].annotations;
|
|
2230
|
+
if (!hasAnnotations) {
|
|
2231
|
+
console.log(`[Discovery] Cache missing annotations for ${serverName}, enriching...`);
|
|
2232
|
+
return this.enrichToolsWithAnnotations(serverName, memCached.tools);
|
|
2233
|
+
}
|
|
2234
|
+
return memCached.tools;
|
|
2235
|
+
}
|
|
2236
|
+
// 2. Check disk cache if memory miss (Phase 1: v1.3.9+)
|
|
2237
|
+
if (this.schemaStore) {
|
|
2238
|
+
try {
|
|
2239
|
+
const diskSchemas = await this.schemaStore.loadFromDisk();
|
|
2240
|
+
const diskCached = diskSchemas.get(serverName);
|
|
2241
|
+
if (diskCached &&
|
|
2242
|
+
Date.now() - diskCached.timestamp < this.schemasCacheTTL) {
|
|
2243
|
+
console.log(`[Discovery] Using disk cache for ${serverName} (${diskCached.tools.length} tools)`);
|
|
2244
|
+
this.toolSchemaCache.set(serverName, diskCached);
|
|
2245
|
+
this.discoveredServers.add(serverName);
|
|
2246
|
+
this.resetDiscoveryTimer(serverName); // v1.4.0: Start/reset expiration timer
|
|
2247
|
+
// Trigger background refresh if nearing expiration
|
|
2248
|
+
if (this.schemasBackgroundRefresh &&
|
|
2249
|
+
Date.now() - diskCached.timestamp > this.schemasCacheTTL * 0.8) {
|
|
2250
|
+
this.refreshSchemaForServer(serverName).catch((error) => {
|
|
2251
|
+
console.warn(`[Discovery] Background refresh failed for ${serverName}:`, error);
|
|
2252
|
+
});
|
|
2253
|
+
}
|
|
2254
|
+
// CRITICAL FIX: Ensure annotations are present in disk cached tools
|
|
2255
|
+
const hasAnnotations = diskCached.tools.length > 0 && diskCached.tools[0].annotations;
|
|
2256
|
+
if (!hasAnnotations) {
|
|
2257
|
+
console.log(`[Discovery] Disk cache missing annotations for ${serverName}, enriching...`);
|
|
2258
|
+
return this.enrichToolsWithAnnotations(serverName, diskCached.tools);
|
|
2259
|
+
}
|
|
2260
|
+
return diskCached.tools;
|
|
2261
|
+
}
|
|
2262
|
+
}
|
|
2263
|
+
catch (error) {
|
|
2264
|
+
console.warn(`[Discovery] Failed to check disk cache for ${serverName}:`, error);
|
|
2265
|
+
// Fall through to server startup
|
|
2266
|
+
}
|
|
2267
|
+
}
|
|
2268
|
+
// 3. If server is already running, use its tools
|
|
2269
|
+
if (this.isServerActive(serverName)) {
|
|
2270
|
+
const tools = this.getServerTools(serverName);
|
|
2271
|
+
console.log(`[Discovery] Using running server tools for ${serverName} (${tools.length} tools)`);
|
|
2272
|
+
const schema = { tools, timestamp: Date.now() };
|
|
2273
|
+
this.toolSchemaCache.set(serverName, schema);
|
|
2274
|
+
this.discoveredServers.add(serverName);
|
|
2275
|
+
this.resetDiscoveryTimer(serverName); // v1.4.0: Start/reset expiration timer
|
|
2276
|
+
// Persist to disk asynchronously
|
|
2277
|
+
if (this.schemaStore) {
|
|
2278
|
+
this.schemaStore.saveToDisk(serverName, schema).catch((error) => {
|
|
2279
|
+
console.warn(`[Discovery] Failed to persist schema for ${serverName}:`, error);
|
|
2280
|
+
});
|
|
2281
|
+
}
|
|
2282
|
+
return tools;
|
|
2283
|
+
}
|
|
2284
|
+
// 4. Auto-start server for discovery if not running (v1.3.8+)
|
|
2285
|
+
console.log(`[Discovery] Auto-starting ${serverName} for schema discovery...`);
|
|
2286
|
+
try {
|
|
2287
|
+
await this.ensureServerStarted(serverName, config);
|
|
2288
|
+
const tools = this.getServerTools(serverName);
|
|
2289
|
+
console.log(`[Discovery] Discovered ${tools.length} tools from ${serverName}`);
|
|
2290
|
+
const schema = { tools, timestamp: Date.now() };
|
|
2291
|
+
this.toolSchemaCache.set(serverName, schema);
|
|
2292
|
+
this.discoveredServers.add(serverName);
|
|
2293
|
+
this.resetDiscoveryTimer(serverName); // v1.4.0: Start/reset expiration timer
|
|
2294
|
+
// Persist to disk asynchronously (Phase 1: v1.3.9+)
|
|
2295
|
+
if (this.schemaStore) {
|
|
2296
|
+
this.schemaStore.saveToDisk(serverName, schema).catch((error) => {
|
|
2297
|
+
console.warn(`[Discovery] Failed to persist schema for ${serverName}:`, error);
|
|
2298
|
+
});
|
|
2299
|
+
}
|
|
2300
|
+
return tools;
|
|
2301
|
+
}
|
|
2302
|
+
catch (error) {
|
|
2303
|
+
console.error(`[Discovery] Failed to auto-start and discover ${serverName}:`, error);
|
|
2304
|
+
throw new Error(`Server ${serverName} not found and failed to auto-start. Details: ${error instanceof Error ? error.message : String(error)}`);
|
|
2305
|
+
}
|
|
2306
|
+
}
|
|
2307
|
+
/**
|
|
2308
|
+
* Refresh schema for a specific server
|
|
2309
|
+
* Used by discovery to keep schemas fresh
|
|
2310
|
+
*/
|
|
2311
|
+
async refreshSchemaForServer(serverName) {
|
|
2312
|
+
if (!this.isServerActive(serverName)) {
|
|
2313
|
+
// Server not running, skip refresh
|
|
2314
|
+
return;
|
|
2315
|
+
}
|
|
2316
|
+
try {
|
|
2317
|
+
const process = this.getProcess(serverName);
|
|
2318
|
+
if (!process)
|
|
2319
|
+
return;
|
|
2320
|
+
const tools = await this.fetchToolsFromProcess(process, serverName);
|
|
2321
|
+
const schema = { tools, timestamp: Date.now() };
|
|
2322
|
+
// Update cache
|
|
2323
|
+
this.toolSchemaCache.set(serverName, schema);
|
|
2324
|
+
// Persist to disk
|
|
2325
|
+
if (this.schemaStore) {
|
|
2326
|
+
await this.schemaStore.saveToDisk(serverName, schema);
|
|
2327
|
+
}
|
|
2328
|
+
console.log(`[Discovery] Refreshed schema for ${serverName}`);
|
|
2329
|
+
}
|
|
2330
|
+
catch (error) {
|
|
2331
|
+
console.warn(`[Discovery] Failed to refresh schema for ${serverName}:`, error);
|
|
2332
|
+
}
|
|
2333
|
+
}
|
|
2334
|
+
/**
|
|
2335
|
+
* Ensure server is started, auto-starting if needed
|
|
2336
|
+
* Used when direct tool call happens and server isn't running
|
|
2337
|
+
*/
|
|
2338
|
+
async ensureServerStarted(serverName, config) {
|
|
2339
|
+
// Check circuit breaker before attempting startup
|
|
2340
|
+
const breaker = this.circuitBreakerManager.getBreaker(serverName);
|
|
2341
|
+
if (!breaker.canExecute()) {
|
|
2342
|
+
const metrics = breaker.getMetrics();
|
|
2343
|
+
const timeUntilReset = metrics.lastFailureTime
|
|
2344
|
+
? Math.max(0, 30000 - (Date.now() - metrics.lastFailureTime))
|
|
2345
|
+
: 0;
|
|
2346
|
+
throw new Error(`Circuit breaker is OPEN for ${serverName}. ` +
|
|
2347
|
+
`Server has failed ${metrics.failures} consecutive times. ` +
|
|
2348
|
+
`Retry in ${Math.ceil(timeUntilReset / 1000)}s.`);
|
|
2349
|
+
}
|
|
2350
|
+
// v1.1.8: Check if startup already in progress (mutex pattern)
|
|
2351
|
+
if (this.startupMutex.has(serverName)) {
|
|
2352
|
+
const mutex = this.startupMutex.get(serverName);
|
|
2353
|
+
mutex.callerCount++;
|
|
2354
|
+
console.log(`[Auto-Start] ${serverName} startup in progress, waiting... (caller ${mutex.callerCount})`);
|
|
2355
|
+
return mutex.promise;
|
|
2356
|
+
}
|
|
2357
|
+
// v1.1.8: Check if already started
|
|
2358
|
+
if (this.processes.has(serverName) || this.httpClients.has(serverName)) {
|
|
2359
|
+
this.discoveredServers.add(serverName);
|
|
2360
|
+
// v1.1.56: Always ensure server is in activeServers if process exists
|
|
2361
|
+
if (!this.activeServers.has(serverName)) {
|
|
2362
|
+
console.warn(`[Auto-Start] ${serverName} has process but not in activeServers. Recovering state...`);
|
|
2363
|
+
// Try to load from cache first
|
|
2364
|
+
const cached = this.toolSchemaCache.get(serverName);
|
|
2365
|
+
if (cached?.tools && cached.tools.length > 0) {
|
|
2366
|
+
const process = this.processes.get(serverName);
|
|
2367
|
+
this.setServerTools(serverName, cached.tools, process);
|
|
2368
|
+
console.log(`[Auto-Start] Recovered ${serverName} from cache: ${cached.tools.length} tools`);
|
|
2369
|
+
}
|
|
2370
|
+
else {
|
|
2371
|
+
// No cache - fetch tools from running process
|
|
2372
|
+
const process = this.processes.get(serverName);
|
|
2373
|
+
if (process && !process.killed) {
|
|
2374
|
+
console.log(`[Auto-Start] No cache for ${serverName}, fetching tools from process...`);
|
|
2375
|
+
try {
|
|
2376
|
+
const tools = await this.fetchToolsFromProcess(process, serverName);
|
|
2377
|
+
this.setServerTools(serverName, tools, process);
|
|
2378
|
+
console.log(`[Auto-Start] Recovered ${serverName}: fetched ${tools.length} tools`);
|
|
2379
|
+
}
|
|
2380
|
+
catch (error) {
|
|
2381
|
+
console.error(`[Auto-Start] Failed to fetch tools for ${serverName}:`, error);
|
|
2382
|
+
// Process exists but can't get tools - kill it and restart
|
|
2383
|
+
console.warn(`[Auto-Start] Killing corrupted process for ${serverName} and restarting...`);
|
|
2384
|
+
await this.stopServer(serverName);
|
|
2385
|
+
await this.startServer(config);
|
|
2386
|
+
}
|
|
2387
|
+
}
|
|
2388
|
+
else {
|
|
2389
|
+
console.log(`[Auto-Start] HTTP server ${serverName} exists, using cached schema`);
|
|
2390
|
+
}
|
|
2391
|
+
}
|
|
2392
|
+
}
|
|
2393
|
+
return;
|
|
2394
|
+
}
|
|
2395
|
+
// v1.1.8: Create mutex entry before starting
|
|
2396
|
+
const startPromise = this.performServerStartup(serverName, config);
|
|
2397
|
+
this.startupMutex.set(serverName, {
|
|
2398
|
+
promise: startPromise,
|
|
2399
|
+
startTime: Date.now(),
|
|
2400
|
+
callerCount: 1,
|
|
2401
|
+
});
|
|
2402
|
+
// v1.1.27: Enhanced mutex cleanup with error logging
|
|
2403
|
+
startPromise
|
|
2404
|
+
.catch((error) => {
|
|
2405
|
+
console.error(`[Auto-Start] ${serverName} startup failed:`, error instanceof Error ? error.message : String(error));
|
|
2406
|
+
})
|
|
2407
|
+
.finally(() => {
|
|
2408
|
+
const mutex = this.startupMutex.get(serverName);
|
|
2409
|
+
if (mutex) {
|
|
2410
|
+
const duration = Date.now() - mutex.startTime;
|
|
2411
|
+
console.log(`[Auto-Start] ${serverName} completed in ${duration}ms (${mutex.callerCount} waiters)`);
|
|
2412
|
+
this.startupMutex.delete(serverName);
|
|
2413
|
+
}
|
|
2414
|
+
});
|
|
2415
|
+
return startPromise;
|
|
2416
|
+
}
|
|
2417
|
+
/**
|
|
2418
|
+
* v1.1.8: Actual server startup logic (called only by first concurrent request)
|
|
2419
|
+
* Other concurrent requests wait for this promise via mutex
|
|
2420
|
+
*/
|
|
2421
|
+
async performServerStartup(serverName, config) {
|
|
2422
|
+
const breaker = this.circuitBreakerManager.getBreaker(serverName);
|
|
2423
|
+
try {
|
|
2424
|
+
if (this.isServerActive(serverName)) {
|
|
2425
|
+
this.discoveredServers.add(serverName);
|
|
2426
|
+
breaker.recordSuccess();
|
|
2427
|
+
return;
|
|
2428
|
+
}
|
|
2429
|
+
console.log(`[Auto-Start] Starting ${serverName} for first tool call...`);
|
|
2430
|
+
await this.startServer(config);
|
|
2431
|
+
// Use cached schemas if available, otherwise fetch
|
|
2432
|
+
if (this.toolSchemaCache.has(serverName)) {
|
|
2433
|
+
const cached = this.toolSchemaCache.get(serverName);
|
|
2434
|
+
// Even with cached schemas, wait for server initialization to complete
|
|
2435
|
+
const process = this.getProcess(serverName);
|
|
2436
|
+
if (process) {
|
|
2437
|
+
try {
|
|
2438
|
+
await this.waitForInitialization(process, serverName);
|
|
2439
|
+
}
|
|
2440
|
+
catch (error) {
|
|
2441
|
+
console.error(`[Auto-Start] Initialization failed for ${serverName}:`, error);
|
|
2442
|
+
breaker.recordFailure();
|
|
2443
|
+
throw error;
|
|
2444
|
+
}
|
|
2445
|
+
}
|
|
2446
|
+
this.setServerTools(serverName, cached.tools);
|
|
2447
|
+
this.discoveredServers.add(serverName);
|
|
2448
|
+
console.log(`[Auto-Start] Using cached schemas for ${serverName} (${cached.tools.length} tools)`);
|
|
2449
|
+
}
|
|
2450
|
+
else {
|
|
2451
|
+
let tools = [];
|
|
2452
|
+
const isHttpServer = this.httpClients.has(serverName);
|
|
2453
|
+
if (isHttpServer) {
|
|
2454
|
+
const client = this.httpClients.get(serverName);
|
|
2455
|
+
try {
|
|
2456
|
+
const response = await client.call("tools/list", {});
|
|
2457
|
+
if (response.result &&
|
|
2458
|
+
typeof response.result === "object" &&
|
|
2459
|
+
"tools" in response.result) {
|
|
2460
|
+
tools = response.result.tools;
|
|
2461
|
+
}
|
|
2462
|
+
}
|
|
2463
|
+
catch (error) {
|
|
2464
|
+
console.warn(`[Auto-Start] Failed to fetch tools from HTTP server ${serverName}:`, error);
|
|
2465
|
+
breaker.recordFailure();
|
|
2466
|
+
throw error;
|
|
2467
|
+
}
|
|
2468
|
+
}
|
|
2469
|
+
else {
|
|
2470
|
+
tools = await this.fetchToolsFromProcess(this.getProcess(serverName), serverName);
|
|
2471
|
+
}
|
|
2472
|
+
this.setServerTools(serverName, tools);
|
|
2473
|
+
const schema = { tools, timestamp: Date.now() };
|
|
2474
|
+
this.toolSchemaCache.set(serverName, schema);
|
|
2475
|
+
this.discoveredServers.add(serverName);
|
|
2476
|
+
// Persist to disk asynchronously
|
|
2477
|
+
if (this.schemaStore) {
|
|
2478
|
+
this.schemaStore.saveToDisk(serverName, schema).catch((error) => {
|
|
2479
|
+
console.warn(`[Auto-Start] Failed to persist schema for ${serverName}:`, error);
|
|
2480
|
+
});
|
|
2481
|
+
}
|
|
2482
|
+
console.log(`[Auto-Start] Fetched schemas from ${serverName} (${tools.length} tools)`);
|
|
2483
|
+
}
|
|
2484
|
+
this.setupServerResponseRouter(serverName);
|
|
2485
|
+
// Record success for circuit breaker
|
|
2486
|
+
breaker.recordSuccess();
|
|
2487
|
+
}
|
|
2488
|
+
catch (error) {
|
|
2489
|
+
// Record failure for circuit breaker
|
|
2490
|
+
breaker.recordFailure();
|
|
2491
|
+
throw error;
|
|
2492
|
+
}
|
|
2493
|
+
}
|
|
2494
|
+
/**
|
|
2495
|
+
* Get discovered servers list
|
|
2496
|
+
*/
|
|
2497
|
+
getDiscoveredServers() {
|
|
2498
|
+
return Array.from(this.discoveredServers);
|
|
2499
|
+
}
|
|
2500
|
+
/**
|
|
2501
|
+
* Get server tools from cache (if discovered)
|
|
2502
|
+
*/
|
|
2503
|
+
getServerToolsFromCache(serverName) {
|
|
2504
|
+
const cached = this.toolSchemaCache.get(serverName);
|
|
2505
|
+
if (cached) {
|
|
2506
|
+
this.discoveredServers.add(serverName);
|
|
2507
|
+
return cached.tools;
|
|
2508
|
+
}
|
|
2509
|
+
return null;
|
|
2510
|
+
}
|
|
2511
|
+
/**
|
|
2512
|
+
* Get circuit breaker metrics for a specific server
|
|
2513
|
+
*
|
|
2514
|
+
* @param serverName - Name of the server
|
|
2515
|
+
* @returns Circuit breaker metrics or null if server not found
|
|
2516
|
+
*/
|
|
2517
|
+
getCircuitBreakerMetrics(serverName) {
|
|
2518
|
+
const breaker = this.circuitBreakerManager.getAllBreakers().get(serverName);
|
|
2519
|
+
if (breaker) {
|
|
2520
|
+
return breaker.getMetrics();
|
|
2521
|
+
}
|
|
2522
|
+
return null;
|
|
2523
|
+
}
|
|
2524
|
+
/**
|
|
2525
|
+
* Get circuit breaker metrics for all servers
|
|
2526
|
+
*
|
|
2527
|
+
* @returns Array of metrics for all servers with circuit breakers
|
|
2528
|
+
*/
|
|
2529
|
+
getAllCircuitBreakerMetrics() {
|
|
2530
|
+
return this.circuitBreakerManager.getAllMetrics();
|
|
2531
|
+
}
|
|
2532
|
+
/**
|
|
2533
|
+
* Manually reset circuit breaker for a server
|
|
2534
|
+
*
|
|
2535
|
+
* @param serverName - Name of the server
|
|
2536
|
+
*/
|
|
2537
|
+
resetCircuitBreaker(serverName) {
|
|
2538
|
+
this.circuitBreakerManager.reset(serverName);
|
|
2539
|
+
}
|
|
2540
|
+
/**
|
|
2541
|
+
* Reset all circuit breakers
|
|
2542
|
+
*/
|
|
2543
|
+
resetAllCircuitBreakers() {
|
|
2544
|
+
this.circuitBreakerManager.resetAll();
|
|
2545
|
+
}
|
|
2546
|
+
/**
|
|
2547
|
+
* Set callback for tools/list changes (v1.4.0)
|
|
2548
|
+
* Called when discovery expires
|
|
2549
|
+
*/
|
|
2550
|
+
setToolsListChangedCallback(callback) {
|
|
2551
|
+
this.onToolsListChanged = callback;
|
|
2552
|
+
}
|
|
2553
|
+
/**
|
|
2554
|
+
* v1.3.72: Set server list provider for proactive schema caching
|
|
2555
|
+
* This allows background refresh to discover all servers, not just running ones
|
|
2556
|
+
*/
|
|
2557
|
+
setServerListProvider(provider) {
|
|
2558
|
+
this.serverListProvider = provider;
|
|
2559
|
+
console.log("[ServerManager] Server list provider set for proactive schema caching");
|
|
2560
|
+
}
|
|
2561
|
+
/**
|
|
2562
|
+
* Phase 2: Set config loader for tool safety classification
|
|
2563
|
+
*/
|
|
2564
|
+
setConfigLoader(configLoader) {
|
|
2565
|
+
this.configLoader = configLoader;
|
|
2566
|
+
}
|
|
2567
|
+
/**
|
|
2568
|
+
* Phase 2: Classify tool safety level: 'safe' (auto-approve) or 'risky' (requires confirmation)
|
|
2569
|
+
* Dynamic classification based on config rules
|
|
2570
|
+
*
|
|
2571
|
+
* @param serverName - Server name (e.g., "memory", "jira-basic-auth")
|
|
2572
|
+
* @param toolName - Tool name (e.g., "create_entities", "search_issues")
|
|
2573
|
+
* @returns Object with safety classification and reason
|
|
2574
|
+
*/
|
|
2575
|
+
classifyToolSafety(serverName, toolName, toolArguments) {
|
|
2576
|
+
if (!this.configLoader) {
|
|
2577
|
+
console.log(`[SafetyClassify] No configLoader set, defaulting to risky for ${serverName}:${toolName}`);
|
|
2578
|
+
return {
|
|
2579
|
+
safety: "risky",
|
|
2580
|
+
reason: "No safety rules configured (default: risky)",
|
|
2581
|
+
};
|
|
2582
|
+
}
|
|
2583
|
+
const rules = this.configLoader.getToolSafetyRules();
|
|
2584
|
+
const fullName = `${serverName}:${toolName}`;
|
|
2585
|
+
// Debug logging
|
|
2586
|
+
console.log(`[SafetyClassify] Checking ${fullName}${toolArguments ? " (with arguments)" : ""}`);
|
|
2587
|
+
console.log(`[SafetyClassify] safeToolOverrides:`, rules.safeToolOverrides);
|
|
2588
|
+
// v1.3.x: Check server-level safety first (highest priority)
|
|
2589
|
+
const serverConfig = this.configLoader.getServer(serverName);
|
|
2590
|
+
if (serverConfig) {
|
|
2591
|
+
const serverSafety = serverConfig.safety;
|
|
2592
|
+
if (serverSafety === "safe") {
|
|
2593
|
+
console.log(`[SafetyClassify] ${fullName} => SAFE (server-level: ${serverName} is trusted)`);
|
|
2594
|
+
return {
|
|
2595
|
+
safety: "safe",
|
|
2596
|
+
reason: `Server '${serverName}' is configured as trusted (all tools safe)`,
|
|
2597
|
+
};
|
|
2598
|
+
}
|
|
2599
|
+
else if (serverSafety === "risky") {
|
|
2600
|
+
console.log(`[SafetyClassify] ${fullName} => RISKY (server-level: ${serverName} is untrusted)`);
|
|
2601
|
+
return {
|
|
2602
|
+
safety: "risky",
|
|
2603
|
+
reason: `Server '${serverName}' is configured as untrusted (all tools risky)`,
|
|
2604
|
+
};
|
|
2605
|
+
}
|
|
2606
|
+
// undefined or 'default' falls through to tool-level classification
|
|
2607
|
+
}
|
|
2608
|
+
// Check explicit risky overrides first (highest priority)
|
|
2609
|
+
if (rules.riskyToolOverrides?.some((pattern) => {
|
|
2610
|
+
// Escape special regex chars, then replace * with .* for wildcard support
|
|
2611
|
+
const escapedPattern = "^" +
|
|
2612
|
+
pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*") +
|
|
2613
|
+
"$";
|
|
2614
|
+
const regex = new RegExp(escapedPattern);
|
|
2615
|
+
const matches = regex.test(fullName);
|
|
2616
|
+
if (matches) {
|
|
2617
|
+
console.log(`[SafetyClassify] ${fullName} matched riskyToolOverride: ${pattern}`);
|
|
2618
|
+
}
|
|
2619
|
+
return matches;
|
|
2620
|
+
})) {
|
|
2621
|
+
console.log(`[SafetyClassify] ${fullName} => RISKY (riskyToolOverrides)`);
|
|
2622
|
+
return { safety: "risky", reason: "Matches risky tool override pattern" };
|
|
2623
|
+
}
|
|
2624
|
+
// Check explicit safe overrides (BEFORE argument inspection)
|
|
2625
|
+
const safeOverrideMatch = rules.safeToolOverrides?.some((pattern) => {
|
|
2626
|
+
// Escape special regex chars, then replace * with .* for wildcard support
|
|
2627
|
+
const escapedPattern = "^" +
|
|
2628
|
+
pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*") +
|
|
2629
|
+
"$";
|
|
2630
|
+
const regex = new RegExp(escapedPattern);
|
|
2631
|
+
const matches = regex.test(fullName);
|
|
2632
|
+
if (matches) {
|
|
2633
|
+
console.log(`[SafetyClassify] ${fullName} matched safeToolOverride: ${pattern} (regex: ${regex})`);
|
|
2634
|
+
}
|
|
2635
|
+
return matches;
|
|
2636
|
+
});
|
|
2637
|
+
if (safeOverrideMatch) {
|
|
2638
|
+
console.log(`[SafetyClassify] ${fullName} => SAFE (safeToolOverrides)`);
|
|
2639
|
+
return { safety: "safe", reason: "Matches safe tool override pattern" };
|
|
2640
|
+
}
|
|
2641
|
+
// v1.1.29: Check argument-level inspection rules
|
|
2642
|
+
if (toolArguments && rules.argumentInspectionRules) {
|
|
2643
|
+
const inspectionResult = this.inspectToolArguments(fullName, toolArguments, rules.argumentInspectionRules);
|
|
2644
|
+
if (inspectionResult) {
|
|
2645
|
+
console.log(`[SafetyClassify] ${fullName} => ${inspectionResult.safety.toUpperCase()} (argument inspection: ${inspectionResult.reason})`);
|
|
2646
|
+
return inspectionResult;
|
|
2647
|
+
}
|
|
2648
|
+
}
|
|
2649
|
+
// Check risky patterns (if matches, it's risky)
|
|
2650
|
+
if (rules.riskyPatterns?.some((pattern) => {
|
|
2651
|
+
const regex = new RegExp(pattern, "i");
|
|
2652
|
+
const matches = regex.test(toolName);
|
|
2653
|
+
if (matches) {
|
|
2654
|
+
console.log(`[SafetyClassify] ${fullName} matched riskyPattern: ${pattern}`);
|
|
2655
|
+
}
|
|
2656
|
+
return matches;
|
|
2657
|
+
})) {
|
|
2658
|
+
console.log(`[SafetyClassify] ${fullName} => RISKY (riskyPatterns)`);
|
|
2659
|
+
return {
|
|
2660
|
+
safety: "risky",
|
|
2661
|
+
reason: "Tool name matches risky pattern (create, update, delete, etc.)",
|
|
2662
|
+
};
|
|
2663
|
+
}
|
|
2664
|
+
// Check safe patterns (if matches, it's safe)
|
|
2665
|
+
if (rules.safePatterns?.some((pattern) => {
|
|
2666
|
+
const regex = new RegExp(pattern, "i");
|
|
2667
|
+
return regex.test(toolName);
|
|
2668
|
+
})) {
|
|
2669
|
+
console.log(`[SafetyClassify] ${fullName} => SAFE (safePatterns)`);
|
|
2670
|
+
return {
|
|
2671
|
+
safety: "safe",
|
|
2672
|
+
reason: "Tool name matches safe pattern (search, get, list, etc.)",
|
|
2673
|
+
};
|
|
2674
|
+
}
|
|
2675
|
+
// v1.1.50: Analyze tool description for safety indicators before defaulting to risky
|
|
2676
|
+
const toolSchema = this.getToolSchema(serverName, toolName);
|
|
2677
|
+
if (toolSchema?.description) {
|
|
2678
|
+
const descResult = this.classifyByDescription(fullName, toolSchema.description);
|
|
2679
|
+
if (descResult) {
|
|
2680
|
+
return descResult;
|
|
2681
|
+
}
|
|
2682
|
+
}
|
|
2683
|
+
// Default to risky (fail-safe: require confirmation for unknown tools)
|
|
2684
|
+
console.log(`[SafetyClassify] ${fullName} => RISKY (default)`);
|
|
2685
|
+
return {
|
|
2686
|
+
safety: "risky",
|
|
2687
|
+
reason: "No matching safety pattern (default: risky)",
|
|
2688
|
+
};
|
|
2689
|
+
}
|
|
2690
|
+
/**
|
|
2691
|
+
* v1.1.50: Classify tool safety based on description analysis
|
|
2692
|
+
* Analyzes tool description for keywords that indicate read-only vs modifying operations
|
|
2693
|
+
*
|
|
2694
|
+
* @param fullName - Full tool name (server:tool)
|
|
2695
|
+
* @param description - Tool description to analyze
|
|
2696
|
+
* @returns Safety classification or null if inconclusive
|
|
2697
|
+
*/
|
|
2698
|
+
classifyByDescription(fullName, description) {
|
|
2699
|
+
const desc = description.toLowerCase();
|
|
2700
|
+
// Keywords that strongly indicate read-only operations
|
|
2701
|
+
const safeKeywords = [
|
|
2702
|
+
/^(gets?|retrieves?|returns?|shows?|displays?|lists?|searches?|fetches?|reads?|views?)\b/,
|
|
2703
|
+
/\b(read[- ]?only|readonly|non[- ]?destructive)\b/,
|
|
2704
|
+
/\breturns?\s+(a\s+)?(list|array|object|string|number|boolean|information|data|details|status|result)/i,
|
|
2705
|
+
/\b(documentation|help|info|metadata|schema|version)\b/,
|
|
2706
|
+
];
|
|
2707
|
+
// Keywords that strongly indicate modifying operations
|
|
2708
|
+
const riskyKeywords = [
|
|
2709
|
+
/^(creates?|deletes?|removes?|updates?|modifies?|changes?|sets?|writes?|adds?|inserts?|drops?|alters?|executes?|runs?|sends?|posts?|puts?)\b/,
|
|
2710
|
+
/\b(will\s+)?(create|delete|remove|update|modify|change|set|write|add|insert|drop|alter|execute|run|send|post|put)\s+(a\s+)?(new\s+)?/i,
|
|
2711
|
+
/\b(destructive|irreversible|permanent)\b/,
|
|
2712
|
+
];
|
|
2713
|
+
// Check for safe indicators
|
|
2714
|
+
for (const pattern of safeKeywords) {
|
|
2715
|
+
if (pattern.test(desc)) {
|
|
2716
|
+
console.log(`[SafetyClassify] ${fullName} => SAFE (description analysis: matches "${pattern}")`);
|
|
2717
|
+
return {
|
|
2718
|
+
safety: "safe",
|
|
2719
|
+
reason: `Description indicates read-only operation`,
|
|
2720
|
+
};
|
|
2721
|
+
}
|
|
2722
|
+
}
|
|
2723
|
+
// Check for risky indicators
|
|
2724
|
+
for (const pattern of riskyKeywords) {
|
|
2725
|
+
if (pattern.test(desc)) {
|
|
2726
|
+
console.log(`[SafetyClassify] ${fullName} => RISKY (description analysis: matches "${pattern}")`);
|
|
2727
|
+
return {
|
|
2728
|
+
safety: "risky",
|
|
2729
|
+
reason: `Description indicates modifying operation`,
|
|
2730
|
+
};
|
|
2731
|
+
}
|
|
2732
|
+
}
|
|
2733
|
+
// Inconclusive - let caller handle default
|
|
2734
|
+
console.log(`[SafetyClassify] ${fullName} description analysis inconclusive`);
|
|
2735
|
+
return null;
|
|
2736
|
+
}
|
|
2737
|
+
/**
|
|
2738
|
+
* v1.3.x: Resolve nested path in object (e.g., "params.path" -> obj.params.path)
|
|
2739
|
+
*
|
|
2740
|
+
* @param obj - Object to extract value from
|
|
2741
|
+
* @param path - Dot-separated path (e.g., "params.path", "body.query.bool")
|
|
2742
|
+
* @returns Value at path or undefined if not found
|
|
2743
|
+
*/
|
|
2744
|
+
resolveNestedPath(obj, path) {
|
|
2745
|
+
const parts = path.split(".");
|
|
2746
|
+
let current = obj;
|
|
2747
|
+
for (const part of parts) {
|
|
2748
|
+
if (current === null || current === undefined) {
|
|
2749
|
+
return undefined;
|
|
2750
|
+
}
|
|
2751
|
+
if (typeof current !== "object") {
|
|
2752
|
+
return undefined;
|
|
2753
|
+
}
|
|
2754
|
+
current = current[part];
|
|
2755
|
+
}
|
|
2756
|
+
return current;
|
|
2757
|
+
}
|
|
2758
|
+
/**
|
|
2759
|
+
* v1.1.29: Inspect tool arguments for command-level safety classification
|
|
2760
|
+
* v1.3.x: Enhanced to support nested paths (e.g., "params.path")
|
|
2761
|
+
*
|
|
2762
|
+
* @param fullToolName - Full tool name (e.g., "ssh:runRemoteCommand")
|
|
2763
|
+
* @param toolArguments - Arguments passed to the tool
|
|
2764
|
+
* @param inspectionRules - Argument inspection rules from config
|
|
2765
|
+
* @returns Safety classification result or null if no matching rule
|
|
2766
|
+
*/
|
|
2767
|
+
inspectToolArguments(fullToolName, toolArguments, inspectionRules) {
|
|
2768
|
+
// v1.1.55: Find matching inspection rule with wildcard support
|
|
2769
|
+
// Supports patterns like "metabase*:execute" to match "metabase:execute", "metabase-foo:execute"
|
|
2770
|
+
const rule = inspectionRules.find((r) => {
|
|
2771
|
+
if (r.tool === fullToolName)
|
|
2772
|
+
return true; // Exact match
|
|
2773
|
+
// Convert wildcard pattern to regex (supports * and ? wildcards)
|
|
2774
|
+
if (r.tool.includes("*") || r.tool.includes("?")) {
|
|
2775
|
+
const regexPattern = r.tool
|
|
2776
|
+
.replace(/[.+^${}()|[\]\\]/g, "\\$&") // Escape regex special chars except * and ?
|
|
2777
|
+
.replace(/\*/g, ".*") // * = any characters
|
|
2778
|
+
.replace(/\?/g, "."); // ? = single character
|
|
2779
|
+
const regex = new RegExp(`^${regexPattern}$`, "i");
|
|
2780
|
+
return regex.test(fullToolName);
|
|
2781
|
+
}
|
|
2782
|
+
return false;
|
|
2783
|
+
});
|
|
2784
|
+
if (!rule) {
|
|
2785
|
+
return null; // No inspection rule for this tool
|
|
2786
|
+
}
|
|
2787
|
+
// v1.3.x: Extract the argument field value with nested path support
|
|
2788
|
+
// e.g., "command" for top-level, "params.path" for nested
|
|
2789
|
+
const argumentValue = rule.argumentField.includes(".")
|
|
2790
|
+
? this.resolveNestedPath(toolArguments, rule.argumentField)
|
|
2791
|
+
: toolArguments[rule.argumentField];
|
|
2792
|
+
if (!argumentValue) {
|
|
2793
|
+
// v1.1.55: If field is optional, skip inspection (return null) instead of marking risky
|
|
2794
|
+
// This allows tools with multiple modes (SQL vs card_id) to fall through to other rules
|
|
2795
|
+
if (rule.optionalField) {
|
|
2796
|
+
console.log(`[ArgumentInspection] ${fullToolName}: Optional field "${rule.argumentField}" missing, skipping inspection`);
|
|
2797
|
+
return null;
|
|
2798
|
+
}
|
|
2799
|
+
console.log(`[ArgumentInspection] ${fullToolName}: Missing argument field "${rule.argumentField}"`);
|
|
2800
|
+
return {
|
|
2801
|
+
safety: "risky",
|
|
2802
|
+
reason: `Missing required argument field for inspection: ${rule.argumentField}`,
|
|
2803
|
+
};
|
|
2804
|
+
}
|
|
2805
|
+
// Handle array of commands (for batch operations)
|
|
2806
|
+
const commands = Array.isArray(argumentValue)
|
|
2807
|
+
? argumentValue
|
|
2808
|
+
: [argumentValue];
|
|
2809
|
+
for (const cmd of commands) {
|
|
2810
|
+
const commandStr = String(cmd);
|
|
2811
|
+
console.log(`[ArgumentInspection] ${fullToolName}: Inspecting command: "${commandStr}"`);
|
|
2812
|
+
// Check risky command patterns first (highest priority)
|
|
2813
|
+
if (rule.riskyCommandPatterns) {
|
|
2814
|
+
for (const pattern of rule.riskyCommandPatterns) {
|
|
2815
|
+
const regex = new RegExp(pattern, "i");
|
|
2816
|
+
if (regex.test(commandStr)) {
|
|
2817
|
+
console.log(`[ArgumentInspection] ${fullToolName}: Command matched risky pattern: ${pattern}`);
|
|
2818
|
+
return {
|
|
2819
|
+
safety: "risky",
|
|
2820
|
+
reason: `Command matches risky pattern: ${pattern}`,
|
|
2821
|
+
};
|
|
2822
|
+
}
|
|
2823
|
+
}
|
|
2824
|
+
}
|
|
2825
|
+
// Check safe command patterns
|
|
2826
|
+
if (rule.safeCommandPatterns) {
|
|
2827
|
+
let matchedSafePattern = false;
|
|
2828
|
+
for (const pattern of rule.safeCommandPatterns) {
|
|
2829
|
+
const regex = new RegExp(pattern, "i");
|
|
2830
|
+
if (regex.test(commandStr)) {
|
|
2831
|
+
console.log(`[ArgumentInspection] ${fullToolName}: Command matched safe pattern: ${pattern}`);
|
|
2832
|
+
matchedSafePattern = true;
|
|
2833
|
+
break;
|
|
2834
|
+
}
|
|
2835
|
+
}
|
|
2836
|
+
if (matchedSafePattern) {
|
|
2837
|
+
continue; // This command is safe, check next
|
|
2838
|
+
}
|
|
2839
|
+
else if (rule.whitelistMode) {
|
|
2840
|
+
// Whitelist mode: reject if not in safe patterns
|
|
2841
|
+
console.log(`[ArgumentInspection] ${fullToolName}: Command NOT in whitelist (whitelist mode enabled)`);
|
|
2842
|
+
return {
|
|
2843
|
+
safety: "risky",
|
|
2844
|
+
reason: "Command not in safe command whitelist",
|
|
2845
|
+
};
|
|
2846
|
+
}
|
|
2847
|
+
else {
|
|
2848
|
+
// Blacklist mode: no match in safe or risky patterns, default to risky
|
|
2849
|
+
console.log(`[ArgumentInspection] ${fullToolName}: Command not matched by any pattern (defaulting to risky)`);
|
|
2850
|
+
return {
|
|
2851
|
+
safety: "risky",
|
|
2852
|
+
reason: "Command does not match any known safe pattern",
|
|
2853
|
+
};
|
|
2854
|
+
}
|
|
2855
|
+
}
|
|
2856
|
+
}
|
|
2857
|
+
// All commands passed inspection
|
|
2858
|
+
console.log(`[ArgumentInspection] ${fullToolName}: All commands safe`);
|
|
2859
|
+
return {
|
|
2860
|
+
safety: "safe",
|
|
2861
|
+
reason: "All commands match safe patterns",
|
|
2862
|
+
};
|
|
2863
|
+
}
|
|
2864
|
+
/**
|
|
2865
|
+
* Phase 2: Enrich tools with safety annotations
|
|
2866
|
+
* Adds annotations field to each tool with safety classification
|
|
2867
|
+
*
|
|
2868
|
+
* @param serverName - Server name
|
|
2869
|
+
* @param tools - Array of tools from server
|
|
2870
|
+
* @returns Tools enriched with annotations
|
|
2871
|
+
*/
|
|
2872
|
+
enrichToolsWithAnnotations(serverName, tools) {
|
|
2873
|
+
return tools.map((tool) => {
|
|
2874
|
+
const classification = this.classifyToolSafety(serverName, tool.name);
|
|
2875
|
+
return {
|
|
2876
|
+
...tool,
|
|
2877
|
+
annotations: {
|
|
2878
|
+
safety: classification.safety,
|
|
2879
|
+
safetyReason: classification.reason,
|
|
2880
|
+
requiresConfirmation: classification.safety === "risky",
|
|
2881
|
+
},
|
|
2882
|
+
};
|
|
2883
|
+
});
|
|
2884
|
+
}
|
|
2885
|
+
/**
|
|
2886
|
+
* Check if server is discovered (v1.4.0)
|
|
2887
|
+
*/
|
|
2888
|
+
isDiscovered(serverName) {
|
|
2889
|
+
return this.discoveredServers.has(serverName);
|
|
2890
|
+
}
|
|
2891
|
+
/**
|
|
2892
|
+
* Refresh discovery timer for active server (v1.4.0)
|
|
2893
|
+
* Called on tool execution to keep active servers available
|
|
2894
|
+
*/
|
|
2895
|
+
refreshDiscoveryTimer(serverName) {
|
|
2896
|
+
if (this.discoveredServers.has(serverName)) {
|
|
2897
|
+
this.resetDiscoveryTimer(serverName);
|
|
2898
|
+
}
|
|
2899
|
+
}
|
|
2900
|
+
/**
|
|
2901
|
+
* Reset/start discovery timer (v1.4.0 - private)
|
|
2902
|
+
* @param serverName Server to reset timer for
|
|
2903
|
+
*/
|
|
2904
|
+
resetDiscoveryTimer(serverName) {
|
|
2905
|
+
// Clear existing timer
|
|
2906
|
+
if (this.discoveryTimers.has(serverName)) {
|
|
2907
|
+
clearTimeout(this.discoveryTimers.get(serverName));
|
|
2908
|
+
}
|
|
2909
|
+
// Don't set timers for base servers (never expire)
|
|
2910
|
+
if (this.baseServers.includes(serverName)) {
|
|
2911
|
+
return;
|
|
2912
|
+
}
|
|
2913
|
+
// Don't set timer if TTL is 0 (disabled)
|
|
2914
|
+
if (this.discoveryTTL === 0) {
|
|
2915
|
+
return;
|
|
2916
|
+
}
|
|
2917
|
+
// Start new timer
|
|
2918
|
+
const timer = setTimeout(() => {
|
|
2919
|
+
this.expireDiscoveredServer(serverName);
|
|
2920
|
+
}, this.discoveryTTL);
|
|
2921
|
+
this.discoveryTimers.set(serverName, timer);
|
|
2922
|
+
}
|
|
2923
|
+
/**
|
|
2924
|
+
* Expire discovered server (v1.4.0 - private)
|
|
2925
|
+
* Removes server from cache and notifies clients
|
|
2926
|
+
*/
|
|
2927
|
+
expireDiscoveredServer(serverName) {
|
|
2928
|
+
// Skip base servers (safety check)
|
|
2929
|
+
if (this.baseServers.includes(serverName)) {
|
|
2930
|
+
return;
|
|
2931
|
+
}
|
|
2932
|
+
// Skip if server has active requests (defer expiration)
|
|
2933
|
+
const serverProcess = this.processes.get(serverName);
|
|
2934
|
+
if (serverProcess && this.hasActiveRequests(serverProcess)) {
|
|
2935
|
+
console.log(`[MetaLink] Deferring expiration of '${serverName}' (active requests)`);
|
|
2936
|
+
this.resetDiscoveryTimer(serverName); // Try again in TTL period
|
|
2937
|
+
return;
|
|
2938
|
+
}
|
|
2939
|
+
// Remove from cache
|
|
2940
|
+
console.log(`[MetaLink] Expiring discovered server: ${serverName}`);
|
|
2941
|
+
this.toolSchemaCache.delete(serverName);
|
|
2942
|
+
this.discoveredServers.delete(serverName);
|
|
2943
|
+
this.discoveryTimers.delete(serverName);
|
|
2944
|
+
// Stop server process if not base server
|
|
2945
|
+
if (this.processes.has(serverName)) {
|
|
2946
|
+
this.stopServer(serverName).catch((err) => {
|
|
2947
|
+
console.warn(`[MetaLink] Failed to stop expired server ${serverName}:`, err);
|
|
2948
|
+
});
|
|
2949
|
+
}
|
|
2950
|
+
// Notify via callback (HTTP server will broadcast)
|
|
2951
|
+
if (this.onToolsListChanged) {
|
|
2952
|
+
this.onToolsListChanged();
|
|
2953
|
+
}
|
|
2954
|
+
}
|
|
2955
|
+
/**
|
|
2956
|
+
* Check if server has active requests (v1.4.0 - private)
|
|
2957
|
+
* Simple heuristic: check if process started recently
|
|
2958
|
+
*/
|
|
2959
|
+
hasActiveRequests(serverProcess) {
|
|
2960
|
+
// Check if there are pending requests for this server
|
|
2961
|
+
const serverName = Array.from(this.processes.entries()).find(([, proc]) => proc === serverProcess)?.[0];
|
|
2962
|
+
if (serverName && this.pendingRequests.has(serverName)) {
|
|
2963
|
+
const pending = this.pendingRequests.get(serverName);
|
|
2964
|
+
if (pending && pending.size > 0) {
|
|
2965
|
+
return true; // Has pending requests
|
|
2966
|
+
}
|
|
2967
|
+
}
|
|
2968
|
+
// Fallback: check if process started recently (< gracefulShutdown timeout)
|
|
2969
|
+
const processAge = Date.now() - (serverProcess.startTime || 0);
|
|
2970
|
+
return processAge < this.timeouts.gracefulShutdown;
|
|
2971
|
+
}
|
|
2972
|
+
/**
|
|
2973
|
+
* Validate tool schemas and log warnings/suggestions
|
|
2974
|
+
* v1.4.0: Schema validation for improved schema quality
|
|
2975
|
+
*/
|
|
2976
|
+
validateToolSchemas(tools, serverName) {
|
|
2977
|
+
const validator = new SchemaValidator();
|
|
2978
|
+
for (const tool of tools) {
|
|
2979
|
+
const result = validator.validateToolSchema(tool);
|
|
2980
|
+
// Log errors
|
|
2981
|
+
if (result.errors.length > 0) {
|
|
2982
|
+
console.error(`[SchemaValidator] ❌ Errors in ${serverName ? `${serverName}-` : ""}${tool.name}:`);
|
|
2983
|
+
for (const error of result.errors) {
|
|
2984
|
+
console.error(` • ${error}`);
|
|
2985
|
+
}
|
|
2986
|
+
}
|
|
2987
|
+
// Log warnings
|
|
2988
|
+
if (result.warnings.length > 0) {
|
|
2989
|
+
console.warn(`[SchemaValidator] ⚠️ Warnings for ${serverName ? `${serverName}-` : ""}${tool.name}:`);
|
|
2990
|
+
for (const warning of result.warnings) {
|
|
2991
|
+
console.warn(` • ${warning.field}: ${warning.message}`);
|
|
2992
|
+
}
|
|
2993
|
+
}
|
|
2994
|
+
// Log suggestions
|
|
2995
|
+
if (result.suggestions && result.suggestions.length > 0) {
|
|
2996
|
+
console.log(`[SchemaValidator] 💡 Suggestions for ${serverName ? `${serverName}-` : ""}${tool.name}:`);
|
|
2997
|
+
for (const suggestion of result.suggestions) {
|
|
2998
|
+
console.log(` • ${suggestion}`);
|
|
2999
|
+
}
|
|
3000
|
+
}
|
|
3001
|
+
// Log inferred required parameters
|
|
3002
|
+
if (result.inferredRequired && result.inferredRequired.length > 0) {
|
|
3003
|
+
console.log(`[SchemaValidator] 🔍 Inferred required params for ${serverName ? `${serverName}-` : ""}${tool.name}: ${result.inferredRequired.join(", ")}`);
|
|
3004
|
+
}
|
|
3005
|
+
}
|
|
3006
|
+
}
|
|
3007
|
+
/**
|
|
3008
|
+
* Cleanup all servers
|
|
3009
|
+
*/
|
|
3010
|
+
async cleanup() {
|
|
3011
|
+
// Clear all discovery timers (v1.4.0)
|
|
3012
|
+
for (const timer of this.discoveryTimers.values()) {
|
|
3013
|
+
clearTimeout(timer);
|
|
3014
|
+
}
|
|
3015
|
+
this.discoveryTimers.clear();
|
|
3016
|
+
// Stop background refresh
|
|
3017
|
+
if (this.backgroundRefreshInterval) {
|
|
3018
|
+
clearInterval(this.backgroundRefreshInterval);
|
|
3019
|
+
this.backgroundRefreshInterval = null;
|
|
3020
|
+
console.log("[SchemaStore] Stopped background refresh");
|
|
3021
|
+
}
|
|
3022
|
+
// Cleanup circuit breakers
|
|
3023
|
+
this.circuitBreakerManager.destroy();
|
|
3024
|
+
console.log("[CircuitBreaker] Cleaned up all circuit breakers");
|
|
3025
|
+
// Flush pending disk writes
|
|
3026
|
+
if (this.schemaStore) {
|
|
3027
|
+
try {
|
|
3028
|
+
await this.schemaStore.flush();
|
|
3029
|
+
}
|
|
3030
|
+
catch (error) {
|
|
3031
|
+
console.warn("[SchemaStore] Failed to flush during cleanup:", error);
|
|
3032
|
+
}
|
|
3033
|
+
}
|
|
3034
|
+
const servers = Array.from(this.processes.keys());
|
|
3035
|
+
for (const serverName of servers) {
|
|
3036
|
+
try {
|
|
3037
|
+
await this.stopServer(serverName);
|
|
3038
|
+
}
|
|
3039
|
+
catch (error) {
|
|
3040
|
+
console.error(`Failed to stop server ${serverName}:`, error);
|
|
3041
|
+
}
|
|
3042
|
+
}
|
|
3043
|
+
// Clear all health check intervals
|
|
3044
|
+
for (const interval of this.healthCheckIntervals.values()) {
|
|
3045
|
+
clearInterval(interval);
|
|
3046
|
+
}
|
|
3047
|
+
this.healthCheckIntervals.clear();
|
|
3048
|
+
this.activeServers.clear();
|
|
3049
|
+
// Clear all auto-restart timers
|
|
3050
|
+
for (const restartInfo of this.restartAttempts.values()) {
|
|
3051
|
+
if (restartInfo.restartTimer) {
|
|
3052
|
+
clearTimeout(restartInfo.restartTimer);
|
|
3053
|
+
}
|
|
3054
|
+
}
|
|
3055
|
+
this.restartAttempts.clear();
|
|
3056
|
+
}
|
|
3057
|
+
/**
|
|
3058
|
+
* Calculate exponential backoff delay for restart attempts
|
|
3059
|
+
*/
|
|
3060
|
+
calculateBackoffDelay(attemptNumber) {
|
|
3061
|
+
const { baseDelay, maxDelay, backoffMultiplier } = this.autoRestartConfig;
|
|
3062
|
+
const exponentialDelay = baseDelay * Math.pow(backoffMultiplier, attemptNumber);
|
|
3063
|
+
return Math.min(exponentialDelay, maxDelay);
|
|
3064
|
+
}
|
|
3065
|
+
/**
|
|
3066
|
+
* Schedule a server restart with exponential backoff
|
|
3067
|
+
*/
|
|
3068
|
+
scheduleServerRestart(serverName) {
|
|
3069
|
+
if (!this.autoRestartConfig.enabled) {
|
|
3070
|
+
console.log(`[AutoRestart] Auto-restart disabled for ${serverName}`);
|
|
3071
|
+
return;
|
|
3072
|
+
}
|
|
3073
|
+
// Get or initialize restart tracking
|
|
3074
|
+
let restartInfo = this.restartAttempts.get(serverName);
|
|
3075
|
+
if (!restartInfo) {
|
|
3076
|
+
restartInfo = {
|
|
3077
|
+
count: 0,
|
|
3078
|
+
lastAttempt: 0,
|
|
3079
|
+
nextRetryTime: 0,
|
|
3080
|
+
};
|
|
3081
|
+
this.restartAttempts.set(serverName, restartInfo);
|
|
3082
|
+
}
|
|
3083
|
+
// Check if we've exceeded max retries
|
|
3084
|
+
if (restartInfo.count >= this.autoRestartConfig.maxRetries) {
|
|
3085
|
+
console.error(`[AutoRestart] ${serverName} has failed ${restartInfo.count} times. ` +
|
|
3086
|
+
`Maximum retry limit (${this.autoRestartConfig.maxRetries}) reached. ` +
|
|
3087
|
+
`Manual intervention required.`);
|
|
3088
|
+
this.emit("server:restart-limit-reached", {
|
|
3089
|
+
serverName,
|
|
3090
|
+
attemptCount: restartInfo.count,
|
|
3091
|
+
maxRetries: this.autoRestartConfig.maxRetries,
|
|
3092
|
+
});
|
|
3093
|
+
return;
|
|
3094
|
+
}
|
|
3095
|
+
// Cancel any existing restart timer
|
|
3096
|
+
if (restartInfo.restartTimer) {
|
|
3097
|
+
clearTimeout(restartInfo.restartTimer);
|
|
3098
|
+
}
|
|
3099
|
+
// Calculate backoff delay
|
|
3100
|
+
const delay = this.calculateBackoffDelay(restartInfo.count);
|
|
3101
|
+
const nextRetryTime = Date.now() + delay;
|
|
3102
|
+
console.log(`[AutoRestart] Scheduling restart attempt ${restartInfo.count + 1}/${this.autoRestartConfig.maxRetries} ` +
|
|
3103
|
+
`for ${serverName} in ${delay}ms (${Math.round(delay / 1000)}s)`);
|
|
3104
|
+
// Update restart tracking
|
|
3105
|
+
restartInfo.count++;
|
|
3106
|
+
restartInfo.nextRetryTime = nextRetryTime;
|
|
3107
|
+
// Schedule the restart
|
|
3108
|
+
restartInfo.restartTimer = setTimeout(async () => {
|
|
3109
|
+
console.log(`[AutoRestart] Attempting restart ${restartInfo.count}/${this.autoRestartConfig.maxRetries} ` +
|
|
3110
|
+
`for ${serverName}`);
|
|
3111
|
+
restartInfo.lastAttempt = Date.now();
|
|
3112
|
+
try {
|
|
3113
|
+
// Attempt to restart the server
|
|
3114
|
+
await this.attemptServerRestart(serverName);
|
|
3115
|
+
// If successful, reset restart tracking
|
|
3116
|
+
console.log(`[AutoRestart] Successfully restarted ${serverName}`);
|
|
3117
|
+
this.resetRestartTracking(serverName);
|
|
3118
|
+
this.emit("server:restart-success", {
|
|
3119
|
+
serverName,
|
|
3120
|
+
attemptCount: restartInfo.count,
|
|
3121
|
+
});
|
|
3122
|
+
}
|
|
3123
|
+
catch (error) {
|
|
3124
|
+
console.error(`[AutoRestart] Restart attempt ${restartInfo.count} failed for ${serverName}: ` +
|
|
3125
|
+
`${error instanceof Error ? error.message : String(error)}`);
|
|
3126
|
+
this.emit("server:restart-failed", {
|
|
3127
|
+
serverName,
|
|
3128
|
+
attemptCount: restartInfo.count,
|
|
3129
|
+
error: error instanceof Error ? error.message : String(error),
|
|
3130
|
+
});
|
|
3131
|
+
// The server will crash again if restart failed, triggering another schedule
|
|
3132
|
+
}
|
|
3133
|
+
}, delay);
|
|
3134
|
+
this.emit("server:restart-scheduled", {
|
|
3135
|
+
serverName,
|
|
3136
|
+
attemptNumber: restartInfo.count,
|
|
3137
|
+
delayMs: delay,
|
|
3138
|
+
nextRetryTime,
|
|
3139
|
+
});
|
|
3140
|
+
}
|
|
3141
|
+
/**
|
|
3142
|
+
* Attempt to restart a failed server
|
|
3143
|
+
*/
|
|
3144
|
+
async attemptServerRestart(serverName) {
|
|
3145
|
+
// Get server config - need to look it up from the server list provider
|
|
3146
|
+
if (!this.serverListProvider) {
|
|
3147
|
+
throw new Error("Server list provider not configured");
|
|
3148
|
+
}
|
|
3149
|
+
const allConfigs = this.serverListProvider();
|
|
3150
|
+
const config = allConfigs.find((c) => c.name === serverName);
|
|
3151
|
+
if (!config) {
|
|
3152
|
+
throw new Error(`No configuration found for server ${serverName}`);
|
|
3153
|
+
}
|
|
3154
|
+
// Clean up old process/client
|
|
3155
|
+
await this.stopServer(serverName);
|
|
3156
|
+
// Wait a brief moment for cleanup
|
|
3157
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
3158
|
+
// Start the server fresh
|
|
3159
|
+
await this.startServer(config);
|
|
3160
|
+
// Verify it started successfully
|
|
3161
|
+
const isRunning = this.isServerActive(serverName);
|
|
3162
|
+
if (!isRunning) {
|
|
3163
|
+
throw new Error(`Server ${serverName} failed to start`);
|
|
3164
|
+
}
|
|
3165
|
+
}
|
|
3166
|
+
/**
|
|
3167
|
+
* Reset restart tracking for a server (called after successful restart)
|
|
3168
|
+
*/
|
|
3169
|
+
resetRestartTracking(serverName) {
|
|
3170
|
+
const restartInfo = this.restartAttempts.get(serverName);
|
|
3171
|
+
if (restartInfo?.restartTimer) {
|
|
3172
|
+
clearTimeout(restartInfo.restartTimer);
|
|
3173
|
+
}
|
|
3174
|
+
this.restartAttempts.delete(serverName);
|
|
3175
|
+
console.log(`[AutoRestart] Reset restart tracking for ${serverName}`);
|
|
3176
|
+
}
|
|
3177
|
+
/**
|
|
3178
|
+
* Get restart status for a server (public API for monitoring)
|
|
3179
|
+
*/
|
|
3180
|
+
getRestartStatus(serverName) {
|
|
3181
|
+
const restartInfo = this.restartAttempts.get(serverName);
|
|
3182
|
+
if (!restartInfo) {
|
|
3183
|
+
return {
|
|
3184
|
+
inProgress: false,
|
|
3185
|
+
attemptCount: 0,
|
|
3186
|
+
nextRetryTime: null,
|
|
3187
|
+
};
|
|
3188
|
+
}
|
|
3189
|
+
return {
|
|
3190
|
+
inProgress: true,
|
|
3191
|
+
attemptCount: restartInfo.count,
|
|
3192
|
+
nextRetryTime: restartInfo.nextRetryTime,
|
|
3193
|
+
};
|
|
3194
|
+
}
|
|
3195
|
+
/**
|
|
3196
|
+
* v1.1.27: Graceful shutdown - clean up all timers and stop all servers
|
|
3197
|
+
* Call this method before process exit to prevent memory leaks and orphaned timers
|
|
3198
|
+
*/
|
|
3199
|
+
async shutdown() {
|
|
3200
|
+
console.log("[ServerManager] Initiating graceful shutdown...");
|
|
3201
|
+
// 1. Clear discovery timers
|
|
3202
|
+
for (const [serverName, timer] of this.discoveryTimers.entries()) {
|
|
3203
|
+
clearTimeout(timer);
|
|
3204
|
+
console.log(`[ServerManager] Cleared discovery timer for ${serverName}`);
|
|
3205
|
+
}
|
|
3206
|
+
this.discoveryTimers.clear();
|
|
3207
|
+
// 2. Clear background refresh interval
|
|
3208
|
+
if (this.backgroundRefreshInterval) {
|
|
3209
|
+
clearInterval(this.backgroundRefreshInterval);
|
|
3210
|
+
this.backgroundRefreshInterval = null;
|
|
3211
|
+
console.log("[ServerManager] Cleared background refresh interval");
|
|
3212
|
+
}
|
|
3213
|
+
// 3. Clear all health check intervals
|
|
3214
|
+
for (const [serverName, interval] of this.healthCheckIntervals.entries()) {
|
|
3215
|
+
clearInterval(interval);
|
|
3216
|
+
console.log(`[ServerManager] Cleared health check interval for ${serverName}`);
|
|
3217
|
+
}
|
|
3218
|
+
this.healthCheckIntervals.clear();
|
|
3219
|
+
// 4. Clear restart timers
|
|
3220
|
+
for (const [serverName, restartInfo] of this.restartAttempts.entries()) {
|
|
3221
|
+
if (restartInfo.restartTimer) {
|
|
3222
|
+
clearTimeout(restartInfo.restartTimer);
|
|
3223
|
+
console.log(`[ServerManager] Cleared restart timer for ${serverName}`);
|
|
3224
|
+
}
|
|
3225
|
+
}
|
|
3226
|
+
this.restartAttempts.clear();
|
|
3227
|
+
// 5. Clear startup mutexes
|
|
3228
|
+
this.startupMutex.clear();
|
|
3229
|
+
// 6. Stop all servers gracefully
|
|
3230
|
+
const serverNames = [...this.processes.keys(), ...this.httpClients.keys()];
|
|
3231
|
+
console.log(`[ServerManager] Stopping ${serverNames.length} servers...`);
|
|
3232
|
+
const stopPromises = serverNames.map(async (serverName) => {
|
|
3233
|
+
try {
|
|
3234
|
+
await this.stopServer(serverName);
|
|
3235
|
+
console.log(`[ServerManager] Stopped server: ${serverName}`);
|
|
3236
|
+
}
|
|
3237
|
+
catch (error) {
|
|
3238
|
+
console.error(`[ServerManager] Error stopping server ${serverName}:`, error);
|
|
3239
|
+
}
|
|
3240
|
+
});
|
|
3241
|
+
await Promise.allSettled(stopPromises);
|
|
3242
|
+
// 7. Clear remaining state
|
|
3243
|
+
this.activeServers.clear();
|
|
3244
|
+
this.discoveredServers.clear();
|
|
3245
|
+
this.toolSchemaCache.clear();
|
|
3246
|
+
this.schemaStabilityMetrics.clear();
|
|
3247
|
+
this.failedDiscoveryAttempts.clear();
|
|
3248
|
+
this.stdoutBuffers.clear();
|
|
3249
|
+
this.stdoutListeners.clear();
|
|
3250
|
+
this.pendingRequests.clear();
|
|
3251
|
+
this.requestIdCounters.clear();
|
|
3252
|
+
console.log("[ServerManager] Shutdown complete");
|
|
3253
|
+
}
|
|
3254
|
+
}
|
|
3255
|
+
//# sourceMappingURL=manager.js.map
|