mcp4openapi 0.2.8 → 0.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 +143 -63
- package/dist/scripts/validate-profile.js +3 -3
- package/dist/scripts/validate-profile.js.map +1 -1
- package/dist/src/argument-normalizer.d.ts +5 -0
- package/dist/src/argument-normalizer.d.ts.map +1 -0
- package/dist/src/argument-normalizer.js +61 -0
- package/dist/src/argument-normalizer.js.map +1 -0
- package/dist/src/auth/oauth-provider.d.ts +131 -0
- package/dist/src/auth/oauth-provider.d.ts.map +1 -0
- package/dist/src/auth/oauth-provider.js +839 -0
- package/dist/src/auth/oauth-provider.js.map +1 -0
- package/dist/src/cli-config.d.ts +9 -0
- package/dist/src/cli-config.d.ts.map +1 -0
- package/dist/src/cli-config.js +111 -0
- package/dist/src/cli-config.js.map +1 -0
- package/dist/src/core/cli-config.d.ts +9 -0
- package/dist/src/core/cli-config.d.ts.map +1 -0
- package/dist/src/core/cli-config.js +125 -0
- package/dist/src/core/cli-config.js.map +1 -0
- package/dist/src/core/constants.d.ts +86 -0
- package/dist/src/core/constants.d.ts.map +1 -0
- package/dist/src/core/constants.js +86 -0
- package/dist/src/core/constants.js.map +1 -0
- package/dist/src/core/errors.d.ts +59 -0
- package/dist/src/core/errors.d.ts.map +1 -0
- package/dist/src/core/errors.js +119 -0
- package/dist/src/core/errors.js.map +1 -0
- package/dist/src/core/filtering.d.ts +19 -0
- package/dist/src/core/filtering.d.ts.map +1 -0
- package/dist/src/core/filtering.js +292 -0
- package/dist/src/core/filtering.js.map +1 -0
- package/dist/src/core/index.d.ts +26 -0
- package/dist/src/core/index.d.ts.map +1 -0
- package/dist/src/core/index.js +276 -0
- package/dist/src/core/index.js.map +1 -0
- package/dist/src/core/lib.d.ts +8 -0
- package/dist/src/core/lib.d.ts.map +1 -0
- package/dist/src/core/lib.js +7 -0
- package/dist/src/core/lib.js.map +1 -0
- package/dist/src/core/logger.d.ts +59 -0
- package/dist/src/core/logger.d.ts.map +1 -0
- package/dist/src/core/logger.js +197 -0
- package/dist/src/core/logger.js.map +1 -0
- package/dist/src/core/metrics.d.ts +97 -0
- package/dist/src/core/metrics.d.ts.map +1 -0
- package/dist/src/core/metrics.js +273 -0
- package/dist/src/core/metrics.js.map +1 -0
- package/dist/src/core/naming-warnings.d.ts +23 -0
- package/dist/src/core/naming-warnings.d.ts.map +1 -0
- package/dist/src/core/naming-warnings.js +83 -0
- package/dist/src/core/naming-warnings.js.map +1 -0
- package/dist/src/core/naming.d.ts +58 -0
- package/dist/src/core/naming.d.ts.map +1 -0
- package/dist/src/core/naming.js +510 -0
- package/dist/src/core/naming.js.map +1 -0
- package/dist/src/errors.d.ts +6 -0
- package/dist/src/errors.d.ts.map +1 -1
- package/dist/src/errors.js +15 -6
- package/dist/src/errors.js.map +1 -1
- package/dist/src/filtering.d.ts +19 -0
- package/dist/src/filtering.d.ts.map +1 -0
- package/dist/src/filtering.js +292 -0
- package/dist/src/filtering.js.map +1 -0
- package/dist/src/generated-schemas.d.ts +290 -79
- package/dist/src/generated-schemas.d.ts.map +1 -1
- package/dist/src/generated-schemas.js +17 -2
- package/dist/src/generated-schemas.js.map +1 -1
- package/dist/src/http-transport-config.d.ts +6 -0
- package/dist/src/http-transport-config.d.ts.map +1 -0
- package/dist/src/http-transport-config.js +47 -0
- package/dist/src/http-transport-config.js.map +1 -0
- package/dist/src/http-transport.d.ts +63 -13
- package/dist/src/http-transport.d.ts.map +1 -1
- package/dist/src/http-transport.js +1045 -482
- package/dist/src/http-transport.js.map +1 -1
- package/dist/src/index.d.ts +1 -6
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +1 -170
- package/dist/src/index.js.map +1 -1
- package/dist/src/interceptors.d.ts +1 -0
- package/dist/src/interceptors.d.ts.map +1 -1
- package/dist/src/interceptors.js +73 -63
- package/dist/src/interceptors.js.map +1 -1
- package/dist/src/lib.d.ts +1 -7
- package/dist/src/lib.d.ts.map +1 -1
- package/dist/src/lib.js +1 -6
- package/dist/src/lib.js.map +1 -1
- package/dist/src/logger.d.ts +5 -0
- package/dist/src/logger.d.ts.map +1 -1
- package/dist/src/logger.js +9 -1
- package/dist/src/logger.js.map +1 -1
- package/dist/src/mcp/mcp-server-manager.d.ts +20 -0
- package/dist/src/mcp/mcp-server-manager.d.ts.map +1 -0
- package/dist/src/mcp/mcp-server-manager.js +38 -0
- package/dist/src/mcp/mcp-server-manager.js.map +1 -0
- package/dist/src/mcp/mcp-server.d.ts +205 -0
- package/dist/src/mcp/mcp-server.d.ts.map +1 -0
- package/dist/src/mcp/mcp-server.js +1473 -0
- package/dist/src/mcp/mcp-server.js.map +1 -0
- package/dist/src/mcp-server-manager.d.ts +20 -0
- package/dist/src/mcp-server-manager.d.ts.map +1 -0
- package/dist/src/mcp-server-manager.js +38 -0
- package/dist/src/mcp-server-manager.js.map +1 -0
- package/dist/src/mcp-server.d.ts +28 -0
- package/dist/src/mcp-server.d.ts.map +1 -1
- package/dist/src/mcp-server.js +406 -109
- package/dist/src/mcp-server.js.map +1 -1
- package/dist/src/metrics.d.ts +11 -0
- package/dist/src/metrics.d.ts.map +1 -1
- package/dist/src/metrics.js +61 -0
- package/dist/src/metrics.js.map +1 -1
- package/dist/src/oauth-provider.d.ts +5 -0
- package/dist/src/oauth-provider.d.ts.map +1 -1
- package/dist/src/oauth-provider.js +29 -1
- package/dist/src/oauth-provider.js.map +1 -1
- package/dist/src/openapi/openapi-parser.d.ts +70 -0
- package/dist/src/openapi/openapi-parser.d.ts.map +1 -0
- package/dist/src/openapi/openapi-parser.js +458 -0
- package/dist/src/openapi/openapi-parser.js.map +1 -0
- package/dist/src/profile/profile-loader.d.ts +78 -0
- package/dist/src/profile/profile-loader.d.ts.map +1 -0
- package/dist/src/profile/profile-loader.js +490 -0
- package/dist/src/profile/profile-loader.js.map +1 -0
- package/dist/src/profile/profile-registry.d.ts +19 -0
- package/dist/src/profile/profile-registry.d.ts.map +1 -0
- package/dist/src/profile/profile-registry.js +43 -0
- package/dist/src/profile/profile-registry.js.map +1 -0
- package/dist/src/profile/profile-resolver.d.ts +41 -0
- package/dist/src/profile/profile-resolver.d.ts.map +1 -0
- package/dist/src/profile/profile-resolver.js +324 -0
- package/dist/src/profile/profile-resolver.js.map +1 -0
- package/dist/src/profile/startup-profile.d.ts +17 -0
- package/dist/src/profile/startup-profile.d.ts.map +1 -0
- package/dist/src/profile/startup-profile.js +30 -0
- package/dist/src/profile/startup-profile.js.map +1 -0
- package/dist/src/profile/startup-validation.d.ts +11 -0
- package/dist/src/profile/startup-validation.d.ts.map +1 -0
- package/dist/src/profile/startup-validation.js +21 -0
- package/dist/src/profile/startup-validation.js.map +1 -0
- package/dist/src/profile-loader.d.ts +1 -0
- package/dist/src/profile-loader.d.ts.map +1 -1
- package/dist/src/profile-loader.js +14 -3
- package/dist/src/profile-loader.js.map +1 -1
- package/dist/src/profile-registry.d.ts +18 -0
- package/dist/src/profile-registry.d.ts.map +1 -0
- package/dist/src/profile-registry.js +26 -0
- package/dist/src/profile-registry.js.map +1 -0
- package/dist/src/profile-resolver.d.ts +19 -0
- package/dist/src/profile-resolver.d.ts.map +1 -0
- package/dist/src/profile-resolver.js +167 -0
- package/dist/src/profile-resolver.js.map +1 -0
- package/dist/src/proxy-executor.d.ts.map +1 -1
- package/dist/src/proxy-executor.js +7 -0
- package/dist/src/proxy-executor.js.map +1 -1
- package/dist/src/startup-profile.d.ts +17 -0
- package/dist/src/startup-profile.d.ts.map +1 -0
- package/dist/src/startup-profile.js +30 -0
- package/dist/src/startup-profile.js.map +1 -0
- package/dist/src/startup-validation.d.ts +11 -0
- package/dist/src/startup-validation.d.ts.map +1 -0
- package/dist/src/startup-validation.js +21 -0
- package/dist/src/startup-validation.js.map +1 -0
- package/dist/src/testing/dynamic-mock-server.d.ts +24 -0
- package/dist/src/testing/dynamic-mock-server.d.ts.map +1 -0
- package/dist/src/testing/dynamic-mock-server.js +138 -0
- package/dist/src/testing/dynamic-mock-server.js.map +1 -0
- package/dist/src/testing/request-assertions.d.ts +5 -0
- package/dist/src/testing/request-assertions.d.ts.map +1 -0
- package/dist/src/testing/request-assertions.js +165 -0
- package/dist/src/testing/request-assertions.js.map +1 -0
- package/dist/src/testing/template-utils.d.ts +10 -0
- package/dist/src/testing/template-utils.d.ts.map +1 -0
- package/dist/src/testing/template-utils.js +72 -0
- package/dist/src/testing/template-utils.js.map +1 -0
- package/dist/src/testing/test-http-utils.d.ts +1 -1
- package/dist/src/testing/test-http-utils.d.ts.map +1 -1
- package/dist/src/testing/test-http-utils.js +1 -1
- package/dist/src/testing/test-http-utils.js.map +1 -1
- package/dist/src/testing/test-loader.d.ts +6 -0
- package/dist/src/testing/test-loader.d.ts.map +1 -0
- package/dist/src/testing/test-loader.js +212 -0
- package/dist/src/testing/test-loader.js.map +1 -0
- package/dist/src/testing/test-schema.d.ts +1270 -0
- package/dist/src/testing/test-schema.d.ts.map +1 -0
- package/dist/src/testing/test-schema.js +76 -0
- package/dist/src/testing/test-schema.js.map +1 -0
- package/dist/src/tool-filter/compat.d.ts +49 -0
- package/dist/src/tool-filter/compat.d.ts.map +1 -0
- package/dist/src/tool-filter/compat.js +72 -0
- package/dist/src/tool-filter/compat.js.map +1 -0
- package/dist/src/tool-filter/config/env-config-parser.d.ts +38 -0
- package/dist/src/tool-filter/config/env-config-parser.d.ts.map +1 -0
- package/dist/src/tool-filter/config/env-config-parser.js +103 -0
- package/dist/src/tool-filter/config/env-config-parser.js.map +1 -0
- package/dist/src/tool-filter/config/header-config-parser.d.ts +37 -0
- package/dist/src/tool-filter/config/header-config-parser.d.ts.map +1 -0
- package/dist/src/tool-filter/config/header-config-parser.js +118 -0
- package/dist/src/tool-filter/config/header-config-parser.js.map +1 -0
- package/dist/src/tool-filter/errors.d.ts +18 -0
- package/dist/src/tool-filter/errors.d.ts.map +1 -0
- package/dist/src/tool-filter/errors.js +21 -0
- package/dist/src/tool-filter/errors.js.map +1 -0
- package/dist/src/tool-filter/filter/filter-engine.d.ts +45 -0
- package/dist/src/tool-filter/filter/filter-engine.d.ts.map +1 -0
- package/dist/src/tool-filter/filter/filter-engine.js +94 -0
- package/dist/src/tool-filter/filter/filter-engine.js.map +1 -0
- package/dist/src/tool-filter/filter/filter-rules.d.ts +44 -0
- package/dist/src/tool-filter/filter/filter-rules.d.ts.map +1 -0
- package/dist/src/tool-filter/filter/filter-rules.js +72 -0
- package/dist/src/tool-filter/filter/filter-rules.js.map +1 -0
- package/dist/src/tool-filter/filter/global-tool-filter.d.ts +40 -0
- package/dist/src/tool-filter/filter/global-tool-filter.d.ts.map +1 -0
- package/dist/src/tool-filter/filter/global-tool-filter.js +92 -0
- package/dist/src/tool-filter/filter/global-tool-filter.js.map +1 -0
- package/dist/src/tool-filter/filter/session-tool-filter.d.ts +29 -0
- package/dist/src/tool-filter/filter/session-tool-filter.d.ts.map +1 -0
- package/dist/src/tool-filter/filter/session-tool-filter.js +69 -0
- package/dist/src/tool-filter/filter/session-tool-filter.js.map +1 -0
- package/dist/src/tool-filter/index.d.ts +25 -0
- package/dist/src/tool-filter/index.d.ts.map +1 -0
- package/dist/src/tool-filter/index.js +30 -0
- package/dist/src/tool-filter/index.js.map +1 -0
- package/dist/src/tool-filter/integration/tool-filter-service.d.ts +44 -0
- package/dist/src/tool-filter/integration/tool-filter-service.d.ts.map +1 -0
- package/dist/src/tool-filter/integration/tool-filter-service.js +68 -0
- package/dist/src/tool-filter/integration/tool-filter-service.js.map +1 -0
- package/dist/src/tool-filter/operation/operation-classifier.d.ts +20 -0
- package/dist/src/tool-filter/operation/operation-classifier.d.ts.map +1 -0
- package/dist/src/tool-filter/operation/operation-classifier.js +26 -0
- package/dist/src/tool-filter/operation/operation-classifier.js.map +1 -0
- package/dist/src/tool-filter/operation/operation-detector.d.ts +30 -0
- package/dist/src/tool-filter/operation/operation-detector.d.ts.map +1 -0
- package/dist/src/tool-filter/operation/operation-detector.js +96 -0
- package/dist/src/tool-filter/operation/operation-detector.js.map +1 -0
- package/dist/src/tool-filter/operation/operation-resolver.d.ts +22 -0
- package/dist/src/tool-filter/operation/operation-resolver.d.ts.map +1 -0
- package/dist/src/tool-filter/operation/operation-resolver.js +32 -0
- package/dist/src/tool-filter/operation/operation-resolver.js.map +1 -0
- package/dist/src/tool-filter/regex/regex-compiler.d.ts +22 -0
- package/dist/src/tool-filter/regex/regex-compiler.d.ts.map +1 -0
- package/dist/src/tool-filter/regex/regex-compiler.js +56 -0
- package/dist/src/tool-filter/regex/regex-compiler.js.map +1 -0
- package/dist/src/tool-filter/regex/regex-validator.d.ts +24 -0
- package/dist/src/tool-filter/regex/regex-validator.d.ts.map +1 -0
- package/dist/src/tool-filter/regex/regex-validator.js +58 -0
- package/dist/src/tool-filter/regex/regex-validator.js.map +1 -0
- package/dist/src/tool-filter/types.d.ts +92 -0
- package/dist/src/tool-filter/types.d.ts.map +1 -0
- package/dist/src/tool-filter/types.js +5 -0
- package/dist/src/tool-filter/types.js.map +1 -0
- package/dist/src/tool-filter/utils.d.ts +11 -0
- package/dist/src/tool-filter/utils.d.ts.map +1 -0
- package/dist/src/tool-filter/utils.js +13 -0
- package/dist/src/tool-filter/utils.js.map +1 -0
- package/dist/src/tool-filter.d.ts +65 -0
- package/dist/src/tool-filter.d.ts.map +1 -0
- package/dist/src/tool-filter.js +471 -0
- package/dist/src/tool-filter.js.map +1 -0
- package/dist/src/tool-generator.d.ts +1 -0
- package/dist/src/tool-generator.d.ts.map +1 -1
- package/dist/src/tool-generator.js +15 -6
- package/dist/src/tool-generator.js.map +1 -1
- package/dist/src/tooling/composite-executor.d.ts +77 -0
- package/dist/src/tooling/composite-executor.d.ts.map +1 -0
- package/dist/src/tooling/composite-executor.js +198 -0
- package/dist/src/tooling/composite-executor.js.map +1 -0
- package/dist/src/tooling/dag-executor.d.ts +49 -0
- package/dist/src/tooling/dag-executor.d.ts.map +1 -0
- package/dist/src/tooling/dag-executor.js +138 -0
- package/dist/src/tooling/dag-executor.js.map +1 -0
- package/dist/src/tooling/proxy-executor.d.ts +86 -0
- package/dist/src/tooling/proxy-executor.d.ts.map +1 -0
- package/dist/src/tooling/proxy-executor.js +501 -0
- package/dist/src/tooling/proxy-executor.js.map +1 -0
- package/dist/src/tooling/tool-generator.d.ts +67 -0
- package/dist/src/tooling/tool-generator.d.ts.map +1 -0
- package/dist/src/tooling/tool-generator.js +222 -0
- package/dist/src/tooling/tool-generator.js.map +1 -0
- package/dist/src/transport/http-client-factory.d.ts +65 -0
- package/dist/src/transport/http-client-factory.d.ts.map +1 -0
- package/dist/src/transport/http-client-factory.js +143 -0
- package/dist/src/transport/http-client-factory.js.map +1 -0
- package/dist/src/transport/http-transport-config.d.ts +6 -0
- package/dist/src/transport/http-transport-config.d.ts.map +1 -0
- package/dist/src/transport/http-transport-config.js +63 -0
- package/dist/src/transport/http-transport-config.js.map +1 -0
- package/dist/src/transport/http-transport.d.ts +329 -0
- package/dist/src/transport/http-transport.d.ts.map +1 -0
- package/dist/src/transport/http-transport.js +2584 -0
- package/dist/src/transport/http-transport.js.map +1 -0
- package/dist/src/transport/interceptors.d.ts +119 -0
- package/dist/src/transport/interceptors.d.ts.map +1 -0
- package/dist/src/transport/interceptors.js +413 -0
- package/dist/src/transport/interceptors.js.map +1 -0
- package/dist/src/transport/profile-index.d.ts +84 -0
- package/dist/src/transport/profile-index.d.ts.map +1 -0
- package/dist/src/transport/profile-index.js +405 -0
- package/dist/src/transport/profile-index.js.map +1 -0
- package/dist/src/types/http-transport.d.ts +26 -0
- package/dist/src/types/http-transport.d.ts.map +1 -1
- package/dist/src/types/openapi.d.ts +3 -0
- package/dist/src/types/openapi.d.ts.map +1 -1
- package/dist/src/types/profile.d.ts +16 -1
- package/dist/src/types/profile.d.ts.map +1 -1
- package/dist/src/validation/argument-normalizer.d.ts +6 -0
- package/dist/src/validation/argument-normalizer.d.ts.map +1 -0
- package/dist/src/validation/argument-normalizer.js +70 -0
- package/dist/src/validation/argument-normalizer.js.map +1 -0
- package/dist/src/validation/jsonrpc-validator.d.ts +27 -0
- package/dist/src/validation/jsonrpc-validator.d.ts.map +1 -0
- package/dist/src/validation/jsonrpc-validator.js +58 -0
- package/dist/src/validation/jsonrpc-validator.js.map +1 -0
- package/dist/src/validation/schema-validator.d.ts +30 -0
- package/dist/src/validation/schema-validator.d.ts.map +1 -0
- package/dist/src/validation/schema-validator.js +128 -0
- package/dist/src/validation/schema-validator.js.map +1 -0
- package/dist/src/validation/validation-utils.d.ts +49 -0
- package/dist/src/validation/validation-utils.d.ts.map +1 -0
- package/dist/src/validation/validation-utils.js +139 -0
- package/dist/src/validation/validation-utils.js.map +1 -0
- package/html/profile-index.html +386 -0
- package/package.json +10 -3
- package/profile-schema.json +77 -3
- package/profiles/gitlab/developer-profile-oauth.json +1520 -0
- package/profiles/gitlab/developer-profile-oauth.test.json +3432 -0
- package/profiles/gitlab/developer-profile.json +1508 -0
- package/profiles/gitlab/developer-profile.test.json +3432 -0
- package/profiles/gitlab/openapi.yaml +6891 -0
- package/profiles/n8n/openapi.yaml +2441 -0
- package/profiles/n8n/profile-optimized.json +965 -0
- package/profiles/n8n/profile-optimized.test.json +1078 -0
- package/profiles/n8n/profile.json +1033 -0
- package/profiles/n8n/profile.test.json +983 -0
- package/profiles/n8n-nodes/openapi.yaml +24 -0
- package/profiles/n8n-nodes/profile-nodes.json +44 -0
- package/profiles/n8n-nodes/profile-nodes.test.json +91 -0
- package/profiles/semgrep/openapi.yaml +4706 -0
- package/profiles/semgrep/profile.json +692 -0
- package/profiles/semgrep/profile.test.json +471 -0
- package/profiles/youtrack/openapi.json +16976 -0
- package/profiles/youtrack/profile.json +608 -0
- package/profiles/youtrack/profile.test.json +1926 -0
- package/dist/src/testing/fixtures.d.ts +0 -684
- package/dist/src/testing/fixtures.d.ts.map +0 -1
- package/dist/src/testing/fixtures.js +0 -528
- package/dist/src/testing/fixtures.js.map +0 -1
- package/dist/src/testing/mock-gitlab-server.d.ts +0 -43
- package/dist/src/testing/mock-gitlab-server.d.ts.map +0 -1
- package/dist/src/testing/mock-gitlab-server.js +0 -1026
- package/dist/src/testing/mock-gitlab-server.js.map +0 -1
- package/dist/src/testing/mock-semgrep-server.d.ts +0 -32
- package/dist/src/testing/mock-semgrep-server.d.ts.map +0 -1
- package/dist/src/testing/mock-semgrep-server.js +0 -213
- package/dist/src/testing/mock-semgrep-server.js.map +0 -1
- package/dist/src/testing/mock-youtrack-server.d.ts +0 -11
- package/dist/src/testing/mock-youtrack-server.d.ts.map +0 -1
- package/dist/src/testing/mock-youtrack-server.js +0 -152
- package/dist/src/testing/mock-youtrack-server.js.map +0 -1
|
@@ -0,0 +1,2584 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP Streamable Transport for MCP
|
|
3
|
+
*
|
|
4
|
+
* Implements MCP Specification 2025-03-26
|
|
5
|
+
* https://modelcontextprotocol.io/specification/2025-03-26/basic/transports
|
|
6
|
+
*
|
|
7
|
+
* Why: Enables remote MCP server access with SSE streaming, session management,
|
|
8
|
+
* and resumability for reliable communication over HTTP.
|
|
9
|
+
*/
|
|
10
|
+
import express from 'express';
|
|
11
|
+
import https from 'https';
|
|
12
|
+
import fs from 'fs';
|
|
13
|
+
import crypto from 'crypto';
|
|
14
|
+
import { isIP } from 'node:net';
|
|
15
|
+
import rateLimit from 'express-rate-limit';
|
|
16
|
+
import { isInitializeRequest } from '../validation/jsonrpc-validator.js';
|
|
17
|
+
import { MetricsCollector } from '../core/metrics.js';
|
|
18
|
+
import { ExternalOAuthProvider } from '../auth/oauth-provider.js';
|
|
19
|
+
import { HTTP_STATUS, MIME_TYPES, OAUTH_PATHS, TIMEOUTS, OAUTH_RATE_LIMIT } from '../core/constants.js';
|
|
20
|
+
import { escapeHtmlSafe, isSafePropertyName } from '../validation/validation-utils.js';
|
|
21
|
+
import { AuthenticationError, AuthorizationError, ConfigurationError, RateLimitError, ValidationError, generateCorrelationId, } from '../core/errors.js';
|
|
22
|
+
import { parseFilteringHeader, normalizeFilteringHeaderValue } from '../core/filtering.js';
|
|
23
|
+
import { ToolFilterService, EnvConfigParser, HeaderConfigParser, RegexCompiler, RegexValidator, OperationClassifier, OpenAPIOperationResolver, OperationDetector, normalizeToolFilterHeaderValue, parseSessionToolFilterHeader, } from '../tool-filter/index.js';
|
|
24
|
+
import { buildProfileIndexPayload, loadProfileIndexTemplate, parseAcceptLanguage, renderProfileIndexHtml, } from './profile-index.js';
|
|
25
|
+
const DEFAULT_MAX_TOKEN_LENGTH = 1000;
|
|
26
|
+
export class HttpTransport {
|
|
27
|
+
static { this.PROFILE_HINT_TTL_MS = 10 * 60 * 1000; }
|
|
28
|
+
constructor(config, logger) {
|
|
29
|
+
this.server = null;
|
|
30
|
+
this.metrics = null;
|
|
31
|
+
this.cleanupInterval = null;
|
|
32
|
+
this.messageHandler = null;
|
|
33
|
+
this.profileContextProvider = null;
|
|
34
|
+
this.profileStates = new Map();
|
|
35
|
+
this.oauthRedirectHostCache = new Map();
|
|
36
|
+
this.warnedMissingOAuthRedirectEnvVars = new Set();
|
|
37
|
+
this.profileHintsByClient = new Map();
|
|
38
|
+
this.profileIndexProvider = null;
|
|
39
|
+
this.hasWarnedAboutBinding = false;
|
|
40
|
+
/**
|
|
41
|
+
* Session destruction listeners for cleanup in other components
|
|
42
|
+
*/
|
|
43
|
+
this.sessionDestroyedListeners = [];
|
|
44
|
+
// Freeze config to prevent runtime mutation of security-critical settings (allowedOrigins, rate limits, etc.)
|
|
45
|
+
this.config = Object.freeze({ ...config });
|
|
46
|
+
this.logger = logger;
|
|
47
|
+
// Initialize metrics if enabled
|
|
48
|
+
if (config.metricsEnabled) {
|
|
49
|
+
this.metrics = new MetricsCollector({
|
|
50
|
+
enabled: true,
|
|
51
|
+
prefix: 'mcp_',
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
this.app = express();
|
|
55
|
+
if (this.config.trustProxy !== undefined) {
|
|
56
|
+
this.app.set('trust proxy', this.config.trustProxy);
|
|
57
|
+
}
|
|
58
|
+
this.setupMiddleware();
|
|
59
|
+
this.setupRoutes();
|
|
60
|
+
}
|
|
61
|
+
getMetricsCollector() {
|
|
62
|
+
return this.metrics;
|
|
63
|
+
}
|
|
64
|
+
setProfileIndexProvider(provider) {
|
|
65
|
+
this.profileIndexProvider = provider;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Setup Express middleware
|
|
69
|
+
*
|
|
70
|
+
* Why: Security (Origin validation, rate limiting), JSON parsing, session extraction, metrics
|
|
71
|
+
*/
|
|
72
|
+
setupMiddleware() {
|
|
73
|
+
// Security: standard headers
|
|
74
|
+
this.app.disable('x-powered-by');
|
|
75
|
+
this.app.use((req, res, next) => {
|
|
76
|
+
res.setHeader('Content-Security-Policy', "default-src 'self'; frame-ancestors 'none'; object-src 'none'; base-uri 'self'");
|
|
77
|
+
res.setHeader('X-Frame-Options', 'DENY');
|
|
78
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
79
|
+
res.setHeader('Referrer-Policy', 'no-referrer');
|
|
80
|
+
res.setHeader('Permissions-Policy', 'geolocation=(), microphone=(), camera=(), payment=()');
|
|
81
|
+
// Additional security headers
|
|
82
|
+
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
|
|
83
|
+
res.setHeader('Cross-Origin-Resource-Policy', 'same-origin');
|
|
84
|
+
res.setHeader('X-Permitted-Cross-Domain-Policies', 'none');
|
|
85
|
+
res.setHeader('X-DNS-Prefetch-Control', 'off');
|
|
86
|
+
next();
|
|
87
|
+
});
|
|
88
|
+
// Request logging (before any middleware)
|
|
89
|
+
this.app.use((req, res, next) => {
|
|
90
|
+
this.logger.debug('Request received', {
|
|
91
|
+
method: req.method,
|
|
92
|
+
url: req.url,
|
|
93
|
+
path: req.path,
|
|
94
|
+
userAgent: req.get('user-agent'),
|
|
95
|
+
ip: req.ip,
|
|
96
|
+
});
|
|
97
|
+
next();
|
|
98
|
+
});
|
|
99
|
+
// DNS rebinding protection when binding to localhost
|
|
100
|
+
// Deny requests with mismatched Host headers to prevent DNS rebinding attacks
|
|
101
|
+
// Applies when server host is localhost/127.0.0.1, regardless of auth configuration
|
|
102
|
+
this.app.use((req, res, next) => {
|
|
103
|
+
const hostCfg = this.config.host?.toLowerCase();
|
|
104
|
+
if (hostCfg === 'localhost' || hostCfg === '127.0.0.1') {
|
|
105
|
+
const hostHeader = (req.headers['host'] || '').toString().toLowerCase();
|
|
106
|
+
const expectedHosts = new Set(['localhost', '127.0.0.1']);
|
|
107
|
+
const headerHostOnly = hostHeader.split(':')[0];
|
|
108
|
+
if (!expectedHosts.has(headerHostOnly)) {
|
|
109
|
+
this.logger.warn('DNS rebinding protection: invalid Host header', {
|
|
110
|
+
hostHeader,
|
|
111
|
+
expected: Array.from(expectedHosts),
|
|
112
|
+
});
|
|
113
|
+
res.status(403).json({ error: 'Forbidden' });
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
next();
|
|
118
|
+
});
|
|
119
|
+
// JSON body parser
|
|
120
|
+
// Limit set to 10MB to support large tool inputs/results (e.g. file content)
|
|
121
|
+
// while preventing massive DoS attacks. Default is 100kb.
|
|
122
|
+
this.app.use(express.json({ limit: '10mb' }));
|
|
123
|
+
// Metrics: Track request start time
|
|
124
|
+
this.app.use((req, res, next) => {
|
|
125
|
+
req.startTime = Date.now();
|
|
126
|
+
// Log response
|
|
127
|
+
const originalSend = res.send;
|
|
128
|
+
const originalJson = res.json;
|
|
129
|
+
const logger = this.logger;
|
|
130
|
+
res.send = function (body) {
|
|
131
|
+
logger.debug('Outgoing response', {
|
|
132
|
+
method: req.method,
|
|
133
|
+
url: req.url,
|
|
134
|
+
status: res.statusCode,
|
|
135
|
+
contentType: res.get('content-type'),
|
|
136
|
+
bodyLength: body ? body.length : 0,
|
|
137
|
+
bodyPreview: typeof body === 'string' ? body.substring(0, 200) : '[object]'
|
|
138
|
+
});
|
|
139
|
+
return originalSend.call(this, body);
|
|
140
|
+
};
|
|
141
|
+
res.json = function (body) {
|
|
142
|
+
logger.debug('Outgoing JSON response', {
|
|
143
|
+
method: req.method,
|
|
144
|
+
url: req.url,
|
|
145
|
+
status: res.statusCode,
|
|
146
|
+
body
|
|
147
|
+
});
|
|
148
|
+
return originalJson.call(this, body);
|
|
149
|
+
};
|
|
150
|
+
next();
|
|
151
|
+
});
|
|
152
|
+
// Debug: Log all requests
|
|
153
|
+
this.app.use((req, res, next) => {
|
|
154
|
+
this.logger.debug('Incoming request', {
|
|
155
|
+
method: req.method,
|
|
156
|
+
url: req.url,
|
|
157
|
+
path: req.path,
|
|
158
|
+
headers: {
|
|
159
|
+
'user-agent': req.headers['user-agent'],
|
|
160
|
+
'accept': req.headers.accept,
|
|
161
|
+
'content-type': req.headers['content-type'],
|
|
162
|
+
'authorization': req.headers.authorization ? '[REDACTED]' : undefined
|
|
163
|
+
},
|
|
164
|
+
ip: req.ip
|
|
165
|
+
});
|
|
166
|
+
next();
|
|
167
|
+
});
|
|
168
|
+
// Capture profile hints for clients using profile routing
|
|
169
|
+
this.app.use((req, _res, next) => {
|
|
170
|
+
if (this.config.profileRoutingEnabled) {
|
|
171
|
+
const profileId = this.resolveProfileIdFromPath(req.path);
|
|
172
|
+
if (profileId) {
|
|
173
|
+
this.storeProfileHint(req, profileId);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
next();
|
|
177
|
+
});
|
|
178
|
+
// Security: Origin validation (DNS rebinding protection)
|
|
179
|
+
this.app.use((req, res, next) => {
|
|
180
|
+
const origin = req.headers.origin;
|
|
181
|
+
// Warn if binding to 0.0.0.0
|
|
182
|
+
if (this.config.host === '0.0.0.0' && !this.hasWarnedAboutBinding) {
|
|
183
|
+
this.logger.warn('HTTP transport bound to 0.0.0.0 - accessible from network. Ensure firewall protection.');
|
|
184
|
+
this.hasWarnedAboutBinding = true;
|
|
185
|
+
}
|
|
186
|
+
// Validate Origin header
|
|
187
|
+
// We do not skip this check for localhost, as requests to localhost can still be CSRF targets
|
|
188
|
+
if (!origin) {
|
|
189
|
+
next();
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
this.isAllowedOriginForRequest(origin, req)
|
|
193
|
+
.then((allowed) => {
|
|
194
|
+
if (!allowed) {
|
|
195
|
+
this.logger.warn('Rejected request from disallowed origin', { origin, ip: req.ip });
|
|
196
|
+
res.status(HTTP_STATUS.FORBIDDEN).json({
|
|
197
|
+
error: 'Forbidden',
|
|
198
|
+
message: 'Origin not allowed'
|
|
199
|
+
});
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
next();
|
|
203
|
+
})
|
|
204
|
+
.catch((error) => {
|
|
205
|
+
this.logger.error('Origin validation failed', error instanceof Error ? error : new Error(String(error)));
|
|
206
|
+
res.status(HTTP_STATUS.FORBIDDEN).json({
|
|
207
|
+
error: 'Forbidden',
|
|
208
|
+
message: 'Origin not allowed'
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
// Extract session ID from header
|
|
213
|
+
this.app.use((req, res, next) => {
|
|
214
|
+
const sessionId = req.headers['mcp-session-id'];
|
|
215
|
+
if (sessionId) {
|
|
216
|
+
req.sessionId = sessionId;
|
|
217
|
+
}
|
|
218
|
+
next();
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
setProfileContextProvider(provider) {
|
|
222
|
+
this.profileContextProvider = provider;
|
|
223
|
+
}
|
|
224
|
+
getDefaultProfileId() {
|
|
225
|
+
if (this.config.defaultProfileId) {
|
|
226
|
+
return this.config.defaultProfileId;
|
|
227
|
+
}
|
|
228
|
+
if (this.config.profileRoutingEnabled) {
|
|
229
|
+
return undefined;
|
|
230
|
+
}
|
|
231
|
+
return 'default';
|
|
232
|
+
}
|
|
233
|
+
buildDefaultProfileContext() {
|
|
234
|
+
const profileId = this.getDefaultProfileId();
|
|
235
|
+
if (!profileId) {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
return {
|
|
239
|
+
profileId,
|
|
240
|
+
oauthConfig: this.config.oauthConfig,
|
|
241
|
+
authConfigs: this.config.authConfigs,
|
|
242
|
+
baseUrl: this.config.baseUrl,
|
|
243
|
+
rateLimitOAuthMax: this.config.rateLimitOAuthMax,
|
|
244
|
+
rateLimitOAuthWindowMs: this.config.rateLimitOAuthWindowMs,
|
|
245
|
+
resourceName: this.config.resourceName,
|
|
246
|
+
resourceDocumentation: this.config.resourceDocumentation,
|
|
247
|
+
parser: this.config.parser,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
async getProfileState(profileId) {
|
|
251
|
+
const existing = this.profileStates.get(profileId);
|
|
252
|
+
if (existing) {
|
|
253
|
+
return existing;
|
|
254
|
+
}
|
|
255
|
+
let context = null;
|
|
256
|
+
if (this.profileContextProvider) {
|
|
257
|
+
try {
|
|
258
|
+
context = await this.profileContextProvider(profileId);
|
|
259
|
+
}
|
|
260
|
+
catch (error) {
|
|
261
|
+
if (error instanceof ConfigurationError && error.message === 'Profile not found') {
|
|
262
|
+
this.logger.warn('Profile not found during request', { profileId });
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
throw error;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
const defaultContext = this.buildDefaultProfileContext();
|
|
270
|
+
if (defaultContext?.profileId === profileId) {
|
|
271
|
+
context = defaultContext;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (!context) {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
let oauthProvider = null;
|
|
278
|
+
if (context.oauthConfig) {
|
|
279
|
+
this.logger.info('Initializing OAuth provider with config', {
|
|
280
|
+
profileId,
|
|
281
|
+
hasClientId: !!context.oauthConfig.client_id,
|
|
282
|
+
});
|
|
283
|
+
oauthProvider = new ExternalOAuthProvider(context.oauthConfig, this.logger);
|
|
284
|
+
this.logger.info('OAuth provider initialized', {
|
|
285
|
+
profileId,
|
|
286
|
+
endpoint: oauthProvider.authorizationEndpoint || '(to be derived from issuer)',
|
|
287
|
+
hasIssuer: !!context.oauthConfig.issuer,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
else {
|
|
291
|
+
this.logger.info('No OAuth config provided - OAuth provider not initialized', { profileId });
|
|
292
|
+
}
|
|
293
|
+
const state = {
|
|
294
|
+
profileId,
|
|
295
|
+
context,
|
|
296
|
+
oauthProvider,
|
|
297
|
+
oauthTokensByAccessToken: new Map(),
|
|
298
|
+
sessions: new Map(),
|
|
299
|
+
};
|
|
300
|
+
this.profileStates.set(profileId, state);
|
|
301
|
+
return state;
|
|
302
|
+
}
|
|
303
|
+
getProfileIdForRequest(req) {
|
|
304
|
+
if (req.profileId) {
|
|
305
|
+
return req.profileId;
|
|
306
|
+
}
|
|
307
|
+
return this.getDefaultProfileId();
|
|
308
|
+
}
|
|
309
|
+
async getProfileStateForRequest(req) {
|
|
310
|
+
const profileId = this.getProfileIdForRequest(req);
|
|
311
|
+
if (!profileId) {
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
return await this.getProfileState(profileId);
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Check if origin is allowed
|
|
318
|
+
*
|
|
319
|
+
* Why: Prevent DNS rebinding attacks
|
|
320
|
+
*
|
|
321
|
+
* Supports:
|
|
322
|
+
* - Exact hostname: 'example.com', 'api.example.com'
|
|
323
|
+
* - Wildcard subdomain: '*.example.com'
|
|
324
|
+
* - IPv4 CIDR: '192.168.1.0/24', '10.0.0.0/8'
|
|
325
|
+
* - IPv4 exact: '192.168.1.100'
|
|
326
|
+
*/
|
|
327
|
+
isAllowedOrigin(origin) {
|
|
328
|
+
try {
|
|
329
|
+
const url = new URL(origin);
|
|
330
|
+
const hostname = url.hostname;
|
|
331
|
+
// Always allow localhost
|
|
332
|
+
if (hostname === 'localhost' || hostname === '127.0.0.1') {
|
|
333
|
+
return true;
|
|
334
|
+
}
|
|
335
|
+
// Allow configured host
|
|
336
|
+
if (hostname === this.config.host) {
|
|
337
|
+
return true;
|
|
338
|
+
}
|
|
339
|
+
// Allow OAuth redirect URI hosts (initialized + cached + configured)
|
|
340
|
+
for (const redirectHost of this.getOAuthRedirectHostPatterns()) {
|
|
341
|
+
if (this.matchOrigin(hostname, redirectHost)) {
|
|
342
|
+
return true;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
// Check custom allowed origins
|
|
346
|
+
if (this.config.allowedOrigins && this.config.allowedOrigins.length > 0) {
|
|
347
|
+
for (const allowed of this.config.allowedOrigins) {
|
|
348
|
+
if (this.matchOrigin(hostname, allowed)) {
|
|
349
|
+
return true;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return false;
|
|
354
|
+
}
|
|
355
|
+
catch {
|
|
356
|
+
return false;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
getOAuthRedirectHostPatterns() {
|
|
360
|
+
const hosts = new Set();
|
|
361
|
+
const defaultProfileId = this.getDefaultProfileId() ?? 'default';
|
|
362
|
+
for (const state of this.profileStates.values()) {
|
|
363
|
+
const redirectUri = state.oauthProvider?.redirectUri;
|
|
364
|
+
if (!redirectUri) {
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
try {
|
|
368
|
+
const redirectUrl = new URL(redirectUri);
|
|
369
|
+
hosts.add(redirectUrl.hostname);
|
|
370
|
+
}
|
|
371
|
+
catch {
|
|
372
|
+
// Ignore invalid URL
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
if (this.config.oauthConfig) {
|
|
376
|
+
for (const pattern of this.extractRedirectHostPatterns(this.config.oauthConfig, defaultProfileId)) {
|
|
377
|
+
hosts.add(pattern);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
for (const patterns of this.oauthRedirectHostCache.values()) {
|
|
381
|
+
for (const pattern of patterns) {
|
|
382
|
+
hosts.add(pattern);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
return Array.from(hosts);
|
|
386
|
+
}
|
|
387
|
+
extractRedirectHostPatterns(oauthConfig, profileId) {
|
|
388
|
+
if (!oauthConfig?.redirect_uri) {
|
|
389
|
+
return [];
|
|
390
|
+
}
|
|
391
|
+
const resolvedRedirectUri = this.resolveRedirectUriFromEnv(oauthConfig.redirect_uri, profileId);
|
|
392
|
+
if (!resolvedRedirectUri) {
|
|
393
|
+
return [];
|
|
394
|
+
}
|
|
395
|
+
try {
|
|
396
|
+
const redirectUrl = new URL(resolvedRedirectUri);
|
|
397
|
+
return [redirectUrl.hostname];
|
|
398
|
+
}
|
|
399
|
+
catch {
|
|
400
|
+
return [];
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
resolveRedirectUriFromEnv(value, profileId) {
|
|
404
|
+
const match = value.match(/^\$\{env:([^}]+)\}$/);
|
|
405
|
+
if (!match) {
|
|
406
|
+
return value;
|
|
407
|
+
}
|
|
408
|
+
const envVar = match[1];
|
|
409
|
+
const envValue = process.env[envVar];
|
|
410
|
+
if (!envValue || envValue.trim().length === 0) {
|
|
411
|
+
const warningKey = `${profileId}:${envVar}`;
|
|
412
|
+
if (!this.warnedMissingOAuthRedirectEnvVars.has(warningKey)) {
|
|
413
|
+
this.warnedMissingOAuthRedirectEnvVars.add(warningKey);
|
|
414
|
+
this.logger.warn('OAuth redirect_uri environment variable is empty', {
|
|
415
|
+
profileId,
|
|
416
|
+
envVar,
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
return undefined;
|
|
420
|
+
}
|
|
421
|
+
return envValue;
|
|
422
|
+
}
|
|
423
|
+
resolveProfileIdFromPath(pathname) {
|
|
424
|
+
if (!this.config.profileRoutingEnabled) {
|
|
425
|
+
return null;
|
|
426
|
+
}
|
|
427
|
+
const match = /^\/profile\/([^/]+)/.exec(pathname);
|
|
428
|
+
if (!match) {
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
431
|
+
try {
|
|
432
|
+
return decodeURIComponent(match[1]);
|
|
433
|
+
}
|
|
434
|
+
catch {
|
|
435
|
+
return null;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
getClientHintKey(req) {
|
|
439
|
+
const userAgent = req.get('user-agent') || '';
|
|
440
|
+
return `${req.ip}|${userAgent}`;
|
|
441
|
+
}
|
|
442
|
+
storeProfileHint(req, profileId) {
|
|
443
|
+
const key = this.getClientHintKey(req);
|
|
444
|
+
this.profileHintsByClient.set(key, { profileId, lastSeen: Date.now() });
|
|
445
|
+
}
|
|
446
|
+
resolveProfileIdFromHint(req) {
|
|
447
|
+
const key = this.getClientHintKey(req);
|
|
448
|
+
const hint = this.profileHintsByClient.get(key);
|
|
449
|
+
if (!hint) {
|
|
450
|
+
return null;
|
|
451
|
+
}
|
|
452
|
+
if (Date.now() - hint.lastSeen > HttpTransport.PROFILE_HINT_TTL_MS) {
|
|
453
|
+
this.profileHintsByClient.delete(key);
|
|
454
|
+
return null;
|
|
455
|
+
}
|
|
456
|
+
return hint.profileId;
|
|
457
|
+
}
|
|
458
|
+
resolveProfileIdForOriginCheck(req) {
|
|
459
|
+
const pathProfileId = this.resolveProfileIdFromPath(req.path);
|
|
460
|
+
if (pathProfileId) {
|
|
461
|
+
return pathProfileId;
|
|
462
|
+
}
|
|
463
|
+
const resource = req.query?.resource;
|
|
464
|
+
if (typeof resource === 'string') {
|
|
465
|
+
const info = this.resolveProfileInfoFromResourceUrl(resource);
|
|
466
|
+
if (info?.profileId) {
|
|
467
|
+
return info.profileId;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
return this.getDefaultProfileId() ?? this.resolveProfileIdFromHint(req) ?? null;
|
|
471
|
+
}
|
|
472
|
+
async primeOAuthRedirectHosts(profileId) {
|
|
473
|
+
if (!this.profileContextProvider) {
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
if (this.oauthRedirectHostCache.has(profileId)) {
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
try {
|
|
480
|
+
const context = await this.profileContextProvider(profileId);
|
|
481
|
+
if (!context?.oauthConfig) {
|
|
482
|
+
this.oauthRedirectHostCache.set(profileId, []);
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
const patterns = this.extractRedirectHostPatterns(context.oauthConfig, profileId);
|
|
486
|
+
this.oauthRedirectHostCache.set(profileId, patterns);
|
|
487
|
+
}
|
|
488
|
+
catch (error) {
|
|
489
|
+
if (error instanceof ConfigurationError && error.message === 'Profile not found') {
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
this.logger.warn('Failed to preload OAuth redirect hosts', {
|
|
493
|
+
profileId,
|
|
494
|
+
error: String(error),
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
async isAllowedOriginForRequest(origin, req) {
|
|
499
|
+
if (this.isAllowedOrigin(origin)) {
|
|
500
|
+
return true;
|
|
501
|
+
}
|
|
502
|
+
if (!this.config.profileRoutingEnabled) {
|
|
503
|
+
return false;
|
|
504
|
+
}
|
|
505
|
+
const profileId = this.resolveProfileIdForOriginCheck(req);
|
|
506
|
+
if (!profileId) {
|
|
507
|
+
return false;
|
|
508
|
+
}
|
|
509
|
+
await this.primeOAuthRedirectHosts(profileId);
|
|
510
|
+
return this.isAllowedOrigin(origin);
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* Match hostname against allowed origin pattern
|
|
514
|
+
*
|
|
515
|
+
* Supports:
|
|
516
|
+
* - Exact match: 'example.com' === 'example.com'
|
|
517
|
+
* - Wildcard: '*.example.com' matches 'api.example.com', 'web.example.com'
|
|
518
|
+
* - CIDR: '192.168.1.0/24' matches '192.168.1.1' through '192.168.1.254'
|
|
519
|
+
*/
|
|
520
|
+
matchOrigin(hostname, pattern) {
|
|
521
|
+
const normalizedHost = this.stripIpv6Brackets(hostname);
|
|
522
|
+
const normalizedPattern = this.stripIpv6Brackets(pattern);
|
|
523
|
+
// Exact match
|
|
524
|
+
if (normalizedHost === normalizedPattern) {
|
|
525
|
+
return true;
|
|
526
|
+
}
|
|
527
|
+
// Wildcard subdomain match (*.example.com)
|
|
528
|
+
if (normalizedPattern.startsWith('*.')) {
|
|
529
|
+
const domain = normalizedPattern.substring(2); // Remove '*.'
|
|
530
|
+
return normalizedHost.endsWith('.' + domain) || normalizedHost === domain;
|
|
531
|
+
}
|
|
532
|
+
// CIDR match (IPv4/IPv6)
|
|
533
|
+
if (normalizedPattern.includes('/')) {
|
|
534
|
+
return this.matchCIDR(normalizedHost, normalizedPattern);
|
|
535
|
+
}
|
|
536
|
+
return false;
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* Check if IP address is within CIDR range (IPv4 or IPv6)
|
|
540
|
+
*
|
|
541
|
+
* Example: '192.168.1.50' matches '192.168.1.0/24'
|
|
542
|
+
* '2001:db8::1' matches '2001:db8::/32'
|
|
543
|
+
*/
|
|
544
|
+
matchCIDR(ip, cidr) {
|
|
545
|
+
const [rawRange, bits] = cidr.split('/');
|
|
546
|
+
const range = this.stripIpv6Brackets(rawRange);
|
|
547
|
+
const maskBits = parseInt(bits, 10);
|
|
548
|
+
if (isNaN(maskBits)) {
|
|
549
|
+
return false;
|
|
550
|
+
}
|
|
551
|
+
const ipVersion = isIP(ip);
|
|
552
|
+
const rangeVersion = isIP(range);
|
|
553
|
+
if (ipVersion === 0 || rangeVersion === 0 || ipVersion !== rangeVersion) {
|
|
554
|
+
return false;
|
|
555
|
+
}
|
|
556
|
+
if (ipVersion === 4) {
|
|
557
|
+
if (maskBits < 0 || maskBits > 32) {
|
|
558
|
+
this.logger.warn('Invalid CIDR mask bits', { cidr });
|
|
559
|
+
return false;
|
|
560
|
+
}
|
|
561
|
+
const ipInt = this.ipv4ToInt(ip);
|
|
562
|
+
const rangeInt = this.ipv4ToInt(range);
|
|
563
|
+
/* c8 ignore start - defensive check for edge cases where isIP() passes but parsing fails
|
|
564
|
+
* This should never happen in practice, but serves as a fail-safe */
|
|
565
|
+
if (ipInt === null || rangeInt === null) {
|
|
566
|
+
return false;
|
|
567
|
+
}
|
|
568
|
+
/* c8 ignore end */
|
|
569
|
+
const mask = (0xFFFFFFFF << (32 - maskBits)) >>> 0;
|
|
570
|
+
return (ipInt & mask) === (rangeInt & mask);
|
|
571
|
+
}
|
|
572
|
+
if (maskBits < 0 || maskBits > 128) {
|
|
573
|
+
this.logger.warn('Invalid IPv6 CIDR mask bits', { cidr });
|
|
574
|
+
return false;
|
|
575
|
+
}
|
|
576
|
+
const ipInt = this.ipv6ToBigInt(ip);
|
|
577
|
+
const rangeInt = this.ipv6ToBigInt(range);
|
|
578
|
+
/* c8 ignore start - defensive check for edge cases where isIP() passes but parsing fails
|
|
579
|
+
* This should never happen in practice, but serves as a fail-safe */
|
|
580
|
+
if (ipInt === null || rangeInt === null) {
|
|
581
|
+
return false;
|
|
582
|
+
}
|
|
583
|
+
/* c8 ignore end */
|
|
584
|
+
const mask = this.ipv6Mask(maskBits);
|
|
585
|
+
return (ipInt & mask) === (rangeInt & mask);
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* Convert IPv4 address to 32-bit integer
|
|
589
|
+
*
|
|
590
|
+
* Example: '192.168.1.1' -> 3232235777
|
|
591
|
+
*/
|
|
592
|
+
ipv4ToInt(ip) {
|
|
593
|
+
const parts = ip.split('.');
|
|
594
|
+
if (parts.length !== 4) {
|
|
595
|
+
return null;
|
|
596
|
+
}
|
|
597
|
+
let result = 0;
|
|
598
|
+
for (let i = 0; i < 4; i++) {
|
|
599
|
+
const octet = parseInt(parts[i], 10);
|
|
600
|
+
if (isNaN(octet) || octet < 0 || octet > 255) {
|
|
601
|
+
return null;
|
|
602
|
+
}
|
|
603
|
+
result = (result << 8) | octet;
|
|
604
|
+
}
|
|
605
|
+
return result >>> 0; // Unsigned
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* Convert IPv6 address to 128-bit BigInt
|
|
609
|
+
*/
|
|
610
|
+
ipv6ToBigInt(ip) {
|
|
611
|
+
const cleaned = this.stripIpv6Brackets(ip);
|
|
612
|
+
// Handle IPv4-mapped IPv6 (e.g., ::ffff:192.168.0.1)
|
|
613
|
+
let ipv4Tail = null;
|
|
614
|
+
let base = cleaned;
|
|
615
|
+
if (cleaned.includes('.')) {
|
|
616
|
+
const lastColon = cleaned.lastIndexOf(':');
|
|
617
|
+
if (lastColon === -1)
|
|
618
|
+
return null;
|
|
619
|
+
const ipv4Part = cleaned.slice(lastColon + 1);
|
|
620
|
+
ipv4Tail = this.ipv4ToInt(ipv4Part);
|
|
621
|
+
if (ipv4Tail === null)
|
|
622
|
+
return null;
|
|
623
|
+
base = cleaned.slice(0, lastColon);
|
|
624
|
+
}
|
|
625
|
+
const parts = base.split('::');
|
|
626
|
+
if (parts.length > 2) {
|
|
627
|
+
return null;
|
|
628
|
+
}
|
|
629
|
+
const head = parts[0] ? parts[0].split(':') : [];
|
|
630
|
+
const tail = parts.length === 2 && parts[1] ? parts[1].split(':') : [];
|
|
631
|
+
if (head.some(p => p === '') || tail.some(p => p === '')) {
|
|
632
|
+
return null;
|
|
633
|
+
}
|
|
634
|
+
const totalSegmentsNeeded = 8 - (ipv4Tail !== null ? 2 : 0);
|
|
635
|
+
let segments = [];
|
|
636
|
+
const parseHextets = (items) => {
|
|
637
|
+
const result = [];
|
|
638
|
+
for (const part of items) {
|
|
639
|
+
const num = parseInt(part || '0', 16);
|
|
640
|
+
if (isNaN(num) || num < 0 || num > 0xFFFF) {
|
|
641
|
+
return null;
|
|
642
|
+
}
|
|
643
|
+
result.push(num);
|
|
644
|
+
}
|
|
645
|
+
return result;
|
|
646
|
+
};
|
|
647
|
+
const headVals = parseHextets(head);
|
|
648
|
+
const tailVals = parseHextets(tail);
|
|
649
|
+
if (!headVals || !tailVals) {
|
|
650
|
+
return null;
|
|
651
|
+
}
|
|
652
|
+
const missing = totalSegmentsNeeded - (headVals.length + tailVals.length);
|
|
653
|
+
if (missing < 0) {
|
|
654
|
+
return null;
|
|
655
|
+
}
|
|
656
|
+
segments = [...headVals, ...Array(missing).fill(0), ...tailVals];
|
|
657
|
+
/* c8 ignore start - defensive check that should never trigger if logic above is correct */
|
|
658
|
+
if (segments.length !== totalSegmentsNeeded) {
|
|
659
|
+
return null;
|
|
660
|
+
}
|
|
661
|
+
/* c8 ignore end */
|
|
662
|
+
if (ipv4Tail !== null) {
|
|
663
|
+
const high = (ipv4Tail >>> 16) & 0xFFFF;
|
|
664
|
+
const low = ipv4Tail & 0xFFFF;
|
|
665
|
+
segments.push(high, low);
|
|
666
|
+
}
|
|
667
|
+
/* c8 ignore start - defensive check that should never trigger if logic above is correct */
|
|
668
|
+
if (segments.length !== 8) {
|
|
669
|
+
return null;
|
|
670
|
+
}
|
|
671
|
+
/* c8 ignore end */
|
|
672
|
+
let value = 0n;
|
|
673
|
+
for (const part of segments) {
|
|
674
|
+
value = (value << 16n) + BigInt(part);
|
|
675
|
+
}
|
|
676
|
+
return value;
|
|
677
|
+
}
|
|
678
|
+
ipv6Mask(maskBits) {
|
|
679
|
+
if (maskBits === 0) {
|
|
680
|
+
return 0n;
|
|
681
|
+
}
|
|
682
|
+
const ones = (1n << BigInt(maskBits)) - 1n;
|
|
683
|
+
return BigInt.asUintN(128, ones << BigInt(128 - maskBits));
|
|
684
|
+
}
|
|
685
|
+
stripIpv6Brackets(value) {
|
|
686
|
+
return value.replace(/^\[/, '').replace(/\]$/, '');
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* Create configured rate limiter or a passthrough handler when disabled
|
|
690
|
+
*
|
|
691
|
+
* Why: Both MCP and metrics endpoints share the same rate limiting setup logic.
|
|
692
|
+
* Centralizing it keeps behaviour consistent and avoids drifting configuration.
|
|
693
|
+
*/
|
|
694
|
+
createRateLimiter(options) {
|
|
695
|
+
if (!options.enabled) {
|
|
696
|
+
return (_req, _res, next) => next();
|
|
697
|
+
}
|
|
698
|
+
const message = options.responseMessage ??
|
|
699
|
+
`Rate limit exceeded. Max ${options.maxRequests} requests per ${options.windowMs / 1000} seconds.`;
|
|
700
|
+
return rateLimit({
|
|
701
|
+
windowMs: options.windowMs,
|
|
702
|
+
max: options.maxRequests,
|
|
703
|
+
standardHeaders: true, // Return rate limit info in `RateLimit-*` headers
|
|
704
|
+
legacyHeaders: false, // Disable deprecated `X-RateLimit-*` headers
|
|
705
|
+
handler: (req, res) => {
|
|
706
|
+
this.logger.warn(options.logMessage, {
|
|
707
|
+
ip: req.ip,
|
|
708
|
+
path: req.path,
|
|
709
|
+
method: req.method,
|
|
710
|
+
});
|
|
711
|
+
res.status(HTTP_STATUS.TOO_MANY_REQUESTS).json({
|
|
712
|
+
error: 'Too Many Requests',
|
|
713
|
+
message,
|
|
714
|
+
});
|
|
715
|
+
},
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
formatRateLimitMessage(scope, maxRequests, windowMs) {
|
|
719
|
+
return `Rate limit exceeded for ${scope}. Max ${maxRequests} requests per ${windowMs / 1000} seconds.`;
|
|
720
|
+
}
|
|
721
|
+
getProfilePrefix(profileId, options) {
|
|
722
|
+
if (!this.config.profileRoutingEnabled) {
|
|
723
|
+
return '';
|
|
724
|
+
}
|
|
725
|
+
if (options?.forceProfilePrefix && profileId) {
|
|
726
|
+
return `/profile/${encodeURIComponent(profileId)}`;
|
|
727
|
+
}
|
|
728
|
+
const defaultProfileId = this.getDefaultProfileId();
|
|
729
|
+
if (!profileId || (defaultProfileId && profileId === defaultProfileId)) {
|
|
730
|
+
return '';
|
|
731
|
+
}
|
|
732
|
+
return `/profile/${encodeURIComponent(profileId)}`;
|
|
733
|
+
}
|
|
734
|
+
buildProfilePath(profileId, path, options) {
|
|
735
|
+
const prefix = this.getProfilePrefix(profileId, options);
|
|
736
|
+
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
|
737
|
+
return `${prefix}${normalizedPath}`;
|
|
738
|
+
}
|
|
739
|
+
getServerOrigin(profileId) {
|
|
740
|
+
if (profileId) {
|
|
741
|
+
const state = this.profileStates.get(profileId);
|
|
742
|
+
if (state?.oauthProvider?.redirectUri) {
|
|
743
|
+
try {
|
|
744
|
+
return new URL(state.oauthProvider.redirectUri).origin;
|
|
745
|
+
}
|
|
746
|
+
catch {
|
|
747
|
+
// Ignore invalid URL
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
const protocol = this.config.host.includes('://') ? '' : 'http://';
|
|
752
|
+
const host = this.config.host.includes('://') ? this.config.host : this.config.host;
|
|
753
|
+
return `${protocol}${host}:${this.config.port}`;
|
|
754
|
+
}
|
|
755
|
+
buildProfileUrl(profileId, path, options) {
|
|
756
|
+
return `${this.getServerOrigin(profileId)}${this.buildProfilePath(profileId, path, options)}`;
|
|
757
|
+
}
|
|
758
|
+
normalizeResourcePath(pathname) {
|
|
759
|
+
const normalized = pathname.replace(/\/+$/, '');
|
|
760
|
+
return normalized === '' ? '/' : normalized;
|
|
761
|
+
}
|
|
762
|
+
resolveProfileInfoFromResourceUrl(resource) {
|
|
763
|
+
let url;
|
|
764
|
+
try {
|
|
765
|
+
url = new URL(resource);
|
|
766
|
+
}
|
|
767
|
+
catch {
|
|
768
|
+
return null;
|
|
769
|
+
}
|
|
770
|
+
const path = this.normalizeResourcePath(url.pathname);
|
|
771
|
+
if (path === '/mcp') {
|
|
772
|
+
const defaultProfileId = this.getDefaultProfileId();
|
|
773
|
+
if (!defaultProfileId) {
|
|
774
|
+
return null;
|
|
775
|
+
}
|
|
776
|
+
return { profileId: defaultProfileId, forceProfilePrefix: false };
|
|
777
|
+
}
|
|
778
|
+
if (!this.config.profileRoutingEnabled) {
|
|
779
|
+
return null;
|
|
780
|
+
}
|
|
781
|
+
const match = /^\/profile\/([^/]+)\/mcp$/.exec(path);
|
|
782
|
+
if (!match) {
|
|
783
|
+
return null;
|
|
784
|
+
}
|
|
785
|
+
try {
|
|
786
|
+
return { profileId: decodeURIComponent(match[1]), forceProfilePrefix: true };
|
|
787
|
+
}
|
|
788
|
+
catch {
|
|
789
|
+
return null;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
resolveProfileIdFromResourceUrl(resource) {
|
|
793
|
+
return this.resolveProfileInfoFromResourceUrl(resource)?.profileId ?? null;
|
|
794
|
+
}
|
|
795
|
+
getOAuthProtectedResourceUrl(profileId) {
|
|
796
|
+
const effectiveProfileId = profileId ?? this.getDefaultProfileId();
|
|
797
|
+
return this.buildProfileUrl(effectiveProfileId, OAUTH_PATHS.WELL_KNOWN_PROTECTED_RESOURCE);
|
|
798
|
+
}
|
|
799
|
+
respondProfileNotFound(res, profileId) {
|
|
800
|
+
res.status(HTTP_STATUS.NOT_FOUND).json({
|
|
801
|
+
error: 'Not Found',
|
|
802
|
+
message: profileId ? `Profile '${profileId}' not found` : 'Profile not found',
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
/**
|
|
806
|
+
* Setup MCP endpoint routes
|
|
807
|
+
*
|
|
808
|
+
* Why: Single endpoint for POST (client→server) and GET (SSE stream)
|
|
809
|
+
*/
|
|
810
|
+
setupRoutes() {
|
|
811
|
+
this.logger.info('Setting up HTTP routes');
|
|
812
|
+
// Security: Rate limiting setup (needed for OAuth routes)
|
|
813
|
+
const rateLimitEnabled = this.config.rateLimitEnabled !== false; // default: true
|
|
814
|
+
const profileRoutingEnabled = this.config.profileRoutingEnabled === true;
|
|
815
|
+
const defaultProfileId = this.getDefaultProfileId();
|
|
816
|
+
const attachProfileId = (req, _res, next) => {
|
|
817
|
+
req.profileId = req.params.profileId;
|
|
818
|
+
next();
|
|
819
|
+
};
|
|
820
|
+
const oauthRateLimiterByProfile = new Map();
|
|
821
|
+
const getOAuthRateLimiter = (profileState) => {
|
|
822
|
+
const existing = oauthRateLimiterByProfile.get(profileState.profileId);
|
|
823
|
+
if (existing) {
|
|
824
|
+
return existing;
|
|
825
|
+
}
|
|
826
|
+
const oauthWindowMs = profileState.context.rateLimitOAuthWindowMs || OAUTH_RATE_LIMIT.WINDOW_MS;
|
|
827
|
+
const oauthMaxRequests = profileState.context.rateLimitOAuthMax || OAUTH_RATE_LIMIT.MAX_REQUESTS;
|
|
828
|
+
const limiter = this.createRateLimiter({
|
|
829
|
+
enabled: rateLimitEnabled,
|
|
830
|
+
windowMs: oauthWindowMs,
|
|
831
|
+
maxRequests: oauthMaxRequests,
|
|
832
|
+
logMessage: 'Rate limit exceeded for OAuth',
|
|
833
|
+
responseMessage: `Too many OAuth requests. Limit: ${oauthMaxRequests} requests per ${Math.round(oauthWindowMs / 60000)} minutes. Please try again later.`,
|
|
834
|
+
});
|
|
835
|
+
oauthRateLimiterByProfile.set(profileState.profileId, limiter);
|
|
836
|
+
return limiter;
|
|
837
|
+
};
|
|
838
|
+
const oauthRateLimiter = async (req, res, next) => {
|
|
839
|
+
const profileState = await this.getProfileStateForRequest(req);
|
|
840
|
+
if (!profileState) {
|
|
841
|
+
this.respondProfileNotFound(res, req.profileId);
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
const limiter = getOAuthRateLimiter(profileState);
|
|
845
|
+
// codeql[js/unvalidated-dynamic-method-call] limiter is a RequestHandler from createRateLimiter.
|
|
846
|
+
return limiter(req, res, next);
|
|
847
|
+
};
|
|
848
|
+
const withProfileState = (handler) => {
|
|
849
|
+
return async (req, res) => {
|
|
850
|
+
const profileState = await this.getProfileStateForRequest(req);
|
|
851
|
+
if (!profileState) {
|
|
852
|
+
this.respondProfileNotFound(res, req.profileId);
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
await handler(req, res, profileState);
|
|
856
|
+
};
|
|
857
|
+
};
|
|
858
|
+
const registerOAuthRoutes = (basePath, includeProfileParam, includeProtectedResource) => {
|
|
859
|
+
const middlewares = includeProfileParam ? [attachProfileId] : [];
|
|
860
|
+
const withOAuthRateLimit = [...middlewares, oauthRateLimiter];
|
|
861
|
+
const withProfile = (handler) => {
|
|
862
|
+
return [...middlewares, oauthRateLimiter, withProfileState(handler)];
|
|
863
|
+
};
|
|
864
|
+
if (includeProtectedResource) {
|
|
865
|
+
this.app.get(`${basePath}${OAUTH_PATHS.WELL_KNOWN_PROTECTED_RESOURCE}`, ...withProfile((req, res, profileState) => this.handleOAuthProtectedResource(req, res, profileState)));
|
|
866
|
+
}
|
|
867
|
+
this.app.get(`${basePath}${OAUTH_PATHS.AUTHORIZE}`, ...withProfile((req, res, profileState) => this.handleOAuthAuthorize(req, res, profileState)));
|
|
868
|
+
this.app.post(`${basePath}${OAUTH_PATHS.TOKEN}`, ...middlewares, oauthRateLimiter, express.urlencoded({ extended: false, limit: '50kb' }), withProfileState((req, res, profileState) => this.handleOAuthToken(req, res, profileState)));
|
|
869
|
+
this.app.get(`${basePath}${OAUTH_PATHS.CALLBACK}`, ...withProfile((req, res, profileState) => this.handleOAuthCallback(req, res, profileState)));
|
|
870
|
+
this.app.get(`${basePath}${OAUTH_PATHS.WELL_KNOWN_AUTHORIZATION_SERVER}`, ...withProfile((req, res, profileState) => this.handleOAuthAuthorizationServerMetadata(req, res, profileState)));
|
|
871
|
+
this.app.get(`${basePath}${OAUTH_PATHS.WELL_KNOWN_OPENID_CONFIGURATION}`, ...withProfile((req, res, profileState) => this.handleOAuthAuthorizationServerMetadata(req, res, profileState)));
|
|
872
|
+
this.app.post(`${basePath}${OAUTH_PATHS.REGISTER}`, ...middlewares, oauthRateLimiter, express.json(), withProfileState((req, res, profileState) => this.handleOAuthRegister(req, res, profileState)));
|
|
873
|
+
this.logger.info('OAuth routes registered', {
|
|
874
|
+
basePath,
|
|
875
|
+
profileRoutingEnabled,
|
|
876
|
+
});
|
|
877
|
+
};
|
|
878
|
+
if (defaultProfileId) {
|
|
879
|
+
registerOAuthRoutes('', false, false);
|
|
880
|
+
}
|
|
881
|
+
if (profileRoutingEnabled) {
|
|
882
|
+
registerOAuthRoutes('/profile/:profileId', true, true);
|
|
883
|
+
}
|
|
884
|
+
const attachProfileFromResourceQuery = (req, res, next) => {
|
|
885
|
+
const { resource } = req.query;
|
|
886
|
+
if (resource === undefined) {
|
|
887
|
+
next();
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
if (typeof resource !== 'string') {
|
|
891
|
+
res.status(HTTP_STATUS.BAD_REQUEST).json({
|
|
892
|
+
error: 'invalid_request',
|
|
893
|
+
message: 'Invalid resource query parameter',
|
|
894
|
+
});
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
const info = this.resolveProfileInfoFromResourceUrl(resource);
|
|
898
|
+
if (!info?.profileId) {
|
|
899
|
+
res.status(HTTP_STATUS.NOT_FOUND).json({
|
|
900
|
+
error: 'Not Found',
|
|
901
|
+
message: 'OAuth metadata unavailable for requested resource',
|
|
902
|
+
});
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
req.profileId = info.profileId;
|
|
906
|
+
req.forceProfilePrefix = info.forceProfilePrefix;
|
|
907
|
+
this.storeProfileHint(req, info.profileId);
|
|
908
|
+
next();
|
|
909
|
+
};
|
|
910
|
+
const attachProfileFromHint = (req, res, next) => {
|
|
911
|
+
const profileId = this.resolveProfileIdFromHint(req);
|
|
912
|
+
if (!profileId) {
|
|
913
|
+
res.status(HTTP_STATUS.NOT_FOUND).json({
|
|
914
|
+
error: 'Not Found',
|
|
915
|
+
message: 'OAuth metadata unavailable for requested resource',
|
|
916
|
+
});
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
req.profileId = profileId;
|
|
920
|
+
req.forceProfilePrefix = true;
|
|
921
|
+
next();
|
|
922
|
+
};
|
|
923
|
+
if (defaultProfileId || profileRoutingEnabled) {
|
|
924
|
+
this.app.get(OAUTH_PATHS.WELL_KNOWN_PROTECTED_RESOURCE, attachProfileFromResourceQuery, oauthRateLimiter, withProfileState((req, res, profileState) => this.handleOAuthProtectedResource(req, res, profileState)));
|
|
925
|
+
}
|
|
926
|
+
if (profileRoutingEnabled) {
|
|
927
|
+
this.app.get('/.well-known/oauth-protected-resource', attachProfileFromResourceQuery, oauthRateLimiter, withProfileState((req, res, profileState) => this.handleOAuthProtectedResource(req, res, profileState)));
|
|
928
|
+
this.app.get('/.well-known/oauth-protected-resource/profile/:profileId/mcp', attachProfileId, oauthRateLimiter, withProfileState((req, res, profileState) => this.handleOAuthProtectedResource(req, res, profileState)));
|
|
929
|
+
if (!defaultProfileId) {
|
|
930
|
+
this.app.get(OAUTH_PATHS.WELL_KNOWN_AUTHORIZATION_SERVER, attachProfileFromHint, oauthRateLimiter, withProfileState((req, res, profileState) => this.handleOAuthAuthorizationServerMetadata(req, res, profileState)));
|
|
931
|
+
this.app.get(OAUTH_PATHS.WELL_KNOWN_OPENID_CONFIGURATION, attachProfileFromHint, oauthRateLimiter, withProfileState((req, res, profileState) => this.handleOAuthAuthorizationServerMetadata(req, res, profileState)));
|
|
932
|
+
this.app.post(OAUTH_PATHS.REGISTER, attachProfileFromHint, oauthRateLimiter, express.json(), withProfileState((req, res, profileState) => this.handleOAuthRegister(req, res, profileState)));
|
|
933
|
+
this.app.get(OAUTH_PATHS.AUTHORIZE, attachProfileFromHint, oauthRateLimiter, withProfileState((req, res, profileState) => this.handleOAuthAuthorize(req, res, profileState)));
|
|
934
|
+
this.app.post(OAUTH_PATHS.TOKEN, attachProfileFromHint, oauthRateLimiter, express.urlencoded({ extended: false, limit: '50kb' }), withProfileState((req, res, profileState) => this.handleOAuthToken(req, res, profileState)));
|
|
935
|
+
this.app.get(OAUTH_PATHS.CALLBACK, attachProfileFromHint, oauthRateLimiter, withProfileState((req, res, profileState) => this.handleOAuthCallback(req, res, profileState)));
|
|
936
|
+
}
|
|
937
|
+
this.app.get(`${OAUTH_PATHS.WELL_KNOWN_AUTHORIZATION_SERVER}/profile/:profileId`, attachProfileId, oauthRateLimiter, withProfileState((req, res, profileState) => this.handleOAuthAuthorizationServerMetadata(req, res, profileState)));
|
|
938
|
+
this.app.get(`${OAUTH_PATHS.WELL_KNOWN_OPENID_CONFIGURATION}/profile/:profileId`, attachProfileId, oauthRateLimiter, withProfileState((req, res, profileState) => this.handleOAuthAuthorizationServerMetadata(req, res, profileState)));
|
|
939
|
+
}
|
|
940
|
+
// Security: Rate limiting setup (for MCP endpoints)
|
|
941
|
+
const windowMs = this.config.rateLimitWindowMs || TIMEOUTS.RATE_LIMIT_WINDOW_MS;
|
|
942
|
+
const maxRequests = this.config.rateLimitMaxRequests || 100; // 100 req/min
|
|
943
|
+
const metricsMaxRequests = this.config.rateLimitMetricsMax || 10; // 10 req/min for metrics
|
|
944
|
+
if (rateLimitEnabled) {
|
|
945
|
+
this.logger.info('Rate limiting enabled', {
|
|
946
|
+
windowMs,
|
|
947
|
+
maxRequests,
|
|
948
|
+
metricsMaxRequests,
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
// Rate limiter for MCP/SSE endpoints (100 req/min by default)
|
|
952
|
+
const mcpRateLimiter = this.createRateLimiter({
|
|
953
|
+
enabled: rateLimitEnabled,
|
|
954
|
+
windowMs,
|
|
955
|
+
maxRequests,
|
|
956
|
+
logMessage: 'Rate limit exceeded',
|
|
957
|
+
});
|
|
958
|
+
// Rate limiter for metrics endpoint (10 req/min by default)
|
|
959
|
+
const metricsRateLimiter = this.createRateLimiter({
|
|
960
|
+
enabled: rateLimitEnabled,
|
|
961
|
+
windowMs,
|
|
962
|
+
maxRequests: metricsMaxRequests,
|
|
963
|
+
logMessage: 'Rate limit exceeded for metrics',
|
|
964
|
+
responseMessage: this.formatRateLimitMessage('metrics', metricsMaxRequests, windowMs),
|
|
965
|
+
});
|
|
966
|
+
const registerMcpRoutes = (basePath, includeProfileParam, isDefault) => {
|
|
967
|
+
const middlewares = includeProfileParam ? [attachProfileId] : [];
|
|
968
|
+
const pathPrefix = basePath || '';
|
|
969
|
+
// Main MCP endpoint - POST for sending messages
|
|
970
|
+
this.app.post(`${pathPrefix}/mcp`, ...middlewares, mcpRateLimiter, this.handlePost.bind(this));
|
|
971
|
+
// CORS preflight handler
|
|
972
|
+
this.app.options(`${pathPrefix}/mcp`, ...middlewares, async (req, res) => {
|
|
973
|
+
const origin = req.headers.origin;
|
|
974
|
+
try {
|
|
975
|
+
// Only send CORS headers for explicitly allowed origins; otherwise reject
|
|
976
|
+
if (origin && await this.isAllowedOriginForRequest(origin, req)) {
|
|
977
|
+
// nosemgrep: javascript.express.security.cors-misconfiguration.cors-misconfiguration
|
|
978
|
+
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
979
|
+
res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS');
|
|
980
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Accept, Mcp-Session-Id');
|
|
981
|
+
// We do not allow credentials; prevents cookie-based attacks by default
|
|
982
|
+
res.setHeader('Access-Control-Allow-Credentials', 'false');
|
|
983
|
+
res.setHeader('Access-Control-Max-Age', '86400'); // 24 hours cache
|
|
984
|
+
return res.status(HTTP_STATUS.OK).send();
|
|
985
|
+
}
|
|
986
|
+
// Disallowed origin: do not echo origin or emit permissive headers
|
|
987
|
+
res.status(HTTP_STATUS.FORBIDDEN).json({ error: 'Forbidden', message: 'Origin not allowed' });
|
|
988
|
+
}
|
|
989
|
+
catch (error) {
|
|
990
|
+
this.logger.error('Origin validation failed', error instanceof Error ? error : new Error(String(error)));
|
|
991
|
+
res.status(HTTP_STATUS.FORBIDDEN).json({ error: 'Forbidden', message: 'Origin not allowed' });
|
|
992
|
+
}
|
|
993
|
+
});
|
|
994
|
+
// Main MCP endpoint - GET for SSE streaming
|
|
995
|
+
this.app.get(`${pathPrefix}/mcp`, ...middlewares, mcpRateLimiter, this.handleGet.bind(this));
|
|
996
|
+
// Session termination
|
|
997
|
+
this.app.delete(`${pathPrefix}/mcp`, ...middlewares, mcpRateLimiter, this.handleDelete.bind(this));
|
|
998
|
+
// Legacy alias endpoints - deprecated
|
|
999
|
+
// Why: Backward compatibility for clients using /sse during migration
|
|
1000
|
+
this.app.post(`${pathPrefix}/sse`, ...middlewares, mcpRateLimiter, (req, res, next) => {
|
|
1001
|
+
this.logger.warn('Deprecated endpoint used: POST /sse. Please migrate to POST /mcp');
|
|
1002
|
+
this.logger.info('Handling POST /sse request');
|
|
1003
|
+
return this.handlePost(req, res, next);
|
|
1004
|
+
});
|
|
1005
|
+
this.app.get(`${pathPrefix}/sse`, ...middlewares, mcpRateLimiter, (req, res, next) => {
|
|
1006
|
+
this.logger.warn('Deprecated endpoint used: GET /sse. Please migrate to GET /mcp');
|
|
1007
|
+
this.logger.info(`Handling GET /sse request from: ${req.ip}`);
|
|
1008
|
+
return this.handleGet(req, res, next);
|
|
1009
|
+
});
|
|
1010
|
+
this.app.delete(`${pathPrefix}/sse`, ...middlewares, mcpRateLimiter, (req, res, next) => {
|
|
1011
|
+
this.logger.warn('Deprecated endpoint used: DELETE /sse. Please migrate to DELETE /mcp');
|
|
1012
|
+
return this.handleDelete(req, res, next);
|
|
1013
|
+
});
|
|
1014
|
+
if (isDefault) {
|
|
1015
|
+
this.logger.info('Registered MCP routes for default profile', { pathPrefix: pathPrefix || '/mcp' });
|
|
1016
|
+
}
|
|
1017
|
+
else {
|
|
1018
|
+
this.logger.info('Registered MCP routes for profile routing', { pathPrefix: `${pathPrefix}/mcp` });
|
|
1019
|
+
}
|
|
1020
|
+
};
|
|
1021
|
+
if (defaultProfileId) {
|
|
1022
|
+
registerMcpRoutes('', false, true);
|
|
1023
|
+
}
|
|
1024
|
+
if (profileRoutingEnabled) {
|
|
1025
|
+
registerMcpRoutes('/profile/:profileId', true, false);
|
|
1026
|
+
}
|
|
1027
|
+
// Metrics endpoint (if enabled)
|
|
1028
|
+
if (this.config.metricsEnabled) {
|
|
1029
|
+
this.app.get(this.config.metricsPath, metricsRateLimiter, this.handleMetrics.bind(this));
|
|
1030
|
+
}
|
|
1031
|
+
// Health check (with rate limiting)
|
|
1032
|
+
this.app.get('/health', mcpRateLimiter, (req, res) => {
|
|
1033
|
+
const startTime = Date.now();
|
|
1034
|
+
let totalSessions = 0;
|
|
1035
|
+
for (const state of this.profileStates.values()) {
|
|
1036
|
+
totalSessions += state.sessions.size;
|
|
1037
|
+
}
|
|
1038
|
+
res.json({ status: 'ok', sessions: totalSessions });
|
|
1039
|
+
if (this.metrics) {
|
|
1040
|
+
const duration = (Date.now() - startTime) / 1000;
|
|
1041
|
+
this.metrics.recordHttpRequest(req.method, req.path, res.statusCode, duration);
|
|
1042
|
+
}
|
|
1043
|
+
});
|
|
1044
|
+
if (this.config.profileIndexEnabled) {
|
|
1045
|
+
this.app.get('/', async (req, res) => {
|
|
1046
|
+
await this.handleProfileIndex(req, res);
|
|
1047
|
+
});
|
|
1048
|
+
}
|
|
1049
|
+
// Debug: SSE route registered
|
|
1050
|
+
this.logger.info('SSE routes registered successfully');
|
|
1051
|
+
// Default 404 handler - MUST be last route registered
|
|
1052
|
+
// This will catch all unmatched requests
|
|
1053
|
+
this.app.use((req, res) => {
|
|
1054
|
+
this.logger.warn('Unhandled request (404)', {
|
|
1055
|
+
method: req.method,
|
|
1056
|
+
url: req.url,
|
|
1057
|
+
path: req.path,
|
|
1058
|
+
headers: req.headers,
|
|
1059
|
+
ip: req.ip
|
|
1060
|
+
});
|
|
1061
|
+
res.status(HTTP_STATUS.NOT_FOUND).json({
|
|
1062
|
+
error: 'Not Found',
|
|
1063
|
+
message: `Endpoint ${req.method} ${req.path} not found`
|
|
1064
|
+
});
|
|
1065
|
+
});
|
|
1066
|
+
}
|
|
1067
|
+
getProfileIssuerUrl(profileId, options) {
|
|
1068
|
+
const origin = this.getServerOrigin(profileId);
|
|
1069
|
+
const prefix = this.getProfilePrefix(profileId, options);
|
|
1070
|
+
return `${origin}${prefix}`;
|
|
1071
|
+
}
|
|
1072
|
+
getRequestOrigin(req) {
|
|
1073
|
+
const hostHeader = req.get('host');
|
|
1074
|
+
if (hostHeader) {
|
|
1075
|
+
return `${req.protocol}://${hostHeader}`;
|
|
1076
|
+
}
|
|
1077
|
+
return this.getServerOrigin();
|
|
1078
|
+
}
|
|
1079
|
+
async handleProfileIndex(req, res) {
|
|
1080
|
+
if (!this.config.profileRoutingEnabled || !this.config.profileIndexEnabled) {
|
|
1081
|
+
res.status(HTTP_STATUS.NOT_FOUND).json({
|
|
1082
|
+
error: 'Not Found',
|
|
1083
|
+
message: 'Endpoint GET / not found',
|
|
1084
|
+
});
|
|
1085
|
+
return;
|
|
1086
|
+
}
|
|
1087
|
+
if (!this.profileIndexProvider) {
|
|
1088
|
+
res.status(HTTP_STATUS.NOT_FOUND).json({
|
|
1089
|
+
error: 'Not Found',
|
|
1090
|
+
message: 'Profile index not available',
|
|
1091
|
+
});
|
|
1092
|
+
return;
|
|
1093
|
+
}
|
|
1094
|
+
const accept = req.headers.accept || '';
|
|
1095
|
+
const prefersJson = accept.includes('application/json') && !accept.includes('text/html');
|
|
1096
|
+
const locale = parseAcceptLanguage(req.headers['accept-language']);
|
|
1097
|
+
let profiles;
|
|
1098
|
+
try {
|
|
1099
|
+
profiles = await this.profileIndexProvider();
|
|
1100
|
+
}
|
|
1101
|
+
catch (error) {
|
|
1102
|
+
this.logger.error('Failed to load profile index', error instanceof Error ? error : new Error(String(error)));
|
|
1103
|
+
res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({
|
|
1104
|
+
error: 'Internal Server Error',
|
|
1105
|
+
message: 'Failed to load profile index',
|
|
1106
|
+
});
|
|
1107
|
+
return;
|
|
1108
|
+
}
|
|
1109
|
+
const origin = this.getRequestOrigin(req);
|
|
1110
|
+
const { payload, templateData } = buildProfileIndexPayload(profiles, origin, locale);
|
|
1111
|
+
if (prefersJson) {
|
|
1112
|
+
res.json(payload);
|
|
1113
|
+
return;
|
|
1114
|
+
}
|
|
1115
|
+
const template = await loadProfileIndexTemplate();
|
|
1116
|
+
const nonce = crypto.randomBytes(16).toString('base64');
|
|
1117
|
+
const html = renderProfileIndexHtml(template, templateData, nonce);
|
|
1118
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
1119
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
1120
|
+
res.setHeader('Content-Security-Policy', `default-src 'self'; frame-ancestors 'none'; object-src 'none'; base-uri 'self'; script-src 'self' 'nonce-${nonce}'; style-src 'self' 'nonce-${nonce}'`);
|
|
1121
|
+
res.send(html);
|
|
1122
|
+
}
|
|
1123
|
+
async handleOAuthProtectedResource(req, res, profileState) {
|
|
1124
|
+
if (!profileState.oauthProvider) {
|
|
1125
|
+
res.status(HTTP_STATUS.NOT_FOUND).json({ error: 'Not Found', message: 'OAuth not configured for this profile' });
|
|
1126
|
+
return;
|
|
1127
|
+
}
|
|
1128
|
+
const profileId = profileState.profileId;
|
|
1129
|
+
const isProfileScoped = typeof req.params?.profileId === 'string';
|
|
1130
|
+
const forceProfilePrefix = isProfileScoped || req.forceProfilePrefix === true;
|
|
1131
|
+
const urlOptions = forceProfilePrefix ? { forceProfilePrefix: true } : undefined;
|
|
1132
|
+
const serverUrl = new URL(this.buildProfileUrl(profileId, '/mcp', urlOptions));
|
|
1133
|
+
const issuerUrl = this.getProfileIssuerUrl(profileId, urlOptions);
|
|
1134
|
+
const metadata = {
|
|
1135
|
+
resource: serverUrl.href,
|
|
1136
|
+
authorization_servers: [issuerUrl],
|
|
1137
|
+
bearer_methods_supported: ['header'],
|
|
1138
|
+
};
|
|
1139
|
+
if (profileState.oauthProvider.scopes && profileState.oauthProvider.scopes.length > 0) {
|
|
1140
|
+
metadata.scopes_supported = profileState.oauthProvider.scopes;
|
|
1141
|
+
}
|
|
1142
|
+
if (profileState.context.resourceName) {
|
|
1143
|
+
metadata.resource_name = profileState.context.resourceName;
|
|
1144
|
+
}
|
|
1145
|
+
if (profileState.context.resourceDocumentation) {
|
|
1146
|
+
metadata.resource_documentation = profileState.context.resourceDocumentation;
|
|
1147
|
+
}
|
|
1148
|
+
res.json(metadata);
|
|
1149
|
+
}
|
|
1150
|
+
async handleOAuthAuthorize(req, res, profileState) {
|
|
1151
|
+
if (!profileState.oauthProvider) {
|
|
1152
|
+
res.status(HTTP_STATUS.NOT_FOUND).send('OAuth not configured for this profile');
|
|
1153
|
+
return;
|
|
1154
|
+
}
|
|
1155
|
+
try {
|
|
1156
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
1157
|
+
const { response_type, client_id, redirect_uri, scope, state, code_challenge, code_challenge_method } = req.query;
|
|
1158
|
+
if (!client_id || typeof client_id !== 'string') {
|
|
1159
|
+
res.status(HTTP_STATUS.BAD_REQUEST).send('Missing client_id');
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
1162
|
+
if (!response_type || typeof response_type !== 'string') {
|
|
1163
|
+
res.status(HTTP_STATUS.BAD_REQUEST).send('Missing response_type');
|
|
1164
|
+
return;
|
|
1165
|
+
}
|
|
1166
|
+
if (response_type !== 'code') {
|
|
1167
|
+
res.status(HTTP_STATUS.BAD_REQUEST).send('Unsupported response_type');
|
|
1168
|
+
return;
|
|
1169
|
+
}
|
|
1170
|
+
if (!redirect_uri || typeof redirect_uri !== 'string') {
|
|
1171
|
+
res.status(HTTP_STATUS.BAD_REQUEST).send('Missing redirect_uri');
|
|
1172
|
+
return;
|
|
1173
|
+
}
|
|
1174
|
+
await profileState.oauthProvider.ensureEndpointsInitialized();
|
|
1175
|
+
const client = await profileState.oauthProvider.clientsStore.getClient(client_id);
|
|
1176
|
+
if (!client) {
|
|
1177
|
+
res.status(HTTP_STATUS.BAD_REQUEST).send('Invalid client_id');
|
|
1178
|
+
return;
|
|
1179
|
+
}
|
|
1180
|
+
const scopeStr = (scope || '').trim();
|
|
1181
|
+
const params = {
|
|
1182
|
+
responseType: response_type,
|
|
1183
|
+
clientId: client_id,
|
|
1184
|
+
redirectUri: redirect_uri,
|
|
1185
|
+
scope: scopeStr ? scopeStr.split(' ') : [],
|
|
1186
|
+
state: state,
|
|
1187
|
+
codeChallenge: code_challenge,
|
|
1188
|
+
codeChallengeMethod: code_challenge_method,
|
|
1189
|
+
scopes: scopeStr ? scopeStr.split(' ') : [],
|
|
1190
|
+
};
|
|
1191
|
+
await profileState.oauthProvider.authorize(client, params, res);
|
|
1192
|
+
}
|
|
1193
|
+
catch (error) {
|
|
1194
|
+
this.logger.error('OAuth authorize error', error instanceof Error ? error : new Error(String(error)));
|
|
1195
|
+
res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send('OAuth authorization failed');
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
async handleOAuthToken(req, res, profileState) {
|
|
1199
|
+
if (!profileState.oauthProvider) {
|
|
1200
|
+
res.status(HTTP_STATUS.NOT_FOUND).json({ error: 'server_error', error_description: 'OAuth provider not initialized' });
|
|
1201
|
+
return;
|
|
1202
|
+
}
|
|
1203
|
+
try {
|
|
1204
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
1205
|
+
const { grant_type, code, redirect_uri, client_id, code_verifier, refresh_token } = req.body;
|
|
1206
|
+
this.logger.debug('OAuth token request', {
|
|
1207
|
+
profileId: profileState.profileId,
|
|
1208
|
+
grant_type,
|
|
1209
|
+
client_id,
|
|
1210
|
+
has_code: !!code,
|
|
1211
|
+
has_code_verifier: !!code_verifier,
|
|
1212
|
+
redirect_uri,
|
|
1213
|
+
});
|
|
1214
|
+
if (grant_type === 'authorization_code') {
|
|
1215
|
+
if (!code) {
|
|
1216
|
+
res.status(HTTP_STATUS.BAD_REQUEST).json({ error: 'invalid_request', error_description: 'Missing code' });
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
1219
|
+
await profileState.oauthProvider.ensureEndpointsInitialized();
|
|
1220
|
+
const client = await profileState.oauthProvider.clientsStore.getClient(client_id);
|
|
1221
|
+
if (!client) {
|
|
1222
|
+
res.status(HTTP_STATUS.BAD_REQUEST).json({ error: 'invalid_client' });
|
|
1223
|
+
return;
|
|
1224
|
+
}
|
|
1225
|
+
const tokens = await profileState.oauthProvider.exchangeAuthorizationCode(client, code, code_verifier, redirect_uri);
|
|
1226
|
+
this.storeOAuthTokens(profileState, tokens, client.client_id, client.scope?.split(' ') || []);
|
|
1227
|
+
res.json(tokens);
|
|
1228
|
+
return;
|
|
1229
|
+
}
|
|
1230
|
+
if (grant_type === 'refresh_token') {
|
|
1231
|
+
if (!refresh_token) {
|
|
1232
|
+
res.status(HTTP_STATUS.BAD_REQUEST).json({ error: 'invalid_request', error_description: 'Missing refresh_token' });
|
|
1233
|
+
return;
|
|
1234
|
+
}
|
|
1235
|
+
await profileState.oauthProvider.ensureEndpointsInitialized();
|
|
1236
|
+
const client = await profileState.oauthProvider.clientsStore.getClient(client_id);
|
|
1237
|
+
if (!client) {
|
|
1238
|
+
res.status(HTTP_STATUS.BAD_REQUEST).json({ error: 'invalid_client' });
|
|
1239
|
+
return;
|
|
1240
|
+
}
|
|
1241
|
+
const tokens = await profileState.oauthProvider.exchangeRefreshToken(client, refresh_token);
|
|
1242
|
+
this.storeOAuthTokens(profileState, tokens, client.client_id, client.scope?.split(' ') || []);
|
|
1243
|
+
res.json(tokens);
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
this.logger.warn('Unsupported grant type', { grant_type, expected: 'authorization_code or refresh_token' });
|
|
1247
|
+
res.status(HTTP_STATUS.BAD_REQUEST).json({ error: 'unsupported_grant_type' });
|
|
1248
|
+
}
|
|
1249
|
+
catch (error) {
|
|
1250
|
+
this.logger.error('OAuth token exchange error', error instanceof Error ? error : new Error(String(error)));
|
|
1251
|
+
res.status(HTTP_STATUS.BAD_REQUEST).json({
|
|
1252
|
+
error: 'invalid_grant',
|
|
1253
|
+
error_description: 'Token exchange failed',
|
|
1254
|
+
});
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
async handleOAuthCallback(req, res, profileState) {
|
|
1258
|
+
if (!profileState.oauthProvider) {
|
|
1259
|
+
res.status(HTTP_STATUS.NOT_FOUND).send('OAuth provider not initialized');
|
|
1260
|
+
return;
|
|
1261
|
+
}
|
|
1262
|
+
try {
|
|
1263
|
+
const { code, state, error, error_description } = req.query;
|
|
1264
|
+
this.logger.info('OAuth callback received', {
|
|
1265
|
+
profileId: profileState.profileId,
|
|
1266
|
+
hasCode: !!code,
|
|
1267
|
+
hasState: !!state,
|
|
1268
|
+
error: error,
|
|
1269
|
+
errorDescription: error_description,
|
|
1270
|
+
});
|
|
1271
|
+
if (error) {
|
|
1272
|
+
const safeError = escapeHtmlSafe(error);
|
|
1273
|
+
const safeErrorDesc = escapeHtmlSafe(error_description);
|
|
1274
|
+
res.status(HTTP_STATUS.BAD_REQUEST).json({
|
|
1275
|
+
error: safeError,
|
|
1276
|
+
error_description: safeErrorDesc || safeError,
|
|
1277
|
+
});
|
|
1278
|
+
return;
|
|
1279
|
+
}
|
|
1280
|
+
if (!code || typeof code !== 'string') {
|
|
1281
|
+
res.status(HTTP_STATUS.BAD_REQUEST).send('Missing authorization code');
|
|
1282
|
+
return;
|
|
1283
|
+
}
|
|
1284
|
+
await profileState.oauthProvider.handleCallback(req, res);
|
|
1285
|
+
}
|
|
1286
|
+
catch (error) {
|
|
1287
|
+
this.logger.error('OAuth callback error', error instanceof Error ? error : new Error(String(error)));
|
|
1288
|
+
if (!res.headersSent) {
|
|
1289
|
+
res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send('OAuth callback failed');
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
async handleOAuthAuthorizationServerMetadata(req, res, profileState) {
|
|
1294
|
+
if (!profileState.oauthProvider) {
|
|
1295
|
+
res.status(HTTP_STATUS.NOT_FOUND).send('OAuth metadata unavailable');
|
|
1296
|
+
return;
|
|
1297
|
+
}
|
|
1298
|
+
try {
|
|
1299
|
+
const profileId = profileState.profileId;
|
|
1300
|
+
const isProfileScoped = typeof req.params?.profileId === 'string';
|
|
1301
|
+
const forceProfilePrefix = isProfileScoped || req.forceProfilePrefix === true;
|
|
1302
|
+
const urlOptions = forceProfilePrefix ? { forceProfilePrefix: true } : undefined;
|
|
1303
|
+
const issuer = this.getProfileIssuerUrl(profileId, urlOptions);
|
|
1304
|
+
res.json({
|
|
1305
|
+
issuer,
|
|
1306
|
+
authorization_endpoint: this.buildProfileUrl(profileId, OAUTH_PATHS.AUTHORIZE, urlOptions),
|
|
1307
|
+
token_endpoint: this.buildProfileUrl(profileId, OAUTH_PATHS.TOKEN, urlOptions),
|
|
1308
|
+
registration_endpoint: this.buildProfileUrl(profileId, OAUTH_PATHS.REGISTER, urlOptions),
|
|
1309
|
+
response_types_supported: ['code'],
|
|
1310
|
+
code_challenge_methods_supported: ['S256'],
|
|
1311
|
+
grant_types_supported: ['authorization_code', 'refresh_token'],
|
|
1312
|
+
scopes_supported: profileState.oauthProvider.scopes || ['api'],
|
|
1313
|
+
});
|
|
1314
|
+
}
|
|
1315
|
+
catch (error) {
|
|
1316
|
+
this.logger.error('OAuth authorization server metadata error', error instanceof Error ? error : new Error(String(error)));
|
|
1317
|
+
res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send('OAuth metadata failed');
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
async handleOAuthRegister(req, res, profileState) {
|
|
1321
|
+
if (!profileState.oauthProvider) {
|
|
1322
|
+
res.status(HTTP_STATUS.NOT_FOUND).json({ error: 'server_error', error_description: 'Registration unavailable' });
|
|
1323
|
+
return;
|
|
1324
|
+
}
|
|
1325
|
+
try {
|
|
1326
|
+
const { redirect_uris } = req.body;
|
|
1327
|
+
this.logger.info('Dynamic client registration request', {
|
|
1328
|
+
profileId: profileState.profileId,
|
|
1329
|
+
redirect_uris,
|
|
1330
|
+
});
|
|
1331
|
+
const clientId = 'mcp-proxy-client';
|
|
1332
|
+
const clientSecret = 'mcp-proxy-secret';
|
|
1333
|
+
const client = {
|
|
1334
|
+
client_id: clientId,
|
|
1335
|
+
client_secret: clientSecret,
|
|
1336
|
+
redirect_uris: redirect_uris || [],
|
|
1337
|
+
grant_types: ['authorization_code', 'refresh_token'],
|
|
1338
|
+
response_types: ['code'],
|
|
1339
|
+
scope: (profileState.oauthProvider.scopes || []).join(' '),
|
|
1340
|
+
};
|
|
1341
|
+
await profileState.oauthProvider.clientsStore.registerClient(client);
|
|
1342
|
+
res.status(HTTP_STATUS.CREATED).json({
|
|
1343
|
+
client_id: clientId,
|
|
1344
|
+
client_secret: clientSecret,
|
|
1345
|
+
redirect_uris: redirect_uris,
|
|
1346
|
+
grant_types: ['authorization_code', 'refresh_token'],
|
|
1347
|
+
response_types: ['code'],
|
|
1348
|
+
scope: (profileState.oauthProvider.scopes || []).join(' '),
|
|
1349
|
+
token_endpoint_auth_method: 'client_secret_post',
|
|
1350
|
+
});
|
|
1351
|
+
}
|
|
1352
|
+
catch (error) {
|
|
1353
|
+
this.logger.error('Client registration failed', error instanceof Error ? error : new Error(String(error)));
|
|
1354
|
+
res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({ error: 'server_error', error_description: 'Registration failed' });
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
/**
|
|
1358
|
+
* Handle metrics endpoint
|
|
1359
|
+
*
|
|
1360
|
+
* Why: Prometheus scraping endpoint
|
|
1361
|
+
*/
|
|
1362
|
+
async handleMetrics(req, res) {
|
|
1363
|
+
const startTime = Date.now();
|
|
1364
|
+
try {
|
|
1365
|
+
if (!this.metrics) {
|
|
1366
|
+
res.status(HTTP_STATUS.NOT_FOUND).json({ error: 'Not Found', message: 'Metrics disabled' });
|
|
1367
|
+
return;
|
|
1368
|
+
}
|
|
1369
|
+
const metrics = await this.metrics.getMetrics();
|
|
1370
|
+
res.set('Content-Type', 'text/plain; version=0.0.4; charset=utf-8');
|
|
1371
|
+
res.send(metrics);
|
|
1372
|
+
// Don't record metrics call in metrics (avoid recursion)
|
|
1373
|
+
}
|
|
1374
|
+
catch (error) {
|
|
1375
|
+
const correlationId = generateCorrelationId();
|
|
1376
|
+
this.logger.error('Metrics endpoint error', error, { correlationId });
|
|
1377
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
1378
|
+
res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({
|
|
1379
|
+
error: 'Internal Server Error',
|
|
1380
|
+
message: `Internal error (correlation ID: ${correlationId})`,
|
|
1381
|
+
correlationId
|
|
1382
|
+
});
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
/**
|
|
1386
|
+
* Validate authentication token by making a probe request to the API
|
|
1387
|
+
*
|
|
1388
|
+
* Supports all auth types: bearer, query, custom-header
|
|
1389
|
+
* Returns true if token is valid, false otherwise
|
|
1390
|
+
*/
|
|
1391
|
+
/**
|
|
1392
|
+
* Builds a URL by intelligently combining base URL and endpoint
|
|
1393
|
+
* Handles absolute URLs, absolute paths, and relative paths correctly
|
|
1394
|
+
*/
|
|
1395
|
+
buildUrl(endpoint, baseUrl) {
|
|
1396
|
+
// If endpoint is already an absolute URL, use it as-is
|
|
1397
|
+
if (endpoint.startsWith('http://') || endpoint.startsWith('https://')) {
|
|
1398
|
+
return new URL(endpoint);
|
|
1399
|
+
}
|
|
1400
|
+
// If endpoint is an absolute path (starts with /), combine with origin of baseUrl
|
|
1401
|
+
if (endpoint.startsWith('/')) {
|
|
1402
|
+
const baseUrlObj = new URL(baseUrl);
|
|
1403
|
+
return new URL(endpoint, baseUrlObj.origin);
|
|
1404
|
+
}
|
|
1405
|
+
// Otherwise, treat as relative path and append to baseUrl
|
|
1406
|
+
// Ensure baseUrl ends with '/' for proper URL construction
|
|
1407
|
+
const normalizedBaseUrl = baseUrl.endsWith('/') ? baseUrl : baseUrl + '/';
|
|
1408
|
+
return new URL(endpoint, normalizedBaseUrl);
|
|
1409
|
+
}
|
|
1410
|
+
async validateAuthToken(authConfig, token, baseUrl) {
|
|
1411
|
+
if (!authConfig.validation_endpoint) {
|
|
1412
|
+
return true; // Skip validation if not configured
|
|
1413
|
+
}
|
|
1414
|
+
const url = this.buildUrl(authConfig.validation_endpoint, baseUrl);
|
|
1415
|
+
const headers = {};
|
|
1416
|
+
const method = authConfig.validation_method || 'GET';
|
|
1417
|
+
const timeout = authConfig.validation_timeout_ms || 5000;
|
|
1418
|
+
const urlString = url.toString();
|
|
1419
|
+
// Apply auth based on type
|
|
1420
|
+
switch (authConfig.type) {
|
|
1421
|
+
case 'oauth':
|
|
1422
|
+
case 'bearer':
|
|
1423
|
+
headers['Authorization'] = `Bearer ${token}`;
|
|
1424
|
+
break;
|
|
1425
|
+
case 'custom-header':
|
|
1426
|
+
if (authConfig.header_name) {
|
|
1427
|
+
headers[authConfig.header_name] = token;
|
|
1428
|
+
}
|
|
1429
|
+
break;
|
|
1430
|
+
case 'query':
|
|
1431
|
+
if (authConfig.query_param) {
|
|
1432
|
+
url.searchParams.set(authConfig.query_param, token);
|
|
1433
|
+
}
|
|
1434
|
+
break;
|
|
1435
|
+
}
|
|
1436
|
+
try {
|
|
1437
|
+
this.logger.debug('Validating auth token', {
|
|
1438
|
+
endpoint: urlString,
|
|
1439
|
+
method,
|
|
1440
|
+
authType: authConfig.type,
|
|
1441
|
+
});
|
|
1442
|
+
const controller = new AbortController();
|
|
1443
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
1444
|
+
const response = await fetch(url.toString(), {
|
|
1445
|
+
method,
|
|
1446
|
+
headers,
|
|
1447
|
+
signal: controller.signal,
|
|
1448
|
+
});
|
|
1449
|
+
clearTimeout(timeoutId);
|
|
1450
|
+
const isValid = response.status >= 200 && response.status < 300;
|
|
1451
|
+
this.logger.debug('Auth token validation result', {
|
|
1452
|
+
status: response.status,
|
|
1453
|
+
isValid,
|
|
1454
|
+
});
|
|
1455
|
+
return isValid;
|
|
1456
|
+
}
|
|
1457
|
+
catch (error) {
|
|
1458
|
+
this.logger.warn(`Auth token validation failed: ${error.message}`, {
|
|
1459
|
+
endpoint: authConfig.validation_endpoint,
|
|
1460
|
+
});
|
|
1461
|
+
return false;
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
/**
|
|
1465
|
+
* Validate token format and length
|
|
1466
|
+
*
|
|
1467
|
+
* Why centralized: Single source of truth for token validation rules
|
|
1468
|
+
*
|
|
1469
|
+
* Relaxed validation: Allow common API token characters including colons,
|
|
1470
|
+
* to support various token formats (GitLab glpat-, YouTrack perm:, etc.)
|
|
1471
|
+
*/
|
|
1472
|
+
validateToken(token, source) {
|
|
1473
|
+
const maxLength = this.config.maxTokenLength ?? DEFAULT_MAX_TOKEN_LENGTH;
|
|
1474
|
+
if (token.length > maxLength) {
|
|
1475
|
+
throw new ValidationError(`${source} too long (max ${maxLength} characters)`);
|
|
1476
|
+
}
|
|
1477
|
+
if (token.length === 0) {
|
|
1478
|
+
throw new ValidationError(`${source} is empty`);
|
|
1479
|
+
}
|
|
1480
|
+
// RFC 6750 Bearer token characters + common API token chars (including colons for YouTrack)
|
|
1481
|
+
// Allow: alphanumeric, dash, underscore, dot, tilde, plus, slash, equals, colon
|
|
1482
|
+
// Note: dash at end of character class to avoid being interpreted as range
|
|
1483
|
+
if (!/^[A-Za-z0-9._~+/:=-]+$/.test(token)) {
|
|
1484
|
+
throw new ValidationError(`Invalid ${source} format`);
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
/**
|
|
1488
|
+
* Extract and validate auth token from request headers
|
|
1489
|
+
*
|
|
1490
|
+
* Supports:
|
|
1491
|
+
* - Authorization: Bearer <token>
|
|
1492
|
+
* - X-API-Token: <token>
|
|
1493
|
+
* - OAuth session (via mcp-session-id header)
|
|
1494
|
+
*
|
|
1495
|
+
* Why strict validation: Prevents header injection attacks
|
|
1496
|
+
*
|
|
1497
|
+
* Returns: { type: 'bearer' | 'oauth' | 'api-token', token: string, sessionId?: string }
|
|
1498
|
+
*/
|
|
1499
|
+
extractAuthToken(req, profileState) {
|
|
1500
|
+
const sessionId = req.sessionId || req.headers['mcp-session-id'];
|
|
1501
|
+
const session = sessionId ? profileState.sessions.get(sessionId) : undefined;
|
|
1502
|
+
const authConfigs = profileState.context.authConfigs;
|
|
1503
|
+
const configs = authConfigs ? (Array.isArray(authConfigs) ? authConfigs : [authConfigs]) : [];
|
|
1504
|
+
const hasOAuth = !!profileState.oauthProvider || configs.some(config => config.type === 'oauth');
|
|
1505
|
+
// 1. Check Authorization: Bearer header
|
|
1506
|
+
const authHeader = req.headers.authorization;
|
|
1507
|
+
if (authHeader) {
|
|
1508
|
+
// Defense against ReDoS: Check length before regex
|
|
1509
|
+
const maxHeaderLength = (this.config.maxTokenLength ?? DEFAULT_MAX_TOKEN_LENGTH) + 10; // Bearer + spaces + margin
|
|
1510
|
+
if (authHeader.length > maxHeaderLength) {
|
|
1511
|
+
throw new ValidationError(`Authorization header too long (max ${maxHeaderLength} characters)`);
|
|
1512
|
+
}
|
|
1513
|
+
// Relaxed Bearer token format validation - allow flexible whitespace
|
|
1514
|
+
// Trim whitespace to handle client variations (IntelliJ, VSCode, etc.)
|
|
1515
|
+
const trimmed = authHeader.trim();
|
|
1516
|
+
const match = trimmed.match(/^Bearer\s+(.+)$/);
|
|
1517
|
+
if (!match) {
|
|
1518
|
+
throw new ValidationError('Invalid Authorization header format. Expected: Bearer <token>');
|
|
1519
|
+
}
|
|
1520
|
+
const token = match[1].trim();
|
|
1521
|
+
this.validateToken(token, 'Authorization token');
|
|
1522
|
+
return { type: 'bearer', token };
|
|
1523
|
+
}
|
|
1524
|
+
// 2. Check configured custom header (if any)
|
|
1525
|
+
if (configs.length > 0) {
|
|
1526
|
+
const sortedConfigs = configs.sort((a, b) => (a.priority || 0) - (b.priority || 0));
|
|
1527
|
+
const customHeaderConfig = sortedConfigs.find(c => c.type === 'custom-header' && c.header_name);
|
|
1528
|
+
if (customHeaderConfig && customHeaderConfig.header_name) {
|
|
1529
|
+
if (!isSafePropertyName(customHeaderConfig.header_name)) {
|
|
1530
|
+
throw new ValidationError(`Invalid custom auth header name: ${customHeaderConfig.header_name}`);
|
|
1531
|
+
}
|
|
1532
|
+
const headerKey = customHeaderConfig.header_name.toLowerCase();
|
|
1533
|
+
const headerValue = req.headers[headerKey];
|
|
1534
|
+
if (headerValue) {
|
|
1535
|
+
if (typeof headerValue !== 'string') {
|
|
1536
|
+
throw new ValidationError(`${customHeaderConfig.header_name} must be a string`);
|
|
1537
|
+
}
|
|
1538
|
+
this.validateToken(headerValue, customHeaderConfig.header_name);
|
|
1539
|
+
return { type: 'api-token', token: headerValue };
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
// 3. Check X-API-Token header (for custom implementations)
|
|
1544
|
+
const apiTokenHeader = req.headers['x-api-token'];
|
|
1545
|
+
if (apiTokenHeader) {
|
|
1546
|
+
if (typeof apiTokenHeader !== 'string') {
|
|
1547
|
+
throw new ValidationError('X-API-Token must be a string');
|
|
1548
|
+
}
|
|
1549
|
+
this.validateToken(apiTokenHeader, 'X-API-Token');
|
|
1550
|
+
return { type: 'api-token', token: apiTokenHeader };
|
|
1551
|
+
}
|
|
1552
|
+
// 4. Fall back to session token (OAuth only when configured)
|
|
1553
|
+
if (session && session.authToken) {
|
|
1554
|
+
return { type: hasOAuth ? 'oauth' : 'api-token', token: session.authToken, sessionId };
|
|
1555
|
+
}
|
|
1556
|
+
return { type: 'none' };
|
|
1557
|
+
}
|
|
1558
|
+
/**
|
|
1559
|
+
* Lazy initialization of ToolFilterService
|
|
1560
|
+
*/
|
|
1561
|
+
getToolFilterService(profileState) {
|
|
1562
|
+
if (!profileState.toolFilterService) {
|
|
1563
|
+
const validator = new RegexValidator();
|
|
1564
|
+
const compiler = new RegexCompiler(validator);
|
|
1565
|
+
const envParser = new EnvConfigParser(compiler);
|
|
1566
|
+
const headerParser = new HeaderConfigParser(compiler);
|
|
1567
|
+
// Create OperationDetector for category filtering (if parser available)
|
|
1568
|
+
let detector;
|
|
1569
|
+
if (profileState.context.parser) {
|
|
1570
|
+
const classifier = new OperationClassifier();
|
|
1571
|
+
const resolver = new OpenAPIOperationResolver(profileState.context.parser);
|
|
1572
|
+
detector = new OperationDetector(classifier, resolver);
|
|
1573
|
+
}
|
|
1574
|
+
profileState.toolFilterService = new ToolFilterService(envParser, headerParser, this.logger, detector);
|
|
1575
|
+
}
|
|
1576
|
+
return profileState.toolFilterService;
|
|
1577
|
+
}
|
|
1578
|
+
/**
|
|
1579
|
+
* Handle POST requests - Client sending messages to server
|
|
1580
|
+
*
|
|
1581
|
+
* MCP Spec: POST can contain requests, notifications, or responses
|
|
1582
|
+
*/
|
|
1583
|
+
async handlePost(req, res) {
|
|
1584
|
+
const startTime = Date.now();
|
|
1585
|
+
try {
|
|
1586
|
+
this.logger.debug('handlePost called', { method: req.method, path: req.path, sessionId: req.sessionId, accept: req.headers.accept });
|
|
1587
|
+
const profileState = await this.getProfileStateForRequest(req);
|
|
1588
|
+
if (!profileState) {
|
|
1589
|
+
this.respondProfileNotFound(res, req.profileId);
|
|
1590
|
+
return;
|
|
1591
|
+
}
|
|
1592
|
+
const requestProfileId = req.profileId ?? profileState.profileId;
|
|
1593
|
+
const sessionId = req.sessionId;
|
|
1594
|
+
const body = req.body;
|
|
1595
|
+
const filteringHeader = normalizeFilteringHeaderValue(this.getFilteringHeaderValue(req));
|
|
1596
|
+
const parsedFiltering = filteringHeader ? parseFilteringHeader(filteringHeader) : undefined;
|
|
1597
|
+
const toolFilterHeader = normalizeToolFilterHeaderValue(this.getToolFilterHeaderValue(req));
|
|
1598
|
+
const parsedToolFilter = toolFilterHeader !== undefined ? parseSessionToolFilterHeader(toolFilterHeader) : undefined;
|
|
1599
|
+
const normalizedToolFilterHeader = parsedToolFilter?.normalizedHeader;
|
|
1600
|
+
// Validate Accept header per MCP Streamable HTTP specification
|
|
1601
|
+
const accept = req.headers.accept || '';
|
|
1602
|
+
// POST requests can return either JSON or SSE, so must accept both if specified
|
|
1603
|
+
// GET requests return SSE, so must accept text/event-stream
|
|
1604
|
+
const acceptsJson = accept.includes(MIME_TYPES.JSON) || accept === '*/*' || accept === '';
|
|
1605
|
+
const acceptsEventStream = accept.includes(MIME_TYPES.EVENT_STREAM) || accept === '*/*' || accept === '';
|
|
1606
|
+
if (req.method === 'GET' && accept && !acceptsEventStream) {
|
|
1607
|
+
this.logger.debug('Accept header validation failed for GET, returning 406');
|
|
1608
|
+
res.status(HTTP_STATUS.NOT_ACCEPTABLE).json({
|
|
1609
|
+
error: 'Not Acceptable',
|
|
1610
|
+
message: `GET requests must accept ${MIME_TYPES.EVENT_STREAM}`
|
|
1611
|
+
});
|
|
1612
|
+
return;
|
|
1613
|
+
}
|
|
1614
|
+
// For POST, be more flexible - allow if client accepts either JSON or SSE
|
|
1615
|
+
if (req.method === 'POST' && accept && !acceptsJson && !acceptsEventStream) {
|
|
1616
|
+
this.logger.debug('Accept header validation failed for POST, returning 406');
|
|
1617
|
+
res.status(HTTP_STATUS.NOT_ACCEPTABLE).json({
|
|
1618
|
+
error: 'Not Acceptable',
|
|
1619
|
+
message: `POST requests must accept ${MIME_TYPES.JSON} or ${MIME_TYPES.EVENT_STREAM}`
|
|
1620
|
+
});
|
|
1621
|
+
return;
|
|
1622
|
+
}
|
|
1623
|
+
// Check if this is initialization (no session ID yet)
|
|
1624
|
+
const isInitialization = isInitializeRequest(body);
|
|
1625
|
+
this.logger.debug('Session validation', { isInitialization, sessionId, bodyMethod: body?.method });
|
|
1626
|
+
// Validate session (except for initialization)
|
|
1627
|
+
if (!isInitialization && sessionId) {
|
|
1628
|
+
const session = profileState.sessions.get(sessionId);
|
|
1629
|
+
if (!session) {
|
|
1630
|
+
res.status(HTTP_STATUS.NOT_FOUND).json({ error: 'Not Found', message: 'Session not found or expired' });
|
|
1631
|
+
return;
|
|
1632
|
+
}
|
|
1633
|
+
if (filteringHeader !== undefined) {
|
|
1634
|
+
if (!session.filteringHeader || session.filteringHeader !== filteringHeader) {
|
|
1635
|
+
throw new ValidationError('X-Mcp4-Params header mismatch for existing session.');
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
if (normalizedToolFilterHeader !== undefined) {
|
|
1639
|
+
if (!session.toolFilterHeader || session.toolFilterHeader !== normalizedToolFilterHeader) {
|
|
1640
|
+
throw new ValidationError(`X-Mcp4-Tools header mismatch for existing session. Expected: '${session.toolFilterHeader ?? ''}', Got: '${normalizedToolFilterHeader}'.`);
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
this.updateSessionActivity(profileState, sessionId);
|
|
1644
|
+
const authInfo = this.extractAuthToken(req, profileState);
|
|
1645
|
+
if (authInfo.token && authInfo.type !== 'oauth') {
|
|
1646
|
+
const previousToken = session.authToken;
|
|
1647
|
+
if (!previousToken || previousToken !== authInfo.token) {
|
|
1648
|
+
session.authToken = authInfo.token;
|
|
1649
|
+
this.logger.info('Session auth token updated', {
|
|
1650
|
+
profileId: profileState.profileId,
|
|
1651
|
+
sessionId,
|
|
1652
|
+
authType: authInfo.type,
|
|
1653
|
+
replaced: !!previousToken,
|
|
1654
|
+
});
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
else if (!isInitialization && !sessionId) {
|
|
1659
|
+
this.logger.debug('Session validation failed: non-init request without sessionId');
|
|
1660
|
+
res.status(HTTP_STATUS.BAD_REQUEST).json({ error: 'Bad Request', message: 'Mcp-Session-Id header required (except for initialization)' });
|
|
1661
|
+
return;
|
|
1662
|
+
}
|
|
1663
|
+
// Determine message type
|
|
1664
|
+
const messageType = this.getMessageType(body);
|
|
1665
|
+
this.logger.debug('Message type determined', { messageType, hasMessageHandler: !!this.messageHandler });
|
|
1666
|
+
// If only notifications/responses, return 202 Accepted
|
|
1667
|
+
if (messageType === 'notification-only' || messageType === 'response-only') {
|
|
1668
|
+
if (this.messageHandler) {
|
|
1669
|
+
await this.messageHandler(body, undefined, requestProfileId);
|
|
1670
|
+
}
|
|
1671
|
+
res.status(HTTP_STATUS.ACCEPTED).send();
|
|
1672
|
+
return;
|
|
1673
|
+
}
|
|
1674
|
+
// If contains requests, process and return response
|
|
1675
|
+
if (messageType === 'request') {
|
|
1676
|
+
if (!this.messageHandler) {
|
|
1677
|
+
res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({ error: 'Internal Server Error', message: 'Message handler not configured' });
|
|
1678
|
+
return;
|
|
1679
|
+
}
|
|
1680
|
+
// Create session on initialization
|
|
1681
|
+
let newSessionId;
|
|
1682
|
+
if (isInitialization) {
|
|
1683
|
+
// Extract and validate auth token from headers
|
|
1684
|
+
const authInfo = this.extractAuthToken(req, profileState);
|
|
1685
|
+
this.logger.debug('Auth token extracted', { authType: authInfo?.type, hasToken: !!authInfo?.token });
|
|
1686
|
+
// If OAuth is configured, require authentication for initialization
|
|
1687
|
+
// This ensures clients like Cursor properly handle OAuth flow
|
|
1688
|
+
if (profileState.oauthProvider && !authInfo.token) {
|
|
1689
|
+
this.logger.debug('OAuth configured but no token provided, triggering OAuth flow');
|
|
1690
|
+
const resourceMetadataUrl = this.getOAuthProtectedResourceUrl(requestProfileId);
|
|
1691
|
+
res.setHeader('WWW-Authenticate', `Bearer resource_metadata="${resourceMetadataUrl}", scope="${profileState.oauthProvider.scopes.join(' ')}"`);
|
|
1692
|
+
res.status(HTTP_STATUS.UNAUTHORIZED).json({
|
|
1693
|
+
error: 'Unauthorized',
|
|
1694
|
+
message: 'Authentication required for OAuth'
|
|
1695
|
+
});
|
|
1696
|
+
return;
|
|
1697
|
+
}
|
|
1698
|
+
// Allow initialization without token for non-OAuth scenarios
|
|
1699
|
+
// Validate token if auth is configured and token is provided
|
|
1700
|
+
if (authInfo && authInfo.token && profileState.context.authConfigs && profileState.context.baseUrl) {
|
|
1701
|
+
// Find matching auth config based on priority (authConfigs is sorted)
|
|
1702
|
+
// For 'bearer' token type, 'oauth' config is also a match
|
|
1703
|
+
const authConfig = profileState.context.authConfigs.find(c => c.type === authInfo.type ||
|
|
1704
|
+
(authInfo.type === 'bearer' && c.type === 'oauth'));
|
|
1705
|
+
if (authConfig && authConfig.validation_endpoint) {
|
|
1706
|
+
this.logger.info('Validating auth token during initialization', {
|
|
1707
|
+
authType: authConfig.type, // Use config type for logging
|
|
1708
|
+
endpoint: authConfig.validation_endpoint,
|
|
1709
|
+
});
|
|
1710
|
+
const isValid = await this.validateAuthToken(authConfig, authInfo.token, profileState.context.baseUrl);
|
|
1711
|
+
if (!isValid) {
|
|
1712
|
+
this.logger.warn('Auth token validation failed during initialization', {
|
|
1713
|
+
authType: authInfo.type,
|
|
1714
|
+
});
|
|
1715
|
+
res.status(HTTP_STATUS.UNAUTHORIZED).json({
|
|
1716
|
+
error: 'Unauthorized',
|
|
1717
|
+
message: 'Invalid or expired authentication token'
|
|
1718
|
+
});
|
|
1719
|
+
return;
|
|
1720
|
+
}
|
|
1721
|
+
this.logger.info('Auth token validation successful');
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
// Look up OAuth tokens if this is an OAuth token
|
|
1725
|
+
let refreshToken;
|
|
1726
|
+
let accessTokenExpiresAt;
|
|
1727
|
+
let scopes;
|
|
1728
|
+
let oauthClientId;
|
|
1729
|
+
if (authInfo.token && (authInfo.type === 'oauth' || authInfo.type === 'bearer')) {
|
|
1730
|
+
const tokenData = profileState.oauthTokensByAccessToken.get(authInfo.token);
|
|
1731
|
+
if (tokenData) {
|
|
1732
|
+
refreshToken = tokenData.refreshToken;
|
|
1733
|
+
accessTokenExpiresAt = tokenData.expiresAt;
|
|
1734
|
+
scopes = tokenData.scopes;
|
|
1735
|
+
oauthClientId = tokenData.clientId;
|
|
1736
|
+
this.logger.debug('Found OAuth token data for session', {
|
|
1737
|
+
hasRefreshToken: !!refreshToken,
|
|
1738
|
+
hasExpiration: !!accessTokenExpiresAt,
|
|
1739
|
+
scopesCount: scopes.length,
|
|
1740
|
+
});
|
|
1741
|
+
}
|
|
1742
|
+
else {
|
|
1743
|
+
this.logger.debug('No OAuth token data found in map (may be non-OAuth bearer token)', {
|
|
1744
|
+
hasToken: true,
|
|
1745
|
+
});
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
newSessionId = this.createSession(profileState, authInfo.token, refreshToken, accessTokenExpiresAt, scopes, oauthClientId, parsedFiltering?.filtering, parsedFiltering?.normalizedHeader, parsedToolFilter, normalizedToolFilterHeader);
|
|
1749
|
+
}
|
|
1750
|
+
this.logger.debug('Calling messageHandler', { body, sessionId: isInitialization ? newSessionId : sessionId });
|
|
1751
|
+
const response = await this.messageHandler(body, isInitialization ? newSessionId : sessionId, requestProfileId);
|
|
1752
|
+
this.logger.debug('MessageHandler response', { response });
|
|
1753
|
+
// Debug: Check OAuth conditions
|
|
1754
|
+
this.logger.debug('Checking OAuth conditions', {
|
|
1755
|
+
responseError: response.error,
|
|
1756
|
+
hasOAuthProvider: !!profileState.oauthProvider,
|
|
1757
|
+
oauthProviderType: typeof profileState.oauthProvider
|
|
1758
|
+
});
|
|
1759
|
+
// Check if response contains OAuth error and add WWW-Authenticate header
|
|
1760
|
+
const responseObj = response;
|
|
1761
|
+
if (responseObj.error && responseObj.error.data && responseObj.error.data.oauth_required) {
|
|
1762
|
+
const resourceMetadataUrl = this.getOAuthProtectedResourceUrl(requestProfileId);
|
|
1763
|
+
res.setHeader('WWW-Authenticate', `Bearer resource_metadata="${resourceMetadataUrl}", scope="api"`);
|
|
1764
|
+
res.status(HTTP_STATUS.UNAUTHORIZED); // Set 401 status for OAuth errors
|
|
1765
|
+
}
|
|
1766
|
+
// Decide response format based on Accept header
|
|
1767
|
+
const accept = req.headers.accept || '';
|
|
1768
|
+
const wantsOnlySSE = accept.trim() === MIME_TYPES.EVENT_STREAM;
|
|
1769
|
+
if (wantsOnlySSE) {
|
|
1770
|
+
// Return SSE response only when client explicitly wants text/event-stream only
|
|
1771
|
+
this.logger.debug('Sending SSE response', { response, newSessionId });
|
|
1772
|
+
this.startSSEResponse(res, response, newSessionId, sessionId);
|
|
1773
|
+
}
|
|
1774
|
+
else {
|
|
1775
|
+
// Return JSON response (default for requests)
|
|
1776
|
+
if (newSessionId) {
|
|
1777
|
+
res.setHeader('Mcp-Session-Id', newSessionId);
|
|
1778
|
+
}
|
|
1779
|
+
// Security: Prevent caching of sensitive API responses
|
|
1780
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
1781
|
+
this.logger.debug('Sending JSON response', { response, newSessionId });
|
|
1782
|
+
res.json(response);
|
|
1783
|
+
}
|
|
1784
|
+
return;
|
|
1785
|
+
}
|
|
1786
|
+
res.status(HTTP_STATUS.BAD_REQUEST).json({ error: 'Bad Request', message: 'Invalid message type' });
|
|
1787
|
+
}
|
|
1788
|
+
catch (error) {
|
|
1789
|
+
const correlationId = generateCorrelationId();
|
|
1790
|
+
this.logger.error('POST request error', error, { correlationId });
|
|
1791
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
1792
|
+
let status = 500;
|
|
1793
|
+
let errorLabel = 'Internal Server Error';
|
|
1794
|
+
let message = `Internal error (correlation ID: ${correlationId})`;
|
|
1795
|
+
if (error instanceof ValidationError) {
|
|
1796
|
+
status = HTTP_STATUS.BAD_REQUEST;
|
|
1797
|
+
errorLabel = 'Bad Request';
|
|
1798
|
+
message = `Validation error: ${error.message} (correlation ID: ${correlationId})`;
|
|
1799
|
+
}
|
|
1800
|
+
else if (error instanceof AuthenticationError) {
|
|
1801
|
+
status = HTTP_STATUS.UNAUTHORIZED;
|
|
1802
|
+
errorLabel = 'Unauthorized';
|
|
1803
|
+
message = `Authentication failed: ${error.message} (correlation ID: ${correlationId})`;
|
|
1804
|
+
}
|
|
1805
|
+
else if (error instanceof AuthorizationError) {
|
|
1806
|
+
status = HTTP_STATUS.FORBIDDEN;
|
|
1807
|
+
errorLabel = 'Forbidden';
|
|
1808
|
+
message = `Authorization failed: ${error.message} (correlation ID: ${correlationId})`;
|
|
1809
|
+
}
|
|
1810
|
+
else if (error instanceof RateLimitError) {
|
|
1811
|
+
status = HTTP_STATUS.TOO_MANY_REQUESTS;
|
|
1812
|
+
errorLabel = 'Too Many Requests';
|
|
1813
|
+
message = `Rate limit exceeded: ${error.message} (correlation ID: ${correlationId})`;
|
|
1814
|
+
}
|
|
1815
|
+
res.status(status).json({ error: errorLabel, message, correlationId });
|
|
1816
|
+
// Record error metrics
|
|
1817
|
+
if (this.metrics) {
|
|
1818
|
+
const duration = (Date.now() - startTime) / 1000;
|
|
1819
|
+
this.metrics.recordHttpRequest(req.method, req.path, status, duration);
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
finally {
|
|
1823
|
+
// Record success metrics (if not already recorded in catch)
|
|
1824
|
+
if (this.metrics && res.statusCode !== 500) {
|
|
1825
|
+
const duration = (Date.now() - startTime) / 1000;
|
|
1826
|
+
this.metrics.recordHttpRequest(req.method, req.path, res.statusCode, duration);
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
/**
|
|
1831
|
+
* Handle GET requests - Client opening SSE stream for server messages
|
|
1832
|
+
*
|
|
1833
|
+
* MCP Spec: GET opens SSE stream for server-initiated requests/notifications
|
|
1834
|
+
*/
|
|
1835
|
+
async handleGet(req, res) {
|
|
1836
|
+
const startTime = Date.now();
|
|
1837
|
+
try {
|
|
1838
|
+
const profileState = await this.getProfileStateForRequest(req);
|
|
1839
|
+
if (!profileState) {
|
|
1840
|
+
this.respondProfileNotFound(res, req.profileId);
|
|
1841
|
+
return;
|
|
1842
|
+
}
|
|
1843
|
+
const sessionId = req.sessionId;
|
|
1844
|
+
const lastEventId = req.headers['last-event-id'];
|
|
1845
|
+
// Validate Accept header
|
|
1846
|
+
const accept = req.headers.accept || '';
|
|
1847
|
+
if (!accept.includes(MIME_TYPES.EVENT_STREAM)) {
|
|
1848
|
+
res.status(HTTP_STATUS.METHOD_NOT_ALLOWED).json({ error: 'Method Not Allowed', message: `Must accept ${MIME_TYPES.EVENT_STREAM}` });
|
|
1849
|
+
return;
|
|
1850
|
+
}
|
|
1851
|
+
// Validate session
|
|
1852
|
+
if (!sessionId) {
|
|
1853
|
+
res.status(HTTP_STATUS.BAD_REQUEST).json({ error: 'Bad Request', message: 'Mcp-Session-Id header required' });
|
|
1854
|
+
return;
|
|
1855
|
+
}
|
|
1856
|
+
const session = profileState.sessions.get(sessionId);
|
|
1857
|
+
if (!session) {
|
|
1858
|
+
res.status(HTTP_STATUS.NOT_FOUND).json({ error: 'Not Found', message: 'Session not found or expired' });
|
|
1859
|
+
return;
|
|
1860
|
+
}
|
|
1861
|
+
this.updateSessionActivity(profileState, sessionId);
|
|
1862
|
+
// Start SSE stream
|
|
1863
|
+
this.startSSEStream(res, sessionId, lastEventId, profileState);
|
|
1864
|
+
// Record metrics for successful SSE start
|
|
1865
|
+
if (this.metrics) {
|
|
1866
|
+
const duration = (Date.now() - startTime) / 1000;
|
|
1867
|
+
this.metrics.recordHttpRequest(req.method, req.path, 200, duration);
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
catch (error) {
|
|
1871
|
+
const correlationId = generateCorrelationId();
|
|
1872
|
+
this.logger.error('GET request error', error, { correlationId });
|
|
1873
|
+
const status = 500;
|
|
1874
|
+
if (!res.headersSent) {
|
|
1875
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
1876
|
+
res.status(status).json({
|
|
1877
|
+
error: 'Internal Server Error',
|
|
1878
|
+
message: `Internal error (correlation ID: ${correlationId})`,
|
|
1879
|
+
correlationId
|
|
1880
|
+
});
|
|
1881
|
+
}
|
|
1882
|
+
// Record error metrics
|
|
1883
|
+
if (this.metrics) {
|
|
1884
|
+
const duration = (Date.now() - startTime) / 1000;
|
|
1885
|
+
this.metrics.recordHttpRequest(req.method, req.path, status, duration);
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
/**
|
|
1890
|
+
* Handle DELETE requests - Client terminating session
|
|
1891
|
+
*
|
|
1892
|
+
* MCP Spec: DELETE explicitly terminates session
|
|
1893
|
+
*/
|
|
1894
|
+
handleDelete(req, res) {
|
|
1895
|
+
const startTime = Date.now();
|
|
1896
|
+
const sessionId = req.sessionId;
|
|
1897
|
+
const profileId = this.getProfileIdForRequest(req);
|
|
1898
|
+
if (!profileId) {
|
|
1899
|
+
this.respondProfileNotFound(res, req.profileId);
|
|
1900
|
+
return;
|
|
1901
|
+
}
|
|
1902
|
+
if (!sessionId) {
|
|
1903
|
+
const status = 400;
|
|
1904
|
+
res.status(status).json({ error: 'Bad Request', message: 'Mcp-Session-Id header required' });
|
|
1905
|
+
if (this.metrics) {
|
|
1906
|
+
const duration = (Date.now() - startTime) / 1000;
|
|
1907
|
+
this.metrics.recordHttpRequest(req.method, req.path, status, duration);
|
|
1908
|
+
}
|
|
1909
|
+
return;
|
|
1910
|
+
}
|
|
1911
|
+
const profileState = this.profileStates.get(profileId);
|
|
1912
|
+
if (!profileState) {
|
|
1913
|
+
this.respondProfileNotFound(res, profileId);
|
|
1914
|
+
return;
|
|
1915
|
+
}
|
|
1916
|
+
const session = profileState.sessions.get(sessionId);
|
|
1917
|
+
if (!session) {
|
|
1918
|
+
const status = 404;
|
|
1919
|
+
res.status(status).json({ error: 'Not Found', message: 'Session not found' });
|
|
1920
|
+
if (this.metrics) {
|
|
1921
|
+
const duration = (Date.now() - startTime) / 1000;
|
|
1922
|
+
this.metrics.recordHttpRequest(req.method, req.path, status, duration);
|
|
1923
|
+
}
|
|
1924
|
+
return;
|
|
1925
|
+
}
|
|
1926
|
+
this.destroySession(profileState, sessionId);
|
|
1927
|
+
const status = 204;
|
|
1928
|
+
res.status(status).send();
|
|
1929
|
+
if (this.metrics) {
|
|
1930
|
+
const duration = (Date.now() - startTime) / 1000;
|
|
1931
|
+
this.metrics.recordHttpRequest(req.method, req.path, status, duration);
|
|
1932
|
+
}
|
|
1933
|
+
}
|
|
1934
|
+
/**
|
|
1935
|
+
* Start SSE response for a POST request
|
|
1936
|
+
*
|
|
1937
|
+
* Why: Returns response via SSE stream, allows server-initiated messages
|
|
1938
|
+
*/
|
|
1939
|
+
startSSEResponse(res, response, newSessionId, sessionId) {
|
|
1940
|
+
res.setHeader('Content-Type', MIME_TYPES.EVENT_STREAM);
|
|
1941
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
1942
|
+
res.setHeader('Connection', 'keep-alive');
|
|
1943
|
+
if (newSessionId) {
|
|
1944
|
+
res.setHeader('Mcp-Session-Id', newSessionId);
|
|
1945
|
+
}
|
|
1946
|
+
// Send response
|
|
1947
|
+
const eventId = Date.now();
|
|
1948
|
+
res.write(`id: ${eventId}\n`);
|
|
1949
|
+
res.write(`data: ${JSON.stringify(response)}\n\n`);
|
|
1950
|
+
// Close stream
|
|
1951
|
+
res.end();
|
|
1952
|
+
}
|
|
1953
|
+
/**
|
|
1954
|
+
* Start SSE stream for GET request
|
|
1955
|
+
*
|
|
1956
|
+
* Why: Allows server to send requests/notifications to client
|
|
1957
|
+
*/
|
|
1958
|
+
startSSEStream(res, sessionId, lastEventId, profileState) {
|
|
1959
|
+
res.setHeader('Content-Type', MIME_TYPES.EVENT_STREAM);
|
|
1960
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
1961
|
+
res.setHeader('Connection', 'keep-alive');
|
|
1962
|
+
const streamId = crypto.randomBytes(16).toString('hex');
|
|
1963
|
+
const session = profileState.sessions.get(sessionId);
|
|
1964
|
+
const streamState = {
|
|
1965
|
+
streamId,
|
|
1966
|
+
lastEventId: lastEventId ? parseInt(lastEventId, 10) : 0,
|
|
1967
|
+
messageQueue: [],
|
|
1968
|
+
active: true,
|
|
1969
|
+
response: res,
|
|
1970
|
+
};
|
|
1971
|
+
session.sseStreams.set(streamId, streamState);
|
|
1972
|
+
// Replay missed messages if resuming
|
|
1973
|
+
if (lastEventId) {
|
|
1974
|
+
this.replayMessages(res, streamState);
|
|
1975
|
+
}
|
|
1976
|
+
// Setup heartbeat if enabled
|
|
1977
|
+
let heartbeatInterval = null;
|
|
1978
|
+
if (this.config.heartbeatEnabled) {
|
|
1979
|
+
heartbeatInterval = setInterval(() => {
|
|
1980
|
+
if (streamState.active) {
|
|
1981
|
+
res.write(':ping\n\n');
|
|
1982
|
+
}
|
|
1983
|
+
}, this.config.heartbeatIntervalMs);
|
|
1984
|
+
}
|
|
1985
|
+
// Handle client disconnect
|
|
1986
|
+
res.on('close', () => {
|
|
1987
|
+
streamState.active = false;
|
|
1988
|
+
if (heartbeatInterval) {
|
|
1989
|
+
clearInterval(heartbeatInterval);
|
|
1990
|
+
}
|
|
1991
|
+
this.logger.info('SSE stream closed', { sessionId, streamId });
|
|
1992
|
+
});
|
|
1993
|
+
this.logger.info('SSE stream opened', { sessionId, streamId, resuming: !!lastEventId });
|
|
1994
|
+
}
|
|
1995
|
+
/**
|
|
1996
|
+
* Replay messages after Last-Event-ID
|
|
1997
|
+
*
|
|
1998
|
+
* Why: Resumability - client can reconnect and receive missed messages
|
|
1999
|
+
*/
|
|
2000
|
+
replayMessages(res, streamState) {
|
|
2001
|
+
const missedMessages = streamState.messageQueue.filter(msg => msg.eventId > streamState.lastEventId);
|
|
2002
|
+
for (const msg of missedMessages) {
|
|
2003
|
+
res.write(`id: ${msg.eventId}\n`);
|
|
2004
|
+
res.write(`data: ${JSON.stringify(msg.data)}\n\n`);
|
|
2005
|
+
}
|
|
2006
|
+
this.logger.info('Replayed messages', { count: missedMessages.length, streamId: streamState.streamId });
|
|
2007
|
+
}
|
|
2008
|
+
/**
|
|
2009
|
+
* Send message to client via SSE
|
|
2010
|
+
*
|
|
2011
|
+
* Why: Server-initiated requests/notifications
|
|
2012
|
+
*/
|
|
2013
|
+
sendToClient(profileId, sessionId, message) {
|
|
2014
|
+
const session = this.profileStates.get(profileId)?.sessions.get(sessionId);
|
|
2015
|
+
if (!session) {
|
|
2016
|
+
this.logger.warn('Cannot send to client: session not found', { profileId, sessionId });
|
|
2017
|
+
return;
|
|
2018
|
+
}
|
|
2019
|
+
const eventId = Date.now();
|
|
2020
|
+
const queuedMessage = {
|
|
2021
|
+
eventId,
|
|
2022
|
+
data: message,
|
|
2023
|
+
timestamp: Date.now(),
|
|
2024
|
+
};
|
|
2025
|
+
// Send to all active streams for this session
|
|
2026
|
+
for (const [streamId, streamState] of session.sseStreams) {
|
|
2027
|
+
if (streamState.active) {
|
|
2028
|
+
// Queue for resumability
|
|
2029
|
+
streamState.messageQueue.push(queuedMessage);
|
|
2030
|
+
// Keep only last 100 messages
|
|
2031
|
+
if (streamState.messageQueue.length > 100) {
|
|
2032
|
+
streamState.messageQueue.shift();
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
}
|
|
2036
|
+
}
|
|
2037
|
+
/**
|
|
2038
|
+
* Determine message type (request, notification, response)
|
|
2039
|
+
*/
|
|
2040
|
+
getMessageType(body) {
|
|
2041
|
+
if (Array.isArray(body)) {
|
|
2042
|
+
// Batch
|
|
2043
|
+
const hasRequest = body.some((msg) => typeof msg === 'object' && msg !== null && 'method' in msg && 'id' in msg);
|
|
2044
|
+
const hasNotification = body.some((msg) => typeof msg === 'object' && msg !== null && 'method' in msg && !('id' in msg));
|
|
2045
|
+
const hasResponse = body.some((msg) => typeof msg === 'object' && msg !== null && ('result' in msg || 'error' in msg));
|
|
2046
|
+
if (hasRequest)
|
|
2047
|
+
return 'request';
|
|
2048
|
+
if (hasNotification && !hasResponse)
|
|
2049
|
+
return 'notification-only';
|
|
2050
|
+
if (hasResponse && !hasNotification)
|
|
2051
|
+
return 'response-only';
|
|
2052
|
+
return 'mixed';
|
|
2053
|
+
}
|
|
2054
|
+
else if (typeof body === 'object' && body !== null) {
|
|
2055
|
+
const msg = body;
|
|
2056
|
+
if ('method' in msg) {
|
|
2057
|
+
return 'id' in msg ? 'request' : 'notification-only';
|
|
2058
|
+
}
|
|
2059
|
+
if ('result' in msg || 'error' in msg) {
|
|
2060
|
+
return 'response-only';
|
|
2061
|
+
}
|
|
2062
|
+
}
|
|
2063
|
+
return 'unknown';
|
|
2064
|
+
}
|
|
2065
|
+
getFilteringHeaderValue(req) {
|
|
2066
|
+
const headerValue = req.headers['x-mcp4-params'];
|
|
2067
|
+
if (Array.isArray(headerValue)) {
|
|
2068
|
+
if (headerValue.length === 0) {
|
|
2069
|
+
return undefined;
|
|
2070
|
+
}
|
|
2071
|
+
if (headerValue.length > 1) {
|
|
2072
|
+
throw new ValidationError('Invalid X-Mcp4-Params header. Expected comma-separated key=value pairs.');
|
|
2073
|
+
}
|
|
2074
|
+
return headerValue[0];
|
|
2075
|
+
}
|
|
2076
|
+
return headerValue;
|
|
2077
|
+
}
|
|
2078
|
+
getToolFilterHeaderValue(req) {
|
|
2079
|
+
const headerValue = req.headers['x-mcp4-tools'];
|
|
2080
|
+
if (Array.isArray(headerValue)) {
|
|
2081
|
+
if (headerValue.length === 0) {
|
|
2082
|
+
return undefined;
|
|
2083
|
+
}
|
|
2084
|
+
if (headerValue.length > 1) {
|
|
2085
|
+
throw new ValidationError('Invalid X-Mcp4-Tools header. Expected comma-separated tool names.');
|
|
2086
|
+
}
|
|
2087
|
+
return headerValue[0];
|
|
2088
|
+
}
|
|
2089
|
+
return headerValue;
|
|
2090
|
+
}
|
|
2091
|
+
/**
|
|
2092
|
+
* Create new session
|
|
2093
|
+
*
|
|
2094
|
+
* Why: Stateful sessions for MCP protocol
|
|
2095
|
+
*/
|
|
2096
|
+
createSession(profileState, authToken, refreshToken, accessTokenExpiresAt, scopes, oauthClientId, filtering, filteringHeader, toolFilterRequest, toolFilterHeader) {
|
|
2097
|
+
// Validate token if provided (defense in depth)
|
|
2098
|
+
if (authToken) {
|
|
2099
|
+
this.validateToken(authToken, 'Session auth token');
|
|
2100
|
+
}
|
|
2101
|
+
const sessionId = crypto.randomUUID();
|
|
2102
|
+
const session = {
|
|
2103
|
+
id: sessionId,
|
|
2104
|
+
createdAt: Date.now(),
|
|
2105
|
+
lastActivityAt: Date.now(),
|
|
2106
|
+
sseStreams: new Map(),
|
|
2107
|
+
authToken,
|
|
2108
|
+
refreshToken,
|
|
2109
|
+
accessTokenExpiresAt,
|
|
2110
|
+
scopes,
|
|
2111
|
+
oauthClientId,
|
|
2112
|
+
filtering,
|
|
2113
|
+
filteringHeader,
|
|
2114
|
+
toolFilterRequest,
|
|
2115
|
+
toolFilterHeader,
|
|
2116
|
+
};
|
|
2117
|
+
profileState.sessions.set(sessionId, session);
|
|
2118
|
+
this.logger.info('Session created', {
|
|
2119
|
+
profileId: profileState.profileId,
|
|
2120
|
+
sessionId,
|
|
2121
|
+
hasAuthToken: !!authToken,
|
|
2122
|
+
hasRefreshToken: !!refreshToken,
|
|
2123
|
+
hasExpiration: !!accessTokenExpiresAt,
|
|
2124
|
+
});
|
|
2125
|
+
// Record metrics
|
|
2126
|
+
if (this.metrics) {
|
|
2127
|
+
this.metrics.recordSessionCreated();
|
|
2128
|
+
}
|
|
2129
|
+
return sessionId;
|
|
2130
|
+
}
|
|
2131
|
+
/**
|
|
2132
|
+
* Update session activity timestamp
|
|
2133
|
+
*/
|
|
2134
|
+
updateSessionActivity(profileState, sessionId) {
|
|
2135
|
+
const session = profileState.sessions.get(sessionId);
|
|
2136
|
+
if (session) {
|
|
2137
|
+
session.lastActivityAt = Date.now();
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
/**
|
|
2141
|
+
* Destroy session and cleanup resources
|
|
2142
|
+
*
|
|
2143
|
+
* Why: Free memory, close streams
|
|
2144
|
+
*/
|
|
2145
|
+
destroySession(profileState, sessionId) {
|
|
2146
|
+
const session = profileState.sessions.get(sessionId);
|
|
2147
|
+
if (session) {
|
|
2148
|
+
// Close all active SSE streams
|
|
2149
|
+
for (const [, streamState] of session.sseStreams) {
|
|
2150
|
+
streamState.active = false;
|
|
2151
|
+
// Close the HTTP response to terminate the SSE connection
|
|
2152
|
+
try {
|
|
2153
|
+
if (!streamState.response.headersSent || !streamState.response.writableEnded) {
|
|
2154
|
+
streamState.response.end();
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
catch (error) {
|
|
2158
|
+
// Ignore errors if response is already closed
|
|
2159
|
+
this.logger.debug('Failed to close SSE response', { error: error.message });
|
|
2160
|
+
}
|
|
2161
|
+
}
|
|
2162
|
+
session.sseStreams.clear();
|
|
2163
|
+
// Clean up OAuth token from map if present
|
|
2164
|
+
if (session.authToken) {
|
|
2165
|
+
profileState.oauthTokensByAccessToken.delete(session.authToken);
|
|
2166
|
+
}
|
|
2167
|
+
profileState.sessions.delete(sessionId);
|
|
2168
|
+
this.logger.info('Session destroyed', { profileId: profileState.profileId, sessionId });
|
|
2169
|
+
// Notify session destruction listeners (for cleanup in MCPServer)
|
|
2170
|
+
this.notifySessionDestroyed(profileState.profileId, sessionId);
|
|
2171
|
+
// Record metrics
|
|
2172
|
+
if (this.metrics) {
|
|
2173
|
+
this.metrics.recordSessionDestroyed();
|
|
2174
|
+
this.metrics.clearToolsSession(sessionId);
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
/**
|
|
2179
|
+
* Register listener for session destruction events
|
|
2180
|
+
*
|
|
2181
|
+
* Why: Allows MCPServer to cleanup per-session HTTP clients
|
|
2182
|
+
*/
|
|
2183
|
+
onSessionDestroyed(listener) {
|
|
2184
|
+
this.sessionDestroyedListeners.push(listener);
|
|
2185
|
+
}
|
|
2186
|
+
/**
|
|
2187
|
+
* Notify all listeners about session destruction
|
|
2188
|
+
*/
|
|
2189
|
+
notifySessionDestroyed(profileId, sessionId) {
|
|
2190
|
+
for (const listener of this.sessionDestroyedListeners) {
|
|
2191
|
+
try {
|
|
2192
|
+
listener(profileId, sessionId);
|
|
2193
|
+
}
|
|
2194
|
+
catch (error) {
|
|
2195
|
+
this.logger.error('Session destroyed listener error', error);
|
|
2196
|
+
}
|
|
2197
|
+
}
|
|
2198
|
+
}
|
|
2199
|
+
/**
|
|
2200
|
+
* Store OAuth tokens in internal map for later session initialization
|
|
2201
|
+
*
|
|
2202
|
+
* Why: Bridge between /oauth/token endpoint (where we see OAuthTokens)
|
|
2203
|
+
* and session initialization (where we only see access token in Authorization header)
|
|
2204
|
+
*/
|
|
2205
|
+
storeOAuthTokens(profileState, tokens, clientId, scopes) {
|
|
2206
|
+
if (!tokens.access_token) {
|
|
2207
|
+
this.logger.warn('OAuth tokens missing access_token, skipping storage');
|
|
2208
|
+
return;
|
|
2209
|
+
}
|
|
2210
|
+
const expiresAt = tokens.expires_in
|
|
2211
|
+
? Date.now() + tokens.expires_in * 1000
|
|
2212
|
+
: undefined;
|
|
2213
|
+
profileState.oauthTokensByAccessToken.set(tokens.access_token, {
|
|
2214
|
+
refreshToken: tokens.refresh_token,
|
|
2215
|
+
expiresAt,
|
|
2216
|
+
clientId,
|
|
2217
|
+
scopes,
|
|
2218
|
+
});
|
|
2219
|
+
this.logger.debug('Stored OAuth tokens', {
|
|
2220
|
+
profileId: profileState.profileId,
|
|
2221
|
+
hasRefreshToken: !!tokens.refresh_token,
|
|
2222
|
+
expiresAt,
|
|
2223
|
+
clientId,
|
|
2224
|
+
scopesCount: scopes.length,
|
|
2225
|
+
});
|
|
2226
|
+
}
|
|
2227
|
+
/**
|
|
2228
|
+
* Cleanup expired sessions
|
|
2229
|
+
*
|
|
2230
|
+
* Why: Prevent memory leaks, enforce session timeout
|
|
2231
|
+
*
|
|
2232
|
+
* OAuth sessions with refresh tokens have extended or unlimited timeout
|
|
2233
|
+
* to avoid forcing users to re-authenticate after periods of inactivity
|
|
2234
|
+
*/
|
|
2235
|
+
cleanupExpiredSessions() {
|
|
2236
|
+
const now = Date.now();
|
|
2237
|
+
const expiredSessions = [];
|
|
2238
|
+
// Default OAuth session timeout: 24 hours (or configurable)
|
|
2239
|
+
const oauthSessionTimeoutMs = this.config.oauthSessionTimeoutMs
|
|
2240
|
+
?? (24 * 60 * 60 * 1000); // 24 hours default
|
|
2241
|
+
for (const profileState of this.profileStates.values()) {
|
|
2242
|
+
// Cleanup OAuth provider resources (states, codes, tokens)
|
|
2243
|
+
if (profileState.oauthProvider) {
|
|
2244
|
+
profileState.oauthProvider.cleanup();
|
|
2245
|
+
}
|
|
2246
|
+
for (const [sessionId, session] of profileState.sessions) {
|
|
2247
|
+
const age = now - session.lastActivityAt;
|
|
2248
|
+
// OAuth sessions with refresh tokens: use extended timeout or never expire
|
|
2249
|
+
if (session.refreshToken) {
|
|
2250
|
+
// If oauthSessionTimeoutMs is 0 or negative, never expire OAuth sessions
|
|
2251
|
+
if (oauthSessionTimeoutMs > 0 && age > oauthSessionTimeoutMs) {
|
|
2252
|
+
expiredSessions.push({ profileId: profileState.profileId, sessionId });
|
|
2253
|
+
}
|
|
2254
|
+
}
|
|
2255
|
+
else {
|
|
2256
|
+
// Non-OAuth sessions: use standard timeout
|
|
2257
|
+
if (age > this.config.sessionTimeoutMs) {
|
|
2258
|
+
expiredSessions.push({ profileId: profileState.profileId, sessionId });
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2261
|
+
}
|
|
2262
|
+
}
|
|
2263
|
+
for (const entry of expiredSessions) {
|
|
2264
|
+
const state = this.profileStates.get(entry.profileId);
|
|
2265
|
+
if (state) {
|
|
2266
|
+
this.destroySession(state, entry.sessionId);
|
|
2267
|
+
}
|
|
2268
|
+
}
|
|
2269
|
+
if (expiredSessions.length > 0) {
|
|
2270
|
+
this.logger.info('Cleaned up expired sessions', { count: expiredSessions.length });
|
|
2271
|
+
}
|
|
2272
|
+
}
|
|
2273
|
+
/**
|
|
2274
|
+
* Get auth token from session
|
|
2275
|
+
*
|
|
2276
|
+
* Why public: Allows MCPServer to securely access session tokens without breaking encapsulation
|
|
2277
|
+
*/
|
|
2278
|
+
getSessionToken(profileId, sessionId) {
|
|
2279
|
+
const session = this.profileStates.get(profileId)?.sessions.get(sessionId);
|
|
2280
|
+
return session?.authToken;
|
|
2281
|
+
}
|
|
2282
|
+
getSessionFiltering(profileId, sessionId) {
|
|
2283
|
+
const session = this.profileStates.get(profileId)?.sessions.get(sessionId);
|
|
2284
|
+
return session?.filtering;
|
|
2285
|
+
}
|
|
2286
|
+
getSessionFilteringHeader(profileId, sessionId) {
|
|
2287
|
+
const session = this.profileStates.get(profileId)?.sessions.get(sessionId);
|
|
2288
|
+
return session?.filteringHeader;
|
|
2289
|
+
}
|
|
2290
|
+
getSessionToolFilterRequest(profileId, sessionId) {
|
|
2291
|
+
const session = this.profileStates.get(profileId)?.sessions.get(sessionId);
|
|
2292
|
+
return session?.toolFilterRequest;
|
|
2293
|
+
}
|
|
2294
|
+
getSessionToolFilter(profileId, sessionId) {
|
|
2295
|
+
const session = this.profileStates.get(profileId)?.sessions.get(sessionId);
|
|
2296
|
+
return session?.toolFilter;
|
|
2297
|
+
}
|
|
2298
|
+
getSessionToolFilterHeader(profileId, sessionId) {
|
|
2299
|
+
const session = this.profileStates.get(profileId)?.sessions.get(sessionId);
|
|
2300
|
+
return session?.toolFilterHeader;
|
|
2301
|
+
}
|
|
2302
|
+
setSessionToolFilter(profileId, sessionId, toolFilter) {
|
|
2303
|
+
const session = this.profileStates.get(profileId)?.sessions.get(sessionId);
|
|
2304
|
+
if (!session) {
|
|
2305
|
+
return;
|
|
2306
|
+
}
|
|
2307
|
+
session.toolFilter = toolFilter;
|
|
2308
|
+
}
|
|
2309
|
+
recordGlobalToolFilterMetrics(summary) {
|
|
2310
|
+
if (!this.metrics) {
|
|
2311
|
+
return;
|
|
2312
|
+
}
|
|
2313
|
+
this.metrics.recordToolsTotal('profile', summary.originalCount);
|
|
2314
|
+
this.metrics.recordToolsFiltered('global_env', 'allowed', summary.allowedCount);
|
|
2315
|
+
this.metrics.recordToolsFiltered('global_env', 'denied', summary.removedCount);
|
|
2316
|
+
for (const [type, count] of Object.entries(summary.patternCounts)) {
|
|
2317
|
+
this.metrics.recordToolFilterPatternCount(type, count);
|
|
2318
|
+
}
|
|
2319
|
+
}
|
|
2320
|
+
recordSessionToolFilterMetrics(sessionId, allowedCount, request) {
|
|
2321
|
+
if (!this.metrics) {
|
|
2322
|
+
return;
|
|
2323
|
+
}
|
|
2324
|
+
this.metrics.recordToolsSession(sessionId, allowedCount);
|
|
2325
|
+
this.metrics.recordToolFilterPatternCount('session_allow_list', request.exactNames.size);
|
|
2326
|
+
this.metrics.recordToolFilterPatternCount('session_allow_regex', request.regexPatterns.length);
|
|
2327
|
+
}
|
|
2328
|
+
recordToolFilterRejection(tool, source) {
|
|
2329
|
+
if (!this.metrics) {
|
|
2330
|
+
return;
|
|
2331
|
+
}
|
|
2332
|
+
this.metrics.recordToolFilterRejection(tool, source);
|
|
2333
|
+
}
|
|
2334
|
+
/**
|
|
2335
|
+
* Ensure session has a valid access token, refreshing if necessary
|
|
2336
|
+
*
|
|
2337
|
+
* Why: Transparently refresh expired OAuth tokens before making API calls
|
|
2338
|
+
* Returns true if token is valid (or was successfully refreshed), false otherwise
|
|
2339
|
+
*/
|
|
2340
|
+
async ensureValidSessionToken(profileId, sessionId) {
|
|
2341
|
+
const profileState = this.profileStates.get(profileId);
|
|
2342
|
+
const session = profileState?.sessions.get(sessionId);
|
|
2343
|
+
if (!session) {
|
|
2344
|
+
return false;
|
|
2345
|
+
}
|
|
2346
|
+
// If no expiration info, assume token is valid (non-OAuth scenarios)
|
|
2347
|
+
if (!session.accessTokenExpiresAt) {
|
|
2348
|
+
return true;
|
|
2349
|
+
}
|
|
2350
|
+
const now = Date.now();
|
|
2351
|
+
const refreshThresholdMs = this.config.oauthRefreshThresholdMs ?? (60 * 1000); // Default: 60 seconds before expiration
|
|
2352
|
+
const timeUntilExpiration = session.accessTokenExpiresAt - now;
|
|
2353
|
+
// If token is expired or about to expire, refresh it
|
|
2354
|
+
if (timeUntilExpiration <= refreshThresholdMs) {
|
|
2355
|
+
this.logger.debug('Access token expired or expiring soon, refreshing', {
|
|
2356
|
+
profileId,
|
|
2357
|
+
sessionId,
|
|
2358
|
+
expiresAt: new Date(session.accessTokenExpiresAt).toISOString(),
|
|
2359
|
+
timeUntilExpiration,
|
|
2360
|
+
});
|
|
2361
|
+
return await this.refreshAccessToken(profileId, sessionId);
|
|
2362
|
+
}
|
|
2363
|
+
return true;
|
|
2364
|
+
}
|
|
2365
|
+
/**
|
|
2366
|
+
* Refresh access token using refresh token
|
|
2367
|
+
*
|
|
2368
|
+
* Why: Automatically renew expired OAuth access tokens without user intervention
|
|
2369
|
+
* Returns true on success, false on failure
|
|
2370
|
+
*/
|
|
2371
|
+
async refreshAccessToken(profileId, sessionId) {
|
|
2372
|
+
const profileState = this.profileStates.get(profileId);
|
|
2373
|
+
const session = profileState?.sessions.get(sessionId);
|
|
2374
|
+
if (!profileState || !session || !session.refreshToken || !profileState.oauthProvider) {
|
|
2375
|
+
this.logger.warn('Cannot refresh token: missing session, refreshToken, or OAuth provider', {
|
|
2376
|
+
profileId,
|
|
2377
|
+
sessionId,
|
|
2378
|
+
hasSession: !!session,
|
|
2379
|
+
hasRefreshToken: !!session?.refreshToken,
|
|
2380
|
+
hasOAuthProvider: !!profileState?.oauthProvider,
|
|
2381
|
+
});
|
|
2382
|
+
return false;
|
|
2383
|
+
}
|
|
2384
|
+
try {
|
|
2385
|
+
// Get client from OAuth provider
|
|
2386
|
+
// Try to find client by clientId stored in session, or use default client
|
|
2387
|
+
let client;
|
|
2388
|
+
if (session.oauthClientId) {
|
|
2389
|
+
await profileState.oauthProvider.ensureEndpointsInitialized();
|
|
2390
|
+
client = await profileState.oauthProvider.clientsStore.getClient(session.oauthClientId);
|
|
2391
|
+
}
|
|
2392
|
+
// Fallback to default client from config if session client not found
|
|
2393
|
+
if (!client && profileState.oauthProvider) {
|
|
2394
|
+
await profileState.oauthProvider.ensureEndpointsInitialized();
|
|
2395
|
+
// Try common client IDs
|
|
2396
|
+
const defaultClientIds = ['mcp-proxy-client'];
|
|
2397
|
+
if (profileState.context.oauthConfig?.client_id) {
|
|
2398
|
+
defaultClientIds.unshift(profileState.context.oauthConfig.client_id);
|
|
2399
|
+
}
|
|
2400
|
+
for (const clientId of defaultClientIds) {
|
|
2401
|
+
client = await profileState.oauthProvider.clientsStore.getClient(clientId);
|
|
2402
|
+
if (client)
|
|
2403
|
+
break;
|
|
2404
|
+
}
|
|
2405
|
+
}
|
|
2406
|
+
if (!client) {
|
|
2407
|
+
this.logger.error('Cannot refresh token: OAuth client not found', undefined, {
|
|
2408
|
+
profileId,
|
|
2409
|
+
sessionId,
|
|
2410
|
+
oauthClientId: session.oauthClientId,
|
|
2411
|
+
});
|
|
2412
|
+
return false;
|
|
2413
|
+
}
|
|
2414
|
+
// Exchange refresh token for new tokens
|
|
2415
|
+
const tokens = await profileState.oauthProvider.exchangeRefreshToken(client, session.refreshToken, session.scopes);
|
|
2416
|
+
// Update session with new tokens
|
|
2417
|
+
const oldAccessToken = session.authToken;
|
|
2418
|
+
session.authToken = tokens.access_token;
|
|
2419
|
+
session.refreshToken = tokens.refresh_token || session.refreshToken; // Keep old refresh token if new one not provided
|
|
2420
|
+
session.accessTokenExpiresAt = tokens.expires_in
|
|
2421
|
+
? Date.now() + tokens.expires_in * 1000
|
|
2422
|
+
: undefined;
|
|
2423
|
+
// Update token map: remove old token, add new one
|
|
2424
|
+
if (oldAccessToken) {
|
|
2425
|
+
profileState.oauthTokensByAccessToken.delete(oldAccessToken);
|
|
2426
|
+
}
|
|
2427
|
+
this.storeOAuthTokens(profileState, tokens, client.client_id, session.scopes || []);
|
|
2428
|
+
this.logger.info('Access token refreshed successfully', {
|
|
2429
|
+
profileId,
|
|
2430
|
+
sessionId,
|
|
2431
|
+
newExpiresAt: session.accessTokenExpiresAt ? new Date(session.accessTokenExpiresAt).toISOString() : undefined,
|
|
2432
|
+
});
|
|
2433
|
+
return true;
|
|
2434
|
+
}
|
|
2435
|
+
catch (error) {
|
|
2436
|
+
this.logger.error('Token refresh failed', error instanceof Error ? error : new Error(String(error)), {
|
|
2437
|
+
profileId,
|
|
2438
|
+
sessionId,
|
|
2439
|
+
});
|
|
2440
|
+
return false;
|
|
2441
|
+
}
|
|
2442
|
+
}
|
|
2443
|
+
/**
|
|
2444
|
+
* Set message handler for processing incoming JSON-RPC messages
|
|
2445
|
+
*/
|
|
2446
|
+
setMessageHandler(handler) {
|
|
2447
|
+
this.messageHandler = handler;
|
|
2448
|
+
}
|
|
2449
|
+
/**
|
|
2450
|
+
* Check if OAuth provider is configured
|
|
2451
|
+
*/
|
|
2452
|
+
hasOAuthProvider(profileId) {
|
|
2453
|
+
if (!profileId) {
|
|
2454
|
+
const defaultProfileId = this.getDefaultProfileId();
|
|
2455
|
+
if (!defaultProfileId) {
|
|
2456
|
+
return false;
|
|
2457
|
+
}
|
|
2458
|
+
const state = this.profileStates.get(defaultProfileId);
|
|
2459
|
+
return state ? state.oauthProvider !== null : false;
|
|
2460
|
+
}
|
|
2461
|
+
const state = this.profileStates.get(profileId);
|
|
2462
|
+
return state ? state.oauthProvider !== null : false;
|
|
2463
|
+
}
|
|
2464
|
+
/**
|
|
2465
|
+
* Get server URL
|
|
2466
|
+
*/
|
|
2467
|
+
getServerUrl(profileId) {
|
|
2468
|
+
return this.getServerOrigin(profileId);
|
|
2469
|
+
}
|
|
2470
|
+
/**
|
|
2471
|
+
* Get OAuth authorization URL
|
|
2472
|
+
*/
|
|
2473
|
+
getOAuthAuthorizationUrl(profileId) {
|
|
2474
|
+
if (!profileId) {
|
|
2475
|
+
const defaultProfileId = this.getDefaultProfileId();
|
|
2476
|
+
if (!defaultProfileId) {
|
|
2477
|
+
return '';
|
|
2478
|
+
}
|
|
2479
|
+
return this.profileStates.get(defaultProfileId)?.oauthProvider?.authorizationEndpoint || '';
|
|
2480
|
+
}
|
|
2481
|
+
return this.profileStates.get(profileId)?.oauthProvider?.authorizationEndpoint || '';
|
|
2482
|
+
}
|
|
2483
|
+
/**
|
|
2484
|
+
* Get OAuth scopes
|
|
2485
|
+
*/
|
|
2486
|
+
getOAuthScopes(profileId) {
|
|
2487
|
+
if (!profileId) {
|
|
2488
|
+
const defaultProfileId = this.getDefaultProfileId();
|
|
2489
|
+
if (!defaultProfileId) {
|
|
2490
|
+
return [];
|
|
2491
|
+
}
|
|
2492
|
+
return this.profileStates.get(defaultProfileId)?.oauthProvider?.scopes || [];
|
|
2493
|
+
}
|
|
2494
|
+
return this.profileStates.get(profileId)?.oauthProvider?.scopes || [];
|
|
2495
|
+
}
|
|
2496
|
+
/**
|
|
2497
|
+
* Start HTTP server
|
|
2498
|
+
*/
|
|
2499
|
+
async start() {
|
|
2500
|
+
return new Promise((resolve, reject) => {
|
|
2501
|
+
try {
|
|
2502
|
+
// Check for SSL configuration from environment variables
|
|
2503
|
+
const sslCertFile = process.env.MCP4_SSL_CERT_FILE;
|
|
2504
|
+
const sslKeyFile = process.env.MCP4_SSL_KEY_FILE;
|
|
2505
|
+
if (sslCertFile && sslKeyFile) {
|
|
2506
|
+
// Start HTTPS server
|
|
2507
|
+
this.logger.info('SSL configuration detected, starting HTTPS server', {
|
|
2508
|
+
certFile: sslCertFile,
|
|
2509
|
+
keyFile: sslKeyFile,
|
|
2510
|
+
});
|
|
2511
|
+
try {
|
|
2512
|
+
const httpsOptions = {
|
|
2513
|
+
cert: fs.readFileSync(sslCertFile),
|
|
2514
|
+
key: fs.readFileSync(sslKeyFile),
|
|
2515
|
+
};
|
|
2516
|
+
this.server = https.createServer(httpsOptions, this.app);
|
|
2517
|
+
this.server.listen(this.config.port, this.config.host, () => {
|
|
2518
|
+
this.logger.info('HTTPS transport started', {
|
|
2519
|
+
host: this.config.host,
|
|
2520
|
+
port: this.config.port,
|
|
2521
|
+
heartbeat: this.config.heartbeatEnabled,
|
|
2522
|
+
metrics: this.config.metricsEnabled,
|
|
2523
|
+
});
|
|
2524
|
+
// Start session cleanup interval
|
|
2525
|
+
this.cleanupInterval = setInterval(() => this.cleanupExpiredSessions(), TIMEOUTS.CLEANUP_INTERVAL_MS);
|
|
2526
|
+
resolve();
|
|
2527
|
+
});
|
|
2528
|
+
}
|
|
2529
|
+
catch (sslError) {
|
|
2530
|
+
this.logger.error('Failed to start HTTPS server', sslError instanceof Error ? sslError : new Error(String(sslError)));
|
|
2531
|
+
reject(sslError);
|
|
2532
|
+
return;
|
|
2533
|
+
}
|
|
2534
|
+
}
|
|
2535
|
+
else {
|
|
2536
|
+
// Start HTTP server
|
|
2537
|
+
this.server = this.app.listen(this.config.port, this.config.host, () => {
|
|
2538
|
+
this.logger.info('HTTP transport started', {
|
|
2539
|
+
host: this.config.host,
|
|
2540
|
+
port: this.config.port,
|
|
2541
|
+
heartbeat: this.config.heartbeatEnabled,
|
|
2542
|
+
metrics: this.config.metricsEnabled,
|
|
2543
|
+
});
|
|
2544
|
+
// Start session cleanup interval
|
|
2545
|
+
this.cleanupInterval = setInterval(() => this.cleanupExpiredSessions(), TIMEOUTS.CLEANUP_INTERVAL_MS);
|
|
2546
|
+
resolve();
|
|
2547
|
+
});
|
|
2548
|
+
}
|
|
2549
|
+
this.server.on('error', reject);
|
|
2550
|
+
}
|
|
2551
|
+
catch (error) {
|
|
2552
|
+
reject(error);
|
|
2553
|
+
}
|
|
2554
|
+
});
|
|
2555
|
+
}
|
|
2556
|
+
/**
|
|
2557
|
+
* Stop HTTP server
|
|
2558
|
+
*/
|
|
2559
|
+
async stop() {
|
|
2560
|
+
if (this.cleanupInterval) {
|
|
2561
|
+
clearInterval(this.cleanupInterval);
|
|
2562
|
+
this.cleanupInterval = null;
|
|
2563
|
+
}
|
|
2564
|
+
// Destroy all sessions
|
|
2565
|
+
for (const profileState of this.profileStates.values()) {
|
|
2566
|
+
for (const sessionId of profileState.sessions.keys()) {
|
|
2567
|
+
this.destroySession(profileState, sessionId);
|
|
2568
|
+
}
|
|
2569
|
+
}
|
|
2570
|
+
if (this.server) {
|
|
2571
|
+
return new Promise((resolve, reject) => {
|
|
2572
|
+
this.server.close((err) => {
|
|
2573
|
+
if (err)
|
|
2574
|
+
reject(err);
|
|
2575
|
+
else {
|
|
2576
|
+
this.logger.info('HTTP transport stopped');
|
|
2577
|
+
resolve();
|
|
2578
|
+
}
|
|
2579
|
+
});
|
|
2580
|
+
});
|
|
2581
|
+
}
|
|
2582
|
+
}
|
|
2583
|
+
}
|
|
2584
|
+
//# sourceMappingURL=http-transport.js.map
|