@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,4253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP Server - Express-based API for MetaLink
|
|
3
|
+
*/
|
|
4
|
+
import express from 'express';
|
|
5
|
+
import rateLimit from 'express-rate-limit';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import { fileURLToPath } from 'url';
|
|
8
|
+
import fs from 'fs';
|
|
9
|
+
import { randomUUID, createHmac } from 'crypto';
|
|
10
|
+
import { ServerManager } from './manager.js';
|
|
11
|
+
import { version } from "../index.js";
|
|
12
|
+
import { calculateTotalTokens } from './token-calculator.js';
|
|
13
|
+
import { globalMetrics, MetricsPersistence, MetricsAggregator } from '../metrics/index.js';
|
|
14
|
+
import { logger, generateRequestId, getOrCreateRequestId } from '../logging/index.js';
|
|
15
|
+
import { getPromptsList, getPrompt } from './prompts.js';
|
|
16
|
+
import { getResourcesList, getResourceTemplatesList, readResource } from './resources.js';
|
|
17
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
// MCP Protocol Version - MUST be set in all HTTP responses per spec 2025-06-18
|
|
19
|
+
const MCP_PROTOCOL_VERSION = '2025-06-18';
|
|
20
|
+
// Custom error class for invalid parameters (JSON-RPC error code -32602)
|
|
21
|
+
class InvalidParamsError extends Error {
|
|
22
|
+
constructor(message) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = 'InvalidParamsError';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
// Custom error class for method not found (JSON-RPC error code -32601)
|
|
28
|
+
class MethodNotFoundError extends Error {
|
|
29
|
+
constructor(method) {
|
|
30
|
+
super(`Method not found: ${method}`);
|
|
31
|
+
this.name = 'MethodNotFoundError';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
// Custom error class for session not initialized (triggers HTTP 404 for MCP spec compliance)
|
|
35
|
+
// Per MCP spec, clients MUST reinitialize when receiving 404 for invalid session
|
|
36
|
+
// Using HTTP 404 allows existing client auto-reinitialize handlers to work
|
|
37
|
+
class SessionNotInitializedError extends Error {
|
|
38
|
+
constructor(method) {
|
|
39
|
+
super(`Session must be initialized before calling ${method}. Call initialize first.`);
|
|
40
|
+
this.name = 'SessionNotInitializedError';
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export class HttpServer {
|
|
44
|
+
constructor(configLoader) {
|
|
45
|
+
this.server = null; // HTTP server instance (keeps event loop alive)
|
|
46
|
+
this.eventClients = new Set();
|
|
47
|
+
// Streamable HTTP session management
|
|
48
|
+
this.sessions = new Map();
|
|
49
|
+
this.SESSION_TIMEOUT = 24 * 60 * 60 * 1000; // 24 hours (security best practice)
|
|
50
|
+
this.sessionCleanupInterval = null;
|
|
51
|
+
// SSE connections for bidirectional communication
|
|
52
|
+
this.sseConnections = new Map();
|
|
53
|
+
// SSE event tracking for Last-Event-ID resumption (per MCP spec 2025-06-18)
|
|
54
|
+
this.sseEventBuffer = new Map(); // sessionId -> events
|
|
55
|
+
this.sseEventCounter = 0;
|
|
56
|
+
this.MAX_EVENTS_PER_SESSION = 100; // Keep last 100 events for resumption
|
|
57
|
+
// SECURITY: Per-server tool execution rate limiting (OWASP DOS Prevention)
|
|
58
|
+
// Prevents denial-of-service attacks by limiting tool calls per server
|
|
59
|
+
this.toolExecutionRateLimiter = new Map();
|
|
60
|
+
this.TOOL_RATE_LIMIT_WINDOW_MS = 60000; // 1 minute window
|
|
61
|
+
this.TOOL_RATE_LIMIT_MAX_CALLS = 100; // Max 100 calls per server per minute
|
|
62
|
+
// SECURITY: Discovery endpoint rate limiting (P1 - OWASP DOS Prevention)
|
|
63
|
+
// Prevents abuse of search_tools and describe_tool endpoints
|
|
64
|
+
this.discoveryRateLimiter = new Map();
|
|
65
|
+
this.DISCOVERY_RATE_LIMIT_WINDOW_MS = 60000; // 1 minute window
|
|
66
|
+
this.DISCOVERY_RATE_LIMIT_MAX_CALLS = 200; // Max 200 discovery calls per session per minute
|
|
67
|
+
// Daemon uptime tracking
|
|
68
|
+
this.startTime = Date.now();
|
|
69
|
+
// === Session Persistence (v1.1.24+) ===
|
|
70
|
+
/**
|
|
71
|
+
* Session persistence file format
|
|
72
|
+
*/
|
|
73
|
+
this.SESSION_PERSIST_VERSION = 1;
|
|
74
|
+
/**
|
|
75
|
+
* Send notifications/tools/list_changed to all connected SSE clients
|
|
76
|
+
* Throttled to max 1 notification per second
|
|
77
|
+
*/
|
|
78
|
+
this.lastToolsListNotification = 0;
|
|
79
|
+
this.TOOLS_LIST_NOTIFICATION_THROTTLE_MS = 1000;
|
|
80
|
+
this.app = express();
|
|
81
|
+
this.configLoader = configLoader;
|
|
82
|
+
this.config = configLoader.getConfig();
|
|
83
|
+
// Pass config with schemasCacheTTL to ServerManager
|
|
84
|
+
this.serverManager = new ServerManager(this.config);
|
|
85
|
+
// v1.4.0: Register callback for discovery expiration notifications
|
|
86
|
+
this.serverManager.setToolsListChangedCallback(() => {
|
|
87
|
+
this.notifyToolsListChanged();
|
|
88
|
+
});
|
|
89
|
+
// v1.3.72: Enable proactive schema caching by providing server list
|
|
90
|
+
this.serverManager.setServerListProvider(() => this.configLoader.getServers());
|
|
91
|
+
// Phase 2: Set config loader for tool safety classification
|
|
92
|
+
this.serverManager.setConfigLoader(this.configLoader);
|
|
93
|
+
// Phase 4 - v1.4.0: Initialize metrics persistence and aggregation
|
|
94
|
+
const metricsDir = process.env.METALINK_METRICS_DIR || '~/.config/metalink';
|
|
95
|
+
this.metricsPersistence = new MetricsPersistence(metricsDir);
|
|
96
|
+
this.metricsAggregator = new MetricsAggregator();
|
|
97
|
+
// Session persistence path (v1.1.24+)
|
|
98
|
+
const configDir = (process.env.METALINK_CONFIG_DIR || '~/.config/metalink').replace(/^~/, process.env.HOME || '~');
|
|
99
|
+
this.sessionPersistPath = path.join(configDir, 'sessions.json');
|
|
100
|
+
// Load persisted metrics on startup
|
|
101
|
+
this.loadPersistedMetrics();
|
|
102
|
+
// Load persisted sessions on startup (v1.1.24+)
|
|
103
|
+
this.loadSessions();
|
|
104
|
+
this.setupMiddleware();
|
|
105
|
+
this.setupRoutes();
|
|
106
|
+
this.setupSSE();
|
|
107
|
+
// Start session cleanup for Streamable HTTP
|
|
108
|
+
this.startSessionCleanup();
|
|
109
|
+
// Start periodic metrics persistence (Phase 4 - v1.4.0)
|
|
110
|
+
this.startMetricsPersistence();
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Get server manager instance
|
|
114
|
+
*/
|
|
115
|
+
getServerManager() {
|
|
116
|
+
return this.serverManager;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Pagination support - Encode cursor data to opaque base64 string
|
|
120
|
+
*/
|
|
121
|
+
encodeCursor(data) {
|
|
122
|
+
return Buffer.from(JSON.stringify(data)).toString('base64');
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Pagination support - Decode cursor from base64 string
|
|
126
|
+
*/
|
|
127
|
+
decodeCursor(cursor) {
|
|
128
|
+
try {
|
|
129
|
+
const decoded = Buffer.from(cursor, 'base64').toString('utf-8');
|
|
130
|
+
return JSON.parse(decoded);
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Pagination support - Truncate string to character limit
|
|
138
|
+
*/
|
|
139
|
+
truncateToChars(value, maxChars) {
|
|
140
|
+
const json = JSON.stringify(value);
|
|
141
|
+
if (json.length <= maxChars) {
|
|
142
|
+
return value;
|
|
143
|
+
}
|
|
144
|
+
// Truncate and add indicator
|
|
145
|
+
const truncated = json.substring(0, maxChars - 20) + '... [truncated]';
|
|
146
|
+
try {
|
|
147
|
+
return JSON.parse(truncated);
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
// If truncated JSON is invalid, return string
|
|
151
|
+
return truncated;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Pagination support - Apply pagination to tool result
|
|
156
|
+
*
|
|
157
|
+
* @param result - Tool execution result (any type)
|
|
158
|
+
* @param params - Pagination parameters from request
|
|
159
|
+
* @returns Paginated result with metadata
|
|
160
|
+
*/
|
|
161
|
+
paginateResult(result, params) {
|
|
162
|
+
const DEFAULT_MAX_CHARS = 50000; // 50KB default limit per MCP spec
|
|
163
|
+
const maxChars = params.max_result_chars ?? DEFAULT_MAX_CHARS;
|
|
164
|
+
// Handle cursor continuation
|
|
165
|
+
let startOffset = 0;
|
|
166
|
+
if (params.cursor) {
|
|
167
|
+
const cursorData = this.decodeCursor(params.cursor);
|
|
168
|
+
if (cursorData) {
|
|
169
|
+
startOffset = cursorData.offset;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
// Check if result is an array and apply max_results
|
|
173
|
+
if (Array.isArray(result) && params.max_results) {
|
|
174
|
+
const totalAvailable = result.length;
|
|
175
|
+
const endOffset = startOffset + params.max_results;
|
|
176
|
+
if (endOffset < totalAvailable) {
|
|
177
|
+
// More results available - create cursor
|
|
178
|
+
const nextCursor = this.encodeCursor({
|
|
179
|
+
offset: endOffset,
|
|
180
|
+
type: 'array'
|
|
181
|
+
});
|
|
182
|
+
return {
|
|
183
|
+
result: result.slice(startOffset, endOffset),
|
|
184
|
+
_pagination: {
|
|
185
|
+
nextCursor,
|
|
186
|
+
totalAvailable,
|
|
187
|
+
truncated: true
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
else if (startOffset > 0) {
|
|
192
|
+
// Last page
|
|
193
|
+
return {
|
|
194
|
+
result: result.slice(startOffset),
|
|
195
|
+
_pagination: {
|
|
196
|
+
totalAvailable,
|
|
197
|
+
truncated: false
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
// Check character limit
|
|
203
|
+
const json = JSON.stringify(result);
|
|
204
|
+
if (json.length > maxChars) {
|
|
205
|
+
// Generate cursor for continuation
|
|
206
|
+
const nextCursor = this.encodeCursor({
|
|
207
|
+
offset: maxChars,
|
|
208
|
+
type: 'chars'
|
|
209
|
+
});
|
|
210
|
+
return {
|
|
211
|
+
result: this.truncateToChars(result, maxChars),
|
|
212
|
+
_pagination: {
|
|
213
|
+
nextCursor,
|
|
214
|
+
totalAvailable: json.length,
|
|
215
|
+
truncated: true
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
// No pagination needed
|
|
220
|
+
return {
|
|
221
|
+
result,
|
|
222
|
+
_pagination: {
|
|
223
|
+
truncated: false
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Setup Express middleware
|
|
229
|
+
*/
|
|
230
|
+
setupMiddleware() {
|
|
231
|
+
// Request ID middleware - generate or extract correlation ID
|
|
232
|
+
this.app.use((req, _res, next) => {
|
|
233
|
+
const requestId = getOrCreateRequestId(req.headers['x-request-id']);
|
|
234
|
+
req.requestId = requestId;
|
|
235
|
+
next();
|
|
236
|
+
});
|
|
237
|
+
// Debug middleware - structured logging for all requests
|
|
238
|
+
this.app.use((req, _res, next) => {
|
|
239
|
+
const requestId = req.requestId;
|
|
240
|
+
logger.debug('Incoming request', {
|
|
241
|
+
requestId,
|
|
242
|
+
method: req.method,
|
|
243
|
+
path: req.path,
|
|
244
|
+
url: req.url,
|
|
245
|
+
userAgent: req.headers['user-agent'],
|
|
246
|
+
});
|
|
247
|
+
next();
|
|
248
|
+
});
|
|
249
|
+
// Metrics middleware (Phase 4 - v1.4.0)
|
|
250
|
+
this.app.use((req, res, next) => {
|
|
251
|
+
const startTime = Date.now();
|
|
252
|
+
const requestId = req.requestId;
|
|
253
|
+
globalMetrics.recordApiRequest();
|
|
254
|
+
res.on('finish', () => {
|
|
255
|
+
const latency = Date.now() - startTime;
|
|
256
|
+
globalMetrics.recordApiResponse(latency);
|
|
257
|
+
if (res.statusCode >= 400) {
|
|
258
|
+
globalMetrics.recordApiError();
|
|
259
|
+
}
|
|
260
|
+
// Log response with correlation ID
|
|
261
|
+
logger.info('Request completed', {
|
|
262
|
+
requestId,
|
|
263
|
+
method: req.method,
|
|
264
|
+
path: req.path,
|
|
265
|
+
status: res.statusCode,
|
|
266
|
+
duration_ms: latency,
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
next();
|
|
270
|
+
});
|
|
271
|
+
// Origin header validation to prevent DNS rebinding attacks
|
|
272
|
+
this.app.use((req, res, next) => {
|
|
273
|
+
// Only validate POST requests (where state changes can occur)
|
|
274
|
+
if (req.method === 'POST') {
|
|
275
|
+
const origin = req.headers.origin;
|
|
276
|
+
const requestId = req.requestId;
|
|
277
|
+
// Allow requests with no Origin header (non-browser clients, Electron apps)
|
|
278
|
+
if (!origin) {
|
|
279
|
+
next();
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
// Parse origin and validate
|
|
283
|
+
try {
|
|
284
|
+
const originUrl = new URL(origin);
|
|
285
|
+
const hostname = originUrl.hostname;
|
|
286
|
+
// Allow localhost, 127.0.0.1, and IPv6 loopback
|
|
287
|
+
const allowedHosts = ['localhost', '127.0.0.1', '[::1]', '::1'];
|
|
288
|
+
if (!allowedHosts.includes(hostname)) {
|
|
289
|
+
logger.warn('Rejected request from unauthorized origin', {
|
|
290
|
+
requestId,
|
|
291
|
+
origin,
|
|
292
|
+
hostname,
|
|
293
|
+
security: 'dns_rebinding_protection',
|
|
294
|
+
});
|
|
295
|
+
res.status(403).json({
|
|
296
|
+
jsonrpc: '2.0',
|
|
297
|
+
error: {
|
|
298
|
+
code: -32600,
|
|
299
|
+
message: 'Invalid Request: Origin not allowed'
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
catch (err) {
|
|
306
|
+
// Invalid origin URL
|
|
307
|
+
logger.warn('Rejected request with malformed origin', {
|
|
308
|
+
requestId,
|
|
309
|
+
origin,
|
|
310
|
+
error: err instanceof Error ? err.message : 'Unknown error',
|
|
311
|
+
security: 'dns_rebinding_protection',
|
|
312
|
+
});
|
|
313
|
+
res.status(403).json({
|
|
314
|
+
jsonrpc: '2.0',
|
|
315
|
+
error: {
|
|
316
|
+
code: -32600,
|
|
317
|
+
message: 'Invalid Request: Malformed origin'
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
next();
|
|
324
|
+
});
|
|
325
|
+
// Raw body parser for MCP endpoints (must come before JSON parser)
|
|
326
|
+
this.app.use('/mcp', express.raw({ type: 'application/json' }));
|
|
327
|
+
this.app.use('/mcp/rpc', express.raw({ type: 'application/json' }));
|
|
328
|
+
this.app.use('/rpc', express.raw({ type: 'application/json' }));
|
|
329
|
+
this.app.use('/rpc/rpc', express.raw({ type: 'application/json' }));
|
|
330
|
+
// JSON body parser for other endpoints
|
|
331
|
+
this.app.use(express.json());
|
|
332
|
+
// Rate limiting
|
|
333
|
+
const daemonConfig = this.config.daemon;
|
|
334
|
+
if (daemonConfig && daemonConfig.rateLimit && daemonConfig.rateLimit.enabled) {
|
|
335
|
+
const limiter = rateLimit({
|
|
336
|
+
windowMs: daemonConfig.rateLimit.windowMs || 60000,
|
|
337
|
+
max: daemonConfig.rateLimit.max || 500,
|
|
338
|
+
keyGenerator: (req) => {
|
|
339
|
+
if (daemonConfig.rateLimit && daemonConfig.rateLimit.keyGenerator === 'user') {
|
|
340
|
+
return req.userId || req.ip || '';
|
|
341
|
+
}
|
|
342
|
+
return req.ip || '';
|
|
343
|
+
},
|
|
344
|
+
});
|
|
345
|
+
this.app.use(limiter);
|
|
346
|
+
}
|
|
347
|
+
// Authentication middleware (if enabled)
|
|
348
|
+
if (daemonConfig && daemonConfig.auth && daemonConfig.auth.enabled) {
|
|
349
|
+
this.app.use(this.authMiddleware.bind(this));
|
|
350
|
+
}
|
|
351
|
+
// Request logging
|
|
352
|
+
this.app.use((req, res, next) => {
|
|
353
|
+
const start = Date.now();
|
|
354
|
+
res.on('finish', () => {
|
|
355
|
+
const duration = Date.now() - start;
|
|
356
|
+
console.log(`[${req.method}] ${req.path} ${res.statusCode} ${duration}ms`);
|
|
357
|
+
});
|
|
358
|
+
next();
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Authentication middleware
|
|
363
|
+
*/
|
|
364
|
+
authMiddleware(req, res, next) {
|
|
365
|
+
const authHeader = req.headers.authorization;
|
|
366
|
+
if (!authHeader) {
|
|
367
|
+
res.status(401).json({ error: 'Missing Authorization header' });
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
const [scheme] = authHeader.split(' ');
|
|
371
|
+
if (scheme !== 'Bearer') {
|
|
372
|
+
res.status(401).json({ error: 'Invalid Authorization scheme' });
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
const token = authHeader.substring(7);
|
|
376
|
+
const secret = this.config?.daemon?.auth?.secret;
|
|
377
|
+
if (!secret) {
|
|
378
|
+
console.warn('[Auth] No auth secret configured, rejecting request');
|
|
379
|
+
res.status(500).json({ error: 'Auth not configured' });
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
const expectedToken = createHmac('sha256', secret)
|
|
383
|
+
.update('metalink-auth-token').digest('hex');
|
|
384
|
+
if (token !== expectedToken) {
|
|
385
|
+
res.status(401).json({ error: 'Invalid authentication token' });
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
req.authenticated = true;
|
|
389
|
+
req.userId = 'authenticated-user';
|
|
390
|
+
next();
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Setup API routes
|
|
394
|
+
*/
|
|
395
|
+
setupRoutes() {
|
|
396
|
+
const api = express.Router();
|
|
397
|
+
// Server endpoints
|
|
398
|
+
api.get('/servers', this.listServers.bind(this));
|
|
399
|
+
api.get('/servers/available/list', this.listAvailableServers.bind(this));
|
|
400
|
+
api.post('/servers/:name/start', this.startServer.bind(this));
|
|
401
|
+
api.post('/servers/:name/stop', this.stopServer.bind(this));
|
|
402
|
+
api.post('/servers/:name/restart', this.restartServer.bind(this));
|
|
403
|
+
api.post('/servers/:name/enable', this.enableServer.bind(this));
|
|
404
|
+
api.post('/servers/:name/disable', this.disableServer.bind(this));
|
|
405
|
+
api.get('/servers/:name/status', this.getServerStatus.bind(this));
|
|
406
|
+
api.get('/servers/:name/info', this.getServerInfo.bind(this));
|
|
407
|
+
api.post('/servers/:name/refresh-tools', this.refreshServerTools.bind(this)); // v1.4.2: Force refresh tools
|
|
408
|
+
// Tool endpoints
|
|
409
|
+
api.get('/servers/:name/tools', this.getServerTools.bind(this));
|
|
410
|
+
api.post('/servers/:name/tools/:toolName/execute', this.executeTool.bind(this));
|
|
411
|
+
// Configuration endpoints
|
|
412
|
+
api.get('/config', this.getConfig.bind(this));
|
|
413
|
+
api.put('/config', this.updateConfig.bind(this));
|
|
414
|
+
// Registry management endpoints
|
|
415
|
+
api.post('/registry/servers', this.addServer.bind(this));
|
|
416
|
+
api.delete('/registry/servers/:name', this.removeServer.bind(this));
|
|
417
|
+
api.post('/registry/validate', this.validateServer.bind(this));
|
|
418
|
+
// Health endpoints
|
|
419
|
+
api.get('/health', this.healthCheck.bind(this));
|
|
420
|
+
// Version endpoint
|
|
421
|
+
api.get('/version', this.getVersion.bind(this));
|
|
422
|
+
// Metrics endpoints (Phase 4 - v1.4.0)
|
|
423
|
+
api.get('/metrics', this.getMetrics.bind(this));
|
|
424
|
+
api.get('/metrics/prometheus', this.getPrometheusMetrics.bind(this));
|
|
425
|
+
api.get('/metrics/api', this.getApiMetrics.bind(this));
|
|
426
|
+
api.get('/metrics/servers/:name', this.getServerMetrics.bind(this));
|
|
427
|
+
api.get('/metrics/hourly', this.getHourlyMetrics.bind(this));
|
|
428
|
+
api.get('/metrics/daily', this.getDailyMetrics.bind(this));
|
|
429
|
+
api.get('/metrics/weekly', this.getWeeklyMetrics.bind(this));
|
|
430
|
+
api.get('/metrics/errors', this.getErrorAnalytics.bind(this));
|
|
431
|
+
api.get('/metrics/errors/:toolName', this.getToolErrorMetrics.bind(this));
|
|
432
|
+
api.get('/metrics/tools', this.getToolMetrics.bind(this)); // Test 187: Tool-specific granular metrics
|
|
433
|
+
// Safety endpoints (Phase 1 - v1.2.0)
|
|
434
|
+
api.get("/safety", this.getSafetyRules.bind(this));
|
|
435
|
+
api.get("/safety/check/:server/:tool", this.checkToolSafety.bind(this));
|
|
436
|
+
api.post("/safety/tools/safe", this.addSafeToolOverride.bind(this));
|
|
437
|
+
api.post("/safety/tools/risky", this.addRiskyToolOverride.bind(this));
|
|
438
|
+
api.post("/safety/patterns/safe", this.addSafePattern.bind(this));
|
|
439
|
+
api.post("/safety/patterns/risky", this.addRiskyPattern.bind(this));
|
|
440
|
+
api.delete("/safety/rules/:rule", this.removeRule.bind(this));
|
|
441
|
+
api.post("/safety/reset", this.resetSafetyRules.bind(this));
|
|
442
|
+
api.post("/safety/import", this.importSafetyRules.bind(this));
|
|
443
|
+
this.app.use('/api/v1', api);
|
|
444
|
+
// Also register /api/metrics endpoint (without /v1 prefix) for backward compatibility with tests
|
|
445
|
+
this.app.get('/api/metrics', this.getMetrics.bind(this));
|
|
446
|
+
// MCP endpoint (JSON-RPC over HTTP) - Primary endpoint per MCP 2025-06-18 spec
|
|
447
|
+
// GET /mcp for streaming/SSE connections (Claude Code HTTP transport)
|
|
448
|
+
this.app.get('/mcp', this.handleMcpRequest.bind(this));
|
|
449
|
+
// POST endpoints for standard JSON-RPC
|
|
450
|
+
this.app.post('/mcp', this.handleMcpRequest.bind(this));
|
|
451
|
+
// DELETE /mcp for session cleanup (per MCP spec)
|
|
452
|
+
this.app.delete('/mcp', this.handleMcpRequest.bind(this));
|
|
453
|
+
// Legacy SSE endpoints for backward compatibility (2025-03-26 and earlier)
|
|
454
|
+
this.app.get('/sse', this.handleMcpRequest.bind(this)); // Legacy SSE endpoint
|
|
455
|
+
this.app.post('/messages', this.handleMcpRequest.bind(this)); // Legacy JSON-RPC endpoint
|
|
456
|
+
// Alternative MCP endpoint paths for compatibility with various clients
|
|
457
|
+
// Grok HTTP transport expects /mcp/rpc path
|
|
458
|
+
this.app.post('/mcp/rpc', this.handleMcpRequest.bind(this));
|
|
459
|
+
// Root-level /rpc for clients that don't use /mcp prefix (e.g., Grok CLI)
|
|
460
|
+
this.app.post('/rpc', this.handleMcpRequest.bind(this));
|
|
461
|
+
// Grok appends /rpc to URL, creating /rpc/rpc when given http://.../rpc
|
|
462
|
+
this.app.post('/rpc/rpc', this.handleMcpRequest.bind(this));
|
|
463
|
+
// Prometheus metrics endpoint (standard /metrics path for scraping)
|
|
464
|
+
this.app.get('/metrics', this.getPrometheusMetrics.bind(this));
|
|
465
|
+
// Serve dashboard static files (if available)
|
|
466
|
+
const dashboardPath = path.join(__dirname, '../../../dashboard/dist');
|
|
467
|
+
const dashboardExists = fs.existsSync(dashboardPath);
|
|
468
|
+
const indexPath = path.join(dashboardPath, 'index.html');
|
|
469
|
+
const indexExists = dashboardExists && fs.existsSync(indexPath);
|
|
470
|
+
console.log('[HTTP] dashboardPath:', dashboardPath);
|
|
471
|
+
console.log('[HTTP] dashboard exists:', dashboardExists);
|
|
472
|
+
console.log('[HTTP] index.html exists:', indexExists);
|
|
473
|
+
// Serve dashboard - explicit root route
|
|
474
|
+
if (indexExists) {
|
|
475
|
+
console.log('[HTTP] Registering GET / route for dashboard');
|
|
476
|
+
this.app.get('/', (_req, res) => {
|
|
477
|
+
console.log('[HTTP] Serving root path from:', indexPath);
|
|
478
|
+
res.sendFile(indexPath);
|
|
479
|
+
});
|
|
480
|
+
console.log('[HTTP] GET / route registered successfully');
|
|
481
|
+
}
|
|
482
|
+
else {
|
|
483
|
+
console.log('[HTTP] Skipping GET / - index.html not found');
|
|
484
|
+
}
|
|
485
|
+
if (dashboardExists) {
|
|
486
|
+
console.log('[HTTP] Registering static file middleware for:', dashboardPath);
|
|
487
|
+
// Serve static assets from dashboard
|
|
488
|
+
this.app.use(express.static(dashboardPath, {
|
|
489
|
+
etag: false
|
|
490
|
+
}));
|
|
491
|
+
console.log('[HTTP] Static middleware registered');
|
|
492
|
+
}
|
|
493
|
+
// SPA fallback - serve index.html for client-side routing (only if dashboard exists)
|
|
494
|
+
if (indexExists) {
|
|
495
|
+
console.log('[HTTP] Registering fallback route (*)');
|
|
496
|
+
this.app.get('*', (_req, res) => {
|
|
497
|
+
console.log('[HTTP] Fallback route hit for:', _req.path);
|
|
498
|
+
res.sendFile(indexPath);
|
|
499
|
+
});
|
|
500
|
+
console.log('[HTTP] Fallback route registered');
|
|
501
|
+
}
|
|
502
|
+
else {
|
|
503
|
+
console.log('[HTTP] Skipping fallback route - dashboard not available');
|
|
504
|
+
}
|
|
505
|
+
// SECURITY: Error handler with sanitization to prevent credential leakage
|
|
506
|
+
// OWASP Reference: A3:2017-Sensitive Data Exposure
|
|
507
|
+
this.app.use((err, _req, res, _next) => {
|
|
508
|
+
// Log full error internally for debugging (not exposed to client)
|
|
509
|
+
console.error('API Error:', err);
|
|
510
|
+
// SECURITY: Sanitize error message before sending to client
|
|
511
|
+
const sanitizedMessage = this.sanitizeErrorMessage(err.message);
|
|
512
|
+
res.status(500).json({
|
|
513
|
+
error: 'Internal Server Error',
|
|
514
|
+
message: sanitizedMessage,
|
|
515
|
+
});
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* SECURITY: Sanitize error messages to prevent credential/path leakage.
|
|
520
|
+
*
|
|
521
|
+
* This function removes or masks sensitive information from error messages
|
|
522
|
+
* before they are sent to clients.
|
|
523
|
+
*
|
|
524
|
+
* OWASP Reference: A3:2017-Sensitive Data Exposure
|
|
525
|
+
*
|
|
526
|
+
* @param message - Original error message
|
|
527
|
+
* @returns Sanitized message safe for client exposure
|
|
528
|
+
*/
|
|
529
|
+
sanitizeErrorMessage(message) {
|
|
530
|
+
if (!message)
|
|
531
|
+
return 'An error occurred';
|
|
532
|
+
// Patterns that indicate sensitive data that should be masked
|
|
533
|
+
const sensitivePatterns = [
|
|
534
|
+
// API keys and tokens (various formats)
|
|
535
|
+
/\b(api[_-]?key|apikey|token|bearer|auth)[=:]\s*['"]?[\w-]{20,}['"]?/gi,
|
|
536
|
+
/\b(sk|pk|rk)[-_][a-zA-Z0-9]{20,}/g, // Stripe-style keys
|
|
537
|
+
/\bghp_[a-zA-Z0-9]{36,}/g, // GitHub tokens
|
|
538
|
+
/\bxox[baprs]-[a-zA-Z0-9-]+/g, // Slack tokens
|
|
539
|
+
// Passwords
|
|
540
|
+
/password[=:]\s*['"]?[^'"\s]+['"]?/gi,
|
|
541
|
+
/pwd[=:]\s*['"]?[^'"\s]+['"]?/gi,
|
|
542
|
+
// Connection strings
|
|
543
|
+
/mongodb(\+srv)?:\/\/[^@]+@/gi,
|
|
544
|
+
/postgres(ql)?:\/\/[^@]+@/gi,
|
|
545
|
+
/mysql:\/\/[^@]+@/gi,
|
|
546
|
+
/redis:\/\/[^@]+@/gi,
|
|
547
|
+
// AWS credentials
|
|
548
|
+
/\bAKIA[A-Z0-9]{16}\b/g,
|
|
549
|
+
/\b[A-Za-z0-9/+=]{40}\b/g, // AWS secret keys (40 chars base64)
|
|
550
|
+
// Home directory paths (may reveal usernames)
|
|
551
|
+
/\/Users\/[a-zA-Z0-9_-]+/g,
|
|
552
|
+
/\/home\/[a-zA-Z0-9_-]+/g,
|
|
553
|
+
/C:\\Users\\[a-zA-Z0-9_-]+/gi,
|
|
554
|
+
// Environment variable dumps
|
|
555
|
+
/\benv\[['"]?[A-Z_]+['"]?\]\s*=\s*['"]?[^'"\s]+['"]?/gi,
|
|
556
|
+
];
|
|
557
|
+
let sanitized = message;
|
|
558
|
+
for (const pattern of sensitivePatterns) {
|
|
559
|
+
sanitized = sanitized.replace(pattern, '[REDACTED]');
|
|
560
|
+
}
|
|
561
|
+
// Additional safety: truncate very long messages that might contain dumps
|
|
562
|
+
if (sanitized.length > 500) {
|
|
563
|
+
sanitized = sanitized.substring(0, 500) + '... [truncated]';
|
|
564
|
+
}
|
|
565
|
+
return sanitized;
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* Setup Server-Sent Events
|
|
569
|
+
*/
|
|
570
|
+
setupSSE() {
|
|
571
|
+
this.app.get('/api/v1/events', (req, res) => {
|
|
572
|
+
// Check authentication
|
|
573
|
+
if (this.config.daemon?.auth?.enabled && !req.authenticated) {
|
|
574
|
+
res.status(401).json({ error: 'Unauthorized' });
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
578
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
579
|
+
res.setHeader('Connection', 'keep-alive');
|
|
580
|
+
// Send initial connection message
|
|
581
|
+
res.write('data: {"type":"connected"}\n\n');
|
|
582
|
+
this.eventClients.add(res);
|
|
583
|
+
// Clean up on disconnect
|
|
584
|
+
req.on('close', () => {
|
|
585
|
+
this.eventClients.delete(res);
|
|
586
|
+
});
|
|
587
|
+
});
|
|
588
|
+
// Broadcast events from server manager
|
|
589
|
+
this.serverManager.on('server:started', (data) => {
|
|
590
|
+
this.broadcastEvent({
|
|
591
|
+
type: 'server:started',
|
|
592
|
+
data,
|
|
593
|
+
});
|
|
594
|
+
});
|
|
595
|
+
this.serverManager.on('server:stopped', (data) => {
|
|
596
|
+
this.broadcastEvent({
|
|
597
|
+
type: 'server:stopped',
|
|
598
|
+
data,
|
|
599
|
+
});
|
|
600
|
+
});
|
|
601
|
+
this.serverManager.on('server:error', (data) => {
|
|
602
|
+
this.broadcastEvent({
|
|
603
|
+
type: 'server:error',
|
|
604
|
+
data,
|
|
605
|
+
});
|
|
606
|
+
});
|
|
607
|
+
this.serverManager.on('health:check', (data) => {
|
|
608
|
+
this.broadcastEvent({
|
|
609
|
+
type: 'health:check',
|
|
610
|
+
data,
|
|
611
|
+
});
|
|
612
|
+
});
|
|
613
|
+
// Listen for server removal events (from removeServer method)
|
|
614
|
+
this.serverManager.on('server:removed', (data) => {
|
|
615
|
+
this.broadcastEvent({
|
|
616
|
+
type: 'server:removed',
|
|
617
|
+
data,
|
|
618
|
+
});
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Broadcast event to all SSE clients
|
|
623
|
+
*/
|
|
624
|
+
broadcastEvent(event) {
|
|
625
|
+
const message = `data: ${JSON.stringify(event)}\n\n`;
|
|
626
|
+
for (const client of this.eventClients) {
|
|
627
|
+
client.write(message);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
// === Route Handlers ===
|
|
631
|
+
/**
|
|
632
|
+
* List all servers
|
|
633
|
+
*/
|
|
634
|
+
async listServers(_req, res) {
|
|
635
|
+
try {
|
|
636
|
+
const servers = this.configLoader.getServers();
|
|
637
|
+
const serverInfos = servers.map((config) => {
|
|
638
|
+
const isStdio = config.transport === 'stdio' || config.transport === undefined;
|
|
639
|
+
const tools = this.serverManager.getServerTools(config.name);
|
|
640
|
+
const baseInfo = {
|
|
641
|
+
name: config.name,
|
|
642
|
+
env: config.env || {},
|
|
643
|
+
status: (this.serverManager.getServerStatus(config.name)?.status || 'stopped'),
|
|
644
|
+
process: this.serverManager.getServerStatus(config.name),
|
|
645
|
+
toolCount: tools.length || 0,
|
|
646
|
+
tokenEstimate: calculateTotalTokens(tools),
|
|
647
|
+
};
|
|
648
|
+
return isStdio
|
|
649
|
+
? {
|
|
650
|
+
...baseInfo,
|
|
651
|
+
command: config.command,
|
|
652
|
+
args: config.args || [],
|
|
653
|
+
}
|
|
654
|
+
: {
|
|
655
|
+
...baseInfo,
|
|
656
|
+
transport: config.transport,
|
|
657
|
+
url: config.url,
|
|
658
|
+
};
|
|
659
|
+
});
|
|
660
|
+
res.json({ servers: serverInfos });
|
|
661
|
+
}
|
|
662
|
+
catch (error) {
|
|
663
|
+
res.status(500).json({
|
|
664
|
+
error: error instanceof Error ? error.message : 'Failed to list servers',
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* List all available servers (including disabled ones)
|
|
670
|
+
*/
|
|
671
|
+
async listAvailableServers(_req, res) {
|
|
672
|
+
try {
|
|
673
|
+
const allServers = this.configLoader.getAllServers();
|
|
674
|
+
const exposedServers = this.configLoader.getServers();
|
|
675
|
+
const exposedNames = new Set(exposedServers.map(s => s.name));
|
|
676
|
+
const serverInfos = allServers.map((config) => {
|
|
677
|
+
const isStdio = config.transport === 'stdio' || config.transport === undefined;
|
|
678
|
+
const tools = this.serverManager.getServerTools(config.name);
|
|
679
|
+
const baseInfo = {
|
|
680
|
+
name: config.name,
|
|
681
|
+
type: isStdio ? 'stdio' : 'http',
|
|
682
|
+
env: config.env || {},
|
|
683
|
+
status: (this.serverManager.getServerStatus(config.name)?.status || 'stopped'),
|
|
684
|
+
process: this.serverManager.getServerStatus(config.name),
|
|
685
|
+
enabled: exposedNames.has(config.name),
|
|
686
|
+
toolCount: tools.length || 0,
|
|
687
|
+
tokenEstimate: calculateTotalTokens(tools),
|
|
688
|
+
};
|
|
689
|
+
return isStdio
|
|
690
|
+
? {
|
|
691
|
+
...baseInfo,
|
|
692
|
+
command: config.command,
|
|
693
|
+
args: config.args || [],
|
|
694
|
+
fullCommand: `${config.command} ${(config.args || []).join(' ')}`.trim(),
|
|
695
|
+
}
|
|
696
|
+
: {
|
|
697
|
+
...baseInfo,
|
|
698
|
+
transport: config.transport,
|
|
699
|
+
url: config.url,
|
|
700
|
+
};
|
|
701
|
+
});
|
|
702
|
+
res.json({ servers: serverInfos });
|
|
703
|
+
}
|
|
704
|
+
catch (error) {
|
|
705
|
+
res.status(500).json({
|
|
706
|
+
error: error instanceof Error ? error.message : 'Failed to list available servers',
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* Enable a server (add to EXPOSE_SERVERS)
|
|
712
|
+
*/
|
|
713
|
+
async enableServer(req, res) {
|
|
714
|
+
try {
|
|
715
|
+
const { name } = req.params;
|
|
716
|
+
const config = this.configLoader.getServer(name);
|
|
717
|
+
if (!config) {
|
|
718
|
+
res.status(404).json({ error: `Server ${name} not found` });
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
// Get current EXPOSE_SERVERS list
|
|
722
|
+
const currentExposed = process.env.EXPOSE_SERVERS
|
|
723
|
+
? process.env.EXPOSE_SERVERS.split(',').map(s => s.trim())
|
|
724
|
+
: (this.config.base_servers || []);
|
|
725
|
+
// Add server if not already exposed
|
|
726
|
+
if (!currentExposed.includes(name)) {
|
|
727
|
+
currentExposed.push(name);
|
|
728
|
+
process.env.EXPOSE_SERVERS = currentExposed.join(',');
|
|
729
|
+
}
|
|
730
|
+
res.json({ success: true, message: `Server ${name} enabled` });
|
|
731
|
+
}
|
|
732
|
+
catch (error) {
|
|
733
|
+
res.status(400).json({
|
|
734
|
+
error: error instanceof Error ? error.message : 'Failed to enable server',
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* Disable a server (remove from EXPOSE_SERVERS)
|
|
740
|
+
*/
|
|
741
|
+
async disableServer(req, res) {
|
|
742
|
+
try {
|
|
743
|
+
const { name } = req.params;
|
|
744
|
+
// Get current EXPOSE_SERVERS list
|
|
745
|
+
const currentExposed = process.env.EXPOSE_SERVERS
|
|
746
|
+
? process.env.EXPOSE_SERVERS.split(',').map(s => s.trim())
|
|
747
|
+
: (this.config.base_servers || []);
|
|
748
|
+
// Remove server if exposed
|
|
749
|
+
const index = currentExposed.indexOf(name);
|
|
750
|
+
if (index >= 0) {
|
|
751
|
+
currentExposed.splice(index, 1);
|
|
752
|
+
process.env.EXPOSE_SERVERS = currentExposed.join(',');
|
|
753
|
+
}
|
|
754
|
+
res.json({ success: true, message: `Server ${name} disabled` });
|
|
755
|
+
}
|
|
756
|
+
catch (error) {
|
|
757
|
+
res.status(400).json({
|
|
758
|
+
error: error instanceof Error ? error.message : 'Failed to disable server',
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
/**
|
|
763
|
+
* Start a server
|
|
764
|
+
*/
|
|
765
|
+
async startServer(req, res) {
|
|
766
|
+
try {
|
|
767
|
+
const { name } = req.params;
|
|
768
|
+
const config = this.configLoader.getServer(name);
|
|
769
|
+
if (!config) {
|
|
770
|
+
res.status(404).json({ error: `Server ${name} not found in configuration` });
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
const process = await this.serverManager.startServer(config);
|
|
774
|
+
res.json({
|
|
775
|
+
success: true,
|
|
776
|
+
process,
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
catch (error) {
|
|
780
|
+
res.status(400).json({
|
|
781
|
+
error: error instanceof Error ? error.message : 'Failed to start server',
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
/**
|
|
786
|
+
* Stop a server
|
|
787
|
+
*/
|
|
788
|
+
async stopServer(req, res) {
|
|
789
|
+
try {
|
|
790
|
+
const { name } = req.params;
|
|
791
|
+
await this.serverManager.stopServer(name);
|
|
792
|
+
res.json({
|
|
793
|
+
success: true,
|
|
794
|
+
message: `Server ${name} stopped`,
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
catch (error) {
|
|
798
|
+
res.status(400).json({
|
|
799
|
+
error: error instanceof Error ? error.message : 'Failed to stop server',
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
/**
|
|
804
|
+
* Restart a server
|
|
805
|
+
*/
|
|
806
|
+
async restartServer(req, res) {
|
|
807
|
+
try {
|
|
808
|
+
const { name } = req.params;
|
|
809
|
+
const config = this.configLoader.getServer(name);
|
|
810
|
+
if (!config) {
|
|
811
|
+
res.status(404).json({ error: `Server ${name} not found in configuration` });
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
const process = await this.serverManager.restartServer(config);
|
|
815
|
+
res.json({
|
|
816
|
+
success: true,
|
|
817
|
+
process,
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
catch (error) {
|
|
821
|
+
res.status(400).json({
|
|
822
|
+
error: error instanceof Error ? error.message : 'Failed to restart server',
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
/**
|
|
827
|
+
* Force refresh tools for a server
|
|
828
|
+
* v1.4.2: Clears cache and re-discovers tools from the MCP server
|
|
829
|
+
*/
|
|
830
|
+
async refreshServerTools(req, res) {
|
|
831
|
+
try {
|
|
832
|
+
const { name } = req.params;
|
|
833
|
+
const config = this.configLoader.getServer(name);
|
|
834
|
+
if (!config) {
|
|
835
|
+
res.status(404).json({ error: `Server ${name} not found in configuration` });
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
console.log(`[HTTP] Force refreshing tools for ${name}...`);
|
|
839
|
+
const tools = await this.serverManager.forceRefreshSchema(name, config);
|
|
840
|
+
res.json({
|
|
841
|
+
success: true,
|
|
842
|
+
serverName: name,
|
|
843
|
+
toolCount: tools.length,
|
|
844
|
+
tools: tools.map(t => t.name),
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
catch (error) {
|
|
848
|
+
res.status(400).json({
|
|
849
|
+
error: error instanceof Error ? error.message : 'Failed to refresh server tools',
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
/**
|
|
854
|
+
* Get server status
|
|
855
|
+
*/
|
|
856
|
+
async getServerStatus(req, res) {
|
|
857
|
+
try {
|
|
858
|
+
const { name } = req.params;
|
|
859
|
+
// Check if server exists in registry first
|
|
860
|
+
const serverConfig = this.configLoader.getServer(name);
|
|
861
|
+
if (!serverConfig) {
|
|
862
|
+
res.status(404).json({ error: `Server ${name} not found` });
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
// Get runtime status (may be undefined if stopped)
|
|
866
|
+
const status = this.serverManager.getServerStatus(name);
|
|
867
|
+
// If no runtime status, server exists but is stopped
|
|
868
|
+
if (!status) {
|
|
869
|
+
res.json({
|
|
870
|
+
server: name,
|
|
871
|
+
status: {
|
|
872
|
+
pid: 0,
|
|
873
|
+
status: 'stopped',
|
|
874
|
+
uptime: 0,
|
|
875
|
+
lastHealthCheck: 0,
|
|
876
|
+
errorCount: 0
|
|
877
|
+
}
|
|
878
|
+
});
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
res.json({ server: name, status });
|
|
882
|
+
}
|
|
883
|
+
catch (error) {
|
|
884
|
+
res.status(500).json({
|
|
885
|
+
error: error instanceof Error ? error.message : 'Failed to get server status',
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
/**
|
|
890
|
+
* Get full server information including configuration and runtime status
|
|
891
|
+
* v1.1.29: Include tools list and support ?forceDiscovery=true to rediscover
|
|
892
|
+
*/
|
|
893
|
+
async getServerInfo(req, res) {
|
|
894
|
+
try {
|
|
895
|
+
const { name } = req.params;
|
|
896
|
+
const forceDiscovery = req.query.forceDiscovery === 'true';
|
|
897
|
+
// Get server configuration from registry
|
|
898
|
+
const serverConfig = this.configLoader.getServer(name);
|
|
899
|
+
if (!serverConfig) {
|
|
900
|
+
res.status(404).json({ error: `Server ${name} not found` });
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
// If forceDiscovery is requested, start server and refresh tools
|
|
904
|
+
if (forceDiscovery) {
|
|
905
|
+
console.log(`[ServerInfo] Force discovery requested for '${name}'`);
|
|
906
|
+
try {
|
|
907
|
+
// v1.1.47: Clear cache before rediscovery to force fresh tool fetch
|
|
908
|
+
// This ensures forceDiscovery actually queries the server, not cache
|
|
909
|
+
this.serverManager.invalidateServerSchema(name);
|
|
910
|
+
console.log(`[ServerInfo] Invalidated cache for '${name}'`);
|
|
911
|
+
await this.serverManager.ensureServerStarted(name, serverConfig);
|
|
912
|
+
console.log(`[ServerInfo] Rediscovered tools for '${name}'`);
|
|
913
|
+
}
|
|
914
|
+
catch (discoveryError) {
|
|
915
|
+
console.warn(`[ServerInfo] Force discovery failed for '${name}':`, discoveryError);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
// Get runtime status
|
|
919
|
+
const runtimeStatus = this.serverManager.getServerStatus(name);
|
|
920
|
+
// Get tools from cache (memory or disk)
|
|
921
|
+
const tools = this.serverManager.getServerTools(name);
|
|
922
|
+
// Get list of exposed servers to determine if this server is enabled
|
|
923
|
+
const exposedServers = this.configLoader.getServers();
|
|
924
|
+
const enabled = exposedServers.some((s) => s.name === name);
|
|
925
|
+
// Build response - different fields for stdio vs HTTP servers
|
|
926
|
+
const isStdio = serverConfig.transport === 'stdio' || serverConfig.transport === undefined;
|
|
927
|
+
const baseResponse = {
|
|
928
|
+
name: serverConfig.name,
|
|
929
|
+
status: runtimeStatus?.status || 'stopped',
|
|
930
|
+
enabled,
|
|
931
|
+
pid: runtimeStatus?.pid,
|
|
932
|
+
uptime: runtimeStatus?.uptime,
|
|
933
|
+
lastHealthCheck: runtimeStatus?.lastHealthCheck,
|
|
934
|
+
errorCount: runtimeStatus?.errorCount,
|
|
935
|
+
tools: tools.map(t => t.name),
|
|
936
|
+
toolCount: tools.length,
|
|
937
|
+
};
|
|
938
|
+
const response = isStdio
|
|
939
|
+
? {
|
|
940
|
+
...baseResponse,
|
|
941
|
+
command: serverConfig.command,
|
|
942
|
+
args: serverConfig.args || [],
|
|
943
|
+
env: serverConfig.env || {},
|
|
944
|
+
}
|
|
945
|
+
: {
|
|
946
|
+
...baseResponse,
|
|
947
|
+
transport: serverConfig.transport,
|
|
948
|
+
url: serverConfig.url,
|
|
949
|
+
auth: serverConfig.auth,
|
|
950
|
+
env: serverConfig.env || {},
|
|
951
|
+
};
|
|
952
|
+
res.json(response);
|
|
953
|
+
}
|
|
954
|
+
catch (error) {
|
|
955
|
+
res.status(500).json({
|
|
956
|
+
error: error instanceof Error ? error.message : 'Failed to get server info',
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
/**
|
|
961
|
+
* Get server tools (stub - Phase 2)
|
|
962
|
+
*/
|
|
963
|
+
async getServerTools(req, res) {
|
|
964
|
+
try {
|
|
965
|
+
const { name } = req.params;
|
|
966
|
+
const status = this.serverManager.getServerStatus(name);
|
|
967
|
+
if (!status || status.status !== 'running') {
|
|
968
|
+
res.status(400).json({ error: `Server ${name} is not running` });
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
// Get tools from ServerManager cache (populated during server start)
|
|
972
|
+
const tools = this.serverManager.getServerTools(name);
|
|
973
|
+
res.json({ server: name, tools });
|
|
974
|
+
}
|
|
975
|
+
catch (error) {
|
|
976
|
+
res.status(500).json({
|
|
977
|
+
error: error instanceof Error ? error.message : 'Failed to get server tools',
|
|
978
|
+
});
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
/**
|
|
982
|
+
* Execute tool (stub - Phase 2)
|
|
983
|
+
*/
|
|
984
|
+
async executeTool(req, res) {
|
|
985
|
+
try {
|
|
986
|
+
const { name, toolName } = req.params;
|
|
987
|
+
const { arguments: args } = req.body;
|
|
988
|
+
const status = this.serverManager.getServerStatus(name);
|
|
989
|
+
if (!status || status.status !== 'running') {
|
|
990
|
+
res.status(400).json({ error: `Server ${name} is not running` });
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
// Execute tool via ServerManager
|
|
994
|
+
const result = await this.serverManager.callTool(name, toolName, args);
|
|
995
|
+
const response = {
|
|
996
|
+
success: true,
|
|
997
|
+
result,
|
|
998
|
+
};
|
|
999
|
+
res.json(response);
|
|
1000
|
+
}
|
|
1001
|
+
catch (error) {
|
|
1002
|
+
const response = {
|
|
1003
|
+
success: false,
|
|
1004
|
+
error: error instanceof Error ? error.message : 'Failed to execute tool',
|
|
1005
|
+
};
|
|
1006
|
+
res.status(500).json(response);
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
/**
|
|
1010
|
+
* Get configuration
|
|
1011
|
+
*/
|
|
1012
|
+
async getConfig(_req, res) {
|
|
1013
|
+
try {
|
|
1014
|
+
const config = this.configLoader.getConfig();
|
|
1015
|
+
res.json(config);
|
|
1016
|
+
}
|
|
1017
|
+
catch (error) {
|
|
1018
|
+
res.status(500).json({
|
|
1019
|
+
error: error instanceof Error ? error.message : 'Failed to get configuration',
|
|
1020
|
+
});
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
/**
|
|
1024
|
+
* Update configuration (stub - Phase 2)
|
|
1025
|
+
*/
|
|
1026
|
+
async updateConfig(req, res) {
|
|
1027
|
+
try {
|
|
1028
|
+
// Extract config updates from request body
|
|
1029
|
+
const updates = req.body;
|
|
1030
|
+
if (!updates || typeof updates !== 'object') {
|
|
1031
|
+
res.status(400).json({ error: 'Invalid configuration: expected object' });
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
// Handle specific config sections that can be updated
|
|
1035
|
+
if (updates.toolSafetyRules) {
|
|
1036
|
+
await this.configLoader.setToolSafetyRules(updates.toolSafetyRules);
|
|
1037
|
+
}
|
|
1038
|
+
// Reload configuration to pick up any file-based changes
|
|
1039
|
+
await this.configLoader.reload();
|
|
1040
|
+
res.json({ success: true, message: 'Configuration updated' });
|
|
1041
|
+
}
|
|
1042
|
+
catch (error) {
|
|
1043
|
+
res.status(400).json({
|
|
1044
|
+
error: error instanceof Error ? error.message : 'Failed to update configuration',
|
|
1045
|
+
});
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
/**
|
|
1049
|
+
* Health check endpoint
|
|
1050
|
+
*/
|
|
1051
|
+
async healthCheck(_req, res) {
|
|
1052
|
+
try {
|
|
1053
|
+
// Calculate daemon uptime
|
|
1054
|
+
const uptimeMs = Date.now() - this.startTime;
|
|
1055
|
+
const uptimeSeconds = Math.floor(uptimeMs / 1000);
|
|
1056
|
+
// Get base servers from config
|
|
1057
|
+
const baseServers = this.config.base_servers || [];
|
|
1058
|
+
// Build health checks for base servers
|
|
1059
|
+
const baseServerChecks = {};
|
|
1060
|
+
let healthyCount = 0;
|
|
1061
|
+
let degradedCount = 0;
|
|
1062
|
+
for (const serverName of baseServers) {
|
|
1063
|
+
try {
|
|
1064
|
+
const isActive = this.serverManager.isServerActive(serverName);
|
|
1065
|
+
if (isActive) {
|
|
1066
|
+
const tools = this.serverManager.getServerTools(serverName);
|
|
1067
|
+
baseServerChecks[serverName] = {
|
|
1068
|
+
status: 'healthy',
|
|
1069
|
+
tools_count: tools.length
|
|
1070
|
+
};
|
|
1071
|
+
healthyCount++;
|
|
1072
|
+
}
|
|
1073
|
+
else {
|
|
1074
|
+
baseServerChecks[serverName] = {
|
|
1075
|
+
status: 'unhealthy',
|
|
1076
|
+
error: 'Server not running'
|
|
1077
|
+
};
|
|
1078
|
+
degradedCount++;
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
catch (error) {
|
|
1082
|
+
baseServerChecks[serverName] = {
|
|
1083
|
+
status: 'unhealthy',
|
|
1084
|
+
error: error instanceof Error ? error.message : 'Unknown error'
|
|
1085
|
+
};
|
|
1086
|
+
degradedCount++;
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
// Determine overall status
|
|
1090
|
+
let overallStatus;
|
|
1091
|
+
let httpStatusCode;
|
|
1092
|
+
if (degradedCount === 0) {
|
|
1093
|
+
// All base servers are healthy
|
|
1094
|
+
overallStatus = 'healthy';
|
|
1095
|
+
httpStatusCode = 200;
|
|
1096
|
+
}
|
|
1097
|
+
else if (healthyCount > 0) {
|
|
1098
|
+
// Some base servers are down but not all
|
|
1099
|
+
overallStatus = 'degraded';
|
|
1100
|
+
httpStatusCode = 200; // Still operational
|
|
1101
|
+
}
|
|
1102
|
+
else {
|
|
1103
|
+
// All base servers are down or critical failure
|
|
1104
|
+
overallStatus = 'unhealthy';
|
|
1105
|
+
httpStatusCode = 503; // Service Unavailable
|
|
1106
|
+
}
|
|
1107
|
+
const health = {
|
|
1108
|
+
status: overallStatus,
|
|
1109
|
+
version,
|
|
1110
|
+
uptime_seconds: uptimeSeconds,
|
|
1111
|
+
checks: {
|
|
1112
|
+
daemon: {
|
|
1113
|
+
status: 'healthy'
|
|
1114
|
+
},
|
|
1115
|
+
base_servers: baseServerChecks
|
|
1116
|
+
}
|
|
1117
|
+
};
|
|
1118
|
+
res.status(httpStatusCode).json(health);
|
|
1119
|
+
}
|
|
1120
|
+
catch (error) {
|
|
1121
|
+
// Critical daemon error
|
|
1122
|
+
res.status(503).json({
|
|
1123
|
+
status: 'unhealthy',
|
|
1124
|
+
version,
|
|
1125
|
+
uptime_seconds: Math.floor((Date.now() - this.startTime) / 1000),
|
|
1126
|
+
checks: {
|
|
1127
|
+
daemon: {
|
|
1128
|
+
status: 'unhealthy',
|
|
1129
|
+
error: error instanceof Error ? error.message : 'Health check failed'
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
});
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
/**
|
|
1136
|
+
* Get MetaLink version
|
|
1137
|
+
*/
|
|
1138
|
+
async getVersion(_req, res) {
|
|
1139
|
+
try {
|
|
1140
|
+
res.json({ version });
|
|
1141
|
+
}
|
|
1142
|
+
catch (error) {
|
|
1143
|
+
res.status(500).json({
|
|
1144
|
+
error: error instanceof Error ? error.message : 'Failed to get version',
|
|
1145
|
+
});
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
// === Metrics Endpoints (Phase 4 - v1.4.0) ===
|
|
1149
|
+
/**
|
|
1150
|
+
* GET /api/v1/metrics - JSON metrics endpoint
|
|
1151
|
+
*/
|
|
1152
|
+
async getMetrics(_req, res) {
|
|
1153
|
+
try {
|
|
1154
|
+
res.json({
|
|
1155
|
+
version,
|
|
1156
|
+
timestamp: Date.now(),
|
|
1157
|
+
api: globalMetrics.getApiMetrics(),
|
|
1158
|
+
servers: globalMetrics.getAllServerMetrics(),
|
|
1159
|
+
tools: globalMetrics.getMetrics(),
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1162
|
+
catch (error) {
|
|
1163
|
+
res.status(500).json({
|
|
1164
|
+
error: error instanceof Error ? error.message : 'Failed to get metrics',
|
|
1165
|
+
});
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
/**
|
|
1169
|
+
* GET /api/v1/metrics/prometheus - Prometheus export endpoint
|
|
1170
|
+
*/
|
|
1171
|
+
async getPrometheusMetrics(_req, res) {
|
|
1172
|
+
try {
|
|
1173
|
+
res.set('Content-Type', 'text/plain');
|
|
1174
|
+
res.send(globalMetrics.exportPrometheus());
|
|
1175
|
+
}
|
|
1176
|
+
catch (error) {
|
|
1177
|
+
res.status(500).send(`# Error exporting metrics: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
/**
|
|
1181
|
+
* GET /api/v1/metrics/api - API-level metrics
|
|
1182
|
+
*/
|
|
1183
|
+
async getApiMetrics(_req, res) {
|
|
1184
|
+
try {
|
|
1185
|
+
res.json(globalMetrics.getApiMetrics());
|
|
1186
|
+
}
|
|
1187
|
+
catch (error) {
|
|
1188
|
+
res.status(500).json({
|
|
1189
|
+
error: error instanceof Error ? error.message : 'Failed to get API metrics',
|
|
1190
|
+
});
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
/**
|
|
1194
|
+
* GET /api/v1/metrics/servers/:name - Server-specific metrics
|
|
1195
|
+
*/
|
|
1196
|
+
async getServerMetrics(req, res) {
|
|
1197
|
+
try {
|
|
1198
|
+
const { name } = req.params;
|
|
1199
|
+
const serverMetrics = globalMetrics.getServerMetrics(name);
|
|
1200
|
+
if (!serverMetrics) {
|
|
1201
|
+
res.status(404).json({ error: `Server '${name}' not found or no metrics available` });
|
|
1202
|
+
return;
|
|
1203
|
+
}
|
|
1204
|
+
res.json(serverMetrics);
|
|
1205
|
+
}
|
|
1206
|
+
catch (error) {
|
|
1207
|
+
res.status(500).json({
|
|
1208
|
+
error: error instanceof Error ? error.message : 'Failed to get server metrics',
|
|
1209
|
+
});
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
/**
|
|
1213
|
+
* GET /api/v1/metrics/hourly - Hourly aggregation
|
|
1214
|
+
*/
|
|
1215
|
+
async getHourlyMetrics(req, res) {
|
|
1216
|
+
try {
|
|
1217
|
+
const hours = parseInt(req.query.hours || '24');
|
|
1218
|
+
const allMetrics = globalMetrics.getMetrics();
|
|
1219
|
+
const hourlyData = this.metricsAggregator.aggregateHourly(allMetrics, hours);
|
|
1220
|
+
res.json({
|
|
1221
|
+
period: 'hourly',
|
|
1222
|
+
hours,
|
|
1223
|
+
data: hourlyData,
|
|
1224
|
+
});
|
|
1225
|
+
}
|
|
1226
|
+
catch (error) {
|
|
1227
|
+
res.status(500).json({
|
|
1228
|
+
error: error instanceof Error ? error.message : 'Failed to get hourly metrics',
|
|
1229
|
+
});
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
/**
|
|
1233
|
+
* GET /api/v1/metrics/daily - Daily aggregation
|
|
1234
|
+
*/
|
|
1235
|
+
async getDailyMetrics(req, res) {
|
|
1236
|
+
try {
|
|
1237
|
+
const days = parseInt(req.query.days || '7');
|
|
1238
|
+
const allMetrics = globalMetrics.getMetrics();
|
|
1239
|
+
const dailyData = this.metricsAggregator.aggregateDaily(allMetrics, days);
|
|
1240
|
+
res.json({
|
|
1241
|
+
period: 'daily',
|
|
1242
|
+
days,
|
|
1243
|
+
data: dailyData,
|
|
1244
|
+
});
|
|
1245
|
+
}
|
|
1246
|
+
catch (error) {
|
|
1247
|
+
res.status(500).json({
|
|
1248
|
+
error: error instanceof Error ? error.message : 'Failed to get daily metrics',
|
|
1249
|
+
});
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
/**
|
|
1253
|
+
* GET /api/v1/metrics/weekly - Weekly aggregation
|
|
1254
|
+
*/
|
|
1255
|
+
async getWeeklyMetrics(req, res) {
|
|
1256
|
+
try {
|
|
1257
|
+
const weeks = parseInt(req.query.weeks || '4');
|
|
1258
|
+
const allMetrics = globalMetrics.getMetrics();
|
|
1259
|
+
const weeklyData = this.metricsAggregator.aggregateWeekly(allMetrics, weeks);
|
|
1260
|
+
res.json({
|
|
1261
|
+
period: 'weekly',
|
|
1262
|
+
weeks,
|
|
1263
|
+
data: weeklyData,
|
|
1264
|
+
});
|
|
1265
|
+
}
|
|
1266
|
+
catch (error) {
|
|
1267
|
+
res.status(500).json({
|
|
1268
|
+
error: error instanceof Error ? error.message : 'Failed to get weekly metrics',
|
|
1269
|
+
});
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
/**
|
|
1273
|
+
* GET /api/v1/metrics/errors - Error analytics across all tools
|
|
1274
|
+
*/
|
|
1275
|
+
async getErrorAnalytics(_req, res) {
|
|
1276
|
+
try {
|
|
1277
|
+
const analytics = globalMetrics.getErrorAnalytics();
|
|
1278
|
+
res.json(analytics);
|
|
1279
|
+
}
|
|
1280
|
+
catch (error) {
|
|
1281
|
+
res.status(500).json({
|
|
1282
|
+
error: error instanceof Error ? error.message : 'Failed to get error analytics',
|
|
1283
|
+
});
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
/**
|
|
1287
|
+
* GET /api/v1/metrics/errors/:toolName - Per-tool error analytics
|
|
1288
|
+
* Query params: ?serverName=<name> (optional)
|
|
1289
|
+
*/
|
|
1290
|
+
async getToolErrorMetrics(req, res) {
|
|
1291
|
+
try {
|
|
1292
|
+
const { toolName } = req.params;
|
|
1293
|
+
const serverName = req.query.serverName;
|
|
1294
|
+
const metrics = globalMetrics.getToolErrorMetrics(toolName, serverName);
|
|
1295
|
+
if (!metrics) {
|
|
1296
|
+
res.status(404).json({
|
|
1297
|
+
error: `No metrics found for tool '${toolName}'${serverName ? ` on server '${serverName}'` : ''}`
|
|
1298
|
+
});
|
|
1299
|
+
return;
|
|
1300
|
+
}
|
|
1301
|
+
res.json(metrics);
|
|
1302
|
+
}
|
|
1303
|
+
catch (error) {
|
|
1304
|
+
res.status(500).json({
|
|
1305
|
+
error: error instanceof Error ? error.message : 'Failed to get tool error metrics',
|
|
1306
|
+
});
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
/**
|
|
1310
|
+
* GET /api/v1/metrics/tools - Granular tool-specific metrics (Test 187)
|
|
1311
|
+
* Query params:
|
|
1312
|
+
* ?serverName=<name> - Filter by server
|
|
1313
|
+
* ?slow=<limit> - Get slowest tools (by p95 latency)
|
|
1314
|
+
* ?errors=<threshold> - Get tools with high error rates (default: 5%)
|
|
1315
|
+
*/
|
|
1316
|
+
async getToolMetrics(req, res) {
|
|
1317
|
+
try {
|
|
1318
|
+
const { serverName } = req.query;
|
|
1319
|
+
const slow = req.query.slow ? parseInt(req.query.slow, 10) : undefined;
|
|
1320
|
+
const errorsThreshold = req.query.errors ? parseFloat(req.query.errors) : undefined;
|
|
1321
|
+
let metrics = globalMetrics.getAllToolMetrics();
|
|
1322
|
+
// Filter by server if requested
|
|
1323
|
+
if (serverName) {
|
|
1324
|
+
metrics = metrics.filter(m => m.serverName === serverName);
|
|
1325
|
+
}
|
|
1326
|
+
// Return slowest tools if requested
|
|
1327
|
+
if (slow !== undefined) {
|
|
1328
|
+
metrics = globalMetrics.getSlowestTools(slow);
|
|
1329
|
+
}
|
|
1330
|
+
// Return high error rate tools if requested
|
|
1331
|
+
if (errorsThreshold !== undefined) {
|
|
1332
|
+
metrics = globalMetrics.getHighErrorRateTools(errorsThreshold);
|
|
1333
|
+
}
|
|
1334
|
+
res.json({
|
|
1335
|
+
tools: metrics,
|
|
1336
|
+
count: metrics.length,
|
|
1337
|
+
});
|
|
1338
|
+
}
|
|
1339
|
+
catch (error) {
|
|
1340
|
+
res.status(500).json({
|
|
1341
|
+
error: error instanceof Error ? error.message : 'Failed to get tool metrics',
|
|
1342
|
+
});
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
/**
|
|
1346
|
+
* Load persisted metrics on startup (Phase 4 - v1.4.0)
|
|
1347
|
+
*/
|
|
1348
|
+
async loadPersistedMetrics() {
|
|
1349
|
+
try {
|
|
1350
|
+
const data = await this.metricsPersistence.load();
|
|
1351
|
+
if (data) {
|
|
1352
|
+
globalMetrics.restoreMetrics(data);
|
|
1353
|
+
console.log('[MetricsPersistence] Successfully restored metrics from disk');
|
|
1354
|
+
}
|
|
1355
|
+
else {
|
|
1356
|
+
console.log('[MetricsPersistence] No persisted metrics found, starting fresh');
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
catch (error) {
|
|
1360
|
+
console.error('[MetricsPersistence] Failed to load metrics:', error);
|
|
1361
|
+
console.log('[MetricsPersistence] Starting with fresh metrics');
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
/**
|
|
1365
|
+
* Start periodic metrics persistence (Phase 4 - v1.4.0)
|
|
1366
|
+
*/
|
|
1367
|
+
startMetricsPersistence() {
|
|
1368
|
+
const getMetricsData = () => ({
|
|
1369
|
+
timestamp: Date.now(),
|
|
1370
|
+
metrics: globalMetrics.getMetrics(),
|
|
1371
|
+
serverMetrics: globalMetrics.getAllServerMetrics(),
|
|
1372
|
+
toolMetrics: Array.from(globalMetrics['toolMetrics'].values()), // Access private field for persistence
|
|
1373
|
+
apiMetrics: globalMetrics.getApiMetrics(),
|
|
1374
|
+
});
|
|
1375
|
+
// Save every 60 seconds
|
|
1376
|
+
this.metricsPersistence.startPeriodicWrites(getMetricsData, 60000);
|
|
1377
|
+
console.log('[MetricsPersistence] Started periodic writes (60s interval)');
|
|
1378
|
+
}
|
|
1379
|
+
// === Session Management (Streamable HTTP - MCP 2025-03-26) ===
|
|
1380
|
+
/**
|
|
1381
|
+
* Track SSE event for Last-Event-ID resumption
|
|
1382
|
+
*/
|
|
1383
|
+
trackSseEvent(sessionId, eventData) {
|
|
1384
|
+
const eventId = ++this.sseEventCounter;
|
|
1385
|
+
let events = this.sseEventBuffer.get(sessionId);
|
|
1386
|
+
if (!events) {
|
|
1387
|
+
events = [];
|
|
1388
|
+
this.sseEventBuffer.set(sessionId, events);
|
|
1389
|
+
}
|
|
1390
|
+
events.push({
|
|
1391
|
+
id: eventId,
|
|
1392
|
+
timestamp: Date.now(),
|
|
1393
|
+
data: eventData
|
|
1394
|
+
});
|
|
1395
|
+
// Keep only last N events to avoid memory bloat
|
|
1396
|
+
if (events.length > this.MAX_EVENTS_PER_SESSION) {
|
|
1397
|
+
events.shift();
|
|
1398
|
+
}
|
|
1399
|
+
return eventId;
|
|
1400
|
+
}
|
|
1401
|
+
/**
|
|
1402
|
+
* Get events for resumption after Last-Event-ID
|
|
1403
|
+
*/
|
|
1404
|
+
getEventsForResumption(sessionId, lastEventId) {
|
|
1405
|
+
const events = this.sseEventBuffer.get(sessionId) || [];
|
|
1406
|
+
if (!lastEventId)
|
|
1407
|
+
return events;
|
|
1408
|
+
// Return all events after lastEventId
|
|
1409
|
+
return events.filter(e => e.id > lastEventId);
|
|
1410
|
+
}
|
|
1411
|
+
createSession(clientInfo) {
|
|
1412
|
+
const sessionId = randomUUID();
|
|
1413
|
+
this.sessions.set(sessionId, {
|
|
1414
|
+
id: sessionId,
|
|
1415
|
+
createdAt: Date.now(),
|
|
1416
|
+
lastActivity: Date.now(),
|
|
1417
|
+
clientInfo,
|
|
1418
|
+
initialized: false,
|
|
1419
|
+
lastEventId: 0,
|
|
1420
|
+
protocolVersion: '2025-06-18'
|
|
1421
|
+
});
|
|
1422
|
+
const clientName = clientInfo?.name || 'unknown';
|
|
1423
|
+
const clientVersion = clientInfo?.version || 'unknown';
|
|
1424
|
+
console.log(`[MCP] Session created: ${sessionId} | Client: ${clientName}/${clientVersion}`);
|
|
1425
|
+
// Persist sessions to disk (v1.1.24+)
|
|
1426
|
+
this.persistSessions();
|
|
1427
|
+
return sessionId;
|
|
1428
|
+
}
|
|
1429
|
+
/**
|
|
1430
|
+
* Get and update session activity with validation logging
|
|
1431
|
+
*/
|
|
1432
|
+
getSession(sessionId) {
|
|
1433
|
+
const session = this.sessions.get(sessionId);
|
|
1434
|
+
if (session) {
|
|
1435
|
+
session.lastActivity = Date.now();
|
|
1436
|
+
const elapsed = Date.now() - session.createdAt;
|
|
1437
|
+
console.log(`[MCP] Session accessed: ${sessionId} | Initialized: ${session.initialized} | Age: ${elapsed}ms`);
|
|
1438
|
+
}
|
|
1439
|
+
else {
|
|
1440
|
+
console.log(`[MCP] Session not found: ${sessionId}`);
|
|
1441
|
+
}
|
|
1442
|
+
return session;
|
|
1443
|
+
}
|
|
1444
|
+
/**
|
|
1445
|
+
* Clean up expired sessions
|
|
1446
|
+
*/
|
|
1447
|
+
cleanupSessions() {
|
|
1448
|
+
const now = Date.now();
|
|
1449
|
+
let cleaned = 0;
|
|
1450
|
+
for (const [id, session] of this.sessions) {
|
|
1451
|
+
if (now - session.lastActivity > this.SESSION_TIMEOUT) {
|
|
1452
|
+
this.sessions.delete(id);
|
|
1453
|
+
cleaned++;
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
if (cleaned > 0) {
|
|
1457
|
+
console.log(`[MCP] Cleaned up ${cleaned} expired sessions`);
|
|
1458
|
+
// Persist cleaned state to disk (v1.1.24+)
|
|
1459
|
+
this.persistSessions();
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
/**
|
|
1463
|
+
* Start session cleanup timer
|
|
1464
|
+
*/
|
|
1465
|
+
startSessionCleanup() {
|
|
1466
|
+
if (this.sessionCleanupInterval) {
|
|
1467
|
+
clearInterval(this.sessionCleanupInterval);
|
|
1468
|
+
}
|
|
1469
|
+
// Run cleanup every 5 minutes
|
|
1470
|
+
this.sessionCleanupInterval = setInterval(() => this.cleanupSessions(), 5 * 60 * 1000);
|
|
1471
|
+
}
|
|
1472
|
+
/**
|
|
1473
|
+
* Stop session cleanup timer
|
|
1474
|
+
*/
|
|
1475
|
+
stopSessionCleanup() {
|
|
1476
|
+
if (this.sessionCleanupInterval) {
|
|
1477
|
+
clearInterval(this.sessionCleanupInterval);
|
|
1478
|
+
this.sessionCleanupInterval = null;
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
/**
|
|
1482
|
+
* Persist sessions to disk with atomic write (temp file + rename)
|
|
1483
|
+
* Called after session creation and cleanup
|
|
1484
|
+
*/
|
|
1485
|
+
persistSessions() {
|
|
1486
|
+
try {
|
|
1487
|
+
// Ensure directory exists
|
|
1488
|
+
const dir = path.dirname(this.sessionPersistPath);
|
|
1489
|
+
if (!fs.existsSync(dir)) {
|
|
1490
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1491
|
+
}
|
|
1492
|
+
// Convert Map to object for JSON serialization
|
|
1493
|
+
const sessionsObj = {};
|
|
1494
|
+
for (const [id, session] of this.sessions) {
|
|
1495
|
+
sessionsObj[id] = session;
|
|
1496
|
+
}
|
|
1497
|
+
const data = {
|
|
1498
|
+
version: this.SESSION_PERSIST_VERSION,
|
|
1499
|
+
persistedAt: Date.now(),
|
|
1500
|
+
sessions: sessionsObj
|
|
1501
|
+
};
|
|
1502
|
+
// Atomic write: write to temp file, then rename
|
|
1503
|
+
const tempPath = `${this.sessionPersistPath}.tmp`;
|
|
1504
|
+
fs.writeFileSync(tempPath, JSON.stringify(data, null, 2), 'utf8');
|
|
1505
|
+
fs.renameSync(tempPath, this.sessionPersistPath);
|
|
1506
|
+
console.log(`[SessionPersistence] Saved ${this.sessions.size} sessions to disk`);
|
|
1507
|
+
}
|
|
1508
|
+
catch (error) {
|
|
1509
|
+
console.error('[SessionPersistence] Failed to persist sessions:', error);
|
|
1510
|
+
// Non-fatal: sessions still work in memory
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
/**
|
|
1514
|
+
* Load sessions from disk on startup
|
|
1515
|
+
* Filters out expired sessions during load
|
|
1516
|
+
*/
|
|
1517
|
+
loadSessions() {
|
|
1518
|
+
try {
|
|
1519
|
+
if (!fs.existsSync(this.sessionPersistPath)) {
|
|
1520
|
+
console.log('[SessionPersistence] No persisted sessions found, starting fresh');
|
|
1521
|
+
return;
|
|
1522
|
+
}
|
|
1523
|
+
const content = fs.readFileSync(this.sessionPersistPath, 'utf8');
|
|
1524
|
+
const data = JSON.parse(content);
|
|
1525
|
+
// Version check for future migrations
|
|
1526
|
+
if (data.version !== this.SESSION_PERSIST_VERSION) {
|
|
1527
|
+
console.log(`[SessionPersistence] Version mismatch (got ${data.version}, expected ${this.SESSION_PERSIST_VERSION}), starting fresh`);
|
|
1528
|
+
return;
|
|
1529
|
+
}
|
|
1530
|
+
const now = Date.now();
|
|
1531
|
+
let loaded = 0;
|
|
1532
|
+
let expired = 0;
|
|
1533
|
+
for (const [id, session] of Object.entries(data.sessions || {})) {
|
|
1534
|
+
const sess = session;
|
|
1535
|
+
// Filter out expired sessions during load
|
|
1536
|
+
if (now - sess.lastActivity > this.SESSION_TIMEOUT) {
|
|
1537
|
+
expired++;
|
|
1538
|
+
continue;
|
|
1539
|
+
}
|
|
1540
|
+
this.sessions.set(id, sess);
|
|
1541
|
+
loaded++;
|
|
1542
|
+
}
|
|
1543
|
+
console.log(`[SessionPersistence] Restored ${loaded} sessions from disk (${expired} expired sessions discarded)`);
|
|
1544
|
+
// If we discarded expired sessions, persist the cleaned state
|
|
1545
|
+
if (expired > 0) {
|
|
1546
|
+
this.persistSessions();
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
catch (error) {
|
|
1550
|
+
const errCode = error.code;
|
|
1551
|
+
if (errCode === 'ENOENT') {
|
|
1552
|
+
console.log('[SessionPersistence] No persisted sessions found, starting fresh');
|
|
1553
|
+
}
|
|
1554
|
+
else {
|
|
1555
|
+
console.error('[SessionPersistence] Failed to load sessions:', error);
|
|
1556
|
+
console.log('[SessionPersistence] Starting with fresh sessions');
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
/**
|
|
1561
|
+
* Handle Streamable HTTP request (MCP 2025-03-26)
|
|
1562
|
+
* Returns tuple of [response, sessionId] for session tracking
|
|
1563
|
+
*/
|
|
1564
|
+
async handleStreamableRequest(request, req) {
|
|
1565
|
+
const { method, params, id } = request;
|
|
1566
|
+
const sessionId = req?.headers['mcp-session-id'];
|
|
1567
|
+
// Validate JSON-RPC 2.0 structure
|
|
1568
|
+
if (!request.jsonrpc || request.jsonrpc !== '2.0') {
|
|
1569
|
+
return [{
|
|
1570
|
+
jsonrpc: '2.0',
|
|
1571
|
+
id,
|
|
1572
|
+
error: { code: -32600, message: 'Invalid Request: jsonrpc field must be "2.0"' }
|
|
1573
|
+
}, sessionId];
|
|
1574
|
+
}
|
|
1575
|
+
try {
|
|
1576
|
+
let result;
|
|
1577
|
+
let session;
|
|
1578
|
+
let responseSessionId;
|
|
1579
|
+
// Handle session-aware methods
|
|
1580
|
+
if (method === 'initialize') {
|
|
1581
|
+
const initParams = params;
|
|
1582
|
+
// Protocol version negotiation per spec 2025-11-25
|
|
1583
|
+
const requestedVersion = initParams.protocolVersion;
|
|
1584
|
+
const supportedVersions = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05']; // List in order of preference
|
|
1585
|
+
let negotiatedVersion = '2025-11-25'; // Default to latest
|
|
1586
|
+
if (requestedVersion) {
|
|
1587
|
+
if (supportedVersions.includes(requestedVersion)) {
|
|
1588
|
+
// Client requested supported version
|
|
1589
|
+
negotiatedVersion = requestedVersion;
|
|
1590
|
+
console.log(`[MCP] Protocol version negotiated: ${requestedVersion}`);
|
|
1591
|
+
}
|
|
1592
|
+
else {
|
|
1593
|
+
// Client requested unsupported version - offer fallback chain
|
|
1594
|
+
console.log(`[MCP] Unsupported protocol version requested: ${requestedVersion}. Offering fallback to ${negotiatedVersion}`);
|
|
1595
|
+
// Return error with supported versions for client to retry with fallback
|
|
1596
|
+
return [{
|
|
1597
|
+
jsonrpc: '2.0',
|
|
1598
|
+
id,
|
|
1599
|
+
error: {
|
|
1600
|
+
code: -32602,
|
|
1601
|
+
message: `Unsupported protocol version: ${requestedVersion}`,
|
|
1602
|
+
data: {
|
|
1603
|
+
requested: requestedVersion,
|
|
1604
|
+
supported: supportedVersions,
|
|
1605
|
+
recommended: negotiatedVersion
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
}, undefined];
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
// Create or retrieve session
|
|
1612
|
+
let newSessionId;
|
|
1613
|
+
if (sessionId && this.getSession(sessionId)) {
|
|
1614
|
+
// Session ID provided and exists - reuse it
|
|
1615
|
+
newSessionId = sessionId;
|
|
1616
|
+
}
|
|
1617
|
+
else if (sessionId) {
|
|
1618
|
+
// Session ID provided but doesn't exist - create it with that ID
|
|
1619
|
+
this.sessions.set(sessionId, {
|
|
1620
|
+
id: sessionId,
|
|
1621
|
+
createdAt: Date.now(),
|
|
1622
|
+
lastActivity: Date.now(),
|
|
1623
|
+
clientInfo: initParams.clientInfo,
|
|
1624
|
+
initialized: false,
|
|
1625
|
+
lastEventId: 0,
|
|
1626
|
+
protocolVersion: '2025-06-18'
|
|
1627
|
+
});
|
|
1628
|
+
newSessionId = sessionId;
|
|
1629
|
+
}
|
|
1630
|
+
else {
|
|
1631
|
+
// No session ID provided - create new one
|
|
1632
|
+
newSessionId = this.createSession(initParams.clientInfo);
|
|
1633
|
+
}
|
|
1634
|
+
session = this.getSession(newSessionId);
|
|
1635
|
+
session.initialized = true;
|
|
1636
|
+
session.protocolVersion = negotiatedVersion;
|
|
1637
|
+
session.userAgent = req?.headers?.['user-agent'] || 'unknown';
|
|
1638
|
+
session.lastEventId = 0;
|
|
1639
|
+
responseSessionId = newSessionId;
|
|
1640
|
+
// Store client capabilities for per-client feature adaptation
|
|
1641
|
+
if (initParams.capabilities) {
|
|
1642
|
+
session.clientCapabilities = initParams.capabilities;
|
|
1643
|
+
}
|
|
1644
|
+
// Extract callback URL from request headers for bidirectional communication (MCP callback protocol)
|
|
1645
|
+
if (req) {
|
|
1646
|
+
const callbackUrl = req.headers['x-callback-url'];
|
|
1647
|
+
const callbackPort = req.headers['x-callback-port'];
|
|
1648
|
+
if (callbackUrl || callbackPort) {
|
|
1649
|
+
const url = callbackUrl || `http://127.0.0.1:${callbackPort}/mcp`;
|
|
1650
|
+
session.callbackUrl = url;
|
|
1651
|
+
session.callbackPort = callbackPort ? parseInt(callbackPort, 10) : undefined;
|
|
1652
|
+
console.log(`[MCP] Callback registered: ${url}`);
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
console.log(`[MCP] Initialize: ${newSessionId} | Protocol: ${negotiatedVersion} | Client: ${initParams.clientInfo?.name}/${initParams.clientInfo?.version}`);
|
|
1656
|
+
result = {
|
|
1657
|
+
protocolVersion: negotiatedVersion,
|
|
1658
|
+
capabilities: {
|
|
1659
|
+
tools: { listChanged: true },
|
|
1660
|
+
prompts: { listChanged: false },
|
|
1661
|
+
resources: { listChanged: false }
|
|
1662
|
+
},
|
|
1663
|
+
serverInfo: {
|
|
1664
|
+
name: 'metalink',
|
|
1665
|
+
version
|
|
1666
|
+
}
|
|
1667
|
+
};
|
|
1668
|
+
return [{
|
|
1669
|
+
jsonrpc: '2.0',
|
|
1670
|
+
id,
|
|
1671
|
+
result
|
|
1672
|
+
}, responseSessionId];
|
|
1673
|
+
}
|
|
1674
|
+
else if (method === 'notifications/initialized') {
|
|
1675
|
+
return [null, sessionId]; // No response for notifications
|
|
1676
|
+
}
|
|
1677
|
+
else {
|
|
1678
|
+
// Allow stateless operation without session
|
|
1679
|
+
responseSessionId = sessionId;
|
|
1680
|
+
if (sessionId) {
|
|
1681
|
+
session = this.getSession(sessionId);
|
|
1682
|
+
if (!session) {
|
|
1683
|
+
// FALLBACK: This should not be reached - caller validates session before calling this function.
|
|
1684
|
+
// If we get here, it means session was invalidated between caller check and this check.
|
|
1685
|
+
// Per MCP spec, should return HTTP 404, but we can't from this function.
|
|
1686
|
+
// Caller handles HTTP 404 at line ~2067. This is defensive programming only.
|
|
1687
|
+
console.error(`[MCP] Session validation fallback triggered for: ${sessionId.substring(0, 8)}...`);
|
|
1688
|
+
return [{
|
|
1689
|
+
jsonrpc: '2.0',
|
|
1690
|
+
id,
|
|
1691
|
+
error: { code: -32603, message: 'Invalid or expired session' }
|
|
1692
|
+
}, sessionId];
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
// Handle other MCP methods
|
|
1696
|
+
if (method === 'ping') {
|
|
1697
|
+
// MCP spec: ping method for keepalive/health checks
|
|
1698
|
+
result = {};
|
|
1699
|
+
}
|
|
1700
|
+
else if (method === 'roots/list') {
|
|
1701
|
+
result = { roots: [] };
|
|
1702
|
+
}
|
|
1703
|
+
else if (method === 'prompts/list') {
|
|
1704
|
+
result = { prompts: getPromptsList() };
|
|
1705
|
+
}
|
|
1706
|
+
else if (method === 'resources/list') {
|
|
1707
|
+
result = { resources: getResourcesList() };
|
|
1708
|
+
}
|
|
1709
|
+
else if (method === 'resources/templates/list') {
|
|
1710
|
+
result = { resourceTemplates: getResourceTemplatesList() };
|
|
1711
|
+
}
|
|
1712
|
+
else if (method === 'prompts/get') {
|
|
1713
|
+
const promptParams = params;
|
|
1714
|
+
if (!promptParams.name) {
|
|
1715
|
+
throw new InvalidParamsError('Missing required parameter: name');
|
|
1716
|
+
}
|
|
1717
|
+
try {
|
|
1718
|
+
result = getPrompt(promptParams.name, promptParams.arguments || {});
|
|
1719
|
+
}
|
|
1720
|
+
catch (err) {
|
|
1721
|
+
throw new InvalidParamsError(err instanceof Error ? err.message : 'Unknown prompt error');
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
else if (method === 'resources/read') {
|
|
1725
|
+
const resourceParams = params;
|
|
1726
|
+
if (!resourceParams.uri) {
|
|
1727
|
+
throw new InvalidParamsError('Missing required parameter: uri');
|
|
1728
|
+
}
|
|
1729
|
+
try {
|
|
1730
|
+
result = await readResource(resourceParams.uri, this.serverManager, this.configLoader);
|
|
1731
|
+
}
|
|
1732
|
+
catch (err) {
|
|
1733
|
+
throw new InvalidParamsError(err instanceof Error ? err.message : 'Unknown resource error');
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
else if (method === 'tools/list') {
|
|
1737
|
+
// Auto-reinitialize session if not initialized (transparent to client)
|
|
1738
|
+
// This handles stale sessions from server restarts without client needing to retry
|
|
1739
|
+
if (!session?.initialized) {
|
|
1740
|
+
const newSessionId = this.createSession({ name: 'auto-reinit', version: '1.0' });
|
|
1741
|
+
const newSession = this.getSession(newSessionId);
|
|
1742
|
+
newSession.initialized = true;
|
|
1743
|
+
newSession.protocolVersion = '2025-06-18';
|
|
1744
|
+
responseSessionId = newSessionId;
|
|
1745
|
+
console.log(`[MCP] Auto-reinitialized session for tools/list: ${newSessionId}`);
|
|
1746
|
+
}
|
|
1747
|
+
result = await this.mcpListTools();
|
|
1748
|
+
}
|
|
1749
|
+
else if (method === 'tools/call') {
|
|
1750
|
+
// Auto-reinitialize session if not initialized (transparent to client)
|
|
1751
|
+
// This handles stale sessions from server restarts without client needing to retry
|
|
1752
|
+
let effectiveSessionId = sessionId;
|
|
1753
|
+
if (!session?.initialized) {
|
|
1754
|
+
const newSessionId = this.createSession({ name: 'auto-reinit', version: '1.0' });
|
|
1755
|
+
const newSession = this.getSession(newSessionId);
|
|
1756
|
+
newSession.initialized = true;
|
|
1757
|
+
newSession.protocolVersion = '2025-06-18';
|
|
1758
|
+
responseSessionId = newSessionId;
|
|
1759
|
+
effectiveSessionId = newSessionId;
|
|
1760
|
+
console.log(`[MCP] Auto-reinitialized session for tools/call: ${newSessionId}`);
|
|
1761
|
+
}
|
|
1762
|
+
const callParams = params;
|
|
1763
|
+
if (!callParams.name) {
|
|
1764
|
+
throw new InvalidParamsError('Missing required parameter: name');
|
|
1765
|
+
}
|
|
1766
|
+
result = await this.mcpCallTool(callParams.name, callParams.arguments, effectiveSessionId);
|
|
1767
|
+
}
|
|
1768
|
+
else {
|
|
1769
|
+
return [{
|
|
1770
|
+
jsonrpc: '2.0',
|
|
1771
|
+
id,
|
|
1772
|
+
error: { code: -32601, message: `Unknown method: ${method}` }
|
|
1773
|
+
}, sessionId];
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
return [{
|
|
1777
|
+
jsonrpc: '2.0',
|
|
1778
|
+
id,
|
|
1779
|
+
result
|
|
1780
|
+
}, responseSessionId];
|
|
1781
|
+
}
|
|
1782
|
+
catch (error) {
|
|
1783
|
+
// SessionNotInitializedError must bubble up to trigger HTTP 404
|
|
1784
|
+
// This allows client auto-reinitialize handlers to work per MCP spec
|
|
1785
|
+
if (error instanceof SessionNotInitializedError) {
|
|
1786
|
+
throw error;
|
|
1787
|
+
}
|
|
1788
|
+
// Check if this is an invalid parameters error (JSON-RPC -32602)
|
|
1789
|
+
const isInvalidParams = error instanceof InvalidParamsError;
|
|
1790
|
+
return [{
|
|
1791
|
+
jsonrpc: '2.0',
|
|
1792
|
+
id,
|
|
1793
|
+
error: {
|
|
1794
|
+
code: isInvalidParams ? -32602 : -32603,
|
|
1795
|
+
message: error instanceof Error ? error.message : 'Internal error'
|
|
1796
|
+
}
|
|
1797
|
+
}, sessionId];
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
/**
|
|
1801
|
+
* Handle MCP JSON-RPC requests over HTTP
|
|
1802
|
+
*/
|
|
1803
|
+
async handleMcpRequest(req, res) {
|
|
1804
|
+
const requestStartTime = Date.now();
|
|
1805
|
+
const userAgent = req.headers['user-agent'] || 'unknown';
|
|
1806
|
+
const sessionId = req.headers['mcp-session-id'];
|
|
1807
|
+
const requestId = req.requestId || generateRequestId();
|
|
1808
|
+
// Create child logger with request context
|
|
1809
|
+
const reqLogger = logger.child({
|
|
1810
|
+
requestId,
|
|
1811
|
+
sessionId,
|
|
1812
|
+
method: req.method,
|
|
1813
|
+
userAgent,
|
|
1814
|
+
});
|
|
1815
|
+
// Log incoming MCP request
|
|
1816
|
+
reqLogger.info('MCP request received', {
|
|
1817
|
+
path: req.path,
|
|
1818
|
+
protocol: req.headers['mcp-protocol-version'],
|
|
1819
|
+
});
|
|
1820
|
+
try {
|
|
1821
|
+
// Handle GET requests per MCP spec 2025-06-18 - SSE streaming for server-sent events
|
|
1822
|
+
if (req.method === 'GET') {
|
|
1823
|
+
const sessionId = req.headers['mcp-session-id'];
|
|
1824
|
+
// If no session ID, handle health check with JSON response
|
|
1825
|
+
if (!sessionId) {
|
|
1826
|
+
const responseTime = Date.now() - requestStartTime;
|
|
1827
|
+
reqLogger.info('Health check request', {
|
|
1828
|
+
accept: req.headers.accept,
|
|
1829
|
+
protocolVersion: req.headers['mcp-protocol-version'],
|
|
1830
|
+
});
|
|
1831
|
+
// Health check - always return JSON regardless of Accept header
|
|
1832
|
+
res.setHeader('Content-Type', 'application/json');
|
|
1833
|
+
res.setHeader('MCP-Protocol-Version', MCP_PROTOCOL_VERSION);
|
|
1834
|
+
res.setHeader('X-Request-Id', requestId);
|
|
1835
|
+
res.status(200).json({ status: 'ready', protocol: MCP_PROTOCOL_VERSION });
|
|
1836
|
+
reqLogger.info('Health check response sent', {
|
|
1837
|
+
status: 200,
|
|
1838
|
+
duration_ms: responseTime,
|
|
1839
|
+
});
|
|
1840
|
+
return;
|
|
1841
|
+
}
|
|
1842
|
+
// Session-based requests - set up SSE streaming with Last-Event-ID resumption support
|
|
1843
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
1844
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
1845
|
+
res.setHeader('Connection', 'keep-alive');
|
|
1846
|
+
res.setHeader('X-Accel-Buffering', 'no');
|
|
1847
|
+
res.setHeader('MCP-Protocol-Version', MCP_PROTOCOL_VERSION);
|
|
1848
|
+
const session = this.getSession(sessionId);
|
|
1849
|
+
if (!session?.initialized) {
|
|
1850
|
+
res.status(404).json({ error: 'Session not found or not initialized' });
|
|
1851
|
+
return;
|
|
1852
|
+
}
|
|
1853
|
+
res.setHeader('Mcp-Session-Id', sessionId);
|
|
1854
|
+
// Check for Last-Event-ID header (per MCP 2025-06-18 spec for resumption)
|
|
1855
|
+
const lastEventIdHeader = req.headers['last-event-id'];
|
|
1856
|
+
const lastEventId = lastEventIdHeader ? parseInt(String(lastEventIdHeader), 10) : undefined;
|
|
1857
|
+
if (lastEventId !== undefined) {
|
|
1858
|
+
reqLogger.info('SSE stream resumption requested', {
|
|
1859
|
+
lastEventId,
|
|
1860
|
+
transport: 'sse',
|
|
1861
|
+
});
|
|
1862
|
+
}
|
|
1863
|
+
else {
|
|
1864
|
+
reqLogger.info('New SSE stream connection', {
|
|
1865
|
+
transport: 'sse',
|
|
1866
|
+
});
|
|
1867
|
+
}
|
|
1868
|
+
// Track SSE connection for server-sent messages
|
|
1869
|
+
this.sseConnections.set(sessionId, res);
|
|
1870
|
+
// Send buffered events for resumption (if Last-Event-ID was provided)
|
|
1871
|
+
if (lastEventId !== undefined) {
|
|
1872
|
+
const bufferedEvents = this.getEventsForResumption(sessionId, lastEventId);
|
|
1873
|
+
if (bufferedEvents.length > 0) {
|
|
1874
|
+
reqLogger.debug('Sending buffered events for resumption', {
|
|
1875
|
+
eventCount: bufferedEvents.length,
|
|
1876
|
+
transport: 'sse',
|
|
1877
|
+
});
|
|
1878
|
+
for (const event of bufferedEvents) {
|
|
1879
|
+
res.write(`id: ${event.id}\ndata: ${JSON.stringify(event.data)}\n\n`);
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
}
|
|
1883
|
+
else {
|
|
1884
|
+
// Send initial endpoint event ONLY on new connection (not on resumption)
|
|
1885
|
+
// Resuming connections should only receive buffered events, not duplicate endpoint events
|
|
1886
|
+
const endpointEventId = this.trackSseEvent(sessionId, { type: 'endpoint' });
|
|
1887
|
+
res.write(`id: ${endpointEventId}\ndata: ${JSON.stringify({ type: 'endpoint' })}\n\n`);
|
|
1888
|
+
// Update session's last event ID
|
|
1889
|
+
if (session) {
|
|
1890
|
+
session.lastEventId = endpointEventId;
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
// Send periodic keepalive comments to prevent connection timeout
|
|
1894
|
+
// SSE comments (lines starting with :) are ignored by clients but keep stream active
|
|
1895
|
+
// This is critical for clients like mcp-remote that rely on async iterators
|
|
1896
|
+
const keepaliveInterval = setInterval(() => {
|
|
1897
|
+
try {
|
|
1898
|
+
res.write(':keepalive\n\n');
|
|
1899
|
+
}
|
|
1900
|
+
catch (error) {
|
|
1901
|
+
reqLogger.debug('SSE keepalive write failed', {
|
|
1902
|
+
error: error instanceof Error ? error.message : 'Unknown error',
|
|
1903
|
+
transport: 'sse',
|
|
1904
|
+
});
|
|
1905
|
+
clearInterval(keepaliveInterval);
|
|
1906
|
+
}
|
|
1907
|
+
}, 15000); // Send keepalive every 15 seconds
|
|
1908
|
+
// Clean up on disconnect
|
|
1909
|
+
req.on('close', () => {
|
|
1910
|
+
clearInterval(keepaliveInterval);
|
|
1911
|
+
this.sseConnections.delete(sessionId);
|
|
1912
|
+
reqLogger.info('SSE connection closed', {
|
|
1913
|
+
transport: 'sse',
|
|
1914
|
+
});
|
|
1915
|
+
});
|
|
1916
|
+
// Keep connection open for server-sent events
|
|
1917
|
+
return;
|
|
1918
|
+
}
|
|
1919
|
+
// Check Accept header for SSE support
|
|
1920
|
+
// Per HTTP spec, Accept header values left-to-right indicate preference
|
|
1921
|
+
// "application/json, text/event-stream" means JSON is preferred (primary), SSE is fallback
|
|
1922
|
+
// Only use SSE if:
|
|
1923
|
+
// 1. Client explicitly requests ONLY SSE ("text/event-stream")
|
|
1924
|
+
// 2. OR client doesn't offer JSON as an option
|
|
1925
|
+
const acceptHeader = req.headers.accept || 'application/json';
|
|
1926
|
+
const hasJSON = acceptHeader.includes('application/json');
|
|
1927
|
+
const hasSSE = acceptHeader.includes('text/event-stream');
|
|
1928
|
+
// Use SSE only if SSE is available AND JSON is NOT preferred
|
|
1929
|
+
const useSSE = hasSSE && !hasJSON;
|
|
1930
|
+
// Debug: Log all headers to understand mcp-remote communication
|
|
1931
|
+
console.log('[MCP] POST request headers:', {
|
|
1932
|
+
'x-callback-url': req.headers['x-callback-url'],
|
|
1933
|
+
'x-callback-port': req.headers['x-callback-port'],
|
|
1934
|
+
'mcp-session-id': req.headers['mcp-session-id'],
|
|
1935
|
+
'user-agent': req.headers['user-agent'],
|
|
1936
|
+
'content-type': req.headers['content-type'],
|
|
1937
|
+
'accept': req.headers['accept']
|
|
1938
|
+
});
|
|
1939
|
+
console.log(`[MCP] SSE mode: ${useSSE} (based on Accept header: "${acceptHeader}")`);
|
|
1940
|
+
// Parse raw body with robust error handling
|
|
1941
|
+
let text = '';
|
|
1942
|
+
try {
|
|
1943
|
+
if (!req.body) {
|
|
1944
|
+
// Empty body - valid for some requests
|
|
1945
|
+
text = '';
|
|
1946
|
+
}
|
|
1947
|
+
else if (typeof req.body === 'string') {
|
|
1948
|
+
text = req.body;
|
|
1949
|
+
}
|
|
1950
|
+
else if (Buffer.isBuffer(req.body)) {
|
|
1951
|
+
text = req.body.toString('utf-8');
|
|
1952
|
+
}
|
|
1953
|
+
else {
|
|
1954
|
+
// Unknown body type - try to convert
|
|
1955
|
+
text = String(req.body);
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1958
|
+
catch (parseError) {
|
|
1959
|
+
console.error('[MCP] Body parsing error:', parseError);
|
|
1960
|
+
res.status(400).json({
|
|
1961
|
+
jsonrpc: '2.0',
|
|
1962
|
+
error: {
|
|
1963
|
+
code: -32700,
|
|
1964
|
+
message: 'Invalid request body'
|
|
1965
|
+
},
|
|
1966
|
+
id: null
|
|
1967
|
+
});
|
|
1968
|
+
return;
|
|
1969
|
+
}
|
|
1970
|
+
if (!text || text.trim().length === 0) {
|
|
1971
|
+
res.setHeader('Content-Type', 'application/json');
|
|
1972
|
+
res.send('');
|
|
1973
|
+
return;
|
|
1974
|
+
}
|
|
1975
|
+
// Parse JSON-RPC requests (support both batch arrays and newline-delimited)
|
|
1976
|
+
const responses = [];
|
|
1977
|
+
let lastSessionId;
|
|
1978
|
+
let requests = [];
|
|
1979
|
+
let isBatchRequest = false;
|
|
1980
|
+
// Try to parse as JSON array (batch request) first
|
|
1981
|
+
try {
|
|
1982
|
+
const parsed = JSON.parse(text.trim());
|
|
1983
|
+
if (Array.isArray(parsed)) {
|
|
1984
|
+
// JSON-RPC 2.0 batch request
|
|
1985
|
+
console.log(`[MCP Request] Batch request with ${parsed.length} requests`);
|
|
1986
|
+
requests = parsed;
|
|
1987
|
+
isBatchRequest = true;
|
|
1988
|
+
}
|
|
1989
|
+
else {
|
|
1990
|
+
// Single request (most common case)
|
|
1991
|
+
requests = [parsed];
|
|
1992
|
+
}
|
|
1993
|
+
}
|
|
1994
|
+
catch (parseError) {
|
|
1995
|
+
// Fallback to newline-delimited format
|
|
1996
|
+
const lines = text.split('\n').filter((line) => line.trim());
|
|
1997
|
+
console.log(`[MCP Request] Newline-delimited format with ${lines.length} lines`);
|
|
1998
|
+
for (const line of lines) {
|
|
1999
|
+
try {
|
|
2000
|
+
requests.push(JSON.parse(line));
|
|
2001
|
+
}
|
|
2002
|
+
catch (lineError) {
|
|
2003
|
+
responses.push({
|
|
2004
|
+
jsonrpc: '2.0',
|
|
2005
|
+
error: { code: -32700, message: 'Parse error' },
|
|
2006
|
+
id: null
|
|
2007
|
+
});
|
|
2008
|
+
}
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
// Log request body for debugging
|
|
2012
|
+
if (requests.length > 0) {
|
|
2013
|
+
console.log(`[MCP Request] Processing ${requests.length} request(s)`);
|
|
2014
|
+
requests.forEach((req, idx) => {
|
|
2015
|
+
const truncatedParams = JSON.stringify(req.params || {}).substring(0, 200);
|
|
2016
|
+
console.log(`[MCP Request] ${idx + 1}: method="${req.method}" id="${req.id}" params=${truncatedParams}${JSON.stringify(req.params || {}).length > 200 ? '...' : ''}`);
|
|
2017
|
+
});
|
|
2018
|
+
}
|
|
2019
|
+
for (const request of requests) {
|
|
2020
|
+
try {
|
|
2021
|
+
// Detect protocol version and route appropriately
|
|
2022
|
+
if (request.method === 'initialize') {
|
|
2023
|
+
const protocolVersion = request.params?.protocolVersion;
|
|
2024
|
+
if (protocolVersion === '2024-11-05') {
|
|
2025
|
+
// Legacy protocol path
|
|
2026
|
+
const response = await this.handleJsonRpcRequest(request);
|
|
2027
|
+
if (response)
|
|
2028
|
+
responses.push(response);
|
|
2029
|
+
}
|
|
2030
|
+
else {
|
|
2031
|
+
// New Streamable HTTP protocol (default to 2025-06-18)
|
|
2032
|
+
const [response, sId] = await this.handleStreamableRequest(request, req);
|
|
2033
|
+
if (response) {
|
|
2034
|
+
responses.push(response);
|
|
2035
|
+
if (sId)
|
|
2036
|
+
lastSessionId = sId;
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
2040
|
+
else {
|
|
2041
|
+
// For non-initialize requests, detect by session presence in header
|
|
2042
|
+
const headerSessionId = req.headers['mcp-session-id'];
|
|
2043
|
+
if (headerSessionId) {
|
|
2044
|
+
// Session ID provided - check if it exists
|
|
2045
|
+
if (!this.getSession(headerSessionId)) {
|
|
2046
|
+
// Session doesn't exist - auto-create and initialize (transparent recovery)
|
|
2047
|
+
// This handles stale sessions from server restarts without client needing to reinitialize
|
|
2048
|
+
const newSessionId = this.createSession({ name: 'auto-reinit', version: '1.0' });
|
|
2049
|
+
const newSession = this.getSession(newSessionId);
|
|
2050
|
+
newSession.initialized = true;
|
|
2051
|
+
newSession.protocolVersion = '2025-06-18';
|
|
2052
|
+
console.log(`[MCP] Auto-reinitialized stale session ${headerSessionId.substring(0, 8)}... → ${newSessionId.substring(0, 8)}...`);
|
|
2053
|
+
// Update header for downstream processing (hacky but works)
|
|
2054
|
+
req.headers['mcp-session-id'] = newSessionId;
|
|
2055
|
+
}
|
|
2056
|
+
// Has valid session (original or auto-created) → use Streamable HTTP
|
|
2057
|
+
const [response, sId] = await this.handleStreamableRequest(request, req);
|
|
2058
|
+
if (response) {
|
|
2059
|
+
responses.push(response);
|
|
2060
|
+
if (sId)
|
|
2061
|
+
lastSessionId = sId;
|
|
2062
|
+
}
|
|
2063
|
+
}
|
|
2064
|
+
else {
|
|
2065
|
+
// No session → use legacy protocol
|
|
2066
|
+
const response = await this.handleJsonRpcRequest(request);
|
|
2067
|
+
if (response)
|
|
2068
|
+
responses.push(response);
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
2071
|
+
}
|
|
2072
|
+
catch (parseError) {
|
|
2073
|
+
responses.push({
|
|
2074
|
+
jsonrpc: '2.0',
|
|
2075
|
+
error: { code: -32700, message: 'Parse error' },
|
|
2076
|
+
id: null
|
|
2077
|
+
});
|
|
2078
|
+
}
|
|
2079
|
+
}
|
|
2080
|
+
// Send responses
|
|
2081
|
+
if (responses.length === 0) {
|
|
2082
|
+
const responseTime = Date.now() - requestStartTime;
|
|
2083
|
+
console.log(`[MCP Response] ${new Date().toISOString()} | Empty response (202) | Time: ${responseTime}ms | User-Agent: ${userAgent}`);
|
|
2084
|
+
res.status(202).end();
|
|
2085
|
+
return;
|
|
2086
|
+
}
|
|
2087
|
+
// Respect the Accept header: if client requests SSE, send SSE
|
|
2088
|
+
// Callback port is independent - it's for fallback/bidirectional, not transport selection
|
|
2089
|
+
const shouldUseSSE = useSSE;
|
|
2090
|
+
// Check if session has callback URL registered
|
|
2091
|
+
const session = lastSessionId ? this.getSession(lastSessionId) : undefined;
|
|
2092
|
+
const hasCallback = session?.callbackUrl !== undefined;
|
|
2093
|
+
const responseTime = Date.now() - requestStartTime;
|
|
2094
|
+
if (shouldUseSSE) {
|
|
2095
|
+
// Server-Sent Events format
|
|
2096
|
+
console.log(`[MCP] Sending ${responses.length} response(s) in SSE format`);
|
|
2097
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
2098
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
2099
|
+
res.setHeader('Connection', 'keep-alive');
|
|
2100
|
+
res.setHeader('X-Accel-Buffering', 'no');
|
|
2101
|
+
res.setHeader('MCP-Protocol-Version', MCP_PROTOCOL_VERSION);
|
|
2102
|
+
for (const response of responses) {
|
|
2103
|
+
const dataStr = `data: ${JSON.stringify(response)}\n\n`;
|
|
2104
|
+
console.log(`[MCP] Writing SSE data: ${dataStr.substring(0, 100)}...`);
|
|
2105
|
+
res.write(dataStr);
|
|
2106
|
+
}
|
|
2107
|
+
console.log(`[MCP] Ending SSE response`);
|
|
2108
|
+
res.end();
|
|
2109
|
+
// Log SSE response summary
|
|
2110
|
+
console.log(`[MCP Response] ${new Date().toISOString()} | SSE | Responses: ${responses.length} | Time: ${responseTime}ms | User-Agent: ${userAgent}`);
|
|
2111
|
+
responses.forEach((resp, idx) => {
|
|
2112
|
+
const truncated = JSON.stringify(resp).substring(0, 300);
|
|
2113
|
+
console.log(`[MCP Response] SSE ${idx + 1}: ${truncated}${JSON.stringify(resp).length > 300 ? '...' : ''}`);
|
|
2114
|
+
});
|
|
2115
|
+
}
|
|
2116
|
+
else {
|
|
2117
|
+
// Standard JSON (array for batch, newline-delimited for multiple single requests)
|
|
2118
|
+
reqLogger.debug('Sending JSON response', {
|
|
2119
|
+
responseCount: responses.length,
|
|
2120
|
+
batch: isBatchRequest,
|
|
2121
|
+
transport: 'json',
|
|
2122
|
+
});
|
|
2123
|
+
res.setHeader('Content-Type', 'application/json');
|
|
2124
|
+
res.setHeader('MCP-Protocol-Version', MCP_PROTOCOL_VERSION);
|
|
2125
|
+
res.setHeader('X-Request-Id', requestId);
|
|
2126
|
+
// Set Mcp-Session-Id header if session exists
|
|
2127
|
+
if (lastSessionId) {
|
|
2128
|
+
res.setHeader('Mcp-Session-Id', lastSessionId);
|
|
2129
|
+
}
|
|
2130
|
+
let responseText;
|
|
2131
|
+
if (isBatchRequest) {
|
|
2132
|
+
// JSON-RPC 2.0 batch response: return as JSON array
|
|
2133
|
+
responseText = JSON.stringify(responses);
|
|
2134
|
+
}
|
|
2135
|
+
else {
|
|
2136
|
+
// Newline-delimited format (backward compatibility)
|
|
2137
|
+
responseText = responses.map(r => JSON.stringify(r)).join('\n');
|
|
2138
|
+
}
|
|
2139
|
+
res.write(responseText);
|
|
2140
|
+
res.end();
|
|
2141
|
+
reqLogger.info('MCP response sent', {
|
|
2142
|
+
responseCount: responses.length,
|
|
2143
|
+
duration_ms: responseTime,
|
|
2144
|
+
transport: 'json',
|
|
2145
|
+
batch: isBatchRequest,
|
|
2146
|
+
});
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2149
|
+
catch (error) {
|
|
2150
|
+
const responseTime = Date.now() - requestStartTime;
|
|
2151
|
+
// SessionNotInitializedError returns HTTP 404 per MCP spec
|
|
2152
|
+
// This triggers client auto-reinitialize handlers
|
|
2153
|
+
if (error instanceof SessionNotInitializedError) {
|
|
2154
|
+
reqLogger.info('Session not initialized - returning HTTP 404 for client auto-reinitialize', {
|
|
2155
|
+
error: error.message,
|
|
2156
|
+
duration_ms: responseTime,
|
|
2157
|
+
});
|
|
2158
|
+
res.setHeader('MCP-Protocol-Version', MCP_PROTOCOL_VERSION);
|
|
2159
|
+
res.setHeader('X-Request-Id', requestId);
|
|
2160
|
+
res.status(404).json({ error: error.message });
|
|
2161
|
+
return;
|
|
2162
|
+
}
|
|
2163
|
+
reqLogger.error('MCP request failed', {
|
|
2164
|
+
error: error instanceof Error ? error.message : 'Internal error',
|
|
2165
|
+
stack: error instanceof Error ? error.stack : undefined,
|
|
2166
|
+
duration_ms: responseTime,
|
|
2167
|
+
});
|
|
2168
|
+
res.setHeader('MCP-Protocol-Version', MCP_PROTOCOL_VERSION);
|
|
2169
|
+
res.setHeader('X-Request-Id', requestId);
|
|
2170
|
+
res.status(500).json({ error: error instanceof Error ? error.message : 'Internal error' });
|
|
2171
|
+
}
|
|
2172
|
+
}
|
|
2173
|
+
/**
|
|
2174
|
+
* Handle JSON-RPC method routing
|
|
2175
|
+
*/
|
|
2176
|
+
async handleJsonRpcRequest(request) {
|
|
2177
|
+
const { method, params, id } = request;
|
|
2178
|
+
// Validate JSON-RPC 2.0 structure
|
|
2179
|
+
if (!request.jsonrpc || request.jsonrpc !== '2.0') {
|
|
2180
|
+
return {
|
|
2181
|
+
jsonrpc: '2.0',
|
|
2182
|
+
id,
|
|
2183
|
+
error: { code: -32600, message: 'Invalid Request: jsonrpc field must be "2.0"' }
|
|
2184
|
+
};
|
|
2185
|
+
}
|
|
2186
|
+
try {
|
|
2187
|
+
let result;
|
|
2188
|
+
// Handle MCP methods
|
|
2189
|
+
if (method === 'notifications/initialized') {
|
|
2190
|
+
return null; // No response for notifications
|
|
2191
|
+
}
|
|
2192
|
+
if (method === 'initialize') {
|
|
2193
|
+
result = {
|
|
2194
|
+
protocolVersion: '2024-11-05',
|
|
2195
|
+
capabilities: {
|
|
2196
|
+
tools: { listChanged: true },
|
|
2197
|
+
prompts: { listChanged: false },
|
|
2198
|
+
resources: { listChanged: false },
|
|
2199
|
+
},
|
|
2200
|
+
serverInfo: {
|
|
2201
|
+
name: 'metalink',
|
|
2202
|
+
version,
|
|
2203
|
+
},
|
|
2204
|
+
};
|
|
2205
|
+
}
|
|
2206
|
+
else if (method === 'ping') {
|
|
2207
|
+
// MCP spec: ping method for keepalive/health checks
|
|
2208
|
+
result = {};
|
|
2209
|
+
}
|
|
2210
|
+
else if (method === 'roots/list') {
|
|
2211
|
+
result = { roots: [] };
|
|
2212
|
+
}
|
|
2213
|
+
else if (method === 'prompts/list') {
|
|
2214
|
+
result = { prompts: getPromptsList() };
|
|
2215
|
+
}
|
|
2216
|
+
else if (method === 'resources/list') {
|
|
2217
|
+
result = { resources: getResourcesList() };
|
|
2218
|
+
}
|
|
2219
|
+
else if (method === 'resources/templates/list') {
|
|
2220
|
+
result = { resourceTemplates: getResourceTemplatesList() };
|
|
2221
|
+
}
|
|
2222
|
+
else if (method === 'prompts/get') {
|
|
2223
|
+
const promptParams = params;
|
|
2224
|
+
if (!promptParams.name) {
|
|
2225
|
+
throw new InvalidParamsError('Missing required parameter: name');
|
|
2226
|
+
}
|
|
2227
|
+
try {
|
|
2228
|
+
result = getPrompt(promptParams.name, promptParams.arguments || {});
|
|
2229
|
+
}
|
|
2230
|
+
catch (err) {
|
|
2231
|
+
throw new InvalidParamsError(err instanceof Error ? err.message : 'Unknown prompt error');
|
|
2232
|
+
}
|
|
2233
|
+
}
|
|
2234
|
+
else if (method === 'resources/read') {
|
|
2235
|
+
const resourceParams = params;
|
|
2236
|
+
if (!resourceParams.uri) {
|
|
2237
|
+
throw new InvalidParamsError('Missing required parameter: uri');
|
|
2238
|
+
}
|
|
2239
|
+
try {
|
|
2240
|
+
result = await readResource(resourceParams.uri, this.serverManager, this.configLoader);
|
|
2241
|
+
}
|
|
2242
|
+
catch (err) {
|
|
2243
|
+
throw new InvalidParamsError(err instanceof Error ? err.message : 'Unknown resource error');
|
|
2244
|
+
}
|
|
2245
|
+
}
|
|
2246
|
+
else if (method === 'tools/list') {
|
|
2247
|
+
result = await this.mcpListTools();
|
|
2248
|
+
}
|
|
2249
|
+
else if (method === 'tools/call') {
|
|
2250
|
+
const callParams = params;
|
|
2251
|
+
if (!callParams.name) {
|
|
2252
|
+
throw new InvalidParamsError('Missing required parameter: name');
|
|
2253
|
+
}
|
|
2254
|
+
result = await this.mcpCallTool(callParams.name, callParams.arguments);
|
|
2255
|
+
}
|
|
2256
|
+
else {
|
|
2257
|
+
throw new MethodNotFoundError(method);
|
|
2258
|
+
}
|
|
2259
|
+
return {
|
|
2260
|
+
jsonrpc: '2.0',
|
|
2261
|
+
id,
|
|
2262
|
+
result,
|
|
2263
|
+
};
|
|
2264
|
+
}
|
|
2265
|
+
catch (error) {
|
|
2266
|
+
console.error(`[MCP] Error handling ${method}:`, error);
|
|
2267
|
+
// Check for specific JSON-RPC error types
|
|
2268
|
+
const isMethodNotFound = error instanceof MethodNotFoundError;
|
|
2269
|
+
const isInvalidParams = error instanceof InvalidParamsError;
|
|
2270
|
+
// Determine appropriate error code
|
|
2271
|
+
let errorCode = -32603; // Default: Internal error
|
|
2272
|
+
if (isMethodNotFound) {
|
|
2273
|
+
errorCode = -32601; // Method not found
|
|
2274
|
+
}
|
|
2275
|
+
else if (isInvalidParams) {
|
|
2276
|
+
errorCode = -32602; // Invalid params
|
|
2277
|
+
}
|
|
2278
|
+
return {
|
|
2279
|
+
jsonrpc: '2.0',
|
|
2280
|
+
id,
|
|
2281
|
+
error: {
|
|
2282
|
+
code: errorCode,
|
|
2283
|
+
message: error instanceof Error ? error.message : 'Internal error',
|
|
2284
|
+
},
|
|
2285
|
+
};
|
|
2286
|
+
}
|
|
2287
|
+
}
|
|
2288
|
+
/**
|
|
2289
|
+
* Generate execute_tool description
|
|
2290
|
+
* v1.3.x: Simplified - removed "Safe by default" list that caused confusion.
|
|
2291
|
+
* Now relies solely on annotations.safety from search_tools results.
|
|
2292
|
+
*/
|
|
2293
|
+
getExecuteToolDescription() {
|
|
2294
|
+
// v1.3.x: Removed dynamic server list - was causing Claude to ignore annotations.safety
|
|
2295
|
+
// The safety classification is now ONLY determined by checking annotations.safety in search_tools results
|
|
2296
|
+
return 'Execute tool (auto-approved for safe tools). BLOCKS risky tools - use execute_tool_confirm for risky tools. IMPORTANT: Check annotations.safety in search_tools results - use execute_tool for "safe" tools, execute_tool_confirm for "risky" tools. Safety annotation is authoritative.';
|
|
2297
|
+
}
|
|
2298
|
+
/**
|
|
2299
|
+
* MCP: tools/list - return available tools
|
|
2300
|
+
*
|
|
2301
|
+
* SPEAKEASY-INSPIRED 4-TOOL APPROACH (v1.3.52):
|
|
2302
|
+
* 1. search_tools - Fuzzy search across all servers
|
|
2303
|
+
* 2. describe_tool - Get full schema for specific tool
|
|
2304
|
+
* 3. execute_tool - Execute safe/read-only tools (BLOCKS risky tools)
|
|
2305
|
+
* 4. execute_tool_confirm - Execute risky/write tools (user confirmed)
|
|
2306
|
+
*
|
|
2307
|
+
* Optional: Base server tools (if dynamicToolExposure=true in config)
|
|
2308
|
+
*/
|
|
2309
|
+
async mcpListTools() {
|
|
2310
|
+
const tools = [];
|
|
2311
|
+
// NO HARDCODING: Read base servers from config
|
|
2312
|
+
const baseServerNames = this.configLoader.getBaseServers();
|
|
2313
|
+
const dynamicToolExposure = this.config.dynamicToolExposure ?? false;
|
|
2314
|
+
/**
|
|
2315
|
+
* SPEAKEASY-INSPIRED 4-TOOL APPROACH (v1.3.52)
|
|
2316
|
+
*
|
|
2317
|
+
* Inspired by Speakeasy's token optimization strategy, we expose only 4 essential meta-tools:
|
|
2318
|
+
*
|
|
2319
|
+
* Discovery Tools (2):
|
|
2320
|
+
* 1. search_tools - Fuzzy search across all available tools
|
|
2321
|
+
* 2. describe_tool - Get full schema for a specific tool
|
|
2322
|
+
*
|
|
2323
|
+
* Execution Tools (2):
|
|
2324
|
+
* 3. execute_tool - Execute safe tools (auto-approved based on annotations.safety)
|
|
2325
|
+
* - BLOCKS risky tools with error
|
|
2326
|
+
* - Safety annotation overrides read/write heuristics
|
|
2327
|
+
* 4. execute_tool_confirm - Execute risky tools (requires user confirmation)
|
|
2328
|
+
* - ALLOWS any tool execution (user already confirmed)
|
|
2329
|
+
* - Logs classification but doesn't block
|
|
2330
|
+
*
|
|
2331
|
+
* Token usage: ~280 tokens (vs 1,300+ with full base server exposure)
|
|
2332
|
+
* See: https://www.speakeasy.com/blog/how-we-reduced-token-usage-by-100x-dynamic-toolsets-v2
|
|
2333
|
+
*
|
|
2334
|
+
* Legacy mode: Set dynamicToolExposure=true to expose all base server tools
|
|
2335
|
+
*/
|
|
2336
|
+
tools.push({
|
|
2337
|
+
name: 'search_tools',
|
|
2338
|
+
description: 'Search tools by keyword across all available servers, or list all tools from a specific server. Can also search by server name to find all tools from matching servers. When exactly 1 tool matches, automatically includes full schema (inputSchema, requiredParams, example) to save a describe_tool call. Each result includes annotations.safety ("safe" or "risky") - use execute_tool for safe, execute_tool_confirm for risky.',
|
|
2339
|
+
inputSchema: {
|
|
2340
|
+
type: 'object',
|
|
2341
|
+
properties: {
|
|
2342
|
+
query: {
|
|
2343
|
+
type: 'string',
|
|
2344
|
+
description: 'Optional keyword to search for in server names, tool names, and descriptions. If query matches a server name, returns all tools from that server.',
|
|
2345
|
+
},
|
|
2346
|
+
server_name: {
|
|
2347
|
+
type: 'string',
|
|
2348
|
+
description: 'Optional server name to filter results. If provided, only returns tools from this specific server.',
|
|
2349
|
+
},
|
|
2350
|
+
},
|
|
2351
|
+
required: [],
|
|
2352
|
+
},
|
|
2353
|
+
}, {
|
|
2354
|
+
name: 'describe_tool',
|
|
2355
|
+
description: 'Get detailed schema for a specific tool including validation hints. Returns inputSchema, requiredParams, and optional validation warnings/suggestions when schema is incomplete or incorrect.',
|
|
2356
|
+
inputSchema: {
|
|
2357
|
+
type: 'object',
|
|
2358
|
+
properties: {
|
|
2359
|
+
server_name: {
|
|
2360
|
+
type: 'string',
|
|
2361
|
+
description: 'Server name (e.g., "memory", "jira-basic-auth")',
|
|
2362
|
+
},
|
|
2363
|
+
tool_name: {
|
|
2364
|
+
type: 'string',
|
|
2365
|
+
description: 'Tool name (e.g., "create_entities", "search_issues")',
|
|
2366
|
+
},
|
|
2367
|
+
},
|
|
2368
|
+
required: ['server_name', 'tool_name'],
|
|
2369
|
+
},
|
|
2370
|
+
}, {
|
|
2371
|
+
name: 'execute_tool',
|
|
2372
|
+
description: this.getExecuteToolDescription(),
|
|
2373
|
+
inputSchema: {
|
|
2374
|
+
type: 'object',
|
|
2375
|
+
properties: {
|
|
2376
|
+
server_name: {
|
|
2377
|
+
type: 'string',
|
|
2378
|
+
description: 'Server name (e.g., "jira-basic-auth", "memory")',
|
|
2379
|
+
example: 'jira-basic-auth'
|
|
2380
|
+
},
|
|
2381
|
+
tool_name: {
|
|
2382
|
+
type: 'string',
|
|
2383
|
+
description: 'Tool name (e.g., "confluence_search", "create_entities")',
|
|
2384
|
+
example: 'confluence_search'
|
|
2385
|
+
},
|
|
2386
|
+
arguments: {
|
|
2387
|
+
type: 'object',
|
|
2388
|
+
description: 'Tool-specific parameters. Call describe_tool(server_name, tool_name) first to discover required parameters, then nest those parameters here.',
|
|
2389
|
+
example: {
|
|
2390
|
+
cql: 'type=page ORDER BY created DESC',
|
|
2391
|
+
limit: 10
|
|
2392
|
+
},
|
|
2393
|
+
additionalProperties: true
|
|
2394
|
+
},
|
|
2395
|
+
max_results: {
|
|
2396
|
+
type: 'number',
|
|
2397
|
+
description: 'Optional: Limit array results to N items (for pagination)',
|
|
2398
|
+
example: 50
|
|
2399
|
+
},
|
|
2400
|
+
max_result_chars: {
|
|
2401
|
+
type: 'number',
|
|
2402
|
+
description: 'Optional: Limit total response size to N characters (default: 50000)',
|
|
2403
|
+
example: 10000
|
|
2404
|
+
},
|
|
2405
|
+
cursor: {
|
|
2406
|
+
type: 'string',
|
|
2407
|
+
description: 'Optional: Opaque cursor from previous response to continue pagination'
|
|
2408
|
+
}
|
|
2409
|
+
},
|
|
2410
|
+
required: ['server_name', 'tool_name', 'arguments'],
|
|
2411
|
+
additionalProperties: false
|
|
2412
|
+
}
|
|
2413
|
+
}, {
|
|
2414
|
+
name: 'execute_tool_confirm',
|
|
2415
|
+
description: 'Execute risky tool (requires user confirmation). IMPORTANT: Check annotations.safety in search_tools results - only use this for "risky" tools, use execute_tool for "safe" tools. Safety annotation is authoritative. Required for external services without explicit safe rules.',
|
|
2416
|
+
inputSchema: {
|
|
2417
|
+
type: 'object',
|
|
2418
|
+
properties: {
|
|
2419
|
+
server_name: {
|
|
2420
|
+
type: 'string',
|
|
2421
|
+
description: 'Server name (e.g., "jira-basic-auth", "memory")',
|
|
2422
|
+
example: 'memory'
|
|
2423
|
+
},
|
|
2424
|
+
tool_name: {
|
|
2425
|
+
type: 'string',
|
|
2426
|
+
description: 'Tool name (e.g., "create_issue", "delete_entities")',
|
|
2427
|
+
example: 'create_entities'
|
|
2428
|
+
},
|
|
2429
|
+
arguments: {
|
|
2430
|
+
type: 'object',
|
|
2431
|
+
description: 'Tool arguments object. REQUIRED. All tool-specific parameters MUST be nested inside this object, not at the top level.',
|
|
2432
|
+
example: {
|
|
2433
|
+
entities: [{ name: 'test', entityType: 'concept', observations: ['example data'] }]
|
|
2434
|
+
},
|
|
2435
|
+
additionalProperties: true
|
|
2436
|
+
},
|
|
2437
|
+
max_results: {
|
|
2438
|
+
type: 'number',
|
|
2439
|
+
description: 'Optional: Limit array results to N items (for pagination)',
|
|
2440
|
+
example: 50
|
|
2441
|
+
},
|
|
2442
|
+
max_result_chars: {
|
|
2443
|
+
type: 'number',
|
|
2444
|
+
description: 'Optional: Limit total response size to N characters (default: 50000)',
|
|
2445
|
+
example: 10000
|
|
2446
|
+
},
|
|
2447
|
+
cursor: {
|
|
2448
|
+
type: 'string',
|
|
2449
|
+
description: 'Optional: Opaque cursor from previous response to continue pagination'
|
|
2450
|
+
}
|
|
2451
|
+
},
|
|
2452
|
+
required: ['server_name', 'tool_name', 'arguments'],
|
|
2453
|
+
additionalProperties: false
|
|
2454
|
+
}
|
|
2455
|
+
});
|
|
2456
|
+
// OPTIONAL: Base server tools - controlled by config options (v1.3.57+)
|
|
2457
|
+
// Three modes:
|
|
2458
|
+
// 1. Speakeasy mode (default): base_servers_auto_expose_tools = false → only 4 meta-tools (~280 tokens)
|
|
2459
|
+
// 2. Base server exposure: base_servers_auto_expose_tools = true → expose base server tools (~1,300+ tokens)
|
|
2460
|
+
// 3. Legacy mode: dynamicToolExposure = true → expose all discovered tools
|
|
2461
|
+
const baseServersAutoExposeTools = this.config.base_servers_auto_expose_tools ?? false;
|
|
2462
|
+
if (dynamicToolExposure || baseServersAutoExposeTools) {
|
|
2463
|
+
const activeServers = this.serverManager.getActiveServers();
|
|
2464
|
+
for (const [serverName, serverData] of activeServers) {
|
|
2465
|
+
// Only expose base servers if baseServersAutoExposeTools is true
|
|
2466
|
+
// Or expose all servers if dynamicToolExposure is true (legacy mode)
|
|
2467
|
+
const isBaseServer = baseServerNames.includes(serverName);
|
|
2468
|
+
const shouldExpose = dynamicToolExposure || (isBaseServer && baseServersAutoExposeTools);
|
|
2469
|
+
if (shouldExpose && serverData.toolsReady && serverData.tools) {
|
|
2470
|
+
for (const tool of serverData.tools) {
|
|
2471
|
+
tools.push({
|
|
2472
|
+
name: `${serverName}-${tool.name}`,
|
|
2473
|
+
description: tool.description || `Tool from ${serverName} server`,
|
|
2474
|
+
inputSchema: tool.inputSchema || { type: 'object', properties: {} },
|
|
2475
|
+
});
|
|
2476
|
+
}
|
|
2477
|
+
}
|
|
2478
|
+
}
|
|
2479
|
+
}
|
|
2480
|
+
// OLD TOOLS (DEPRECATED in v1.3.0 - commented out for reference)
|
|
2481
|
+
// These have been replaced by discovery tools to save tokens
|
|
2482
|
+
// tools.push(
|
|
2483
|
+
// { name: 'list_available_servers', ... },
|
|
2484
|
+
// { name: 'enable_server', ... },
|
|
2485
|
+
// { name: 'disable_server', ... },
|
|
2486
|
+
// { name: 'list_servers', ... },
|
|
2487
|
+
// { name: 'list_tools', ... },
|
|
2488
|
+
// { name: 'execute_tool', ... },
|
|
2489
|
+
// { name: 'help', ... }
|
|
2490
|
+
// );
|
|
2491
|
+
return { tools };
|
|
2492
|
+
}
|
|
2493
|
+
/**
|
|
2494
|
+
* MCP: tools/call - route tool calls
|
|
2495
|
+
*
|
|
2496
|
+
* SECURITY: Includes per-server rate limiting to prevent DOS attacks.
|
|
2497
|
+
* Rate limit: 100 calls per server per minute (configurable).
|
|
2498
|
+
* Discovery endpoints (search_tools, describe_tool) have separate rate limiting.
|
|
2499
|
+
*/
|
|
2500
|
+
async mcpCallTool(name, args, sessionId) {
|
|
2501
|
+
// Track metrics for tool call (Phase 4 - v1.4.0)
|
|
2502
|
+
const startTime = Date.now();
|
|
2503
|
+
globalMetrics.incrementCounter(`tool_calls_${name}`, 'calls');
|
|
2504
|
+
// SECURITY: Extract server name for rate limiting
|
|
2505
|
+
// For meta-tools (execute_tool, search_tools), extract from args
|
|
2506
|
+
// For direct calls (server-tool), extract from name
|
|
2507
|
+
const serverName = this.extractServerNameForRateLimit(name, args);
|
|
2508
|
+
// Record tool call for error rate tracking
|
|
2509
|
+
globalMetrics.recordToolCall(name, serverName || undefined);
|
|
2510
|
+
// Log tool call with structured context
|
|
2511
|
+
logger.info('Tool call initiated', {
|
|
2512
|
+
tool: name,
|
|
2513
|
+
server: serverName || undefined,
|
|
2514
|
+
sessionId,
|
|
2515
|
+
});
|
|
2516
|
+
// SECURITY: Check rate limit before proceeding
|
|
2517
|
+
if (serverName) {
|
|
2518
|
+
this.checkToolRateLimit(serverName);
|
|
2519
|
+
}
|
|
2520
|
+
// SECURITY: Discovery endpoint rate limiting (P1)
|
|
2521
|
+
// search_tools and describe_tool have separate rate limits per session
|
|
2522
|
+
if (name === 'search_tools' || name === 'describe_tool') {
|
|
2523
|
+
const rateLimitKey = sessionId || 'anonymous';
|
|
2524
|
+
this.checkDiscoveryRateLimit(rateLimitKey);
|
|
2525
|
+
}
|
|
2526
|
+
try {
|
|
2527
|
+
const result = await this.executeToolCall(name, args);
|
|
2528
|
+
// Record successful execution metrics
|
|
2529
|
+
const latency = Date.now() - startTime;
|
|
2530
|
+
globalMetrics.setGauge(`tool_latency_${name}`, latency, 'ms');
|
|
2531
|
+
// Record granular tool-specific metrics (Test 187)
|
|
2532
|
+
if (serverName) {
|
|
2533
|
+
globalMetrics.recordToolExecution(serverName, name.replace(`${serverName}-`, ''), latency);
|
|
2534
|
+
}
|
|
2535
|
+
// Log successful tool execution
|
|
2536
|
+
logger.info('Tool call completed', {
|
|
2537
|
+
tool: name,
|
|
2538
|
+
server: serverName || undefined,
|
|
2539
|
+
duration_ms: latency,
|
|
2540
|
+
});
|
|
2541
|
+
return result;
|
|
2542
|
+
}
|
|
2543
|
+
catch (error) {
|
|
2544
|
+
// Record error metrics with detailed tracking
|
|
2545
|
+
globalMetrics.incrementCounter(`tool_errors_${name}`, 'errors');
|
|
2546
|
+
globalMetrics.recordToolError(error, name, serverName || undefined);
|
|
2547
|
+
// Record granular tool-specific error (Test 187)
|
|
2548
|
+
if (serverName) {
|
|
2549
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2550
|
+
globalMetrics.recordToolFailure(serverName, name.replace(`${serverName}-`, ''), errorMessage);
|
|
2551
|
+
}
|
|
2552
|
+
// Log tool execution error
|
|
2553
|
+
const latency = Date.now() - startTime;
|
|
2554
|
+
logger.error('Tool call failed', {
|
|
2555
|
+
tool: name,
|
|
2556
|
+
server: serverName || undefined,
|
|
2557
|
+
error: error instanceof Error ? error.message : String(error),
|
|
2558
|
+
duration_ms: latency,
|
|
2559
|
+
});
|
|
2560
|
+
throw error;
|
|
2561
|
+
}
|
|
2562
|
+
}
|
|
2563
|
+
/**
|
|
2564
|
+
* SECURITY: Extract server name from tool call for rate limiting.
|
|
2565
|
+
*
|
|
2566
|
+
* @param name - Tool name (e.g., "execute_tool", "memory-search_nodes")
|
|
2567
|
+
* @param args - Tool arguments
|
|
2568
|
+
* @returns Server name or null for meta-tools without server context
|
|
2569
|
+
*/
|
|
2570
|
+
extractServerNameForRateLimit(name, args) {
|
|
2571
|
+
// Meta-tools with explicit server_name argument
|
|
2572
|
+
if (name === 'execute_tool' || name === 'execute_tool_confirm' ||
|
|
2573
|
+
name === 'describe_tool' || name === 'list_tools') {
|
|
2574
|
+
const callArgs = args;
|
|
2575
|
+
return callArgs?.server_name || null;
|
|
2576
|
+
}
|
|
2577
|
+
// Discovery tools - no server-specific rate limiting
|
|
2578
|
+
if (name === 'search_tools' || name === 'list_available_servers' || name === 'list_servers') {
|
|
2579
|
+
return null;
|
|
2580
|
+
}
|
|
2581
|
+
// Direct tool calls: "server-toolName" format
|
|
2582
|
+
if (name.includes('-')) {
|
|
2583
|
+
const parts = name.split('-');
|
|
2584
|
+
return parts[0];
|
|
2585
|
+
}
|
|
2586
|
+
return null;
|
|
2587
|
+
}
|
|
2588
|
+
/**
|
|
2589
|
+
* SECURITY: Check and enforce per-server rate limiting.
|
|
2590
|
+
*
|
|
2591
|
+
* OWASP Reference: A6:2017-Security Misconfiguration - DOS Prevention
|
|
2592
|
+
*
|
|
2593
|
+
* @param serverName - Name of the server to check rate limit for
|
|
2594
|
+
* @throws Error if rate limit exceeded
|
|
2595
|
+
*/
|
|
2596
|
+
checkToolRateLimit(serverName) {
|
|
2597
|
+
const now = Date.now();
|
|
2598
|
+
const limiter = this.toolExecutionRateLimiter.get(serverName);
|
|
2599
|
+
if (!limiter || now > limiter.resetTime) {
|
|
2600
|
+
// First call or window expired - reset counter
|
|
2601
|
+
this.toolExecutionRateLimiter.set(serverName, {
|
|
2602
|
+
count: 1,
|
|
2603
|
+
resetTime: now + this.TOOL_RATE_LIMIT_WINDOW_MS,
|
|
2604
|
+
});
|
|
2605
|
+
return;
|
|
2606
|
+
}
|
|
2607
|
+
// Increment counter
|
|
2608
|
+
limiter.count++;
|
|
2609
|
+
// Check if rate limit exceeded
|
|
2610
|
+
if (limiter.count > this.TOOL_RATE_LIMIT_MAX_CALLS) {
|
|
2611
|
+
const waitTime = Math.ceil((limiter.resetTime - now) / 1000);
|
|
2612
|
+
console.warn(`[SECURITY] Rate limit exceeded for server '${serverName}': ` +
|
|
2613
|
+
`${limiter.count} calls in window, max ${this.TOOL_RATE_LIMIT_MAX_CALLS}`);
|
|
2614
|
+
globalMetrics.incrementCounter(`rate_limit_exceeded_${serverName}`, 'rate_limits');
|
|
2615
|
+
throw new Error(`Rate limit exceeded for server '${serverName}'. ` +
|
|
2616
|
+
`Maximum ${this.TOOL_RATE_LIMIT_MAX_CALLS} tool calls per minute. ` +
|
|
2617
|
+
`Please wait ${waitTime} seconds before retrying.`);
|
|
2618
|
+
}
|
|
2619
|
+
this.toolExecutionRateLimiter.set(serverName, limiter);
|
|
2620
|
+
}
|
|
2621
|
+
/**
|
|
2622
|
+
* SECURITY: Check discovery endpoint rate limit (P1 - OWASP DOS Prevention)
|
|
2623
|
+
* Prevents abuse of search_tools and describe_tool endpoints
|
|
2624
|
+
*
|
|
2625
|
+
* @param sessionId - Session ID to track rate limit for
|
|
2626
|
+
* @throws Error if rate limit exceeded
|
|
2627
|
+
*/
|
|
2628
|
+
checkDiscoveryRateLimit(sessionId) {
|
|
2629
|
+
const now = Date.now();
|
|
2630
|
+
const limiter = this.discoveryRateLimiter.get(sessionId);
|
|
2631
|
+
if (!limiter || now > limiter.resetTime) {
|
|
2632
|
+
// First call or window expired - reset counter
|
|
2633
|
+
this.discoveryRateLimiter.set(sessionId, {
|
|
2634
|
+
count: 1,
|
|
2635
|
+
resetTime: now + this.DISCOVERY_RATE_LIMIT_WINDOW_MS,
|
|
2636
|
+
});
|
|
2637
|
+
return;
|
|
2638
|
+
}
|
|
2639
|
+
// Increment counter
|
|
2640
|
+
limiter.count++;
|
|
2641
|
+
// Check if rate limit exceeded
|
|
2642
|
+
if (limiter.count > this.DISCOVERY_RATE_LIMIT_MAX_CALLS) {
|
|
2643
|
+
const waitTime = Math.ceil((limiter.resetTime - now) / 1000);
|
|
2644
|
+
console.warn(`[SECURITY] Discovery rate limit exceeded for session '${sessionId}': ` +
|
|
2645
|
+
`${limiter.count} calls in window, max ${this.DISCOVERY_RATE_LIMIT_MAX_CALLS}`);
|
|
2646
|
+
globalMetrics.incrementCounter('discovery_rate_limit_exceeded', 'rate_limits');
|
|
2647
|
+
throw new Error(`Discovery rate limit exceeded. ` +
|
|
2648
|
+
`Maximum ${this.DISCOVERY_RATE_LIMIT_MAX_CALLS} discovery calls per minute. ` +
|
|
2649
|
+
`Please wait ${waitTime} seconds before retrying.`);
|
|
2650
|
+
}
|
|
2651
|
+
this.discoveryRateLimiter.set(sessionId, limiter);
|
|
2652
|
+
}
|
|
2653
|
+
/**
|
|
2654
|
+
* Execute tool call logic (extracted for metrics tracking)
|
|
2655
|
+
*/
|
|
2656
|
+
async executeToolCall(name, args) {
|
|
2657
|
+
const callArgs = args;
|
|
2658
|
+
switch (name) {
|
|
2659
|
+
case 'list_available_servers': {
|
|
2660
|
+
const allServers = this.configLoader.getAllServers();
|
|
2661
|
+
const enabledServers = this.configLoader.getServers();
|
|
2662
|
+
const enabledNames = new Set(enabledServers.map(s => s.name));
|
|
2663
|
+
return {
|
|
2664
|
+
content: [
|
|
2665
|
+
{
|
|
2666
|
+
type: 'text',
|
|
2667
|
+
text: JSON.stringify({
|
|
2668
|
+
servers: allServers.map(s => {
|
|
2669
|
+
const isStdio = s.transport === 'stdio' || s.transport === undefined;
|
|
2670
|
+
return {
|
|
2671
|
+
name: s.name,
|
|
2672
|
+
...(isStdio ? { command: s.command } : { url: s.url }),
|
|
2673
|
+
enabled: enabledNames.has(s.name),
|
|
2674
|
+
};
|
|
2675
|
+
}),
|
|
2676
|
+
}, null, 2)
|
|
2677
|
+
}
|
|
2678
|
+
]
|
|
2679
|
+
};
|
|
2680
|
+
}
|
|
2681
|
+
case 'list_servers': {
|
|
2682
|
+
const servers = this.configLoader.getServers();
|
|
2683
|
+
return {
|
|
2684
|
+
content: [
|
|
2685
|
+
{
|
|
2686
|
+
type: 'text',
|
|
2687
|
+
text: JSON.stringify({
|
|
2688
|
+
servers: servers.map(s => {
|
|
2689
|
+
const isStdio = s.transport === 'stdio' || s.transport === undefined;
|
|
2690
|
+
return {
|
|
2691
|
+
name: s.name,
|
|
2692
|
+
...(isStdio ? { command: s.command } : { url: s.url }),
|
|
2693
|
+
status: this.serverManager.getServerStatus(s.name)?.status || 'stopped',
|
|
2694
|
+
toolCount: this.serverManager.getServerTools(s.name).length || 0,
|
|
2695
|
+
};
|
|
2696
|
+
}),
|
|
2697
|
+
}, null, 2)
|
|
2698
|
+
}
|
|
2699
|
+
]
|
|
2700
|
+
};
|
|
2701
|
+
}
|
|
2702
|
+
case 'list_tools': {
|
|
2703
|
+
const serverName = callArgs?.server_name;
|
|
2704
|
+
if (!serverName)
|
|
2705
|
+
throw new InvalidParamsError('server_name required');
|
|
2706
|
+
// Get tools from server manager
|
|
2707
|
+
const tools = this.serverManager.getServerTools(serverName);
|
|
2708
|
+
return {
|
|
2709
|
+
content: [
|
|
2710
|
+
{
|
|
2711
|
+
type: 'text',
|
|
2712
|
+
text: JSON.stringify({
|
|
2713
|
+
server: serverName,
|
|
2714
|
+
tools: tools || [],
|
|
2715
|
+
}, null, 2)
|
|
2716
|
+
}
|
|
2717
|
+
]
|
|
2718
|
+
};
|
|
2719
|
+
}
|
|
2720
|
+
case 'execute_tool': {
|
|
2721
|
+
const args = callArgs;
|
|
2722
|
+
// DEBUG: Log raw incoming args to diagnose Raycast/Grok issues
|
|
2723
|
+
console.log(`[DEBUG execute_tool] RAW INCOMING ARGS: ${JSON.stringify(args)}`);
|
|
2724
|
+
// Phase 2a: Detect and fix Raycast format issues
|
|
2725
|
+
const fixedArgs = this.serverManager.detectAndFixRaycastFormat(args);
|
|
2726
|
+
const serverName = fixedArgs.server_name;
|
|
2727
|
+
const toolName = fixedArgs.tool_name;
|
|
2728
|
+
if (!serverName || !toolName) {
|
|
2729
|
+
throw new InvalidParamsError('server_name and tool_name are required. Format: {"server_name": "memory", "tool_name": "search_nodes", "arguments": {...}}');
|
|
2730
|
+
}
|
|
2731
|
+
// v1.1.29: Extract tool arguments for argument-level safety inspection
|
|
2732
|
+
const toolArgs = fixedArgs.arguments || {};
|
|
2733
|
+
// SAFETY CHECK: Verify this tool is classified as 'safe' (with argument inspection)
|
|
2734
|
+
const safetyResult = this.serverManager.classifyToolSafety(serverName, toolName, toolArgs);
|
|
2735
|
+
if (safetyResult.safety === 'risky') {
|
|
2736
|
+
throw new InvalidParamsError(`Tool ${serverName}:${toolName} is classified as RISKY and requires user confirmation.\n` +
|
|
2737
|
+
`Use 'execute_tool_confirm' instead for this tool.\n` +
|
|
2738
|
+
`Classification reason: ${safetyResult.reason}`);
|
|
2739
|
+
}
|
|
2740
|
+
// Phase 2b: Get tool schema for validation
|
|
2741
|
+
const toolSchema = this.serverManager.getToolSchema(serverName, toolName);
|
|
2742
|
+
if (!toolSchema) {
|
|
2743
|
+
throw new InvalidParamsError(`Tool '${toolName}' not found in server '${serverName}'. Use describe_tool to get the tool schema or search_tools to find available tools.`);
|
|
2744
|
+
}
|
|
2745
|
+
// Phase 2c: Detect missing arguments
|
|
2746
|
+
const missingArgs = this.serverManager.detectMissingArguments(fixedArgs, toolSchema);
|
|
2747
|
+
if (missingArgs) {
|
|
2748
|
+
const inputSchema = toolSchema.inputSchema;
|
|
2749
|
+
const requiredParams = inputSchema?.required?.join(', ') || 'unknown';
|
|
2750
|
+
const errorMsg = `❌ Missing required parameters for ${serverName}:${toolName}\n` +
|
|
2751
|
+
`\n` +
|
|
2752
|
+
`Required: ${requiredParams}\n` +
|
|
2753
|
+
`\n` +
|
|
2754
|
+
`✅ CORRECT FORMAT:\n` +
|
|
2755
|
+
` {"server_name": "${serverName}", "tool_name": "${toolName}", "arguments": {...}}\n` +
|
|
2756
|
+
`\n` +
|
|
2757
|
+
`For full schema: describe_tool('${serverName}', '${toolName}')`;
|
|
2758
|
+
throw new InvalidParamsError(errorMsg);
|
|
2759
|
+
}
|
|
2760
|
+
// Phase 2d: Validate required params exist inside arguments
|
|
2761
|
+
const args2 = fixedArgs.arguments || {};
|
|
2762
|
+
const inputSchema2 = toolSchema.inputSchema;
|
|
2763
|
+
const requiredParams2 = inputSchema2?.required || [];
|
|
2764
|
+
// Check if required params are missing from arguments
|
|
2765
|
+
const missingRequiredParams = requiredParams2.filter(param => !(param in args2));
|
|
2766
|
+
if (missingRequiredParams.length > 0) {
|
|
2767
|
+
// Generate example values for each required param
|
|
2768
|
+
const exampleArgs = {};
|
|
2769
|
+
for (const param of requiredParams2) {
|
|
2770
|
+
const propSchema = inputSchema2?.properties?.[param];
|
|
2771
|
+
if (propSchema?.example)
|
|
2772
|
+
exampleArgs[param] = propSchema.example;
|
|
2773
|
+
else if (propSchema?.default)
|
|
2774
|
+
exampleArgs[param] = propSchema.default;
|
|
2775
|
+
else if (propSchema?.type === 'string')
|
|
2776
|
+
exampleArgs[param] = `<${param}>`;
|
|
2777
|
+
else if (propSchema?.type === 'number')
|
|
2778
|
+
exampleArgs[param] = 10;
|
|
2779
|
+
else
|
|
2780
|
+
exampleArgs[param] = `<${param}>`;
|
|
2781
|
+
}
|
|
2782
|
+
const errorMsg = `❌ Missing required parameters for ${serverName}:${toolName}\n` +
|
|
2783
|
+
`\n` +
|
|
2784
|
+
`You provided: ${JSON.stringify(args2)}\n` +
|
|
2785
|
+
`Missing: ${missingRequiredParams.join(', ')}\n` +
|
|
2786
|
+
`\n` +
|
|
2787
|
+
`✅ CORRECT FORMAT:\n` +
|
|
2788
|
+
` {"server_name": "${serverName}", "tool_name": "${toolName}", "arguments": ${JSON.stringify(exampleArgs)}}\n` +
|
|
2789
|
+
`\n` +
|
|
2790
|
+
`For full schema: describe_tool('${serverName}', '${toolName}')`;
|
|
2791
|
+
throw new InvalidParamsError(errorMsg);
|
|
2792
|
+
}
|
|
2793
|
+
console.log(`[MetaLink] execute_tool (SAFE): ${serverName}:${toolName} with args ${JSON.stringify(args2)}`);
|
|
2794
|
+
// Phase 2e: Auto-start server if not running
|
|
2795
|
+
const serverConfig = this.configLoader.getServer(serverName);
|
|
2796
|
+
if (serverConfig) {
|
|
2797
|
+
await this.serverManager.ensureServerStarted(serverName, serverConfig);
|
|
2798
|
+
}
|
|
2799
|
+
// Phase 3: Call the actual tool via response router
|
|
2800
|
+
try {
|
|
2801
|
+
const result = await this.serverManager.callTool(serverName, toolName, args2);
|
|
2802
|
+
// Phase 4: Apply pagination if parameters provided
|
|
2803
|
+
const paginationParams = {
|
|
2804
|
+
max_results: fixedArgs.max_results,
|
|
2805
|
+
max_result_chars: fixedArgs.max_result_chars,
|
|
2806
|
+
cursor: fixedArgs.cursor
|
|
2807
|
+
};
|
|
2808
|
+
// Only paginate if at least one pagination param is provided
|
|
2809
|
+
if (paginationParams.max_results || paginationParams.max_result_chars || paginationParams.cursor) {
|
|
2810
|
+
const paginated = this.paginateResult(result, paginationParams);
|
|
2811
|
+
// Return result with pagination metadata merged in
|
|
2812
|
+
if (typeof result === 'object' && result !== null && !Array.isArray(result)) {
|
|
2813
|
+
return {
|
|
2814
|
+
...result,
|
|
2815
|
+
result: paginated.result,
|
|
2816
|
+
_pagination: paginated._pagination
|
|
2817
|
+
};
|
|
2818
|
+
}
|
|
2819
|
+
else {
|
|
2820
|
+
// If result is not an object (e.g., primitive or array), wrap it
|
|
2821
|
+
return {
|
|
2822
|
+
result: paginated.result,
|
|
2823
|
+
_pagination: paginated._pagination
|
|
2824
|
+
};
|
|
2825
|
+
}
|
|
2826
|
+
}
|
|
2827
|
+
return result;
|
|
2828
|
+
}
|
|
2829
|
+
catch (toolError) {
|
|
2830
|
+
// Enhanced error with inputSchema for debugging
|
|
2831
|
+
const errorMsg = `Tool execution failed for ${serverName}:${toolName}: ${toolError instanceof Error ? toolError.message : String(toolError)}`;
|
|
2832
|
+
const inputSchema = toolSchema.inputSchema;
|
|
2833
|
+
// Check if error is parameter-related
|
|
2834
|
+
const errorStr = (toolError instanceof Error ? toolError.message : String(toolError)).toLowerCase();
|
|
2835
|
+
const isParamError = errorStr.includes('null') || errorStr.includes('undefined') ||
|
|
2836
|
+
errorStr.includes('required') || errorStr.includes('missing');
|
|
2837
|
+
if (isParamError && inputSchema) {
|
|
2838
|
+
const requiredParams = inputSchema.required || [];
|
|
2839
|
+
const availableParams = Object.keys(inputSchema.properties || {});
|
|
2840
|
+
const optionalParams = availableParams.filter(p => !requiredParams.includes(p));
|
|
2841
|
+
const requiredList = requiredParams.length > 0 ? requiredParams.join(', ') : 'none';
|
|
2842
|
+
const optionalList = optionalParams.length > 0 ? optionalParams.join(', ') : 'none';
|
|
2843
|
+
const hint = `\n\n💡 Hint: This tool expects:\n` +
|
|
2844
|
+
` Required: ${requiredList}\n` +
|
|
2845
|
+
` Optional: ${optionalList}\n` +
|
|
2846
|
+
` Use describe_tool('${serverName}', '${toolName}') for full schema.`;
|
|
2847
|
+
throw new Error(errorMsg + hint);
|
|
2848
|
+
}
|
|
2849
|
+
throw new Error(errorMsg);
|
|
2850
|
+
}
|
|
2851
|
+
}
|
|
2852
|
+
case 'execute_tool_confirm': {
|
|
2853
|
+
const args = callArgs;
|
|
2854
|
+
// Phase 2a: Detect and fix Raycast format issues
|
|
2855
|
+
const fixedArgs = this.serverManager.detectAndFixRaycastFormat(args);
|
|
2856
|
+
const serverName = fixedArgs.server_name;
|
|
2857
|
+
const toolName = fixedArgs.tool_name;
|
|
2858
|
+
if (!serverName || !toolName) {
|
|
2859
|
+
throw new InvalidParamsError('server_name and tool_name are required. Format: {"server_name": "task-master-ai", "tool_name": "set_task_status", "arguments": {...}}');
|
|
2860
|
+
}
|
|
2861
|
+
// v1.1.29: Extract tool arguments for argument-level safety inspection
|
|
2862
|
+
const toolArgs = fixedArgs.arguments || {};
|
|
2863
|
+
// SAFETY CHECK: Log classification (but allow regardless - user confirmed)
|
|
2864
|
+
const safetyResult = this.serverManager.classifyToolSafety(serverName, toolName, toolArgs);
|
|
2865
|
+
console.log(`[MetaLink] execute_tool_confirm (${safetyResult.safety.toUpperCase()}): ${serverName}:${toolName} - ${safetyResult.reason}`);
|
|
2866
|
+
// Phase 2b: Get tool schema for validation
|
|
2867
|
+
const toolSchema = this.serverManager.getToolSchema(serverName, toolName);
|
|
2868
|
+
if (!toolSchema) {
|
|
2869
|
+
throw new InvalidParamsError(`Tool '${toolName}' not found in server '${serverName}'. Use describe_tool to get the tool schema or search_tools to find available tools.`);
|
|
2870
|
+
}
|
|
2871
|
+
// Phase 2c: Detect missing arguments
|
|
2872
|
+
const missingArgs = this.serverManager.detectMissingArguments(fixedArgs, toolSchema);
|
|
2873
|
+
if (missingArgs) {
|
|
2874
|
+
const inputSchema = toolSchema.inputSchema;
|
|
2875
|
+
const requiredParams = inputSchema?.required?.join(', ') || 'unknown';
|
|
2876
|
+
const errorMsg = `❌ Missing required parameters for ${serverName}:${toolName}\n` +
|
|
2877
|
+
`\n` +
|
|
2878
|
+
`Required: ${requiredParams}\n` +
|
|
2879
|
+
`\n` +
|
|
2880
|
+
`✅ CORRECT FORMAT:\n` +
|
|
2881
|
+
` {"server_name": "${serverName}", "tool_name": "${toolName}", "arguments": {...}}\n` +
|
|
2882
|
+
`\n` +
|
|
2883
|
+
`For full schema: describe_tool('${serverName}', '${toolName}')`;
|
|
2884
|
+
throw new InvalidParamsError(errorMsg);
|
|
2885
|
+
}
|
|
2886
|
+
// Phase 2d: Lenient mode - if arguments missing, default to empty object
|
|
2887
|
+
const args2 = fixedArgs.arguments || {};
|
|
2888
|
+
console.log(`[MetaLink] execute_tool_confirm: ${serverName}:${toolName} with args ${JSON.stringify(args2)}`);
|
|
2889
|
+
// Phase 2e: Auto-start server if not running
|
|
2890
|
+
const serverConfig = this.configLoader.getServer(serverName);
|
|
2891
|
+
if (serverConfig) {
|
|
2892
|
+
await this.serverManager.ensureServerStarted(serverName, serverConfig);
|
|
2893
|
+
}
|
|
2894
|
+
// Phase 3: Call the actual tool via response router
|
|
2895
|
+
try {
|
|
2896
|
+
const result = await this.serverManager.callTool(serverName, toolName, args2);
|
|
2897
|
+
// Phase 4: Apply pagination if parameters provided
|
|
2898
|
+
const paginationParams = {
|
|
2899
|
+
max_results: fixedArgs.max_results,
|
|
2900
|
+
max_result_chars: fixedArgs.max_result_chars,
|
|
2901
|
+
cursor: fixedArgs.cursor
|
|
2902
|
+
};
|
|
2903
|
+
// Only paginate if at least one pagination param is provided
|
|
2904
|
+
if (paginationParams.max_results || paginationParams.max_result_chars || paginationParams.cursor) {
|
|
2905
|
+
const paginated = this.paginateResult(result, paginationParams);
|
|
2906
|
+
// Return result with pagination metadata merged in
|
|
2907
|
+
if (typeof result === 'object' && result !== null && !Array.isArray(result)) {
|
|
2908
|
+
return {
|
|
2909
|
+
...result,
|
|
2910
|
+
result: paginated.result,
|
|
2911
|
+
_pagination: paginated._pagination
|
|
2912
|
+
};
|
|
2913
|
+
}
|
|
2914
|
+
else {
|
|
2915
|
+
// If result is not an object (e.g., primitive or array), wrap it
|
|
2916
|
+
return {
|
|
2917
|
+
result: paginated.result,
|
|
2918
|
+
_pagination: paginated._pagination
|
|
2919
|
+
};
|
|
2920
|
+
}
|
|
2921
|
+
}
|
|
2922
|
+
return result;
|
|
2923
|
+
}
|
|
2924
|
+
catch (toolError) {
|
|
2925
|
+
// Enhanced error with inputSchema for debugging
|
|
2926
|
+
const errorMsg = `Tool execution failed for ${serverName}:${toolName}: ${toolError instanceof Error ? toolError.message : String(toolError)}`;
|
|
2927
|
+
const inputSchema = toolSchema.inputSchema;
|
|
2928
|
+
// Check if error is parameter-related
|
|
2929
|
+
const errorStr = (toolError instanceof Error ? toolError.message : String(toolError)).toLowerCase();
|
|
2930
|
+
const isParamError = errorStr.includes('null') || errorStr.includes('undefined') ||
|
|
2931
|
+
errorStr.includes('required') || errorStr.includes('missing');
|
|
2932
|
+
if (isParamError && inputSchema) {
|
|
2933
|
+
const requiredParams = inputSchema.required || [];
|
|
2934
|
+
const availableParams = Object.keys(inputSchema.properties || {});
|
|
2935
|
+
const optionalParams = availableParams.filter(p => !requiredParams.includes(p));
|
|
2936
|
+
const requiredList = requiredParams.length > 0 ? requiredParams.join(', ') : 'none';
|
|
2937
|
+
const optionalList = optionalParams.length > 0 ? optionalParams.join(', ') : 'none';
|
|
2938
|
+
const hint = `\n\n💡 Hint: This tool expects:\n` +
|
|
2939
|
+
` Required: ${requiredList}\n` +
|
|
2940
|
+
` Optional: ${optionalList}\n` +
|
|
2941
|
+
` Use describe_tool('${serverName}', '${toolName}') for full schema.`;
|
|
2942
|
+
throw new Error(errorMsg + hint);
|
|
2943
|
+
}
|
|
2944
|
+
throw new Error(errorMsg);
|
|
2945
|
+
}
|
|
2946
|
+
}
|
|
2947
|
+
// ===== 4-TOOL SPEAKEASY APPROACH (v1.3.52) =====
|
|
2948
|
+
// Discovery tools: search_tools, describe_tool
|
|
2949
|
+
// Execution tools: execute_tool, execute_tool_confirm
|
|
2950
|
+
case 'search_tools': {
|
|
2951
|
+
// FIX v1.3.52: Search ALL servers using CACHED schemas (no auto-start)
|
|
2952
|
+
// UPDATE v1.3.56: Support optional server_name filter and server name matching
|
|
2953
|
+
// - query (optional): Search term for server names, tool names, and descriptions
|
|
2954
|
+
// - server_name (optional): Filter to specific server
|
|
2955
|
+
// - At least one parameter required
|
|
2956
|
+
try {
|
|
2957
|
+
const query = callArgs?.query || '';
|
|
2958
|
+
const serverNameFilter = callArgs?.server_name || '';
|
|
2959
|
+
// Validate: at least one parameter provided
|
|
2960
|
+
if (!query && !serverNameFilter) {
|
|
2961
|
+
throw new InvalidParamsError('At least one parameter required: query or server_name');
|
|
2962
|
+
}
|
|
2963
|
+
const results = [];
|
|
2964
|
+
// Get all servers or filter to specific server
|
|
2965
|
+
const allServers = this.configLoader.getAllServers();
|
|
2966
|
+
let serversToSearch = serverNameFilter
|
|
2967
|
+
? allServers.filter((s) => s.name === serverNameFilter)
|
|
2968
|
+
: allServers;
|
|
2969
|
+
// Validate server_name if provided
|
|
2970
|
+
if (serverNameFilter && serversToSearch.length === 0) {
|
|
2971
|
+
throw new InvalidParamsError(`Server '${serverNameFilter}' not found in registry`);
|
|
2972
|
+
}
|
|
2973
|
+
// Multi-keyword matching: split query into individual words
|
|
2974
|
+
const keywords = query.toLowerCase().split(/\s+/).filter(k => k.length > 0);
|
|
2975
|
+
// Step 1: Check if any keyword matches a server name
|
|
2976
|
+
let serverKeyword;
|
|
2977
|
+
if (keywords.length > 0 && !serverNameFilter) {
|
|
2978
|
+
for (const keyword of keywords) {
|
|
2979
|
+
const matchedServer = allServers.find((s) => s.name.toLowerCase().includes(keyword) || keyword.includes(s.name.toLowerCase()));
|
|
2980
|
+
if (matchedServer) {
|
|
2981
|
+
serverKeyword = keyword;
|
|
2982
|
+
serversToSearch = [matchedServer];
|
|
2983
|
+
break;
|
|
2984
|
+
}
|
|
2985
|
+
}
|
|
2986
|
+
}
|
|
2987
|
+
// Step 2: Get remaining keywords (exclude server name keyword)
|
|
2988
|
+
const searchKeywords = serverKeyword
|
|
2989
|
+
? keywords.filter(k => k !== serverKeyword)
|
|
2990
|
+
: keywords;
|
|
2991
|
+
for (const serverConfig of serversToSearch) {
|
|
2992
|
+
const serverName = serverConfig.name.toLowerCase();
|
|
2993
|
+
const tools = this.serverManager.getServerTools(serverConfig.name);
|
|
2994
|
+
for (const tool of tools) {
|
|
2995
|
+
const toolName = tool.name.toLowerCase();
|
|
2996
|
+
const toolDesc = (tool.description || '').toLowerCase();
|
|
2997
|
+
// Determine match type
|
|
2998
|
+
let matchType = 'all';
|
|
2999
|
+
let shouldInclude = false;
|
|
3000
|
+
if (keywords.length === 0) {
|
|
3001
|
+
// No query, just listing all tools from filtered server
|
|
3002
|
+
matchType = 'all';
|
|
3003
|
+
shouldInclude = true;
|
|
3004
|
+
}
|
|
3005
|
+
else if (serverKeyword && searchKeywords.length === 0) {
|
|
3006
|
+
// Only server name in query - return ALL tools from this server
|
|
3007
|
+
matchType = 'server';
|
|
3008
|
+
shouldInclude = true;
|
|
3009
|
+
}
|
|
3010
|
+
else if (searchKeywords.length > 0) {
|
|
3011
|
+
// Multi-keyword matching: check if ANY keyword matches tool name or description
|
|
3012
|
+
const nameMatches = searchKeywords.some(keyword => toolName.includes(keyword));
|
|
3013
|
+
const descMatches = searchKeywords.some(keyword => toolDesc.includes(keyword));
|
|
3014
|
+
if (nameMatches && descMatches) {
|
|
3015
|
+
matchType = 'all';
|
|
3016
|
+
shouldInclude = true;
|
|
3017
|
+
}
|
|
3018
|
+
else if (nameMatches) {
|
|
3019
|
+
matchType = 'name';
|
|
3020
|
+
shouldInclude = true;
|
|
3021
|
+
}
|
|
3022
|
+
else if (descMatches) {
|
|
3023
|
+
matchType = 'description';
|
|
3024
|
+
shouldInclude = true;
|
|
3025
|
+
}
|
|
3026
|
+
else if (serverKeyword) {
|
|
3027
|
+
// Server matched but no tool/description keywords matched
|
|
3028
|
+
// Still include if we're filtering by server (be permissive)
|
|
3029
|
+
matchType = 'server';
|
|
3030
|
+
shouldInclude = true;
|
|
3031
|
+
}
|
|
3032
|
+
}
|
|
3033
|
+
if (shouldInclude) {
|
|
3034
|
+
// Extract required and optional params from inputSchema
|
|
3035
|
+
const inputSchema = tool.inputSchema;
|
|
3036
|
+
const properties = inputSchema?.properties;
|
|
3037
|
+
const requiredArray = inputSchema?.required;
|
|
3038
|
+
const allParams = properties ? Object.keys(properties) : [];
|
|
3039
|
+
const requiredParams = requiredArray || [];
|
|
3040
|
+
const optionalParams = allParams.filter(p => !requiredParams.includes(p));
|
|
3041
|
+
// Generate example arguments from inputSchema
|
|
3042
|
+
const exampleArgs = {};
|
|
3043
|
+
if (properties) {
|
|
3044
|
+
for (const [key, value] of Object.entries(properties)) {
|
|
3045
|
+
const prop = value;
|
|
3046
|
+
// Use example from schema if available, otherwise generate placeholder
|
|
3047
|
+
if (prop.example !== undefined) {
|
|
3048
|
+
exampleArgs[key] = prop.example;
|
|
3049
|
+
}
|
|
3050
|
+
else if (prop.default !== undefined) {
|
|
3051
|
+
exampleArgs[key] = prop.default;
|
|
3052
|
+
}
|
|
3053
|
+
else if (prop.type === 'string') {
|
|
3054
|
+
// Generate meaningful examples for common param names
|
|
3055
|
+
if (key === 'query')
|
|
3056
|
+
exampleArgs[key] = 'search term';
|
|
3057
|
+
else if (key === 'cql')
|
|
3058
|
+
exampleArgs[key] = 'type=page ORDER BY created DESC';
|
|
3059
|
+
else if (key === 'jql')
|
|
3060
|
+
exampleArgs[key] = 'project = PROJ ORDER BY created DESC';
|
|
3061
|
+
else if (key === 'timezone')
|
|
3062
|
+
exampleArgs[key] = 'UTC';
|
|
3063
|
+
else if (key === 'url')
|
|
3064
|
+
exampleArgs[key] = 'https://example.com';
|
|
3065
|
+
else
|
|
3066
|
+
exampleArgs[key] = `<${key}>`;
|
|
3067
|
+
}
|
|
3068
|
+
else if (prop.type === 'number' || prop.type === 'integer') {
|
|
3069
|
+
if (key === 'limit' || key === 'max_results' || key === 'maxResults')
|
|
3070
|
+
exampleArgs[key] = 10;
|
|
3071
|
+
else
|
|
3072
|
+
exampleArgs[key] = 1;
|
|
3073
|
+
}
|
|
3074
|
+
else if (prop.type === 'boolean') {
|
|
3075
|
+
exampleArgs[key] = true;
|
|
3076
|
+
}
|
|
3077
|
+
else if (prop.type === 'array') {
|
|
3078
|
+
exampleArgs[key] = [];
|
|
3079
|
+
}
|
|
3080
|
+
else if (prop.type === 'object') {
|
|
3081
|
+
exampleArgs[key] = {};
|
|
3082
|
+
}
|
|
3083
|
+
}
|
|
3084
|
+
}
|
|
3085
|
+
// Store schema metadata for potential auto-describe
|
|
3086
|
+
// v1.1.31: Include safety annotations in search results
|
|
3087
|
+
// v1.3.x: Add argument inspection hints for risky tools with safe patterns
|
|
3088
|
+
let safetyAnnotation;
|
|
3089
|
+
if (tool.annotations) {
|
|
3090
|
+
safetyAnnotation = tool.annotations;
|
|
3091
|
+
}
|
|
3092
|
+
else {
|
|
3093
|
+
const classification = this.serverManager.classifyToolSafety(serverConfig.name, tool.name);
|
|
3094
|
+
safetyAnnotation = {
|
|
3095
|
+
safety: classification.safety,
|
|
3096
|
+
safetyReason: classification.reason,
|
|
3097
|
+
requiresConfirmation: classification.safety === 'risky',
|
|
3098
|
+
};
|
|
3099
|
+
}
|
|
3100
|
+
// v1.3.x: Check for argument inspection rules
|
|
3101
|
+
const toolSafetyRules = this.configLoader.getToolSafetyRules();
|
|
3102
|
+
const argInspectionRules = toolSafetyRules?.argumentInspectionRules || [];
|
|
3103
|
+
const fullToolName = `${serverConfig.name}:${tool.name}`;
|
|
3104
|
+
const argInspectionRule = argInspectionRules.find((rule) => rule.tool === fullToolName);
|
|
3105
|
+
let toolNote = "Use describe_tool to get full schema before execution";
|
|
3106
|
+
if (argInspectionRule && safetyAnnotation.safety === 'risky') {
|
|
3107
|
+
// Add argument inspection hint for risky tools with auto-approval patterns
|
|
3108
|
+
safetyAnnotation.argumentInspection = {
|
|
3109
|
+
enabled: true,
|
|
3110
|
+
field: argInspectionRule.argumentField,
|
|
3111
|
+
safePatterns: argInspectionRule.safeCommandPatterns || [],
|
|
3112
|
+
note: `Auto-approved when ${argInspectionRule.argumentField} matches safe patterns (e.g., ${(argInspectionRule.safeCommandPatterns || []).slice(0, 3).join(', ')})`
|
|
3113
|
+
};
|
|
3114
|
+
toolNote = `Risky by default, but AUTO-APPROVED for read-only operations. Check ${argInspectionRule.argumentField} - patterns like ${(argInspectionRule.safeCommandPatterns || []).slice(0, 3).join(', ')} are safe.`;
|
|
3115
|
+
}
|
|
3116
|
+
results.push({
|
|
3117
|
+
server: serverConfig.name,
|
|
3118
|
+
tool: tool.name,
|
|
3119
|
+
name: `${serverConfig.name}-${tool.name}`,
|
|
3120
|
+
description: tool.description,
|
|
3121
|
+
note: toolNote,
|
|
3122
|
+
matchType,
|
|
3123
|
+
// v1.3.x: Top-level safety fields for easier access
|
|
3124
|
+
safety: safetyAnnotation.safety,
|
|
3125
|
+
execute_with: safetyAnnotation.safety === 'safe' ? 'execute_tool' : 'execute_tool_confirm',
|
|
3126
|
+
annotations: safetyAnnotation,
|
|
3127
|
+
_schema: {
|
|
3128
|
+
inputSchema,
|
|
3129
|
+
requiredParams,
|
|
3130
|
+
optionalParams,
|
|
3131
|
+
exampleArgs
|
|
3132
|
+
}
|
|
3133
|
+
});
|
|
3134
|
+
}
|
|
3135
|
+
}
|
|
3136
|
+
}
|
|
3137
|
+
// Auto-describe for single tool results
|
|
3138
|
+
let autoDescribed = false;
|
|
3139
|
+
if (results.length === 1 && results[0]._schema) {
|
|
3140
|
+
const result = results[0];
|
|
3141
|
+
const schema = result._schema;
|
|
3142
|
+
if (schema) {
|
|
3143
|
+
// Add full schema details to the single result
|
|
3144
|
+
result.inputSchema = schema.inputSchema;
|
|
3145
|
+
result.requiredParams = schema.requiredParams;
|
|
3146
|
+
result.optionalParams = schema.optionalParams;
|
|
3147
|
+
result.example = {
|
|
3148
|
+
server_name: result.server,
|
|
3149
|
+
tool_name: result.tool,
|
|
3150
|
+
arguments: schema.exampleArgs
|
|
3151
|
+
};
|
|
3152
|
+
// Anthropic Advanced Tool Use: Include tool use examples if available
|
|
3153
|
+
// https://www.anthropic.com/engineering/advanced-tool-use
|
|
3154
|
+
const tool = this.serverManager.getServerTools(result.server).find(t => t.name === result.tool);
|
|
3155
|
+
if (tool?.inputExamples && Array.isArray(tool.inputExamples) && tool.inputExamples.length > 0) {
|
|
3156
|
+
result.inputExamples = tool.inputExamples;
|
|
3157
|
+
}
|
|
3158
|
+
// Remove the note since we're providing full schema
|
|
3159
|
+
delete result.note;
|
|
3160
|
+
delete result._schema;
|
|
3161
|
+
autoDescribed = true;
|
|
3162
|
+
}
|
|
3163
|
+
}
|
|
3164
|
+
else {
|
|
3165
|
+
// Include inputSchema in all results for Raycast compatibility
|
|
3166
|
+
// v1.1.67: Always include inputSchema even when not auto-describing
|
|
3167
|
+
results.forEach(r => {
|
|
3168
|
+
if (r._schema) {
|
|
3169
|
+
r.inputSchema = r._schema.inputSchema;
|
|
3170
|
+
r.requiredParams = r._schema.requiredParams;
|
|
3171
|
+
r.optionalParams = r._schema.optionalParams;
|
|
3172
|
+
delete r._schema;
|
|
3173
|
+
}
|
|
3174
|
+
});
|
|
3175
|
+
}
|
|
3176
|
+
return {
|
|
3177
|
+
content: [
|
|
3178
|
+
{
|
|
3179
|
+
type: 'text',
|
|
3180
|
+
text: JSON.stringify({
|
|
3181
|
+
query: query || undefined,
|
|
3182
|
+
server_name: serverNameFilter || undefined,
|
|
3183
|
+
count: results.length,
|
|
3184
|
+
autoDescribed,
|
|
3185
|
+
tools: results.slice(0, 50)
|
|
3186
|
+
}, null, 2)
|
|
3187
|
+
}
|
|
3188
|
+
]
|
|
3189
|
+
};
|
|
3190
|
+
}
|
|
3191
|
+
catch (error) {
|
|
3192
|
+
throw new Error(`Failed to search tools: ${error instanceof Error ? error.message : String(error)}`);
|
|
3193
|
+
}
|
|
3194
|
+
}
|
|
3195
|
+
case 'describe_tool': {
|
|
3196
|
+
try {
|
|
3197
|
+
const serverName = callArgs?.server_name;
|
|
3198
|
+
const toolName = callArgs?.tool_name;
|
|
3199
|
+
// RAYCAST DEBUG: Log what client is requesting
|
|
3200
|
+
console.log(`[describe_tool] REQUEST: server="${serverName}" tool="${toolName}"`);
|
|
3201
|
+
if (!serverName || !toolName) {
|
|
3202
|
+
throw new InvalidParamsError('server_name and tool_name are required');
|
|
3203
|
+
}
|
|
3204
|
+
// Get server config
|
|
3205
|
+
const allServers = this.configLoader.getAllServers();
|
|
3206
|
+
const serverConfig = allServers.find((s) => s.name === serverName);
|
|
3207
|
+
if (!serverConfig) {
|
|
3208
|
+
throw new InvalidParamsError(`Server '${serverName}' not found in registry. Use search_tools to find available tools.`);
|
|
3209
|
+
}
|
|
3210
|
+
// Discover tools from the server
|
|
3211
|
+
const toolSchemas = await this.serverManager.discoverToolSchemas(serverName, serverConfig);
|
|
3212
|
+
const toolSchema = toolSchemas.find(t => t.name === toolName);
|
|
3213
|
+
if (!toolSchema) {
|
|
3214
|
+
const availableTools = toolSchemas.map(t => t.name).join(', ');
|
|
3215
|
+
throw new Error(`Tool '${toolName}' not found in server '${serverName}'.\n` +
|
|
3216
|
+
`Available tools: ${availableTools}\n` +
|
|
3217
|
+
`Use search_tools with a keyword to find tools across all servers.`);
|
|
3218
|
+
}
|
|
3219
|
+
// v1.4.0: Validate schema and collect hints
|
|
3220
|
+
const { SchemaValidator } = await import('./schema-validator.js');
|
|
3221
|
+
const validator = new SchemaValidator();
|
|
3222
|
+
const validationResult = validator.validateToolSchema(toolSchema);
|
|
3223
|
+
// Generate example arguments from inputSchema
|
|
3224
|
+
const inputSchema = toolSchema.inputSchema;
|
|
3225
|
+
const properties = inputSchema?.properties;
|
|
3226
|
+
const requiredArray = inputSchema?.required;
|
|
3227
|
+
const exampleArgs = {};
|
|
3228
|
+
if (properties) {
|
|
3229
|
+
for (const [key, value] of Object.entries(properties)) {
|
|
3230
|
+
const prop = value;
|
|
3231
|
+
// Use example from schema if available, otherwise generate placeholder
|
|
3232
|
+
if (prop.example !== undefined) {
|
|
3233
|
+
exampleArgs[key] = prop.example;
|
|
3234
|
+
}
|
|
3235
|
+
else if (prop.default !== undefined) {
|
|
3236
|
+
exampleArgs[key] = prop.default;
|
|
3237
|
+
}
|
|
3238
|
+
else if (prop.type === 'string') {
|
|
3239
|
+
exampleArgs[key] = prop.description ? `<${key}>` : 'example';
|
|
3240
|
+
}
|
|
3241
|
+
else if (prop.type === 'number') {
|
|
3242
|
+
exampleArgs[key] = 10;
|
|
3243
|
+
}
|
|
3244
|
+
else if (prop.type === 'boolean') {
|
|
3245
|
+
exampleArgs[key] = true;
|
|
3246
|
+
}
|
|
3247
|
+
else if (prop.type === 'array') {
|
|
3248
|
+
exampleArgs[key] = [];
|
|
3249
|
+
}
|
|
3250
|
+
else if (prop.type === 'object') {
|
|
3251
|
+
exampleArgs[key] = {};
|
|
3252
|
+
}
|
|
3253
|
+
}
|
|
3254
|
+
}
|
|
3255
|
+
// Calculate optional params (all params not in required array)
|
|
3256
|
+
const allParams = properties ? Object.keys(properties) : [];
|
|
3257
|
+
const optionalParams = allParams.filter(p => !(requiredArray || []).includes(p));
|
|
3258
|
+
// Build tool object matching search_tools auto-describe format for Raycast compatibility
|
|
3259
|
+
const toolObj = {
|
|
3260
|
+
server: serverName,
|
|
3261
|
+
tool: toolName,
|
|
3262
|
+
name: `${serverName}-${toolName}`,
|
|
3263
|
+
description: toolSchema.description || '',
|
|
3264
|
+
inputSchema: toolSchema.inputSchema || {},
|
|
3265
|
+
requiredParams: requiredArray || [],
|
|
3266
|
+
optionalParams: optionalParams,
|
|
3267
|
+
example: {
|
|
3268
|
+
server_name: serverName,
|
|
3269
|
+
tool_name: toolName,
|
|
3270
|
+
arguments: exampleArgs
|
|
3271
|
+
}
|
|
3272
|
+
};
|
|
3273
|
+
// Anthropic Advanced Tool Use: Include tool use examples if available
|
|
3274
|
+
// https://www.anthropic.com/engineering/advanced-tool-use
|
|
3275
|
+
if (toolSchema.inputExamples && Array.isArray(toolSchema.inputExamples) && toolSchema.inputExamples.length > 0) {
|
|
3276
|
+
toolObj.inputExamples = toolSchema.inputExamples;
|
|
3277
|
+
}
|
|
3278
|
+
// Phase 2: Include safety annotations if available
|
|
3279
|
+
if (toolSchema.annotations) {
|
|
3280
|
+
toolObj.annotations = toolSchema.annotations;
|
|
3281
|
+
}
|
|
3282
|
+
// Build response in discover_tools format (array with single tool)
|
|
3283
|
+
const responseObj = {
|
|
3284
|
+
server: serverName,
|
|
3285
|
+
tools: [toolObj],
|
|
3286
|
+
count: 1,
|
|
3287
|
+
detailLevel: 'full' // Always full for describe_tool
|
|
3288
|
+
};
|
|
3289
|
+
// RAYCAST DEBUG: Log what schema is returned
|
|
3290
|
+
console.log(`[describe_tool] RESPONSE: ${serverName}:${toolName} - requiredParams=${JSON.stringify(requiredArray || [])} - hasInputSchema=${!!toolSchema.inputSchema}`);
|
|
3291
|
+
return {
|
|
3292
|
+
content: [
|
|
3293
|
+
{
|
|
3294
|
+
type: 'text',
|
|
3295
|
+
text: JSON.stringify(responseObj, null, 2)
|
|
3296
|
+
}
|
|
3297
|
+
]
|
|
3298
|
+
};
|
|
3299
|
+
}
|
|
3300
|
+
catch (error) {
|
|
3301
|
+
throw new Error(`Failed to describe tool: ${error instanceof Error ? error.message : String(error)}`);
|
|
3302
|
+
}
|
|
3303
|
+
}
|
|
3304
|
+
// ===== END 4-TOOL SPEAKEASY APPROACH =====
|
|
3305
|
+
// ===== OLD MANAGEMENT TOOLS (DEPRECATED v1.3.0) =====
|
|
3306
|
+
// These have been replaced by discovery tools to reduce token usage
|
|
3307
|
+
// Commented out but kept for reference. Can be removed in v2.0.0
|
|
3308
|
+
/*
|
|
3309
|
+
case 'enable_server': {
|
|
3310
|
+
const serverName = callArgs?.server_name;
|
|
3311
|
+
if (!serverName) throw new Error('server_name required');
|
|
3312
|
+
|
|
3313
|
+
const timestamp = new Date().toISOString();
|
|
3314
|
+
console.log(`[metalink] Enabling server: ${serverName} at ${timestamp}`);
|
|
3315
|
+
|
|
3316
|
+
try {
|
|
3317
|
+
// Get server config from registry
|
|
3318
|
+
const allServers = this.configLoader.getAllServers();
|
|
3319
|
+
const serverConfig = allServers.find((s: any) => s.name === serverName);
|
|
3320
|
+
|
|
3321
|
+
if (!serverConfig) {
|
|
3322
|
+
const availableNames = allServers.map((s: any) => s.name).join(', ');
|
|
3323
|
+
throw new Error(
|
|
3324
|
+
`Server '${serverName}' not found in registry. Available: ${availableNames}`
|
|
3325
|
+
);
|
|
3326
|
+
}
|
|
3327
|
+
|
|
3328
|
+
// Check if already enabled
|
|
3329
|
+
const activeServers = this.serverManager.getActiveServers();
|
|
3330
|
+
if (activeServers.has(serverName)) {
|
|
3331
|
+
const serverData = activeServers.get(serverName);
|
|
3332
|
+
const tools = serverData?.tools || [];
|
|
3333
|
+
return {
|
|
3334
|
+
content: [
|
|
3335
|
+
{
|
|
3336
|
+
type: 'text',
|
|
3337
|
+
text: JSON.stringify({
|
|
3338
|
+
status: 'already_enabled',
|
|
3339
|
+
server: serverName,
|
|
3340
|
+
tools_count: tools.length,
|
|
3341
|
+
tools: tools.map((t: any) => ({
|
|
3342
|
+
name: t.name,
|
|
3343
|
+
description: t.description || '',
|
|
3344
|
+
})),
|
|
3345
|
+
}, null, 2)
|
|
3346
|
+
}
|
|
3347
|
+
]
|
|
3348
|
+
};
|
|
3349
|
+
}
|
|
3350
|
+
|
|
3351
|
+
// Start server process
|
|
3352
|
+
console.log(`[metalink] Using mcpm to run ${serverName}`);
|
|
3353
|
+
await this.serverManager.startServer(serverConfig);
|
|
3354
|
+
|
|
3355
|
+
// Get process and fetch tools
|
|
3356
|
+
console.log(`[metalink] Retrieving tools from ${serverName}...`);
|
|
3357
|
+
const serverProcess = this.serverManager.getProcess(serverName);
|
|
3358
|
+
if (!serverProcess) {
|
|
3359
|
+
throw new Error(`Failed to start ${serverName} - no process found`);
|
|
3360
|
+
}
|
|
3361
|
+
|
|
3362
|
+
// Fetch tools from the process
|
|
3363
|
+
const tools = await this.serverManager.fetchToolsFromProcess(serverProcess);
|
|
3364
|
+
this.serverManager.setServerTools(serverName, tools);
|
|
3365
|
+
|
|
3366
|
+
console.log(`[metalink] Successfully retrieved ${tools.length} tools from ${serverName}`);
|
|
3367
|
+
console.log(`[metalink] Response router set up for ${serverName}`);
|
|
3368
|
+
|
|
3369
|
+
return {
|
|
3370
|
+
content: [
|
|
3371
|
+
{
|
|
3372
|
+
type: 'text',
|
|
3373
|
+
text: JSON.stringify({
|
|
3374
|
+
status: 'enabled',
|
|
3375
|
+
server: serverName,
|
|
3376
|
+
tools_count: tools.length,
|
|
3377
|
+
tools: tools.map((t: any) => ({
|
|
3378
|
+
name: t.name,
|
|
3379
|
+
description: t.description || '',
|
|
3380
|
+
})),
|
|
3381
|
+
}, null, 2)
|
|
3382
|
+
}
|
|
3383
|
+
]
|
|
3384
|
+
};
|
|
3385
|
+
} catch (error: any) {
|
|
3386
|
+
console.error(`[metalink] Failed to enable ${serverName}:`, error);
|
|
3387
|
+
throw new Error(`Failed to enable server '${serverName}': ${error.message}`);
|
|
3388
|
+
}
|
|
3389
|
+
}
|
|
3390
|
+
|
|
3391
|
+
case 'disable_server': {
|
|
3392
|
+
const serverName = callArgs?.server_name;
|
|
3393
|
+
if (!serverName) throw new InvalidParamsError('server_name required');
|
|
3394
|
+
// This would disable the server at runtime
|
|
3395
|
+
return {
|
|
3396
|
+
content: [
|
|
3397
|
+
{
|
|
3398
|
+
type: 'text',
|
|
3399
|
+
text: JSON.stringify({
|
|
3400
|
+
disabled: true,
|
|
3401
|
+
server: serverName,
|
|
3402
|
+
}, null, 2)
|
|
3403
|
+
}
|
|
3404
|
+
]
|
|
3405
|
+
};
|
|
3406
|
+
}
|
|
3407
|
+
|
|
3408
|
+
case 'help': {
|
|
3409
|
+
const topic = (callArgs?.topic as string) || 'all';
|
|
3410
|
+
|
|
3411
|
+
const help = {
|
|
3412
|
+
overview: 'MetaLink provides three ways to interact with MCP servers and management tools',
|
|
3413
|
+
|
|
3414
|
+
management_tools: {
|
|
3415
|
+
description: 'Control server lifecycle and discover tools',
|
|
3416
|
+
tools: [
|
|
3417
|
+
{
|
|
3418
|
+
name: 'list_available_servers',
|
|
3419
|
+
description: 'List all servers that can be dynamically loaded',
|
|
3420
|
+
usage: 'No arguments needed',
|
|
3421
|
+
example: '{"name": "list_available_servers", "arguments": {}}'
|
|
3422
|
+
},
|
|
3423
|
+
{
|
|
3424
|
+
name: 'enable_server',
|
|
3425
|
+
description: 'Enable a server and load its tools',
|
|
3426
|
+
usage: 'Provide server_name (e.g., "memory", "docs-server")',
|
|
3427
|
+
example: '{"name": "enable_server", "arguments": {"server_name": "task-master-ai"}}'
|
|
3428
|
+
},
|
|
3429
|
+
{
|
|
3430
|
+
name: 'disable_server',
|
|
3431
|
+
description: 'Disable an enabled server',
|
|
3432
|
+
usage: 'Provide server_name',
|
|
3433
|
+
example: '{"name": "disable_server", "arguments": {"server_name": "memory"}}'
|
|
3434
|
+
},
|
|
3435
|
+
{
|
|
3436
|
+
name: 'list_servers',
|
|
3437
|
+
description: 'Show all currently enabled servers with tool counts',
|
|
3438
|
+
usage: 'No arguments needed',
|
|
3439
|
+
example: '{"name": "list_servers", "arguments": {}}'
|
|
3440
|
+
},
|
|
3441
|
+
{
|
|
3442
|
+
name: 'list_tools',
|
|
3443
|
+
description: 'List tools available on a specific server',
|
|
3444
|
+
usage: 'Provide server_name',
|
|
3445
|
+
example: '{"name": "list_tools", "arguments": {"server_name": "memory"}}'
|
|
3446
|
+
}
|
|
3447
|
+
]
|
|
3448
|
+
},
|
|
3449
|
+
|
|
3450
|
+
direct_tool_calls: {
|
|
3451
|
+
description: 'Call tools using hyphenated format: server-toolName',
|
|
3452
|
+
format: 'server-toolName (e.g., memory-search_nodes, time-get_current_time, docs-server-list_libraries)',
|
|
3453
|
+
example: '{"name": "memory-search_nodes", "arguments": {"query": "metalink"}}',
|
|
3454
|
+
note: 'Preferred method for direct tool access when you know the server and tool names'
|
|
3455
|
+
},
|
|
3456
|
+
|
|
3457
|
+
execute_tool_method: {
|
|
3458
|
+
description: 'Generic tool executor with explicit parameters',
|
|
3459
|
+
format: 'Use execute_tool with server_name, tool_name, and arguments (nested)',
|
|
3460
|
+
example: '{"name": "execute_tool", "arguments": {"server_name": "memory", "tool_name": "search_nodes", "arguments": {"query": "metalink"}}}',
|
|
3461
|
+
critical: 'ALWAYS include "arguments" key in the outer structure, even if the tool takes no args: {"arguments": {}}'
|
|
3462
|
+
},
|
|
3463
|
+
|
|
3464
|
+
execute_tool_confirm: {
|
|
3465
|
+
description: 'Execute tools with user confirmation workflow',
|
|
3466
|
+
format: 'Same as execute_tool, but returns confirmation request before execution',
|
|
3467
|
+
example: '{"name": "execute_tool_confirm", "arguments": {"server_name": "memory", "tool_name": "delete_entities", "arguments": {"entityNames": ["test"]}}}',
|
|
3468
|
+
use_case: 'For sensitive/destructive operations that require explicit user approval'
|
|
3469
|
+
},
|
|
3470
|
+
|
|
3471
|
+
workflow: {
|
|
3472
|
+
step1_discover: 'list_available_servers → see what servers can be loaded',
|
|
3473
|
+
step2_enable: 'enable_server → load a server and fetch its tools',
|
|
3474
|
+
step3_explore: 'list_tools → see all tools available on that server',
|
|
3475
|
+
step4_execute: 'Use direct calls (server-toolName) OR execute_tool to call tools',
|
|
3476
|
+
step5_search: 'search_tools → find tools by keyword across all servers'
|
|
3477
|
+
}
|
|
3478
|
+
};
|
|
3479
|
+
|
|
3480
|
+
// Filter by topic
|
|
3481
|
+
if (topic === 'management') {
|
|
3482
|
+
return {
|
|
3483
|
+
content: [
|
|
3484
|
+
{
|
|
3485
|
+
type: 'text',
|
|
3486
|
+
text: JSON.stringify({ help: { overview: help.overview, management_tools: help.management_tools } }, null, 2)
|
|
3487
|
+
}
|
|
3488
|
+
]
|
|
3489
|
+
};
|
|
3490
|
+
} else if (topic === 'direct') {
|
|
3491
|
+
return {
|
|
3492
|
+
content: [
|
|
3493
|
+
{
|
|
3494
|
+
type: 'text',
|
|
3495
|
+
text: JSON.stringify({ help: { overview: help.overview, direct_tool_calls: help.direct_tool_calls } }, null, 2)
|
|
3496
|
+
}
|
|
3497
|
+
]
|
|
3498
|
+
};
|
|
3499
|
+
} else if (topic === 'execute_tool') {
|
|
3500
|
+
return {
|
|
3501
|
+
content: [
|
|
3502
|
+
{
|
|
3503
|
+
type: 'text',
|
|
3504
|
+
text: JSON.stringify({ help: { overview: help.overview, execute_tool_method: help.execute_tool_method, execute_tool_confirm: help.execute_tool_confirm } }, null, 2)
|
|
3505
|
+
}
|
|
3506
|
+
]
|
|
3507
|
+
};
|
|
3508
|
+
} else if (topic === 'workflow') {
|
|
3509
|
+
return {
|
|
3510
|
+
content: [
|
|
3511
|
+
{
|
|
3512
|
+
type: 'text',
|
|
3513
|
+
text: JSON.stringify({ help: { overview: help.overview, workflow: help.workflow } }, null, 2)
|
|
3514
|
+
}
|
|
3515
|
+
]
|
|
3516
|
+
};
|
|
3517
|
+
}
|
|
3518
|
+
|
|
3519
|
+
return {
|
|
3520
|
+
content: [
|
|
3521
|
+
{
|
|
3522
|
+
type: 'text',
|
|
3523
|
+
text: JSON.stringify({ help }, null, 2)
|
|
3524
|
+
}
|
|
3525
|
+
]
|
|
3526
|
+
};
|
|
3527
|
+
}
|
|
3528
|
+
*/
|
|
3529
|
+
// ===== END OLD MANAGEMENT TOOLS =====
|
|
3530
|
+
default: {
|
|
3531
|
+
// Handle base server tools in format "server-toolName"
|
|
3532
|
+
// Support hyphenated server names like "duckduckgo-mcp"
|
|
3533
|
+
if (name.includes('-')) {
|
|
3534
|
+
const lastHyphenIndex = name.lastIndexOf('-');
|
|
3535
|
+
if (lastHyphenIndex === -1) {
|
|
3536
|
+
throw new InvalidParamsError(`Invalid tool name format: ${name}`);
|
|
3537
|
+
}
|
|
3538
|
+
const serverName = name.substring(0, lastHyphenIndex);
|
|
3539
|
+
const toolName = name.substring(lastHyphenIndex + 1);
|
|
3540
|
+
if (serverName && toolName) {
|
|
3541
|
+
// v1.4.0: Refresh discovery timer on tool activity
|
|
3542
|
+
if (this.serverManager.isDiscovered(serverName)) {
|
|
3543
|
+
this.serverManager.refreshDiscoveryTimer(serverName);
|
|
3544
|
+
}
|
|
3545
|
+
// Phase 3: Auto-start servers on first tool call (including base servers as fallback)
|
|
3546
|
+
console.log(`[MetaLink] Ensuring server is started: ${serverName}`);
|
|
3547
|
+
try {
|
|
3548
|
+
// Get server config and ensure it's started
|
|
3549
|
+
const allServers = this.configLoader.getAllServers();
|
|
3550
|
+
const serverConfig = allServers.find((s) => s.name === serverName);
|
|
3551
|
+
if (!serverConfig) {
|
|
3552
|
+
throw new InvalidParamsError(`Server '${serverName}' not found in registry`);
|
|
3553
|
+
}
|
|
3554
|
+
// Always call ensureServerStarted - it's idempotent and handles base servers too
|
|
3555
|
+
await this.serverManager.ensureServerStarted(serverName, serverConfig);
|
|
3556
|
+
// Notify connected clients that tools/list has changed
|
|
3557
|
+
this.notifyToolsListChanged();
|
|
3558
|
+
}
|
|
3559
|
+
catch (autoStartError) {
|
|
3560
|
+
throw new Error(`Failed to auto-start server ${serverName}: ${autoStartError instanceof Error ? autoStartError.message : String(autoStartError)}`);
|
|
3561
|
+
}
|
|
3562
|
+
// Phase 2b: Get tool schema for validation
|
|
3563
|
+
const toolSchema = this.serverManager.getToolSchema(serverName, toolName);
|
|
3564
|
+
if (!toolSchema) {
|
|
3565
|
+
throw new InvalidParamsError(`Tool '${toolName}' not found in server '${serverName}'. Use describe_tool to get the tool schema or search_tools to find available tools.`);
|
|
3566
|
+
}
|
|
3567
|
+
// Phase 2d: Use args passed from mcpCallTool (from the 'arguments' parameter in the request)
|
|
3568
|
+
// args is the 'arguments' object from the MCP request, not callArgs
|
|
3569
|
+
const args2 = args || {};
|
|
3570
|
+
console.log(`[MetaLink] direct tool call: ${name} with args ${JSON.stringify(args2)}`);
|
|
3571
|
+
// Phase 3: Special handling for memory-search_nodes - use fallback directly
|
|
3572
|
+
if (serverName === 'memory' && toolName === 'search_nodes') {
|
|
3573
|
+
console.log(`[MetaLink] Intercepting memory-search_nodes - using read_graph with client-side filtering`);
|
|
3574
|
+
try {
|
|
3575
|
+
const graphResult = await this.serverManager.callTool('memory', 'read_graph', {});
|
|
3576
|
+
const query = (args2.query || '').toLowerCase();
|
|
3577
|
+
// Parse the nested response
|
|
3578
|
+
if (graphResult && typeof graphResult === 'object') {
|
|
3579
|
+
const resultObj = graphResult;
|
|
3580
|
+
if (resultObj.content && Array.isArray(resultObj.content)) {
|
|
3581
|
+
const textContent = resultObj.content[0]?.text;
|
|
3582
|
+
if (textContent) {
|
|
3583
|
+
const graphData = JSON.parse(textContent);
|
|
3584
|
+
// Filter entities by query
|
|
3585
|
+
const filteredEntities = graphData.entities?.filter((e) => query === '' ||
|
|
3586
|
+
e.name.toLowerCase().includes(query) ||
|
|
3587
|
+
e.observations?.some((obs) => obs.toLowerCase().includes(query))) || [];
|
|
3588
|
+
return {
|
|
3589
|
+
content: [{
|
|
3590
|
+
type: 'text',
|
|
3591
|
+
text: JSON.stringify({
|
|
3592
|
+
entities: filteredEntities,
|
|
3593
|
+
relations: graphData.relations || []
|
|
3594
|
+
}, null, 2)
|
|
3595
|
+
}]
|
|
3596
|
+
};
|
|
3597
|
+
}
|
|
3598
|
+
}
|
|
3599
|
+
}
|
|
3600
|
+
}
|
|
3601
|
+
catch (fallbackError) {
|
|
3602
|
+
console.error(`[MetaLink] Fallback for memory:search_nodes failed:`, fallbackError);
|
|
3603
|
+
throw new Error(`Tool execution failed for ${name}: memory:search_nodes fallback failed - ${fallbackError instanceof Error ? fallbackError.message : String(fallbackError)}`);
|
|
3604
|
+
}
|
|
3605
|
+
}
|
|
3606
|
+
// Phase 3: Call the actual tool via response router
|
|
3607
|
+
try {
|
|
3608
|
+
const result = await this.serverManager.callTool(serverName, toolName, args2);
|
|
3609
|
+
return result;
|
|
3610
|
+
}
|
|
3611
|
+
catch (toolError) {
|
|
3612
|
+
// Enhanced error with inputSchema for debugging
|
|
3613
|
+
const errorMsg = `Tool execution failed for ${name}: ${toolError instanceof Error ? toolError.message : String(toolError)}`;
|
|
3614
|
+
// Check if error is parameter-related
|
|
3615
|
+
const errorStr = (toolError instanceof Error ? toolError.message : String(toolError)).toLowerCase();
|
|
3616
|
+
const isParamError = errorStr.includes('null') || errorStr.includes('undefined') ||
|
|
3617
|
+
errorStr.includes('required') || errorStr.includes('missing');
|
|
3618
|
+
if (isParamError && toolSchema) {
|
|
3619
|
+
const inputSchema = toolSchema.inputSchema;
|
|
3620
|
+
if (inputSchema) {
|
|
3621
|
+
const requiredParams = inputSchema.required || [];
|
|
3622
|
+
const availableParams = Object.keys(inputSchema.properties || {});
|
|
3623
|
+
const optionalParams = availableParams.filter(p => !requiredParams.includes(p));
|
|
3624
|
+
const requiredList = requiredParams.length > 0 ? requiredParams.join(', ') : 'none';
|
|
3625
|
+
const optionalList = optionalParams.length > 0 ? optionalParams.join(', ') : 'none';
|
|
3626
|
+
const hint = `\n\n💡 Hint: This tool expects:\n` +
|
|
3627
|
+
` Required: ${requiredList}\n` +
|
|
3628
|
+
` Optional: ${optionalList}\n` +
|
|
3629
|
+
` Use describe_tool('${serverName}', '${toolName}') for full schema.`;
|
|
3630
|
+
throw new Error(errorMsg + hint);
|
|
3631
|
+
}
|
|
3632
|
+
}
|
|
3633
|
+
throw new Error(errorMsg);
|
|
3634
|
+
}
|
|
3635
|
+
}
|
|
3636
|
+
}
|
|
3637
|
+
throw new InvalidParamsError(`Unknown tool: ${name}`);
|
|
3638
|
+
}
|
|
3639
|
+
}
|
|
3640
|
+
}
|
|
3641
|
+
/**
|
|
3642
|
+
* Add a new server to the registry
|
|
3643
|
+
*/
|
|
3644
|
+
async addServer(req, res) {
|
|
3645
|
+
try {
|
|
3646
|
+
const serverConfig = req.body;
|
|
3647
|
+
// Validate and add server to registry
|
|
3648
|
+
const server = await this.configLoader.saveServerToRegistry(serverConfig, {
|
|
3649
|
+
allowAnyCommand: false,
|
|
3650
|
+
timeout: 5000,
|
|
3651
|
+
});
|
|
3652
|
+
// v1.1.29: Trigger immediate tool discovery
|
|
3653
|
+
let discoveredToolCount = 0;
|
|
3654
|
+
try {
|
|
3655
|
+
console.log(`[AddServer] Starting immediate tool discovery for '${server.name}'`);
|
|
3656
|
+
await this.serverManager.ensureServerStarted(server.name, server);
|
|
3657
|
+
const tools = this.serverManager.getServerTools(server.name);
|
|
3658
|
+
discoveredToolCount = tools.length;
|
|
3659
|
+
console.log(`[AddServer] Discovered ${discoveredToolCount} tools for '${server.name}': ${tools.map(t => t.name).join(', ')}`);
|
|
3660
|
+
}
|
|
3661
|
+
catch (discoveryError) {
|
|
3662
|
+
console.warn(`[AddServer] Tool discovery failed for '${server.name}' (non-fatal):`, discoveryError);
|
|
3663
|
+
// Don't fail the add operation if discovery fails
|
|
3664
|
+
}
|
|
3665
|
+
// Broadcast server:added event
|
|
3666
|
+
this.broadcastEvent({
|
|
3667
|
+
type: 'server:added',
|
|
3668
|
+
data: {
|
|
3669
|
+
server,
|
|
3670
|
+
timestamp: Date.now(),
|
|
3671
|
+
},
|
|
3672
|
+
});
|
|
3673
|
+
res.status(201).json({
|
|
3674
|
+
success: true,
|
|
3675
|
+
message: `Server '${server.name}' added to registry`,
|
|
3676
|
+
server,
|
|
3677
|
+
discoveredTools: discoveredToolCount,
|
|
3678
|
+
});
|
|
3679
|
+
}
|
|
3680
|
+
catch (error) {
|
|
3681
|
+
const message = error instanceof Error ? error.message : 'Failed to add server';
|
|
3682
|
+
res.status(400).json({
|
|
3683
|
+
error: message,
|
|
3684
|
+
});
|
|
3685
|
+
}
|
|
3686
|
+
}
|
|
3687
|
+
/**
|
|
3688
|
+
* Remove a server from the registry
|
|
3689
|
+
*/
|
|
3690
|
+
async removeServer(req, res) {
|
|
3691
|
+
try {
|
|
3692
|
+
const { name } = req.params;
|
|
3693
|
+
// Check if server exists
|
|
3694
|
+
const server = this.configLoader.getServer(name);
|
|
3695
|
+
if (!server) {
|
|
3696
|
+
res.status(404).json({
|
|
3697
|
+
error: `Server '${name}' not found in registry`,
|
|
3698
|
+
});
|
|
3699
|
+
return;
|
|
3700
|
+
}
|
|
3701
|
+
// Check if server is running and require force if so
|
|
3702
|
+
const status = this.serverManager.getServerStatus(name);
|
|
3703
|
+
if (status?.status === 'running') {
|
|
3704
|
+
// Check for force flag in query params
|
|
3705
|
+
const force = req.query.force === 'true';
|
|
3706
|
+
if (!force) {
|
|
3707
|
+
res.status(409).json({
|
|
3708
|
+
error: `Server '${name}' is currently running. Use ?force=true to remove it anyway.`,
|
|
3709
|
+
running: true,
|
|
3710
|
+
});
|
|
3711
|
+
return;
|
|
3712
|
+
}
|
|
3713
|
+
// Clean up running server before removing
|
|
3714
|
+
try {
|
|
3715
|
+
await this.serverManager.removeServer(name);
|
|
3716
|
+
}
|
|
3717
|
+
catch (error) {
|
|
3718
|
+
// Continue anyway - cleanup happened
|
|
3719
|
+
}
|
|
3720
|
+
}
|
|
3721
|
+
// Remove from registry
|
|
3722
|
+
await this.configLoader.removeServerFromRegistry(name, { timeout: 5000 });
|
|
3723
|
+
// Broadcast server:removed event
|
|
3724
|
+
this.broadcastEvent({
|
|
3725
|
+
type: 'server:removed',
|
|
3726
|
+
data: {
|
|
3727
|
+
name,
|
|
3728
|
+
timestamp: Date.now(),
|
|
3729
|
+
},
|
|
3730
|
+
});
|
|
3731
|
+
res.json({
|
|
3732
|
+
success: true,
|
|
3733
|
+
message: `Server '${name}' removed from registry`,
|
|
3734
|
+
});
|
|
3735
|
+
}
|
|
3736
|
+
catch (error) {
|
|
3737
|
+
const message = error instanceof Error ? error.message : 'Failed to remove server';
|
|
3738
|
+
res.status(400).json({
|
|
3739
|
+
error: message,
|
|
3740
|
+
});
|
|
3741
|
+
}
|
|
3742
|
+
}
|
|
3743
|
+
/**
|
|
3744
|
+
* Validate a server configuration without saving
|
|
3745
|
+
*/
|
|
3746
|
+
async validateServer(req, res) {
|
|
3747
|
+
try {
|
|
3748
|
+
const { RegistryManager } = await import('../config/registry.js');
|
|
3749
|
+
const registry = new RegistryManager();
|
|
3750
|
+
// Validate configuration
|
|
3751
|
+
const validation = await registry.validateServer(req.body, {
|
|
3752
|
+
allowAnyCommand: false,
|
|
3753
|
+
});
|
|
3754
|
+
if (validation.valid) {
|
|
3755
|
+
res.json({
|
|
3756
|
+
valid: true,
|
|
3757
|
+
message: 'Server configuration is valid',
|
|
3758
|
+
});
|
|
3759
|
+
}
|
|
3760
|
+
else {
|
|
3761
|
+
res.status(400).json({
|
|
3762
|
+
valid: false,
|
|
3763
|
+
errors: validation.errors || [],
|
|
3764
|
+
});
|
|
3765
|
+
}
|
|
3766
|
+
}
|
|
3767
|
+
catch (error) {
|
|
3768
|
+
const message = error instanceof Error ? error.message : 'Validation error';
|
|
3769
|
+
res.status(400).json({
|
|
3770
|
+
error: message,
|
|
3771
|
+
});
|
|
3772
|
+
}
|
|
3773
|
+
}
|
|
3774
|
+
/**
|
|
3775
|
+
* Start the HTTP server
|
|
3776
|
+
* NOTE: This Promise never resolves - it keeps the event loop alive for background mode
|
|
3777
|
+
*/
|
|
3778
|
+
async listen(port, host) {
|
|
3779
|
+
return new Promise((_resolve, reject) => {
|
|
3780
|
+
this.server = this.app.listen(port, host, () => {
|
|
3781
|
+
console.log(`[MetaLink] HTTP server listening on http://${host}:${port}`);
|
|
3782
|
+
console.log(`[MetaLink] Dashboard available at http://${host}:${port}`);
|
|
3783
|
+
console.log(`[MetaLink] API available at http://${host}:${port}/api/v1`);
|
|
3784
|
+
console.log(`[MetaLink] Events available at http://${host}:${port}/api/v1/events`);
|
|
3785
|
+
console.log(`[MetaLink] MCP endpoint available at http://${host}:${port}/mcp`);
|
|
3786
|
+
// NOTE: Intentionally NOT resolving - keeps event loop alive for background mode
|
|
3787
|
+
});
|
|
3788
|
+
// Handle server errors
|
|
3789
|
+
this.server.on('error', (err) => {
|
|
3790
|
+
reject(err);
|
|
3791
|
+
});
|
|
3792
|
+
});
|
|
3793
|
+
}
|
|
3794
|
+
/**
|
|
3795
|
+
* Send message via SSE to connected client
|
|
3796
|
+
*/
|
|
3797
|
+
sendSSEMessage(sessionId, message) {
|
|
3798
|
+
const conn = this.sseConnections.get(sessionId);
|
|
3799
|
+
if (conn && !conn.destroyed) {
|
|
3800
|
+
try {
|
|
3801
|
+
conn.write(`data: ${JSON.stringify(message)}\n\n`);
|
|
3802
|
+
}
|
|
3803
|
+
catch (err) {
|
|
3804
|
+
console.log(`[SSE] Error sending message to session ${sessionId}:`, err instanceof Error ? err.message : err);
|
|
3805
|
+
this.sseConnections.delete(sessionId);
|
|
3806
|
+
}
|
|
3807
|
+
}
|
|
3808
|
+
}
|
|
3809
|
+
notifyToolsListChanged() {
|
|
3810
|
+
const now = Date.now();
|
|
3811
|
+
if (now - this.lastToolsListNotification < this.TOOLS_LIST_NOTIFICATION_THROTTLE_MS) {
|
|
3812
|
+
console.log('[MCP] Throttling tools/list_changed notification');
|
|
3813
|
+
return;
|
|
3814
|
+
}
|
|
3815
|
+
this.lastToolsListNotification = now;
|
|
3816
|
+
const notification = {
|
|
3817
|
+
jsonrpc: '2.0',
|
|
3818
|
+
method: 'notifications/tools/list_changed'
|
|
3819
|
+
};
|
|
3820
|
+
let sentCount = 0;
|
|
3821
|
+
for (const [sessionId, conn] of this.sseConnections) {
|
|
3822
|
+
if (!conn.destroyed) {
|
|
3823
|
+
try {
|
|
3824
|
+
const eventId = this.trackSseEvent(sessionId, notification);
|
|
3825
|
+
conn.write(`id: ${eventId}\ndata: ${JSON.stringify(notification)}\n\n`);
|
|
3826
|
+
sentCount++;
|
|
3827
|
+
}
|
|
3828
|
+
catch (error) {
|
|
3829
|
+
console.warn(`[MCP] Failed to send notification to session ${sessionId}:`, error);
|
|
3830
|
+
this.sseConnections.delete(sessionId);
|
|
3831
|
+
}
|
|
3832
|
+
}
|
|
3833
|
+
}
|
|
3834
|
+
if (sentCount > 0) {
|
|
3835
|
+
console.log(`[MCP] Sent notifications/tools/list_changed to ${sentCount} client(s)`);
|
|
3836
|
+
}
|
|
3837
|
+
}
|
|
3838
|
+
/**
|
|
3839
|
+
* Send response via callback URL (MCP callback protocol for bidirectional communication)
|
|
3840
|
+
*/
|
|
3841
|
+
async sendViaCallback(session, response) {
|
|
3842
|
+
if (!session.callbackUrl) {
|
|
3843
|
+
return false;
|
|
3844
|
+
}
|
|
3845
|
+
try {
|
|
3846
|
+
console.log(`[CALLBACK] Posting response to ${session.callbackUrl}`);
|
|
3847
|
+
// POST response to callback URL
|
|
3848
|
+
const callbackResponse = await fetch(session.callbackUrl, {
|
|
3849
|
+
method: 'POST',
|
|
3850
|
+
headers: {
|
|
3851
|
+
'Content-Type': 'application/json',
|
|
3852
|
+
'Mcp-Session-Id': session.id,
|
|
3853
|
+
'MCP-Protocol-Version': MCP_PROTOCOL_VERSION
|
|
3854
|
+
},
|
|
3855
|
+
body: JSON.stringify(response),
|
|
3856
|
+
signal: AbortSignal.timeout(10000) // 10 second timeout
|
|
3857
|
+
});
|
|
3858
|
+
if (!callbackResponse.ok) {
|
|
3859
|
+
console.warn(`[CALLBACK] Warning: callback returned ${callbackResponse.status}`);
|
|
3860
|
+
}
|
|
3861
|
+
return true;
|
|
3862
|
+
}
|
|
3863
|
+
catch (error) {
|
|
3864
|
+
console.error(`[CALLBACK] Error sending to ${session.callbackUrl}:`, error instanceof Error ? error.message : error);
|
|
3865
|
+
return false;
|
|
3866
|
+
}
|
|
3867
|
+
}
|
|
3868
|
+
/**
|
|
3869
|
+
* Cleanup
|
|
3870
|
+
*/
|
|
3871
|
+
/**
|
|
3872
|
+
* Get all safety rules
|
|
3873
|
+
*/
|
|
3874
|
+
async getSafetyRules(req, res) {
|
|
3875
|
+
try {
|
|
3876
|
+
const includePatterns = req.query.include_patterns === 'true';
|
|
3877
|
+
const rules = this.configLoader.getToolSafetyRules();
|
|
3878
|
+
const response = {
|
|
3879
|
+
safeToolOverrides: rules.safeToolOverrides || [],
|
|
3880
|
+
riskyToolOverrides: rules.riskyToolOverrides || [],
|
|
3881
|
+
};
|
|
3882
|
+
if (includePatterns) {
|
|
3883
|
+
response.safePatterns = rules.safePatterns || [];
|
|
3884
|
+
response.riskyPatterns = rules.riskyPatterns || [];
|
|
3885
|
+
}
|
|
3886
|
+
// Always include argumentInspectionRules (v1.1.29+)
|
|
3887
|
+
response.argumentInspectionRules = rules.argumentInspectionRules || [];
|
|
3888
|
+
res.json(response);
|
|
3889
|
+
}
|
|
3890
|
+
catch (error) {
|
|
3891
|
+
res.status(500).json({
|
|
3892
|
+
error: error instanceof Error ? error.message : 'Failed to get safety rules',
|
|
3893
|
+
});
|
|
3894
|
+
}
|
|
3895
|
+
}
|
|
3896
|
+
/**
|
|
3897
|
+
* Check if a specific tool is safe or risky
|
|
3898
|
+
*/
|
|
3899
|
+
async checkToolSafety(req, res) {
|
|
3900
|
+
try {
|
|
3901
|
+
const { server, tool } = req.params;
|
|
3902
|
+
if (!server || !tool) {
|
|
3903
|
+
res.status(400).json({
|
|
3904
|
+
error: 'Missing required parameters: server and tool',
|
|
3905
|
+
});
|
|
3906
|
+
return;
|
|
3907
|
+
}
|
|
3908
|
+
const result = this.serverManager.classifyToolSafety(server, tool);
|
|
3909
|
+
res.json({
|
|
3910
|
+
server,
|
|
3911
|
+
tool,
|
|
3912
|
+
fullName: `${server}:${tool}`,
|
|
3913
|
+
safety: result.safety,
|
|
3914
|
+
reason: result.reason,
|
|
3915
|
+
requiresConfirmation: result.safety === 'risky',
|
|
3916
|
+
});
|
|
3917
|
+
}
|
|
3918
|
+
catch (error) {
|
|
3919
|
+
res.status(500).json({
|
|
3920
|
+
error: error instanceof Error ? error.message : 'Failed to check tool safety',
|
|
3921
|
+
});
|
|
3922
|
+
}
|
|
3923
|
+
}
|
|
3924
|
+
/**
|
|
3925
|
+
* Add a safe tool override
|
|
3926
|
+
*/
|
|
3927
|
+
async addSafeToolOverride(req, res) {
|
|
3928
|
+
try {
|
|
3929
|
+
const { tool, reason } = req.body;
|
|
3930
|
+
if (!tool || typeof tool !== 'string') {
|
|
3931
|
+
res.status(400).json({
|
|
3932
|
+
error: 'Missing or invalid required parameter: tool (string)',
|
|
3933
|
+
});
|
|
3934
|
+
return;
|
|
3935
|
+
}
|
|
3936
|
+
// Validate tool format
|
|
3937
|
+
const toolPattern = /^[a-zA-Z0-9_-]+:[a-zA-Z0-9_*-]+$/;
|
|
3938
|
+
if (!toolPattern.test(tool)) {
|
|
3939
|
+
res.status(400).json({
|
|
3940
|
+
error: 'Invalid tool format. Expected: server:tool or server:*',
|
|
3941
|
+
});
|
|
3942
|
+
return;
|
|
3943
|
+
}
|
|
3944
|
+
await this.configLoader.addSafeToolOverride(tool);
|
|
3945
|
+
res.status(201).json({
|
|
3946
|
+
success: true,
|
|
3947
|
+
message: `Added ${tool} to safe tools`,
|
|
3948
|
+
tool,
|
|
3949
|
+
reason: reason || undefined,
|
|
3950
|
+
});
|
|
3951
|
+
}
|
|
3952
|
+
catch (error) {
|
|
3953
|
+
res.status(500).json({
|
|
3954
|
+
error: error instanceof Error ? error.message : 'Failed to add safe tool override',
|
|
3955
|
+
});
|
|
3956
|
+
}
|
|
3957
|
+
}
|
|
3958
|
+
/**
|
|
3959
|
+
* Add a risky tool override
|
|
3960
|
+
*/
|
|
3961
|
+
async addRiskyToolOverride(req, res) {
|
|
3962
|
+
try {
|
|
3963
|
+
const { tool, reason } = req.body;
|
|
3964
|
+
if (!tool || typeof tool !== 'string') {
|
|
3965
|
+
res.status(400).json({
|
|
3966
|
+
error: 'Missing or invalid required parameter: tool (string)',
|
|
3967
|
+
});
|
|
3968
|
+
return;
|
|
3969
|
+
}
|
|
3970
|
+
// Validate tool format
|
|
3971
|
+
const toolPattern = /^[a-zA-Z0-9_-]+:[a-zA-Z0-9_*-]+$/;
|
|
3972
|
+
if (!toolPattern.test(tool)) {
|
|
3973
|
+
res.status(400).json({
|
|
3974
|
+
error: 'Invalid tool format. Expected: server:tool or server:*',
|
|
3975
|
+
});
|
|
3976
|
+
return;
|
|
3977
|
+
}
|
|
3978
|
+
await this.configLoader.addRiskyToolOverride(tool);
|
|
3979
|
+
res.status(201).json({
|
|
3980
|
+
success: true,
|
|
3981
|
+
message: `Added ${tool} to risky tools`,
|
|
3982
|
+
tool,
|
|
3983
|
+
reason: reason || undefined,
|
|
3984
|
+
});
|
|
3985
|
+
}
|
|
3986
|
+
catch (error) {
|
|
3987
|
+
res.status(500).json({
|
|
3988
|
+
error: error instanceof Error ? error.message : 'Failed to add risky tool override',
|
|
3989
|
+
});
|
|
3990
|
+
}
|
|
3991
|
+
}
|
|
3992
|
+
/**
|
|
3993
|
+
* Add a safe pattern
|
|
3994
|
+
*/
|
|
3995
|
+
async addSafePattern(req, res) {
|
|
3996
|
+
try {
|
|
3997
|
+
const { pattern, reason } = req.body;
|
|
3998
|
+
if (!pattern || typeof pattern !== 'string') {
|
|
3999
|
+
res.status(400).json({
|
|
4000
|
+
error: 'Missing or invalid required parameter: pattern (string)',
|
|
4001
|
+
});
|
|
4002
|
+
return;
|
|
4003
|
+
}
|
|
4004
|
+
// Validate regex pattern
|
|
4005
|
+
try {
|
|
4006
|
+
new RegExp(pattern);
|
|
4007
|
+
}
|
|
4008
|
+
catch (e) {
|
|
4009
|
+
res.status(400).json({
|
|
4010
|
+
error: `Invalid regex pattern: ${pattern}`,
|
|
4011
|
+
});
|
|
4012
|
+
return;
|
|
4013
|
+
}
|
|
4014
|
+
await this.configLoader.addSafePattern(pattern);
|
|
4015
|
+
res.status(201).json({
|
|
4016
|
+
success: true,
|
|
4017
|
+
message: `Added pattern ${pattern} to safe patterns`,
|
|
4018
|
+
pattern,
|
|
4019
|
+
reason: reason || undefined,
|
|
4020
|
+
});
|
|
4021
|
+
}
|
|
4022
|
+
catch (error) {
|
|
4023
|
+
res.status(500).json({
|
|
4024
|
+
error: error instanceof Error ? error.message : 'Failed to add safe pattern',
|
|
4025
|
+
});
|
|
4026
|
+
}
|
|
4027
|
+
}
|
|
4028
|
+
/**
|
|
4029
|
+
* Add a risky pattern
|
|
4030
|
+
*/
|
|
4031
|
+
async addRiskyPattern(req, res) {
|
|
4032
|
+
try {
|
|
4033
|
+
const { pattern, reason } = req.body;
|
|
4034
|
+
if (!pattern || typeof pattern !== 'string') {
|
|
4035
|
+
res.status(400).json({
|
|
4036
|
+
error: 'Missing or invalid required parameter: pattern (string)',
|
|
4037
|
+
});
|
|
4038
|
+
return;
|
|
4039
|
+
}
|
|
4040
|
+
// Validate regex pattern
|
|
4041
|
+
try {
|
|
4042
|
+
new RegExp(pattern);
|
|
4043
|
+
}
|
|
4044
|
+
catch (e) {
|
|
4045
|
+
res.status(400).json({
|
|
4046
|
+
error: `Invalid regex pattern: ${pattern}`,
|
|
4047
|
+
});
|
|
4048
|
+
return;
|
|
4049
|
+
}
|
|
4050
|
+
await this.configLoader.addRiskyPattern(pattern);
|
|
4051
|
+
res.status(201).json({
|
|
4052
|
+
success: true,
|
|
4053
|
+
message: `Added pattern ${pattern} to risky patterns`,
|
|
4054
|
+
pattern,
|
|
4055
|
+
reason: reason || undefined,
|
|
4056
|
+
});
|
|
4057
|
+
}
|
|
4058
|
+
catch (error) {
|
|
4059
|
+
res.status(500).json({
|
|
4060
|
+
error: error instanceof Error ? error.message : 'Failed to add risky pattern',
|
|
4061
|
+
});
|
|
4062
|
+
}
|
|
4063
|
+
}
|
|
4064
|
+
/**
|
|
4065
|
+
* Remove a rule (tool override or pattern)
|
|
4066
|
+
*/
|
|
4067
|
+
async removeRule(req, res) {
|
|
4068
|
+
try {
|
|
4069
|
+
const { rule } = req.params;
|
|
4070
|
+
if (!rule) {
|
|
4071
|
+
res.status(400).json({
|
|
4072
|
+
error: 'Missing required parameter: rule',
|
|
4073
|
+
});
|
|
4074
|
+
return;
|
|
4075
|
+
}
|
|
4076
|
+
// URL decode the rule
|
|
4077
|
+
const decodedRule = decodeURIComponent(rule);
|
|
4078
|
+
// Auto-detect rule type
|
|
4079
|
+
const rules = this.configLoader.getToolSafetyRules();
|
|
4080
|
+
let removed = false;
|
|
4081
|
+
let type = '';
|
|
4082
|
+
if (rules.safeToolOverrides?.includes(decodedRule)) {
|
|
4083
|
+
await this.configLoader.removeSafeToolOverride(decodedRule);
|
|
4084
|
+
removed = true;
|
|
4085
|
+
type = 'safe_tool_override';
|
|
4086
|
+
}
|
|
4087
|
+
else if (rules.riskyToolOverrides?.includes(decodedRule)) {
|
|
4088
|
+
await this.configLoader.removeRiskyToolOverride(decodedRule);
|
|
4089
|
+
removed = true;
|
|
4090
|
+
type = 'risky_tool_override';
|
|
4091
|
+
}
|
|
4092
|
+
else if (rules.safePatterns?.includes(decodedRule)) {
|
|
4093
|
+
await this.configLoader.removeSafePattern(decodedRule);
|
|
4094
|
+
removed = true;
|
|
4095
|
+
type = 'safe_pattern';
|
|
4096
|
+
}
|
|
4097
|
+
else if (rules.riskyPatterns?.includes(decodedRule)) {
|
|
4098
|
+
await this.configLoader.removeRiskyPattern(decodedRule);
|
|
4099
|
+
removed = true;
|
|
4100
|
+
type = 'risky_pattern';
|
|
4101
|
+
}
|
|
4102
|
+
if (!removed) {
|
|
4103
|
+
res.status(404).json({
|
|
4104
|
+
error: `Rule not found: ${decodedRule}`,
|
|
4105
|
+
});
|
|
4106
|
+
return;
|
|
4107
|
+
}
|
|
4108
|
+
res.json({
|
|
4109
|
+
success: true,
|
|
4110
|
+
message: `Removed ${type}: ${decodedRule}`,
|
|
4111
|
+
rule: decodedRule,
|
|
4112
|
+
type,
|
|
4113
|
+
});
|
|
4114
|
+
}
|
|
4115
|
+
catch (error) {
|
|
4116
|
+
res.status(500).json({
|
|
4117
|
+
error: error instanceof Error ? error.message : 'Failed to remove rule',
|
|
4118
|
+
});
|
|
4119
|
+
}
|
|
4120
|
+
}
|
|
4121
|
+
/**
|
|
4122
|
+
* Reset safety rules to defaults
|
|
4123
|
+
*/
|
|
4124
|
+
async resetSafetyRules(req, res) {
|
|
4125
|
+
try {
|
|
4126
|
+
const { force } = req.body;
|
|
4127
|
+
if (!force) {
|
|
4128
|
+
res.status(400).json({
|
|
4129
|
+
error: 'Reset requires explicit confirmation. Set force: true',
|
|
4130
|
+
});
|
|
4131
|
+
return;
|
|
4132
|
+
}
|
|
4133
|
+
await this.configLoader.resetToDefaults();
|
|
4134
|
+
res.json({
|
|
4135
|
+
success: true,
|
|
4136
|
+
message: 'Reset all safety rules to defaults',
|
|
4137
|
+
});
|
|
4138
|
+
}
|
|
4139
|
+
catch (error) {
|
|
4140
|
+
res.status(500).json({
|
|
4141
|
+
error: error instanceof Error ? error.message : 'Failed to reset safety rules',
|
|
4142
|
+
});
|
|
4143
|
+
}
|
|
4144
|
+
}
|
|
4145
|
+
/**
|
|
4146
|
+
* Import safety rules from JSON
|
|
4147
|
+
*/
|
|
4148
|
+
async importSafetyRules(req, res) {
|
|
4149
|
+
try {
|
|
4150
|
+
const { rules, merge = true } = req.body; // Default to merge mode
|
|
4151
|
+
if (!rules || typeof rules !== 'object') {
|
|
4152
|
+
res.status(400).json({
|
|
4153
|
+
error: 'Missing or invalid required parameter: rules (object)',
|
|
4154
|
+
});
|
|
4155
|
+
return;
|
|
4156
|
+
}
|
|
4157
|
+
// Get current rules to preserve argumentInspectionRules
|
|
4158
|
+
const currentRules = this.configLoader.getToolSafetyRules();
|
|
4159
|
+
// Extract arrays from the rules object
|
|
4160
|
+
const safeToolOverrides = Array.isArray(rules.safeToolOverrides) ? rules.safeToolOverrides : [];
|
|
4161
|
+
const riskyToolOverrides = Array.isArray(rules.riskyToolOverrides) ? rules.riskyToolOverrides : [];
|
|
4162
|
+
const safePatterns = Array.isArray(rules.safePatterns) ? rules.safePatterns : [];
|
|
4163
|
+
const riskyPatterns = Array.isArray(rules.riskyPatterns) ? rules.riskyPatterns : [];
|
|
4164
|
+
// Preserve argumentInspectionRules unless explicitly provided in import
|
|
4165
|
+
const argumentInspectionRules = Array.isArray(rules.argumentInspectionRules)
|
|
4166
|
+
? rules.argumentInspectionRules
|
|
4167
|
+
: (currentRules.argumentInspectionRules || []);
|
|
4168
|
+
if (merge) {
|
|
4169
|
+
// Merge mode: Add new rules to existing ones (default)
|
|
4170
|
+
// Merge tool overrides (avoid duplicates)
|
|
4171
|
+
const mergedSafeTools = [...new Set([...(currentRules.safeToolOverrides || []), ...safeToolOverrides])];
|
|
4172
|
+
const mergedRiskyTools = [...new Set([...(currentRules.riskyToolOverrides || []), ...riskyToolOverrides])];
|
|
4173
|
+
const mergedSafePatterns = [...new Set([...(currentRules.safePatterns || []), ...safePatterns])];
|
|
4174
|
+
const mergedRiskyPatterns = [...new Set([...(currentRules.riskyPatterns || []), ...riskyPatterns])];
|
|
4175
|
+
await this.configLoader.setToolSafetyRules({
|
|
4176
|
+
safeToolOverrides: mergedSafeTools,
|
|
4177
|
+
riskyToolOverrides: mergedRiskyTools,
|
|
4178
|
+
safePatterns: mergedSafePatterns,
|
|
4179
|
+
riskyPatterns: mergedRiskyPatterns,
|
|
4180
|
+
argumentInspectionRules, // Always preserve
|
|
4181
|
+
});
|
|
4182
|
+
res.json({
|
|
4183
|
+
success: true,
|
|
4184
|
+
message: 'Safety rules imported and merged successfully',
|
|
4185
|
+
imported: {
|
|
4186
|
+
safeTools: mergedSafeTools.length,
|
|
4187
|
+
riskyTools: mergedRiskyTools.length,
|
|
4188
|
+
safePatterns: mergedSafePatterns.length,
|
|
4189
|
+
riskyPatterns: mergedRiskyPatterns.length,
|
|
4190
|
+
},
|
|
4191
|
+
});
|
|
4192
|
+
}
|
|
4193
|
+
else {
|
|
4194
|
+
// Replace mode: Replace all rules with imported ones
|
|
4195
|
+
await this.configLoader.setToolSafetyRules({
|
|
4196
|
+
safeToolOverrides,
|
|
4197
|
+
riskyToolOverrides,
|
|
4198
|
+
safePatterns,
|
|
4199
|
+
riskyPatterns,
|
|
4200
|
+
argumentInspectionRules, // Still preserve if not provided
|
|
4201
|
+
});
|
|
4202
|
+
res.json({
|
|
4203
|
+
success: true,
|
|
4204
|
+
message: 'Safety rules imported successfully (replace mode)',
|
|
4205
|
+
imported: {
|
|
4206
|
+
safeTools: safeToolOverrides.length,
|
|
4207
|
+
riskyTools: riskyToolOverrides.length,
|
|
4208
|
+
safePatterns: safePatterns.length,
|
|
4209
|
+
riskyPatterns: riskyPatterns.length,
|
|
4210
|
+
},
|
|
4211
|
+
});
|
|
4212
|
+
}
|
|
4213
|
+
}
|
|
4214
|
+
catch (error) {
|
|
4215
|
+
res.status(500).json({
|
|
4216
|
+
error: error instanceof Error ? error.message : 'Failed to import safety rules',
|
|
4217
|
+
});
|
|
4218
|
+
}
|
|
4219
|
+
}
|
|
4220
|
+
async cleanup() {
|
|
4221
|
+
// Save metrics before shutdown (Phase 4 - v1.4.0)
|
|
4222
|
+
try {
|
|
4223
|
+
this.metricsPersistence.stopPeriodicWrites();
|
|
4224
|
+
const finalMetrics = {
|
|
4225
|
+
timestamp: Date.now(),
|
|
4226
|
+
metrics: globalMetrics.getMetrics(),
|
|
4227
|
+
serverMetrics: globalMetrics.getAllServerMetrics(),
|
|
4228
|
+
apiMetrics: globalMetrics.getApiMetrics(),
|
|
4229
|
+
};
|
|
4230
|
+
await this.metricsPersistence.save(finalMetrics);
|
|
4231
|
+
console.log('[MetricsPersistence] Saved final metrics on shutdown');
|
|
4232
|
+
}
|
|
4233
|
+
catch (error) {
|
|
4234
|
+
console.error('[MetricsPersistence] Failed to save metrics on shutdown:', error);
|
|
4235
|
+
}
|
|
4236
|
+
await this.serverManager.cleanup();
|
|
4237
|
+
for (const client of this.eventClients) {
|
|
4238
|
+
client.end();
|
|
4239
|
+
}
|
|
4240
|
+
this.eventClients.clear();
|
|
4241
|
+
// Close all SSE connections
|
|
4242
|
+
for (const [sessionId, conn] of this.sseConnections) {
|
|
4243
|
+
try {
|
|
4244
|
+
conn.end();
|
|
4245
|
+
}
|
|
4246
|
+
catch (err) {
|
|
4247
|
+
console.log(`[SSE] Error closing connection for session ${sessionId}`);
|
|
4248
|
+
}
|
|
4249
|
+
}
|
|
4250
|
+
this.sseConnections.clear();
|
|
4251
|
+
}
|
|
4252
|
+
}
|
|
4253
|
+
//# sourceMappingURL=http.js.map
|