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
|
@@ -18,20 +18,20 @@ import { MetricsCollector } from './metrics.js';
|
|
|
18
18
|
import { ExternalOAuthProvider } from './oauth-provider.js';
|
|
19
19
|
import { HTTP_STATUS, MIME_TYPES, OAUTH_PATHS, TIMEOUTS, OAUTH_RATE_LIMIT } from './constants.js';
|
|
20
20
|
import { escapeHtmlSafe } from './validation-utils.js';
|
|
21
|
-
import { AuthenticationError, AuthorizationError, RateLimitError, ValidationError, generateCorrelationId, } from './errors.js';
|
|
22
|
-
|
|
21
|
+
import { AuthenticationError, AuthorizationError, ConfigurationError, RateLimitError, ValidationError, generateCorrelationId, } from './errors.js';
|
|
22
|
+
import { parseFilteringHeader, normalizeFilteringHeaderValue } from './filtering.js';
|
|
23
|
+
import { ToolFilterService, EnvConfigParser, HeaderConfigParser, RegexCompiler, RegexValidator, OperationClassifier, OpenAPIOperationResolver, OperationDetector, normalizeToolFilterHeaderValue, parseSessionToolFilterHeader, } from './tool-filter/index.js';
|
|
23
24
|
const DEFAULT_MAX_TOKEN_LENGTH = 1000;
|
|
24
25
|
export class HttpTransport {
|
|
25
26
|
constructor(config, logger) {
|
|
26
27
|
this.server = null;
|
|
27
|
-
this.sessions = new Map();
|
|
28
28
|
this.metrics = null;
|
|
29
29
|
this.cleanupInterval = null;
|
|
30
30
|
this.messageHandler = null;
|
|
31
|
-
this.
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
this.
|
|
31
|
+
this.profileContextProvider = null;
|
|
32
|
+
this.profileStates = new Map();
|
|
33
|
+
this.oauthRedirectHostCache = new Map();
|
|
34
|
+
this.warnedMissingOAuthRedirectEnvVars = new Set();
|
|
35
35
|
this.hasWarnedAboutBinding = false;
|
|
36
36
|
/**
|
|
37
37
|
* Session destruction listeners for cleanup in other components
|
|
@@ -47,20 +47,6 @@ export class HttpTransport {
|
|
|
47
47
|
prefix: 'mcp_',
|
|
48
48
|
});
|
|
49
49
|
}
|
|
50
|
-
// Initialize OAuth provider if configured
|
|
51
|
-
if (config.oauthConfig) {
|
|
52
|
-
this.logger.info('Initializing OAuth provider with config', { hasClientId: !!config.oauthConfig.client_id });
|
|
53
|
-
this.oauthProvider = new ExternalOAuthProvider(config.oauthConfig, logger);
|
|
54
|
-
// Note: authorizationEndpoint may be undefined at this point if config uses issuer-based discovery
|
|
55
|
-
// It will be resolved lazily on first OAuth operation (authorize/token)
|
|
56
|
-
this.logger.info('OAuth provider initialized', {
|
|
57
|
-
endpoint: this.oauthProvider.authorizationEndpoint || '(to be derived from issuer)',
|
|
58
|
-
hasIssuer: !!config.oauthConfig.issuer,
|
|
59
|
-
});
|
|
60
|
-
}
|
|
61
|
-
else {
|
|
62
|
-
this.logger.info('No OAuth config provided - OAuth provider not initialized');
|
|
63
|
-
}
|
|
64
50
|
this.app = express();
|
|
65
51
|
this.setupMiddleware();
|
|
66
52
|
this.setupRoutes();
|
|
@@ -71,6 +57,21 @@ export class HttpTransport {
|
|
|
71
57
|
* Why: Security (Origin validation, rate limiting), JSON parsing, session extraction, metrics
|
|
72
58
|
*/
|
|
73
59
|
setupMiddleware() {
|
|
60
|
+
// Security: standard headers
|
|
61
|
+
this.app.disable('x-powered-by');
|
|
62
|
+
this.app.use((req, res, next) => {
|
|
63
|
+
res.setHeader('Content-Security-Policy', "default-src 'self'; frame-ancestors 'none'");
|
|
64
|
+
res.setHeader('X-Frame-Options', 'DENY');
|
|
65
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
66
|
+
res.setHeader('Referrer-Policy', 'no-referrer');
|
|
67
|
+
res.setHeader('Permissions-Policy', 'geolocation=(), microphone=(), camera=(), payment=()');
|
|
68
|
+
// Additional security headers
|
|
69
|
+
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
|
|
70
|
+
res.setHeader('Cross-Origin-Resource-Policy', 'same-origin');
|
|
71
|
+
res.setHeader('X-Permitted-Cross-Domain-Policies', 'none');
|
|
72
|
+
res.setHeader('X-DNS-Prefetch-Control', 'off');
|
|
73
|
+
next();
|
|
74
|
+
});
|
|
74
75
|
// Request logging (before any middleware)
|
|
75
76
|
this.app.use((req, res, next) => {
|
|
76
77
|
this.logger.debug('Request received', {
|
|
@@ -103,7 +104,9 @@ export class HttpTransport {
|
|
|
103
104
|
next();
|
|
104
105
|
});
|
|
105
106
|
// JSON body parser
|
|
106
|
-
|
|
107
|
+
// Limit set to 10MB to support large tool inputs/results (e.g. file content)
|
|
108
|
+
// while preventing massive DoS attacks. Default is 100kb.
|
|
109
|
+
this.app.use(express.json({ limit: '10mb' }));
|
|
107
110
|
// Metrics: Track request start time
|
|
108
111
|
this.app.use((req, res, next) => {
|
|
109
112
|
req.startTime = Date.now();
|
|
@@ -157,19 +160,31 @@ export class HttpTransport {
|
|
|
157
160
|
this.logger.warn('HTTP transport bound to 0.0.0.0 - accessible from network. Ensure firewall protection.');
|
|
158
161
|
this.hasWarnedAboutBinding = true;
|
|
159
162
|
}
|
|
160
|
-
//
|
|
161
|
-
|
|
162
|
-
|
|
163
|
+
// Validate Origin header
|
|
164
|
+
// We do not skip this check for localhost, as requests to localhost can still be CSRF targets
|
|
165
|
+
if (!origin) {
|
|
166
|
+
next();
|
|
167
|
+
return;
|
|
163
168
|
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
169
|
+
this.isAllowedOriginForRequest(origin, req)
|
|
170
|
+
.then((allowed) => {
|
|
171
|
+
if (!allowed) {
|
|
172
|
+
this.logger.warn('Rejected request from disallowed origin', { origin, ip: req.ip });
|
|
173
|
+
res.status(HTTP_STATUS.FORBIDDEN).json({
|
|
174
|
+
error: 'Forbidden',
|
|
175
|
+
message: 'Origin not allowed'
|
|
176
|
+
});
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
next();
|
|
180
|
+
})
|
|
181
|
+
.catch((error) => {
|
|
182
|
+
this.logger.error('Origin validation failed', error instanceof Error ? error : new Error(String(error)));
|
|
183
|
+
res.status(HTTP_STATUS.FORBIDDEN).json({
|
|
168
184
|
error: 'Forbidden',
|
|
169
185
|
message: 'Origin not allowed'
|
|
170
186
|
});
|
|
171
|
-
}
|
|
172
|
-
next();
|
|
187
|
+
});
|
|
173
188
|
});
|
|
174
189
|
// Extract session ID from header
|
|
175
190
|
this.app.use((req, res, next) => {
|
|
@@ -180,6 +195,101 @@ export class HttpTransport {
|
|
|
180
195
|
next();
|
|
181
196
|
});
|
|
182
197
|
}
|
|
198
|
+
setProfileContextProvider(provider) {
|
|
199
|
+
this.profileContextProvider = provider;
|
|
200
|
+
}
|
|
201
|
+
getDefaultProfileId() {
|
|
202
|
+
if (this.config.defaultProfileId) {
|
|
203
|
+
return this.config.defaultProfileId;
|
|
204
|
+
}
|
|
205
|
+
if (this.config.profileRoutingEnabled) {
|
|
206
|
+
return undefined;
|
|
207
|
+
}
|
|
208
|
+
return 'default';
|
|
209
|
+
}
|
|
210
|
+
buildDefaultProfileContext() {
|
|
211
|
+
const profileId = this.getDefaultProfileId();
|
|
212
|
+
if (!profileId) {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
profileId,
|
|
217
|
+
oauthConfig: this.config.oauthConfig,
|
|
218
|
+
authConfigs: this.config.authConfigs,
|
|
219
|
+
baseUrl: this.config.baseUrl,
|
|
220
|
+
rateLimitOAuthMax: this.config.rateLimitOAuthMax,
|
|
221
|
+
rateLimitOAuthWindowMs: this.config.rateLimitOAuthWindowMs,
|
|
222
|
+
resourceName: this.config.resourceName,
|
|
223
|
+
resourceDocumentation: this.config.resourceDocumentation,
|
|
224
|
+
parser: this.config.parser,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
async getProfileState(profileId) {
|
|
228
|
+
const existing = this.profileStates.get(profileId);
|
|
229
|
+
if (existing) {
|
|
230
|
+
return existing;
|
|
231
|
+
}
|
|
232
|
+
let context = null;
|
|
233
|
+
if (this.profileContextProvider) {
|
|
234
|
+
try {
|
|
235
|
+
context = await this.profileContextProvider(profileId);
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
if (error instanceof ConfigurationError && error.message === 'Profile not found') {
|
|
239
|
+
this.logger.warn('Profile not found during request', { profileId });
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
throw error;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
else {
|
|
246
|
+
const defaultContext = this.buildDefaultProfileContext();
|
|
247
|
+
if (defaultContext?.profileId === profileId) {
|
|
248
|
+
context = defaultContext;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (!context) {
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
let oauthProvider = null;
|
|
255
|
+
if (context.oauthConfig) {
|
|
256
|
+
this.logger.info('Initializing OAuth provider with config', {
|
|
257
|
+
profileId,
|
|
258
|
+
hasClientId: !!context.oauthConfig.client_id,
|
|
259
|
+
});
|
|
260
|
+
oauthProvider = new ExternalOAuthProvider(context.oauthConfig, this.logger);
|
|
261
|
+
this.logger.info('OAuth provider initialized', {
|
|
262
|
+
profileId,
|
|
263
|
+
endpoint: oauthProvider.authorizationEndpoint || '(to be derived from issuer)',
|
|
264
|
+
hasIssuer: !!context.oauthConfig.issuer,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
else {
|
|
268
|
+
this.logger.info('No OAuth config provided - OAuth provider not initialized', { profileId });
|
|
269
|
+
}
|
|
270
|
+
const state = {
|
|
271
|
+
profileId,
|
|
272
|
+
context,
|
|
273
|
+
oauthProvider,
|
|
274
|
+
oauthTokensByAccessToken: new Map(),
|
|
275
|
+
sessions: new Map(),
|
|
276
|
+
};
|
|
277
|
+
this.profileStates.set(profileId, state);
|
|
278
|
+
return state;
|
|
279
|
+
}
|
|
280
|
+
getProfileIdForRequest(req) {
|
|
281
|
+
if (req.profileId) {
|
|
282
|
+
return req.profileId;
|
|
283
|
+
}
|
|
284
|
+
return this.getDefaultProfileId();
|
|
285
|
+
}
|
|
286
|
+
async getProfileStateForRequest(req) {
|
|
287
|
+
const profileId = this.getProfileIdForRequest(req);
|
|
288
|
+
if (!profileId) {
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
return await this.getProfileState(profileId);
|
|
292
|
+
}
|
|
183
293
|
/**
|
|
184
294
|
* Check if origin is allowed
|
|
185
295
|
*
|
|
@@ -203,16 +313,10 @@ export class HttpTransport {
|
|
|
203
313
|
if (hostname === this.config.host) {
|
|
204
314
|
return true;
|
|
205
315
|
}
|
|
206
|
-
// Allow OAuth redirect URI
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
if (hostname === redirectUrl.hostname) {
|
|
211
|
-
return true;
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
catch {
|
|
215
|
-
// Invalid URL, ignore
|
|
316
|
+
// Allow OAuth redirect URI hosts (initialized + cached + configured)
|
|
317
|
+
for (const redirectHost of this.getOAuthRedirectHostPatterns()) {
|
|
318
|
+
if (this.matchOrigin(hostname, redirectHost)) {
|
|
319
|
+
return true;
|
|
216
320
|
}
|
|
217
321
|
}
|
|
218
322
|
// Check custom allowed origins
|
|
@@ -229,6 +333,139 @@ export class HttpTransport {
|
|
|
229
333
|
return false;
|
|
230
334
|
}
|
|
231
335
|
}
|
|
336
|
+
getOAuthRedirectHostPatterns() {
|
|
337
|
+
const hosts = new Set();
|
|
338
|
+
const defaultProfileId = this.getDefaultProfileId() ?? 'default';
|
|
339
|
+
for (const state of this.profileStates.values()) {
|
|
340
|
+
const redirectUri = state.oauthProvider?.redirectUri;
|
|
341
|
+
if (!redirectUri) {
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
try {
|
|
345
|
+
const redirectUrl = new URL(redirectUri);
|
|
346
|
+
hosts.add(redirectUrl.hostname);
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
// Ignore invalid URL
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
if (this.config.oauthConfig) {
|
|
353
|
+
for (const pattern of this.extractRedirectHostPatterns(this.config.oauthConfig, defaultProfileId)) {
|
|
354
|
+
hosts.add(pattern);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
for (const patterns of this.oauthRedirectHostCache.values()) {
|
|
358
|
+
for (const pattern of patterns) {
|
|
359
|
+
hosts.add(pattern);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return Array.from(hosts);
|
|
363
|
+
}
|
|
364
|
+
extractRedirectHostPatterns(oauthConfig, profileId) {
|
|
365
|
+
if (!oauthConfig?.redirect_uri) {
|
|
366
|
+
return [];
|
|
367
|
+
}
|
|
368
|
+
const resolvedRedirectUri = this.resolveRedirectUriFromEnv(oauthConfig.redirect_uri, profileId);
|
|
369
|
+
if (!resolvedRedirectUri) {
|
|
370
|
+
return [];
|
|
371
|
+
}
|
|
372
|
+
try {
|
|
373
|
+
const redirectUrl = new URL(resolvedRedirectUri);
|
|
374
|
+
return [redirectUrl.hostname];
|
|
375
|
+
}
|
|
376
|
+
catch {
|
|
377
|
+
return [];
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
resolveRedirectUriFromEnv(value, profileId) {
|
|
381
|
+
const match = value.match(/^\$\{env:([^}]+)\}$/);
|
|
382
|
+
if (!match) {
|
|
383
|
+
return value;
|
|
384
|
+
}
|
|
385
|
+
const envVar = match[1];
|
|
386
|
+
const envValue = process.env[envVar];
|
|
387
|
+
if (!envValue || envValue.trim().length === 0) {
|
|
388
|
+
const warningKey = `${profileId}:${envVar}`;
|
|
389
|
+
if (!this.warnedMissingOAuthRedirectEnvVars.has(warningKey)) {
|
|
390
|
+
this.warnedMissingOAuthRedirectEnvVars.add(warningKey);
|
|
391
|
+
this.logger.warn('OAuth redirect_uri environment variable is empty', {
|
|
392
|
+
profileId,
|
|
393
|
+
envVar,
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
return undefined;
|
|
397
|
+
}
|
|
398
|
+
return envValue;
|
|
399
|
+
}
|
|
400
|
+
resolveProfileIdFromPath(pathname) {
|
|
401
|
+
if (!this.config.profileRoutingEnabled) {
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
404
|
+
const match = /^\/profile\/([^/]+)/.exec(pathname);
|
|
405
|
+
if (!match) {
|
|
406
|
+
return null;
|
|
407
|
+
}
|
|
408
|
+
try {
|
|
409
|
+
return decodeURIComponent(match[1]);
|
|
410
|
+
}
|
|
411
|
+
catch {
|
|
412
|
+
return null;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
resolveProfileIdForOriginCheck(req) {
|
|
416
|
+
const pathProfileId = this.resolveProfileIdFromPath(req.path);
|
|
417
|
+
if (pathProfileId) {
|
|
418
|
+
return pathProfileId;
|
|
419
|
+
}
|
|
420
|
+
const resource = req.query?.resource;
|
|
421
|
+
if (typeof resource === 'string') {
|
|
422
|
+
const profileId = this.resolveProfileIdFromResourceUrl(resource);
|
|
423
|
+
if (profileId) {
|
|
424
|
+
return profileId;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return this.getDefaultProfileId() ?? null;
|
|
428
|
+
}
|
|
429
|
+
async primeOAuthRedirectHosts(profileId) {
|
|
430
|
+
if (!this.profileContextProvider) {
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
if (this.oauthRedirectHostCache.has(profileId)) {
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
try {
|
|
437
|
+
const context = await this.profileContextProvider(profileId);
|
|
438
|
+
if (!context?.oauthConfig) {
|
|
439
|
+
this.oauthRedirectHostCache.set(profileId, []);
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
const patterns = this.extractRedirectHostPatterns(context.oauthConfig, profileId);
|
|
443
|
+
this.oauthRedirectHostCache.set(profileId, patterns);
|
|
444
|
+
}
|
|
445
|
+
catch (error) {
|
|
446
|
+
if (error instanceof ConfigurationError && error.message === 'Profile not found') {
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
this.logger.warn('Failed to preload OAuth redirect hosts', {
|
|
450
|
+
profileId,
|
|
451
|
+
error: String(error),
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
async isAllowedOriginForRequest(origin, req) {
|
|
456
|
+
if (this.isAllowedOrigin(origin)) {
|
|
457
|
+
return true;
|
|
458
|
+
}
|
|
459
|
+
if (!this.config.profileRoutingEnabled) {
|
|
460
|
+
return false;
|
|
461
|
+
}
|
|
462
|
+
const profileId = this.resolveProfileIdForOriginCheck(req);
|
|
463
|
+
if (!profileId) {
|
|
464
|
+
return false;
|
|
465
|
+
}
|
|
466
|
+
await this.primeOAuthRedirectHosts(profileId);
|
|
467
|
+
return this.isAllowedOrigin(origin);
|
|
468
|
+
}
|
|
232
469
|
/**
|
|
233
470
|
* Match hostname against allowed origin pattern
|
|
234
471
|
*
|
|
@@ -280,9 +517,12 @@ export class HttpTransport {
|
|
|
280
517
|
}
|
|
281
518
|
const ipInt = this.ipv4ToInt(ip);
|
|
282
519
|
const rangeInt = this.ipv4ToInt(range);
|
|
520
|
+
/* c8 ignore start - defensive check for edge cases where isIP() passes but parsing fails
|
|
521
|
+
* This should never happen in practice, but serves as a fail-safe */
|
|
283
522
|
if (ipInt === null || rangeInt === null) {
|
|
284
523
|
return false;
|
|
285
524
|
}
|
|
525
|
+
/* c8 ignore end */
|
|
286
526
|
const mask = (0xFFFFFFFF << (32 - maskBits)) >>> 0;
|
|
287
527
|
return (ipInt & mask) === (rangeInt & mask);
|
|
288
528
|
}
|
|
@@ -292,9 +532,12 @@ export class HttpTransport {
|
|
|
292
532
|
}
|
|
293
533
|
const ipInt = this.ipv6ToBigInt(ip);
|
|
294
534
|
const rangeInt = this.ipv6ToBigInt(range);
|
|
535
|
+
/* c8 ignore start - defensive check for edge cases where isIP() passes but parsing fails
|
|
536
|
+
* This should never happen in practice, but serves as a fail-safe */
|
|
295
537
|
if (ipInt === null || rangeInt === null) {
|
|
296
538
|
return false;
|
|
297
539
|
}
|
|
540
|
+
/* c8 ignore end */
|
|
298
541
|
const mask = this.ipv6Mask(maskBits);
|
|
299
542
|
return (ipInt & mask) === (rangeInt & mask);
|
|
300
543
|
}
|
|
@@ -368,17 +611,21 @@ export class HttpTransport {
|
|
|
368
611
|
return null;
|
|
369
612
|
}
|
|
370
613
|
segments = [...headVals, ...Array(missing).fill(0), ...tailVals];
|
|
614
|
+
/* c8 ignore start - defensive check that should never trigger if logic above is correct */
|
|
371
615
|
if (segments.length !== totalSegmentsNeeded) {
|
|
372
616
|
return null;
|
|
373
617
|
}
|
|
618
|
+
/* c8 ignore end */
|
|
374
619
|
if (ipv4Tail !== null) {
|
|
375
620
|
const high = (ipv4Tail >>> 16) & 0xFFFF;
|
|
376
621
|
const low = ipv4Tail & 0xFFFF;
|
|
377
622
|
segments.push(high, low);
|
|
378
623
|
}
|
|
624
|
+
/* c8 ignore start - defensive check that should never trigger if logic above is correct */
|
|
379
625
|
if (segments.length !== 8) {
|
|
380
626
|
return null;
|
|
381
627
|
}
|
|
628
|
+
/* c8 ignore end */
|
|
382
629
|
let value = 0n;
|
|
383
630
|
for (const part of segments) {
|
|
384
631
|
value = (value << 16n) + BigInt(part);
|
|
@@ -428,6 +675,83 @@ export class HttpTransport {
|
|
|
428
675
|
formatRateLimitMessage(scope, maxRequests, windowMs) {
|
|
429
676
|
return `Rate limit exceeded for ${scope}. Max ${maxRequests} requests per ${windowMs / 1000} seconds.`;
|
|
430
677
|
}
|
|
678
|
+
getProfilePrefix(profileId, options) {
|
|
679
|
+
if (!this.config.profileRoutingEnabled) {
|
|
680
|
+
return '';
|
|
681
|
+
}
|
|
682
|
+
if (options?.forceProfilePrefix && profileId) {
|
|
683
|
+
return `/profile/${encodeURIComponent(profileId)}`;
|
|
684
|
+
}
|
|
685
|
+
const defaultProfileId = this.getDefaultProfileId();
|
|
686
|
+
if (!profileId || (defaultProfileId && profileId === defaultProfileId)) {
|
|
687
|
+
return '';
|
|
688
|
+
}
|
|
689
|
+
return `/profile/${encodeURIComponent(profileId)}`;
|
|
690
|
+
}
|
|
691
|
+
buildProfilePath(profileId, path, options) {
|
|
692
|
+
const prefix = this.getProfilePrefix(profileId, options);
|
|
693
|
+
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
|
694
|
+
return `${prefix}${normalizedPath}`;
|
|
695
|
+
}
|
|
696
|
+
getServerOrigin(profileId) {
|
|
697
|
+
if (profileId) {
|
|
698
|
+
const state = this.profileStates.get(profileId);
|
|
699
|
+
if (state?.oauthProvider?.redirectUri) {
|
|
700
|
+
try {
|
|
701
|
+
return new URL(state.oauthProvider.redirectUri).origin;
|
|
702
|
+
}
|
|
703
|
+
catch {
|
|
704
|
+
// Ignore invalid URL
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
const protocol = this.config.host.includes('://') ? '' : 'http://';
|
|
709
|
+
const host = this.config.host.includes('://') ? this.config.host : this.config.host;
|
|
710
|
+
return `${protocol}${host}:${this.config.port}`;
|
|
711
|
+
}
|
|
712
|
+
buildProfileUrl(profileId, path, options) {
|
|
713
|
+
return `${this.getServerOrigin(profileId)}${this.buildProfilePath(profileId, path, options)}`;
|
|
714
|
+
}
|
|
715
|
+
normalizeResourcePath(pathname) {
|
|
716
|
+
const normalized = pathname.replace(/\/+$/, '');
|
|
717
|
+
return normalized === '' ? '/' : normalized;
|
|
718
|
+
}
|
|
719
|
+
resolveProfileIdFromResourceUrl(resource) {
|
|
720
|
+
let url;
|
|
721
|
+
try {
|
|
722
|
+
url = new URL(resource);
|
|
723
|
+
}
|
|
724
|
+
catch {
|
|
725
|
+
return null;
|
|
726
|
+
}
|
|
727
|
+
const path = this.normalizeResourcePath(url.pathname);
|
|
728
|
+
if (path === '/mcp') {
|
|
729
|
+
return this.getDefaultProfileId() ?? null;
|
|
730
|
+
}
|
|
731
|
+
if (!this.config.profileRoutingEnabled) {
|
|
732
|
+
return null;
|
|
733
|
+
}
|
|
734
|
+
const match = /^\/profile\/([^/]+)\/mcp$/.exec(path);
|
|
735
|
+
if (!match) {
|
|
736
|
+
return null;
|
|
737
|
+
}
|
|
738
|
+
try {
|
|
739
|
+
return decodeURIComponent(match[1]);
|
|
740
|
+
}
|
|
741
|
+
catch {
|
|
742
|
+
return null;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
getOAuthProtectedResourceUrl(profileId) {
|
|
746
|
+
const effectiveProfileId = profileId ?? this.getDefaultProfileId();
|
|
747
|
+
return this.buildProfileUrl(effectiveProfileId, OAUTH_PATHS.WELL_KNOWN_PROTECTED_RESOURCE);
|
|
748
|
+
}
|
|
749
|
+
respondProfileNotFound(res, profileId) {
|
|
750
|
+
res.status(HTTP_STATUS.NOT_FOUND).json({
|
|
751
|
+
error: 'Not Found',
|
|
752
|
+
message: profileId ? `Profile '${profileId}' not found` : 'Profile not found',
|
|
753
|
+
});
|
|
754
|
+
}
|
|
431
755
|
/**
|
|
432
756
|
* Setup MCP endpoint routes
|
|
433
757
|
*
|
|
@@ -437,303 +761,100 @@ export class HttpTransport {
|
|
|
437
761
|
this.logger.info('Setting up HTTP routes');
|
|
438
762
|
// Security: Rate limiting setup (needed for OAuth routes)
|
|
439
763
|
const rateLimitEnabled = this.config.rateLimitEnabled !== false; // default: true
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
});
|
|
452
|
-
// OAuth 2.0 routes (if configured)
|
|
453
|
-
if (this.oauthProvider) {
|
|
454
|
-
// Build redirect URI
|
|
455
|
-
const redirectUri = this.oauthProvider.redirectUri ||
|
|
456
|
-
`http://${this.config.host}:${this.config.port}/oauth/callback`;
|
|
457
|
-
// Derive serverUrl from redirectUri
|
|
458
|
-
const baseUrl = new URL(redirectUri).origin;
|
|
459
|
-
const serverUrl = new URL(`${baseUrl}/mcp`);
|
|
460
|
-
// issuerUrl should be the base URL of the authorization server (e.g. https://gitlab.com),
|
|
461
|
-
// NOT the authorization endpoint (e.g. https://gitlab.com/oauth/authorize)
|
|
462
|
-
// We try to derive it from authorizationEndpoint if not explicitly configured
|
|
463
|
-
// Note: authorizationEndpoint might not be ready yet (async initialization),
|
|
464
|
-
// so we use serverUrl.origin as fallback
|
|
465
|
-
let issuerUrl;
|
|
466
|
-
try {
|
|
467
|
-
const authEndpoint = this.oauthProvider.authorizationEndpoint;
|
|
468
|
-
if (authEndpoint) {
|
|
469
|
-
// Try to extract base URL from auth endpoint
|
|
470
|
-
const authUrl = new URL(authEndpoint);
|
|
471
|
-
issuerUrl = new URL(authUrl.origin);
|
|
472
|
-
}
|
|
473
|
-
else {
|
|
474
|
-
// Fallback: use server origin (will be updated after async init)
|
|
475
|
-
issuerUrl = new URL(serverUrl.origin);
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
catch (e) {
|
|
479
|
-
// Fallback: use server origin
|
|
480
|
-
issuerUrl = new URL(serverUrl.origin);
|
|
764
|
+
const profileRoutingEnabled = this.config.profileRoutingEnabled === true;
|
|
765
|
+
const defaultProfileId = this.getDefaultProfileId();
|
|
766
|
+
const attachProfileId = (req, _res, next) => {
|
|
767
|
+
req.profileId = req.params.profileId;
|
|
768
|
+
next();
|
|
769
|
+
};
|
|
770
|
+
const oauthRateLimiterByProfile = new Map();
|
|
771
|
+
const getOAuthRateLimiter = (profileState) => {
|
|
772
|
+
const existing = oauthRateLimiterByProfile.get(profileState.profileId);
|
|
773
|
+
if (existing) {
|
|
774
|
+
return existing;
|
|
481
775
|
}
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
// - /.well-known/oauth-protected-resource
|
|
491
|
-
// - /oauth/authorize
|
|
492
|
-
// - /oauth/token
|
|
493
|
-
// - /oauth/register (dynamic client registration)
|
|
494
|
-
// - /oauth/revoke (token revocation)
|
|
495
|
-
// Only register resource server endpoints, not authorization server endpoints
|
|
496
|
-
// since our MCP server is not an OAuth authorization server
|
|
497
|
-
this.app.get(OAUTH_PATHS.WELL_KNOWN_PROTECTED_RESOURCE, oauthRateLimiter, (req, res) => {
|
|
498
|
-
// Build metadata object with only defined fields (RFC 8707)
|
|
499
|
-
const metadata = {
|
|
500
|
-
resource: serverUrl.href,
|
|
501
|
-
authorization_servers: [serverUrl.origin], // We are the authorization server (proxy)
|
|
502
|
-
bearer_methods_supported: ['header'],
|
|
503
|
-
};
|
|
504
|
-
// Optional: scopes_supported (only if scopes are defined)
|
|
505
|
-
if (this.oauthProvider?.scopes && this.oauthProvider.scopes.length > 0) {
|
|
506
|
-
metadata.scopes_supported = this.oauthProvider.scopes;
|
|
507
|
-
}
|
|
508
|
-
// Optional: resource_name (from config, already has fallback in mcp-server.ts)
|
|
509
|
-
if (this.config.resourceName) {
|
|
510
|
-
metadata.resource_name = this.config.resourceName;
|
|
511
|
-
}
|
|
512
|
-
// Optional: resource_documentation (from config, may be undefined)
|
|
513
|
-
if (this.config.resourceDocumentation) {
|
|
514
|
-
metadata.resource_documentation = this.config.resourceDocumentation;
|
|
515
|
-
}
|
|
516
|
-
res.json(metadata);
|
|
517
|
-
});
|
|
518
|
-
// Authorization endpoint
|
|
519
|
-
// Initiates the OAuth flow by redirecting the user to the external provider
|
|
520
|
-
this.app.get(OAUTH_PATHS.AUTHORIZE, oauthRateLimiter, async (req, res) => {
|
|
521
|
-
try {
|
|
522
|
-
const { response_type, client_id, redirect_uri, scope, state, code_challenge, code_challenge_method } = req.query;
|
|
523
|
-
if (!client_id || typeof client_id !== 'string') {
|
|
524
|
-
res.status(HTTP_STATUS.BAD_REQUEST).send('Missing client_id');
|
|
525
|
-
return;
|
|
526
|
-
}
|
|
527
|
-
if (!redirect_uri || typeof redirect_uri !== 'string') {
|
|
528
|
-
res.status(HTTP_STATUS.BAD_REQUEST).send('Missing redirect_uri');
|
|
529
|
-
return;
|
|
530
|
-
}
|
|
531
|
-
if (this.oauthProvider) {
|
|
532
|
-
// Ensure provider is initialized before client validation
|
|
533
|
-
// This registers configured client_id if present
|
|
534
|
-
await this.oauthProvider.ensureEndpointsInitialized();
|
|
535
|
-
// Find the client to validate configuration
|
|
536
|
-
const client = await this.oauthProvider.clientsStore.getClient(client_id);
|
|
537
|
-
if (!client) {
|
|
538
|
-
res.status(HTTP_STATUS.BAD_REQUEST).send('Invalid client_id');
|
|
539
|
-
return;
|
|
540
|
-
}
|
|
541
|
-
// Prepare parameters for provider authorization
|
|
542
|
-
const scopeStr = (scope || '').trim();
|
|
543
|
-
const params = {
|
|
544
|
-
responseType: response_type || 'code',
|
|
545
|
-
clientId: client_id,
|
|
546
|
-
redirectUri: redirect_uri,
|
|
547
|
-
scope: scopeStr ? scopeStr.split(' ') : [],
|
|
548
|
-
state: state,
|
|
549
|
-
codeChallenge: code_challenge,
|
|
550
|
-
codeChallengeMethod: code_challenge_method,
|
|
551
|
-
scopes: scopeStr ? scopeStr.split(' ') : [],
|
|
552
|
-
};
|
|
553
|
-
// Call provider authorize method which handles the redirect logic
|
|
554
|
-
await this.oauthProvider.authorize(client, params, res);
|
|
555
|
-
}
|
|
556
|
-
else {
|
|
557
|
-
res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send('OAuth provider not initialized');
|
|
558
|
-
}
|
|
559
|
-
}
|
|
560
|
-
catch (error) {
|
|
561
|
-
this.logger.error('OAuth authorize error', error instanceof Error ? error : new Error(String(error)));
|
|
562
|
-
res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send('OAuth authorization failed');
|
|
563
|
-
}
|
|
564
|
-
});
|
|
565
|
-
// Token endpoint
|
|
566
|
-
// Exchanges authorization code or refresh token for access token
|
|
567
|
-
this.app.post(OAUTH_PATHS.TOKEN, oauthRateLimiter, express.urlencoded({ extended: false }), async (req, res) => {
|
|
568
|
-
try {
|
|
569
|
-
const { grant_type, code, redirect_uri, client_id, code_verifier, refresh_token } = req.body;
|
|
570
|
-
this.logger.debug('OAuth token request', {
|
|
571
|
-
grant_type,
|
|
572
|
-
client_id,
|
|
573
|
-
has_code: !!code,
|
|
574
|
-
has_code_verifier: !!code_verifier,
|
|
575
|
-
redirect_uri,
|
|
576
|
-
});
|
|
577
|
-
if (grant_type === 'authorization_code') {
|
|
578
|
-
// Authorization Code Flow
|
|
579
|
-
if (!code) {
|
|
580
|
-
res.status(HTTP_STATUS.BAD_REQUEST).json({ error: 'invalid_request', error_description: 'Missing code' });
|
|
581
|
-
return;
|
|
582
|
-
}
|
|
583
|
-
if (this.oauthProvider) {
|
|
584
|
-
// Ensure provider is initialized before client validation
|
|
585
|
-
await this.oauthProvider.ensureEndpointsInitialized();
|
|
586
|
-
const client = await this.oauthProvider.clientsStore.getClient(client_id);
|
|
587
|
-
if (!client) {
|
|
588
|
-
res.status(HTTP_STATUS.BAD_REQUEST).json({ error: 'invalid_client' });
|
|
589
|
-
return;
|
|
590
|
-
}
|
|
591
|
-
const tokens = await this.oauthProvider.exchangeAuthorizationCode(client, code, code_verifier, redirect_uri);
|
|
592
|
-
// Store OAuth tokens for later session initialization
|
|
593
|
-
this.storeOAuthTokens(tokens, client.client_id, client.scope?.split(' ') || []);
|
|
594
|
-
res.json(tokens);
|
|
595
|
-
}
|
|
596
|
-
else {
|
|
597
|
-
res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({ error: 'server_error', error_description: 'OAuth provider not initialized' });
|
|
598
|
-
}
|
|
599
|
-
}
|
|
600
|
-
else if (grant_type === 'refresh_token') {
|
|
601
|
-
// Refresh Token Flow
|
|
602
|
-
if (!refresh_token) {
|
|
603
|
-
res.status(HTTP_STATUS.BAD_REQUEST).json({ error: 'invalid_request', error_description: 'Missing refresh_token' });
|
|
604
|
-
return;
|
|
605
|
-
}
|
|
606
|
-
if (this.oauthProvider) {
|
|
607
|
-
// Ensure provider is initialized before client validation
|
|
608
|
-
await this.oauthProvider.ensureEndpointsInitialized();
|
|
609
|
-
const client = await this.oauthProvider.clientsStore.getClient(client_id);
|
|
610
|
-
if (!client) {
|
|
611
|
-
res.status(HTTP_STATUS.BAD_REQUEST).json({ error: 'invalid_client' });
|
|
612
|
-
return;
|
|
613
|
-
}
|
|
614
|
-
const tokens = await this.oauthProvider.exchangeRefreshToken(client, refresh_token);
|
|
615
|
-
// Store OAuth tokens for later session initialization
|
|
616
|
-
// Note: When refreshing, the old access token should be invalidated
|
|
617
|
-
// but we don't track it here - the new token replaces it in the map
|
|
618
|
-
this.storeOAuthTokens(tokens, client.client_id, client.scope?.split(' ') || []);
|
|
619
|
-
res.json(tokens);
|
|
620
|
-
}
|
|
621
|
-
else {
|
|
622
|
-
res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({ error: 'server_error', error_description: 'OAuth provider not initialized' });
|
|
623
|
-
}
|
|
624
|
-
}
|
|
625
|
-
else {
|
|
626
|
-
this.logger.warn('Unsupported grant type', { grant_type, expected: 'authorization_code or refresh_token' });
|
|
627
|
-
res.status(HTTP_STATUS.BAD_REQUEST).json({ error: 'unsupported_grant_type' });
|
|
628
|
-
return;
|
|
629
|
-
}
|
|
630
|
-
}
|
|
631
|
-
catch (error) {
|
|
632
|
-
this.logger.error('OAuth token exchange error', error instanceof Error ? error : new Error(String(error)));
|
|
633
|
-
res.status(HTTP_STATUS.BAD_REQUEST).json({ error: 'invalid_grant', error_description: String(error) });
|
|
634
|
-
}
|
|
776
|
+
const oauthWindowMs = profileState.context.rateLimitOAuthWindowMs || OAUTH_RATE_LIMIT.WINDOW_MS;
|
|
777
|
+
const oauthMaxRequests = profileState.context.rateLimitOAuthMax || OAUTH_RATE_LIMIT.MAX_REQUESTS;
|
|
778
|
+
const limiter = this.createRateLimiter({
|
|
779
|
+
enabled: rateLimitEnabled,
|
|
780
|
+
windowMs: oauthWindowMs,
|
|
781
|
+
maxRequests: oauthMaxRequests,
|
|
782
|
+
logMessage: 'Rate limit exceeded for OAuth',
|
|
783
|
+
responseMessage: `Too many OAuth requests. Limit: ${oauthMaxRequests} requests per ${Math.round(oauthWindowMs / 60000)} minutes. Please try again later.`,
|
|
635
784
|
});
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
return;
|
|
655
|
-
}
|
|
656
|
-
if (!code || typeof code !== 'string') {
|
|
657
|
-
res.status(HTTP_STATUS.BAD_REQUEST).send('Missing authorization code');
|
|
658
|
-
return;
|
|
659
|
-
}
|
|
660
|
-
// Delegate to OAuth provider to handle token exchange and redirect back to client (Cursor)
|
|
661
|
-
if (this.oauthProvider) {
|
|
662
|
-
await this.oauthProvider.handleCallback(req, res);
|
|
663
|
-
}
|
|
664
|
-
else {
|
|
665
|
-
res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send('OAuth provider not initialized');
|
|
666
|
-
}
|
|
667
|
-
}
|
|
668
|
-
catch (error) {
|
|
669
|
-
this.logger.error('OAuth callback error', error instanceof Error ? error : new Error(String(error)));
|
|
670
|
-
if (!res.headersSent) {
|
|
671
|
-
res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send('OAuth callback failed');
|
|
672
|
-
}
|
|
673
|
-
}
|
|
674
|
-
});
|
|
675
|
-
// Provide authorization server metadata
|
|
676
|
-
// We advertise the MCP server itself as the authorization server (Proxy Mode)
|
|
677
|
-
// This allows us to handle the redirect dance between Cursor -> MCP -> GitLab -> MCP -> Cursor
|
|
678
|
-
this.app.get(OAUTH_PATHS.WELL_KNOWN_AUTHORIZATION_SERVER, async (req, res) => {
|
|
679
|
-
try {
|
|
680
|
-
res.json({
|
|
681
|
-
issuer: serverUrl.origin, // We are the issuer for the client
|
|
682
|
-
authorization_endpoint: new URL(OAUTH_PATHS.AUTHORIZE, serverUrl.origin).href,
|
|
683
|
-
token_endpoint: new URL(OAUTH_PATHS.TOKEN, serverUrl.origin).href,
|
|
684
|
-
registration_endpoint: new URL(OAUTH_PATHS.REGISTER, serverUrl.origin).href,
|
|
685
|
-
response_types_supported: ['code'],
|
|
686
|
-
code_challenge_methods_supported: ['S256'],
|
|
687
|
-
grant_types_supported: ['authorization_code', 'refresh_token'],
|
|
688
|
-
scopes_supported: this.oauthProvider?.scopes || ['api'],
|
|
689
|
-
});
|
|
690
|
-
}
|
|
691
|
-
catch (error) {
|
|
692
|
-
this.logger.error('OAuth authorization server metadata error', error instanceof Error ? error : new Error(String(error)));
|
|
693
|
-
res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send('OAuth metadata failed');
|
|
694
|
-
}
|
|
695
|
-
});
|
|
696
|
-
// Dynamic Client Registration endpoint
|
|
697
|
-
// Cursor requires this to register itself with a redirect URI
|
|
698
|
-
this.app.post(OAUTH_PATHS.REGISTER, express.json(), async (req, res) => {
|
|
699
|
-
try {
|
|
700
|
-
const { redirect_uris } = req.body;
|
|
701
|
-
this.logger.info('Dynamic client registration request', { redirect_uris });
|
|
702
|
-
// We don't actually strictly enforce registration in this proxy mode,
|
|
703
|
-
// but we return a valid client configuration to satisfy the client.
|
|
704
|
-
// We use a static client ID for the internal mapping.
|
|
705
|
-
const clientId = 'mcp-proxy-client';
|
|
706
|
-
const clientSecret = 'mcp-proxy-secret';
|
|
707
|
-
// Register this client in our internal store so authorize requests pass validation
|
|
708
|
-
if (this.oauthProvider) {
|
|
709
|
-
const client = {
|
|
710
|
-
client_id: clientId,
|
|
711
|
-
client_secret: clientSecret,
|
|
712
|
-
redirect_uris: redirect_uris || [],
|
|
713
|
-
grant_types: ['authorization_code', 'refresh_token'],
|
|
714
|
-
response_types: ['code'],
|
|
715
|
-
scope: (this.oauthProvider.scopes || []).join(' '),
|
|
716
|
-
};
|
|
717
|
-
// We need to cast to any because registerClient might not be exposed on the interface
|
|
718
|
-
// but we know ExternalOAuthProvider uses InMemoryClientsStore
|
|
719
|
-
await this.oauthProvider.clientsStore.registerClient(client);
|
|
720
|
-
}
|
|
721
|
-
res.status(HTTP_STATUS.CREATED).json({
|
|
722
|
-
client_id: clientId,
|
|
723
|
-
client_secret: clientSecret,
|
|
724
|
-
redirect_uris: redirect_uris,
|
|
725
|
-
grant_types: ['authorization_code', 'refresh_token'],
|
|
726
|
-
response_types: ['code'],
|
|
727
|
-
scope: (this.oauthProvider?.scopes || []).join(' '),
|
|
728
|
-
token_endpoint_auth_method: 'client_secret_post'
|
|
729
|
-
});
|
|
730
|
-
}
|
|
731
|
-
catch (error) {
|
|
732
|
-
this.logger.error('Client registration failed', error instanceof Error ? error : new Error(String(error)));
|
|
733
|
-
res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({ error: 'server_error', error_description: 'Registration failed' });
|
|
785
|
+
oauthRateLimiterByProfile.set(profileState.profileId, limiter);
|
|
786
|
+
return limiter;
|
|
787
|
+
};
|
|
788
|
+
const oauthRateLimiter = async (req, res, next) => {
|
|
789
|
+
const profileState = await this.getProfileStateForRequest(req);
|
|
790
|
+
if (!profileState) {
|
|
791
|
+
this.respondProfileNotFound(res, req.profileId);
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
const limiter = getOAuthRateLimiter(profileState);
|
|
795
|
+
return limiter(req, res, next);
|
|
796
|
+
};
|
|
797
|
+
const withProfileState = (handler) => {
|
|
798
|
+
return async (req, res) => {
|
|
799
|
+
const profileState = await this.getProfileStateForRequest(req);
|
|
800
|
+
if (!profileState) {
|
|
801
|
+
this.respondProfileNotFound(res, req.profileId);
|
|
802
|
+
return;
|
|
734
803
|
}
|
|
804
|
+
await handler(req, res, profileState);
|
|
805
|
+
};
|
|
806
|
+
};
|
|
807
|
+
const registerOAuthRoutes = (basePath, includeProfileParam, includeProtectedResource) => {
|
|
808
|
+
const middlewares = includeProfileParam ? [attachProfileId] : [];
|
|
809
|
+
const withOAuthRateLimit = [...middlewares, oauthRateLimiter];
|
|
810
|
+
const withProfile = (handler) => {
|
|
811
|
+
return [...middlewares, oauthRateLimiter, withProfileState(handler)];
|
|
812
|
+
};
|
|
813
|
+
if (includeProtectedResource) {
|
|
814
|
+
this.app.get(`${basePath}${OAUTH_PATHS.WELL_KNOWN_PROTECTED_RESOURCE}`, ...withProfile((req, res, profileState) => this.handleOAuthProtectedResource(req, res, profileState)));
|
|
815
|
+
}
|
|
816
|
+
this.app.get(`${basePath}${OAUTH_PATHS.AUTHORIZE}`, ...withProfile((req, res, profileState) => this.handleOAuthAuthorize(req, res, profileState)));
|
|
817
|
+
this.app.post(`${basePath}${OAUTH_PATHS.TOKEN}`, ...middlewares, oauthRateLimiter, express.urlencoded({ extended: false, limit: '50kb' }), withProfileState((req, res, profileState) => this.handleOAuthToken(req, res, profileState)));
|
|
818
|
+
this.app.get(`${basePath}${OAUTH_PATHS.CALLBACK}`, ...withProfile((req, res, profileState) => this.handleOAuthCallback(req, res, profileState)));
|
|
819
|
+
this.app.get(`${basePath}${OAUTH_PATHS.WELL_KNOWN_AUTHORIZATION_SERVER}`, ...withProfile((req, res, profileState) => this.handleOAuthAuthorizationServerMetadata(req, res, profileState)));
|
|
820
|
+
this.app.post(`${basePath}${OAUTH_PATHS.REGISTER}`, ...middlewares, oauthRateLimiter, express.json(), withProfileState((req, res, profileState) => this.handleOAuthRegister(req, res, profileState)));
|
|
821
|
+
this.logger.info('OAuth routes registered', {
|
|
822
|
+
basePath,
|
|
823
|
+
profileRoutingEnabled,
|
|
735
824
|
});
|
|
736
|
-
|
|
825
|
+
};
|
|
826
|
+
if (defaultProfileId) {
|
|
827
|
+
registerOAuthRoutes('', false, false);
|
|
828
|
+
}
|
|
829
|
+
if (profileRoutingEnabled) {
|
|
830
|
+
registerOAuthRoutes('/profile/:profileId', true, true);
|
|
831
|
+
}
|
|
832
|
+
const attachProfileFromResourceQuery = (req, res, next) => {
|
|
833
|
+
const { resource } = req.query;
|
|
834
|
+
if (resource === undefined) {
|
|
835
|
+
next();
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
if (typeof resource !== 'string') {
|
|
839
|
+
res.status(HTTP_STATUS.BAD_REQUEST).json({
|
|
840
|
+
error: 'invalid_request',
|
|
841
|
+
message: 'Invalid resource query parameter',
|
|
842
|
+
});
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
const profileId = this.resolveProfileIdFromResourceUrl(resource);
|
|
846
|
+
if (!profileId) {
|
|
847
|
+
res.status(HTTP_STATUS.NOT_FOUND).json({
|
|
848
|
+
error: 'Not Found',
|
|
849
|
+
message: 'OAuth metadata unavailable for requested resource',
|
|
850
|
+
});
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
req.profileId = profileId;
|
|
854
|
+
next();
|
|
855
|
+
};
|
|
856
|
+
if (defaultProfileId || profileRoutingEnabled) {
|
|
857
|
+
this.app.get(OAUTH_PATHS.WELL_KNOWN_PROTECTED_RESOURCE, attachProfileFromResourceQuery, oauthRateLimiter, withProfileState((req, res, profileState) => this.handleOAuthProtectedResource(req, res, profileState)));
|
|
737
858
|
}
|
|
738
859
|
// Security: Rate limiting setup (for MCP endpoints)
|
|
739
860
|
const windowMs = this.config.rateLimitWindowMs || TIMEOUTS.RATE_LIMIT_WINDOW_MS;
|
|
@@ -761,47 +882,67 @@ export class HttpTransport {
|
|
|
761
882
|
logMessage: 'Rate limit exceeded for metrics',
|
|
762
883
|
responseMessage: this.formatRateLimitMessage('metrics', metricsMaxRequests, windowMs),
|
|
763
884
|
});
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
//
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
885
|
+
const registerMcpRoutes = (basePath, includeProfileParam, isDefault) => {
|
|
886
|
+
const middlewares = includeProfileParam ? [attachProfileId] : [];
|
|
887
|
+
const pathPrefix = basePath || '';
|
|
888
|
+
// Main MCP endpoint - POST for sending messages
|
|
889
|
+
this.app.post(`${pathPrefix}/mcp`, ...middlewares, mcpRateLimiter, this.handlePost.bind(this));
|
|
890
|
+
// CORS preflight handler
|
|
891
|
+
this.app.options(`${pathPrefix}/mcp`, ...middlewares, async (req, res) => {
|
|
892
|
+
const origin = req.headers.origin;
|
|
893
|
+
try {
|
|
894
|
+
// Only send CORS headers for explicitly allowed origins; otherwise reject
|
|
895
|
+
if (origin && await this.isAllowedOriginForRequest(origin, req)) {
|
|
896
|
+
// nosemgrep: javascript.express.security.cors-misconfiguration.cors-misconfiguration
|
|
897
|
+
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
898
|
+
res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS');
|
|
899
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Accept, Mcp-Session-Id');
|
|
900
|
+
// We do not allow credentials; prevents cookie-based attacks by default
|
|
901
|
+
res.setHeader('Access-Control-Allow-Credentials', 'false');
|
|
902
|
+
res.setHeader('Access-Control-Max-Age', '86400'); // 24 hours cache
|
|
903
|
+
return res.status(HTTP_STATUS.OK).send();
|
|
904
|
+
}
|
|
905
|
+
// Disallowed origin: do not echo origin or emit permissive headers
|
|
906
|
+
res.status(HTTP_STATUS.FORBIDDEN).json({ error: 'Forbidden', message: 'Origin not allowed' });
|
|
907
|
+
}
|
|
908
|
+
catch (error) {
|
|
909
|
+
this.logger.error('Origin validation failed', error instanceof Error ? error : new Error(String(error)));
|
|
910
|
+
res.status(HTTP_STATUS.FORBIDDEN).json({ error: 'Forbidden', message: 'Origin not allowed' });
|
|
911
|
+
}
|
|
912
|
+
});
|
|
913
|
+
// Main MCP endpoint - GET for SSE streaming
|
|
914
|
+
this.app.get(`${pathPrefix}/mcp`, ...middlewares, mcpRateLimiter, this.handleGet.bind(this));
|
|
915
|
+
// Session termination
|
|
916
|
+
this.app.delete(`${pathPrefix}/mcp`, ...middlewares, mcpRateLimiter, this.handleDelete.bind(this));
|
|
917
|
+
// Legacy alias endpoints - deprecated
|
|
918
|
+
// Why: Backward compatibility for clients using /sse during migration
|
|
919
|
+
this.app.post(`${pathPrefix}/sse`, ...middlewares, mcpRateLimiter, (req, res, next) => {
|
|
920
|
+
this.logger.warn('Deprecated endpoint used: POST /sse. Please migrate to POST /mcp');
|
|
921
|
+
this.logger.info('Handling POST /sse request');
|
|
922
|
+
return this.handlePost(req, res, next);
|
|
923
|
+
});
|
|
924
|
+
this.app.get(`${pathPrefix}/sse`, ...middlewares, mcpRateLimiter, (req, res, next) => {
|
|
925
|
+
this.logger.warn('Deprecated endpoint used: GET /sse. Please migrate to GET /mcp');
|
|
926
|
+
this.logger.info(`Handling GET /sse request from: ${req.ip}`);
|
|
927
|
+
return this.handleGet(req, res, next);
|
|
928
|
+
});
|
|
929
|
+
this.app.delete(`${pathPrefix}/sse`, ...middlewares, mcpRateLimiter, (req, res, next) => {
|
|
930
|
+
this.logger.warn('Deprecated endpoint used: DELETE /sse. Please migrate to DELETE /mcp');
|
|
931
|
+
return this.handleDelete(req, res, next);
|
|
932
|
+
});
|
|
933
|
+
if (isDefault) {
|
|
934
|
+
this.logger.info('Registered MCP routes for default profile', { pathPrefix: pathPrefix || '/mcp' });
|
|
935
|
+
}
|
|
936
|
+
else {
|
|
937
|
+
this.logger.info('Registered MCP routes for profile routing', { pathPrefix: `${pathPrefix}/mcp` });
|
|
938
|
+
}
|
|
939
|
+
};
|
|
940
|
+
if (defaultProfileId) {
|
|
941
|
+
registerMcpRoutes('', false, true);
|
|
942
|
+
}
|
|
943
|
+
if (profileRoutingEnabled) {
|
|
944
|
+
registerMcpRoutes('/profile/:profileId', true, false);
|
|
945
|
+
}
|
|
805
946
|
// Metrics endpoint (if enabled)
|
|
806
947
|
if (this.config.metricsEnabled) {
|
|
807
948
|
this.app.get(this.config.metricsPath, metricsRateLimiter, this.handleMetrics.bind(this));
|
|
@@ -809,7 +950,11 @@ export class HttpTransport {
|
|
|
809
950
|
// Health check (with rate limiting)
|
|
810
951
|
this.app.get('/health', mcpRateLimiter, (req, res) => {
|
|
811
952
|
const startTime = Date.now();
|
|
812
|
-
|
|
953
|
+
let totalSessions = 0;
|
|
954
|
+
for (const state of this.profileStates.values()) {
|
|
955
|
+
totalSessions += state.sessions.size;
|
|
956
|
+
}
|
|
957
|
+
res.json({ status: 'ok', sessions: totalSessions });
|
|
813
958
|
if (this.metrics) {
|
|
814
959
|
const duration = (Date.now() - startTime) / 1000;
|
|
815
960
|
this.metrics.recordHttpRequest(req.method, req.path, res.statusCode, duration);
|
|
@@ -833,6 +978,243 @@ export class HttpTransport {
|
|
|
833
978
|
});
|
|
834
979
|
});
|
|
835
980
|
}
|
|
981
|
+
getProfileIssuerUrl(profileId, options) {
|
|
982
|
+
const origin = this.getServerOrigin(profileId);
|
|
983
|
+
const prefix = this.getProfilePrefix(profileId, options);
|
|
984
|
+
return `${origin}${prefix}`;
|
|
985
|
+
}
|
|
986
|
+
async handleOAuthProtectedResource(req, res, profileState) {
|
|
987
|
+
if (!profileState.oauthProvider) {
|
|
988
|
+
res.status(HTTP_STATUS.NOT_FOUND).json({ error: 'Not Found', message: 'OAuth not configured for this profile' });
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
991
|
+
const profileId = profileState.profileId;
|
|
992
|
+
const isProfileScoped = typeof req.params?.profileId === 'string';
|
|
993
|
+
const urlOptions = isProfileScoped ? { forceProfilePrefix: true } : undefined;
|
|
994
|
+
const serverUrl = new URL(this.buildProfileUrl(profileId, '/mcp', urlOptions));
|
|
995
|
+
const issuerUrl = this.getProfileIssuerUrl(profileId, urlOptions);
|
|
996
|
+
const metadata = {
|
|
997
|
+
resource: serverUrl.href,
|
|
998
|
+
authorization_servers: [issuerUrl],
|
|
999
|
+
bearer_methods_supported: ['header'],
|
|
1000
|
+
};
|
|
1001
|
+
if (profileState.oauthProvider.scopes && profileState.oauthProvider.scopes.length > 0) {
|
|
1002
|
+
metadata.scopes_supported = profileState.oauthProvider.scopes;
|
|
1003
|
+
}
|
|
1004
|
+
if (profileState.context.resourceName) {
|
|
1005
|
+
metadata.resource_name = profileState.context.resourceName;
|
|
1006
|
+
}
|
|
1007
|
+
if (profileState.context.resourceDocumentation) {
|
|
1008
|
+
metadata.resource_documentation = profileState.context.resourceDocumentation;
|
|
1009
|
+
}
|
|
1010
|
+
res.json(metadata);
|
|
1011
|
+
}
|
|
1012
|
+
async handleOAuthAuthorize(req, res, profileState) {
|
|
1013
|
+
if (!profileState.oauthProvider) {
|
|
1014
|
+
res.status(HTTP_STATUS.NOT_FOUND).send('OAuth not configured for this profile');
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
try {
|
|
1018
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
1019
|
+
const { response_type, client_id, redirect_uri, scope, state, code_challenge, code_challenge_method } = req.query;
|
|
1020
|
+
if (!client_id || typeof client_id !== 'string') {
|
|
1021
|
+
res.status(HTTP_STATUS.BAD_REQUEST).send('Missing client_id');
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
1024
|
+
if (!response_type || typeof response_type !== 'string') {
|
|
1025
|
+
res.status(HTTP_STATUS.BAD_REQUEST).send('Missing response_type');
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
if (response_type !== 'code') {
|
|
1029
|
+
res.status(HTTP_STATUS.BAD_REQUEST).send('Unsupported response_type');
|
|
1030
|
+
return;
|
|
1031
|
+
}
|
|
1032
|
+
if (!redirect_uri || typeof redirect_uri !== 'string') {
|
|
1033
|
+
res.status(HTTP_STATUS.BAD_REQUEST).send('Missing redirect_uri');
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
await profileState.oauthProvider.ensureEndpointsInitialized();
|
|
1037
|
+
const client = await profileState.oauthProvider.clientsStore.getClient(client_id);
|
|
1038
|
+
if (!client) {
|
|
1039
|
+
res.status(HTTP_STATUS.BAD_REQUEST).send('Invalid client_id');
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
1042
|
+
const scopeStr = (scope || '').trim();
|
|
1043
|
+
const params = {
|
|
1044
|
+
responseType: response_type,
|
|
1045
|
+
clientId: client_id,
|
|
1046
|
+
redirectUri: redirect_uri,
|
|
1047
|
+
scope: scopeStr ? scopeStr.split(' ') : [],
|
|
1048
|
+
state: state,
|
|
1049
|
+
codeChallenge: code_challenge,
|
|
1050
|
+
codeChallengeMethod: code_challenge_method,
|
|
1051
|
+
scopes: scopeStr ? scopeStr.split(' ') : [],
|
|
1052
|
+
};
|
|
1053
|
+
await profileState.oauthProvider.authorize(client, params, res);
|
|
1054
|
+
}
|
|
1055
|
+
catch (error) {
|
|
1056
|
+
this.logger.error('OAuth authorize error', error instanceof Error ? error : new Error(String(error)));
|
|
1057
|
+
res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send('OAuth authorization failed');
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
async handleOAuthToken(req, res, profileState) {
|
|
1061
|
+
if (!profileState.oauthProvider) {
|
|
1062
|
+
res.status(HTTP_STATUS.NOT_FOUND).json({ error: 'server_error', error_description: 'OAuth provider not initialized' });
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
try {
|
|
1066
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
1067
|
+
const { grant_type, code, redirect_uri, client_id, code_verifier, refresh_token } = req.body;
|
|
1068
|
+
this.logger.debug('OAuth token request', {
|
|
1069
|
+
profileId: profileState.profileId,
|
|
1070
|
+
grant_type,
|
|
1071
|
+
client_id,
|
|
1072
|
+
has_code: !!code,
|
|
1073
|
+
has_code_verifier: !!code_verifier,
|
|
1074
|
+
redirect_uri,
|
|
1075
|
+
});
|
|
1076
|
+
if (grant_type === 'authorization_code') {
|
|
1077
|
+
if (!code) {
|
|
1078
|
+
res.status(HTTP_STATUS.BAD_REQUEST).json({ error: 'invalid_request', error_description: 'Missing code' });
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
await profileState.oauthProvider.ensureEndpointsInitialized();
|
|
1082
|
+
const client = await profileState.oauthProvider.clientsStore.getClient(client_id);
|
|
1083
|
+
if (!client) {
|
|
1084
|
+
res.status(HTTP_STATUS.BAD_REQUEST).json({ error: 'invalid_client' });
|
|
1085
|
+
return;
|
|
1086
|
+
}
|
|
1087
|
+
const tokens = await profileState.oauthProvider.exchangeAuthorizationCode(client, code, code_verifier, redirect_uri);
|
|
1088
|
+
this.storeOAuthTokens(profileState, tokens, client.client_id, client.scope?.split(' ') || []);
|
|
1089
|
+
res.json(tokens);
|
|
1090
|
+
return;
|
|
1091
|
+
}
|
|
1092
|
+
if (grant_type === 'refresh_token') {
|
|
1093
|
+
if (!refresh_token) {
|
|
1094
|
+
res.status(HTTP_STATUS.BAD_REQUEST).json({ error: 'invalid_request', error_description: 'Missing refresh_token' });
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
await profileState.oauthProvider.ensureEndpointsInitialized();
|
|
1098
|
+
const client = await profileState.oauthProvider.clientsStore.getClient(client_id);
|
|
1099
|
+
if (!client) {
|
|
1100
|
+
res.status(HTTP_STATUS.BAD_REQUEST).json({ error: 'invalid_client' });
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
const tokens = await profileState.oauthProvider.exchangeRefreshToken(client, refresh_token);
|
|
1104
|
+
this.storeOAuthTokens(profileState, tokens, client.client_id, client.scope?.split(' ') || []);
|
|
1105
|
+
res.json(tokens);
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1108
|
+
this.logger.warn('Unsupported grant type', { grant_type, expected: 'authorization_code or refresh_token' });
|
|
1109
|
+
res.status(HTTP_STATUS.BAD_REQUEST).json({ error: 'unsupported_grant_type' });
|
|
1110
|
+
}
|
|
1111
|
+
catch (error) {
|
|
1112
|
+
this.logger.error('OAuth token exchange error', error instanceof Error ? error : new Error(String(error)));
|
|
1113
|
+
res.status(HTTP_STATUS.BAD_REQUEST).json({
|
|
1114
|
+
error: 'invalid_grant',
|
|
1115
|
+
error_description: 'Token exchange failed',
|
|
1116
|
+
});
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
async handleOAuthCallback(req, res, profileState) {
|
|
1120
|
+
if (!profileState.oauthProvider) {
|
|
1121
|
+
res.status(HTTP_STATUS.NOT_FOUND).send('OAuth provider not initialized');
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
try {
|
|
1125
|
+
const { code, state, error, error_description } = req.query;
|
|
1126
|
+
this.logger.info('OAuth callback received', {
|
|
1127
|
+
profileId: profileState.profileId,
|
|
1128
|
+
hasCode: !!code,
|
|
1129
|
+
hasState: !!state,
|
|
1130
|
+
error: error,
|
|
1131
|
+
errorDescription: error_description,
|
|
1132
|
+
});
|
|
1133
|
+
if (error) {
|
|
1134
|
+
const safeError = escapeHtmlSafe(error);
|
|
1135
|
+
const safeErrorDesc = escapeHtmlSafe(error_description);
|
|
1136
|
+
res.status(HTTP_STATUS.BAD_REQUEST).json({
|
|
1137
|
+
error: safeError,
|
|
1138
|
+
error_description: safeErrorDesc || safeError,
|
|
1139
|
+
});
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
if (!code || typeof code !== 'string') {
|
|
1143
|
+
res.status(HTTP_STATUS.BAD_REQUEST).send('Missing authorization code');
|
|
1144
|
+
return;
|
|
1145
|
+
}
|
|
1146
|
+
await profileState.oauthProvider.handleCallback(req, res);
|
|
1147
|
+
}
|
|
1148
|
+
catch (error) {
|
|
1149
|
+
this.logger.error('OAuth callback error', error instanceof Error ? error : new Error(String(error)));
|
|
1150
|
+
if (!res.headersSent) {
|
|
1151
|
+
res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send('OAuth callback failed');
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
async handleOAuthAuthorizationServerMetadata(req, res, profileState) {
|
|
1156
|
+
if (!profileState.oauthProvider) {
|
|
1157
|
+
res.status(HTTP_STATUS.NOT_FOUND).send('OAuth metadata unavailable');
|
|
1158
|
+
return;
|
|
1159
|
+
}
|
|
1160
|
+
try {
|
|
1161
|
+
const profileId = profileState.profileId;
|
|
1162
|
+
const isProfileScoped = typeof req.params?.profileId === 'string';
|
|
1163
|
+
const urlOptions = isProfileScoped ? { forceProfilePrefix: true } : undefined;
|
|
1164
|
+
const issuer = this.getProfileIssuerUrl(profileId, urlOptions);
|
|
1165
|
+
res.json({
|
|
1166
|
+
issuer,
|
|
1167
|
+
authorization_endpoint: this.buildProfileUrl(profileId, OAUTH_PATHS.AUTHORIZE, urlOptions),
|
|
1168
|
+
token_endpoint: this.buildProfileUrl(profileId, OAUTH_PATHS.TOKEN, urlOptions),
|
|
1169
|
+
registration_endpoint: this.buildProfileUrl(profileId, OAUTH_PATHS.REGISTER, urlOptions),
|
|
1170
|
+
response_types_supported: ['code'],
|
|
1171
|
+
code_challenge_methods_supported: ['S256'],
|
|
1172
|
+
grant_types_supported: ['authorization_code', 'refresh_token'],
|
|
1173
|
+
scopes_supported: profileState.oauthProvider.scopes || ['api'],
|
|
1174
|
+
});
|
|
1175
|
+
}
|
|
1176
|
+
catch (error) {
|
|
1177
|
+
this.logger.error('OAuth authorization server metadata error', error instanceof Error ? error : new Error(String(error)));
|
|
1178
|
+
res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send('OAuth metadata failed');
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
async handleOAuthRegister(req, res, profileState) {
|
|
1182
|
+
if (!profileState.oauthProvider) {
|
|
1183
|
+
res.status(HTTP_STATUS.NOT_FOUND).json({ error: 'server_error', error_description: 'Registration unavailable' });
|
|
1184
|
+
return;
|
|
1185
|
+
}
|
|
1186
|
+
try {
|
|
1187
|
+
const { redirect_uris } = req.body;
|
|
1188
|
+
this.logger.info('Dynamic client registration request', {
|
|
1189
|
+
profileId: profileState.profileId,
|
|
1190
|
+
redirect_uris,
|
|
1191
|
+
});
|
|
1192
|
+
const clientId = 'mcp-proxy-client';
|
|
1193
|
+
const clientSecret = 'mcp-proxy-secret';
|
|
1194
|
+
const client = {
|
|
1195
|
+
client_id: clientId,
|
|
1196
|
+
client_secret: clientSecret,
|
|
1197
|
+
redirect_uris: redirect_uris || [],
|
|
1198
|
+
grant_types: ['authorization_code', 'refresh_token'],
|
|
1199
|
+
response_types: ['code'],
|
|
1200
|
+
scope: (profileState.oauthProvider.scopes || []).join(' '),
|
|
1201
|
+
};
|
|
1202
|
+
await profileState.oauthProvider.clientsStore.registerClient(client);
|
|
1203
|
+
res.status(HTTP_STATUS.CREATED).json({
|
|
1204
|
+
client_id: clientId,
|
|
1205
|
+
client_secret: clientSecret,
|
|
1206
|
+
redirect_uris: redirect_uris,
|
|
1207
|
+
grant_types: ['authorization_code', 'refresh_token'],
|
|
1208
|
+
response_types: ['code'],
|
|
1209
|
+
scope: (profileState.oauthProvider.scopes || []).join(' '),
|
|
1210
|
+
token_endpoint_auth_method: 'client_secret_post',
|
|
1211
|
+
});
|
|
1212
|
+
}
|
|
1213
|
+
catch (error) {
|
|
1214
|
+
this.logger.error('Client registration failed', error instanceof Error ? error : new Error(String(error)));
|
|
1215
|
+
res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({ error: 'server_error', error_description: 'Registration failed' });
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
836
1218
|
/**
|
|
837
1219
|
* Handle metrics endpoint
|
|
838
1220
|
*
|
|
@@ -851,8 +1233,14 @@ export class HttpTransport {
|
|
|
851
1233
|
// Don't record metrics call in metrics (avoid recursion)
|
|
852
1234
|
}
|
|
853
1235
|
catch (error) {
|
|
854
|
-
|
|
855
|
-
|
|
1236
|
+
const correlationId = generateCorrelationId();
|
|
1237
|
+
this.logger.error('Metrics endpoint error', error, { correlationId });
|
|
1238
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
1239
|
+
res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({
|
|
1240
|
+
error: 'Internal Server Error',
|
|
1241
|
+
message: `Internal error (correlation ID: ${correlationId})`,
|
|
1242
|
+
correlationId
|
|
1243
|
+
});
|
|
856
1244
|
}
|
|
857
1245
|
}
|
|
858
1246
|
/**
|
|
@@ -969,11 +1357,11 @@ export class HttpTransport {
|
|
|
969
1357
|
*
|
|
970
1358
|
* Returns: { type: 'bearer' | 'oauth' | 'api-token', token: string, sessionId?: string }
|
|
971
1359
|
*/
|
|
972
|
-
extractAuthToken(req) {
|
|
1360
|
+
extractAuthToken(req, profileState) {
|
|
973
1361
|
// 1. Check for OAuth session first (highest priority for authenticated sessions)
|
|
974
1362
|
const sessionId = req.sessionId || req.headers['mcp-session-id'];
|
|
975
|
-
if (sessionId &&
|
|
976
|
-
const session =
|
|
1363
|
+
if (sessionId && profileState.sessions.has(sessionId)) {
|
|
1364
|
+
const session = profileState.sessions.get(sessionId);
|
|
977
1365
|
if (session && session.authToken) {
|
|
978
1366
|
return { type: 'oauth', token: session.authToken, sessionId };
|
|
979
1367
|
}
|
|
@@ -1008,6 +1396,26 @@ export class HttpTransport {
|
|
|
1008
1396
|
}
|
|
1009
1397
|
return { type: 'none' };
|
|
1010
1398
|
}
|
|
1399
|
+
/**
|
|
1400
|
+
* Lazy initialization of ToolFilterService
|
|
1401
|
+
*/
|
|
1402
|
+
getToolFilterService(profileState) {
|
|
1403
|
+
if (!profileState.toolFilterService) {
|
|
1404
|
+
const validator = new RegexValidator();
|
|
1405
|
+
const compiler = new RegexCompiler(validator);
|
|
1406
|
+
const envParser = new EnvConfigParser(compiler);
|
|
1407
|
+
const headerParser = new HeaderConfigParser(compiler);
|
|
1408
|
+
// Create OperationDetector for category filtering (if parser available)
|
|
1409
|
+
let detector;
|
|
1410
|
+
if (profileState.context.parser) {
|
|
1411
|
+
const classifier = new OperationClassifier();
|
|
1412
|
+
const resolver = new OpenAPIOperationResolver(profileState.context.parser);
|
|
1413
|
+
detector = new OperationDetector(classifier, resolver);
|
|
1414
|
+
}
|
|
1415
|
+
profileState.toolFilterService = new ToolFilterService(envParser, headerParser, this.logger, detector);
|
|
1416
|
+
}
|
|
1417
|
+
return profileState.toolFilterService;
|
|
1418
|
+
}
|
|
1011
1419
|
/**
|
|
1012
1420
|
* Handle POST requests - Client sending messages to server
|
|
1013
1421
|
*
|
|
@@ -1017,8 +1425,19 @@ export class HttpTransport {
|
|
|
1017
1425
|
const startTime = Date.now();
|
|
1018
1426
|
try {
|
|
1019
1427
|
this.logger.debug('handlePost called', { method: req.method, path: req.path, sessionId: req.sessionId, accept: req.headers.accept });
|
|
1428
|
+
const profileState = await this.getProfileStateForRequest(req);
|
|
1429
|
+
if (!profileState) {
|
|
1430
|
+
this.respondProfileNotFound(res, req.profileId);
|
|
1431
|
+
return;
|
|
1432
|
+
}
|
|
1433
|
+
const requestProfileId = req.profileId ?? profileState.profileId;
|
|
1020
1434
|
const sessionId = req.sessionId;
|
|
1021
1435
|
const body = req.body;
|
|
1436
|
+
const filteringHeader = normalizeFilteringHeaderValue(this.getFilteringHeaderValue(req));
|
|
1437
|
+
const parsedFiltering = filteringHeader ? parseFilteringHeader(filteringHeader) : undefined;
|
|
1438
|
+
const toolFilterHeader = normalizeToolFilterHeaderValue(this.getToolFilterHeaderValue(req));
|
|
1439
|
+
const parsedToolFilter = toolFilterHeader !== undefined ? parseSessionToolFilterHeader(toolFilterHeader) : undefined;
|
|
1440
|
+
const normalizedToolFilterHeader = parsedToolFilter?.normalizedHeader;
|
|
1022
1441
|
// Validate Accept header per MCP Streamable HTTP specification
|
|
1023
1442
|
const accept = req.headers.accept || '';
|
|
1024
1443
|
// POST requests can return either JSON or SSE, so must accept both if specified
|
|
@@ -1047,12 +1466,22 @@ export class HttpTransport {
|
|
|
1047
1466
|
this.logger.debug('Session validation', { isInitialization, sessionId, bodyMethod: body?.method });
|
|
1048
1467
|
// Validate session (except for initialization)
|
|
1049
1468
|
if (!isInitialization && sessionId) {
|
|
1050
|
-
const session =
|
|
1469
|
+
const session = profileState.sessions.get(sessionId);
|
|
1051
1470
|
if (!session) {
|
|
1052
1471
|
res.status(HTTP_STATUS.NOT_FOUND).json({ error: 'Not Found', message: 'Session not found or expired' });
|
|
1053
1472
|
return;
|
|
1054
1473
|
}
|
|
1055
|
-
|
|
1474
|
+
if (filteringHeader !== undefined) {
|
|
1475
|
+
if (!session.filteringHeader || session.filteringHeader !== filteringHeader) {
|
|
1476
|
+
throw new ValidationError('X-Mcp4-Params header mismatch for existing session.');
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
if (normalizedToolFilterHeader !== undefined) {
|
|
1480
|
+
if (!session.toolFilterHeader || session.toolFilterHeader !== normalizedToolFilterHeader) {
|
|
1481
|
+
throw new ValidationError(`X-Mcp4-Tools header mismatch for existing session. Expected: '${session.toolFilterHeader ?? ''}', Got: '${normalizedToolFilterHeader}'.`);
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
this.updateSessionActivity(profileState, sessionId);
|
|
1056
1485
|
}
|
|
1057
1486
|
else if (!isInitialization && !sessionId) {
|
|
1058
1487
|
this.logger.debug('Session validation failed: non-init request without sessionId');
|
|
@@ -1065,7 +1494,7 @@ export class HttpTransport {
|
|
|
1065
1494
|
// If only notifications/responses, return 202 Accepted
|
|
1066
1495
|
if (messageType === 'notification-only' || messageType === 'response-only') {
|
|
1067
1496
|
if (this.messageHandler) {
|
|
1068
|
-
await this.messageHandler(body);
|
|
1497
|
+
await this.messageHandler(body, undefined, requestProfileId);
|
|
1069
1498
|
}
|
|
1070
1499
|
res.status(HTTP_STATUS.ACCEPTED).send();
|
|
1071
1500
|
return;
|
|
@@ -1080,14 +1509,14 @@ export class HttpTransport {
|
|
|
1080
1509
|
let newSessionId;
|
|
1081
1510
|
if (isInitialization) {
|
|
1082
1511
|
// Extract and validate auth token from headers
|
|
1083
|
-
const authInfo = this.extractAuthToken(req);
|
|
1512
|
+
const authInfo = this.extractAuthToken(req, profileState);
|
|
1084
1513
|
this.logger.debug('Auth token extracted', { authType: authInfo?.type, hasToken: !!authInfo?.token });
|
|
1085
1514
|
// If OAuth is configured, require authentication for initialization
|
|
1086
1515
|
// This ensures clients like Cursor properly handle OAuth flow
|
|
1087
|
-
if (
|
|
1516
|
+
if (profileState.oauthProvider && !authInfo.token) {
|
|
1088
1517
|
this.logger.debug('OAuth configured but no token provided, triggering OAuth flow');
|
|
1089
|
-
const resourceMetadataUrl =
|
|
1090
|
-
res.setHeader('WWW-Authenticate', `Bearer resource_metadata="${resourceMetadataUrl}", scope="${
|
|
1518
|
+
const resourceMetadataUrl = this.getOAuthProtectedResourceUrl(requestProfileId);
|
|
1519
|
+
res.setHeader('WWW-Authenticate', `Bearer resource_metadata="${resourceMetadataUrl}", scope="${profileState.oauthProvider.scopes.join(' ')}"`);
|
|
1091
1520
|
res.status(HTTP_STATUS.UNAUTHORIZED).json({
|
|
1092
1521
|
error: 'Unauthorized',
|
|
1093
1522
|
message: 'Authentication required for OAuth'
|
|
@@ -1096,17 +1525,17 @@ export class HttpTransport {
|
|
|
1096
1525
|
}
|
|
1097
1526
|
// Allow initialization without token for non-OAuth scenarios
|
|
1098
1527
|
// Validate token if auth is configured and token is provided
|
|
1099
|
-
if (authInfo && authInfo.token &&
|
|
1528
|
+
if (authInfo && authInfo.token && profileState.context.authConfigs && profileState.context.baseUrl) {
|
|
1100
1529
|
// Find matching auth config based on priority (authConfigs is sorted)
|
|
1101
1530
|
// For 'bearer' token type, 'oauth' config is also a match
|
|
1102
|
-
const authConfig =
|
|
1531
|
+
const authConfig = profileState.context.authConfigs.find(c => c.type === authInfo.type ||
|
|
1103
1532
|
(authInfo.type === 'bearer' && c.type === 'oauth'));
|
|
1104
1533
|
if (authConfig && authConfig.validation_endpoint) {
|
|
1105
1534
|
this.logger.info('Validating auth token during initialization', {
|
|
1106
1535
|
authType: authConfig.type, // Use config type for logging
|
|
1107
1536
|
endpoint: authConfig.validation_endpoint,
|
|
1108
1537
|
});
|
|
1109
|
-
const isValid = await this.validateAuthToken(authConfig, authInfo.token,
|
|
1538
|
+
const isValid = await this.validateAuthToken(authConfig, authInfo.token, profileState.context.baseUrl);
|
|
1110
1539
|
if (!isValid) {
|
|
1111
1540
|
this.logger.warn('Auth token validation failed during initialization', {
|
|
1112
1541
|
authType: authInfo.type,
|
|
@@ -1126,7 +1555,7 @@ export class HttpTransport {
|
|
|
1126
1555
|
let scopes;
|
|
1127
1556
|
let oauthClientId;
|
|
1128
1557
|
if (authInfo.token && (authInfo.type === 'oauth' || authInfo.type === 'bearer')) {
|
|
1129
|
-
const tokenData =
|
|
1558
|
+
const tokenData = profileState.oauthTokensByAccessToken.get(authInfo.token);
|
|
1130
1559
|
if (tokenData) {
|
|
1131
1560
|
refreshToken = tokenData.refreshToken;
|
|
1132
1561
|
accessTokenExpiresAt = tokenData.expiresAt;
|
|
@@ -1144,21 +1573,21 @@ export class HttpTransport {
|
|
|
1144
1573
|
});
|
|
1145
1574
|
}
|
|
1146
1575
|
}
|
|
1147
|
-
newSessionId = this.createSession(authInfo.token, refreshToken, accessTokenExpiresAt, scopes, oauthClientId);
|
|
1576
|
+
newSessionId = this.createSession(profileState, authInfo.token, refreshToken, accessTokenExpiresAt, scopes, oauthClientId, parsedFiltering?.filtering, parsedFiltering?.normalizedHeader, parsedToolFilter, normalizedToolFilterHeader);
|
|
1148
1577
|
}
|
|
1149
1578
|
this.logger.debug('Calling messageHandler', { body, sessionId: isInitialization ? newSessionId : sessionId });
|
|
1150
|
-
const response = await this.messageHandler(body, isInitialization ? newSessionId : sessionId);
|
|
1579
|
+
const response = await this.messageHandler(body, isInitialization ? newSessionId : sessionId, requestProfileId);
|
|
1151
1580
|
this.logger.debug('MessageHandler response', { response });
|
|
1152
1581
|
// Debug: Check OAuth conditions
|
|
1153
1582
|
this.logger.debug('Checking OAuth conditions', {
|
|
1154
1583
|
responseError: response.error,
|
|
1155
|
-
hasOAuthProvider: !!
|
|
1156
|
-
oauthProviderType: typeof
|
|
1584
|
+
hasOAuthProvider: !!profileState.oauthProvider,
|
|
1585
|
+
oauthProviderType: typeof profileState.oauthProvider
|
|
1157
1586
|
});
|
|
1158
1587
|
// Check if response contains OAuth error and add WWW-Authenticate header
|
|
1159
1588
|
const responseObj = response;
|
|
1160
1589
|
if (responseObj.error && responseObj.error.data && responseObj.error.data.oauth_required) {
|
|
1161
|
-
const resourceMetadataUrl =
|
|
1590
|
+
const resourceMetadataUrl = this.getOAuthProtectedResourceUrl(requestProfileId);
|
|
1162
1591
|
res.setHeader('WWW-Authenticate', `Bearer resource_metadata="${resourceMetadataUrl}", scope="api"`);
|
|
1163
1592
|
res.status(HTTP_STATUS.UNAUTHORIZED); // Set 401 status for OAuth errors
|
|
1164
1593
|
}
|
|
@@ -1175,6 +1604,8 @@ export class HttpTransport {
|
|
|
1175
1604
|
if (newSessionId) {
|
|
1176
1605
|
res.setHeader('Mcp-Session-Id', newSessionId);
|
|
1177
1606
|
}
|
|
1607
|
+
// Security: Prevent caching of sensitive API responses
|
|
1608
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
1178
1609
|
this.logger.debug('Sending JSON response', { response, newSessionId });
|
|
1179
1610
|
res.json(response);
|
|
1180
1611
|
}
|
|
@@ -1185,6 +1616,7 @@ export class HttpTransport {
|
|
|
1185
1616
|
catch (error) {
|
|
1186
1617
|
const correlationId = generateCorrelationId();
|
|
1187
1618
|
this.logger.error('POST request error', error, { correlationId });
|
|
1619
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
1188
1620
|
let status = 500;
|
|
1189
1621
|
let errorLabel = 'Internal Server Error';
|
|
1190
1622
|
let message = `Internal error (correlation ID: ${correlationId})`;
|
|
@@ -1231,6 +1663,11 @@ export class HttpTransport {
|
|
|
1231
1663
|
async handleGet(req, res) {
|
|
1232
1664
|
const startTime = Date.now();
|
|
1233
1665
|
try {
|
|
1666
|
+
const profileState = await this.getProfileStateForRequest(req);
|
|
1667
|
+
if (!profileState) {
|
|
1668
|
+
this.respondProfileNotFound(res, req.profileId);
|
|
1669
|
+
return;
|
|
1670
|
+
}
|
|
1234
1671
|
const sessionId = req.sessionId;
|
|
1235
1672
|
const lastEventId = req.headers['last-event-id'];
|
|
1236
1673
|
// Validate Accept header
|
|
@@ -1244,14 +1681,14 @@ export class HttpTransport {
|
|
|
1244
1681
|
res.status(HTTP_STATUS.BAD_REQUEST).json({ error: 'Bad Request', message: 'Mcp-Session-Id header required' });
|
|
1245
1682
|
return;
|
|
1246
1683
|
}
|
|
1247
|
-
const session =
|
|
1684
|
+
const session = profileState.sessions.get(sessionId);
|
|
1248
1685
|
if (!session) {
|
|
1249
1686
|
res.status(HTTP_STATUS.NOT_FOUND).json({ error: 'Not Found', message: 'Session not found or expired' });
|
|
1250
1687
|
return;
|
|
1251
1688
|
}
|
|
1252
|
-
this.updateSessionActivity(sessionId);
|
|
1689
|
+
this.updateSessionActivity(profileState, sessionId);
|
|
1253
1690
|
// Start SSE stream
|
|
1254
|
-
this.startSSEStream(res, sessionId, lastEventId);
|
|
1691
|
+
this.startSSEStream(res, sessionId, lastEventId, profileState);
|
|
1255
1692
|
// Record metrics for successful SSE start
|
|
1256
1693
|
if (this.metrics) {
|
|
1257
1694
|
const duration = (Date.now() - startTime) / 1000;
|
|
@@ -1259,10 +1696,16 @@ export class HttpTransport {
|
|
|
1259
1696
|
}
|
|
1260
1697
|
}
|
|
1261
1698
|
catch (error) {
|
|
1262
|
-
|
|
1699
|
+
const correlationId = generateCorrelationId();
|
|
1700
|
+
this.logger.error('GET request error', error, { correlationId });
|
|
1263
1701
|
const status = 500;
|
|
1264
1702
|
if (!res.headersSent) {
|
|
1265
|
-
res.
|
|
1703
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
1704
|
+
res.status(status).json({
|
|
1705
|
+
error: 'Internal Server Error',
|
|
1706
|
+
message: `Internal error (correlation ID: ${correlationId})`,
|
|
1707
|
+
correlationId
|
|
1708
|
+
});
|
|
1266
1709
|
}
|
|
1267
1710
|
// Record error metrics
|
|
1268
1711
|
if (this.metrics) {
|
|
@@ -1279,6 +1722,11 @@ export class HttpTransport {
|
|
|
1279
1722
|
handleDelete(req, res) {
|
|
1280
1723
|
const startTime = Date.now();
|
|
1281
1724
|
const sessionId = req.sessionId;
|
|
1725
|
+
const profileId = this.getProfileIdForRequest(req);
|
|
1726
|
+
if (!profileId) {
|
|
1727
|
+
this.respondProfileNotFound(res, req.profileId);
|
|
1728
|
+
return;
|
|
1729
|
+
}
|
|
1282
1730
|
if (!sessionId) {
|
|
1283
1731
|
const status = 400;
|
|
1284
1732
|
res.status(status).json({ error: 'Bad Request', message: 'Mcp-Session-Id header required' });
|
|
@@ -1288,7 +1736,12 @@ export class HttpTransport {
|
|
|
1288
1736
|
}
|
|
1289
1737
|
return;
|
|
1290
1738
|
}
|
|
1291
|
-
const
|
|
1739
|
+
const profileState = this.profileStates.get(profileId);
|
|
1740
|
+
if (!profileState) {
|
|
1741
|
+
this.respondProfileNotFound(res, profileId);
|
|
1742
|
+
return;
|
|
1743
|
+
}
|
|
1744
|
+
const session = profileState.sessions.get(sessionId);
|
|
1292
1745
|
if (!session) {
|
|
1293
1746
|
const status = 404;
|
|
1294
1747
|
res.status(status).json({ error: 'Not Found', message: 'Session not found' });
|
|
@@ -1298,7 +1751,7 @@ export class HttpTransport {
|
|
|
1298
1751
|
}
|
|
1299
1752
|
return;
|
|
1300
1753
|
}
|
|
1301
|
-
this.destroySession(sessionId);
|
|
1754
|
+
this.destroySession(profileState, sessionId);
|
|
1302
1755
|
const status = 204;
|
|
1303
1756
|
res.status(status).send();
|
|
1304
1757
|
if (this.metrics) {
|
|
@@ -1330,12 +1783,12 @@ export class HttpTransport {
|
|
|
1330
1783
|
*
|
|
1331
1784
|
* Why: Allows server to send requests/notifications to client
|
|
1332
1785
|
*/
|
|
1333
|
-
startSSEStream(res, sessionId, lastEventId) {
|
|
1786
|
+
startSSEStream(res, sessionId, lastEventId, profileState) {
|
|
1334
1787
|
res.setHeader('Content-Type', MIME_TYPES.EVENT_STREAM);
|
|
1335
1788
|
res.setHeader('Cache-Control', 'no-cache');
|
|
1336
1789
|
res.setHeader('Connection', 'keep-alive');
|
|
1337
1790
|
const streamId = crypto.randomBytes(16).toString('hex');
|
|
1338
|
-
const session =
|
|
1791
|
+
const session = profileState.sessions.get(sessionId);
|
|
1339
1792
|
const streamState = {
|
|
1340
1793
|
streamId,
|
|
1341
1794
|
lastEventId: lastEventId ? parseInt(lastEventId, 10) : 0,
|
|
@@ -1385,10 +1838,10 @@ export class HttpTransport {
|
|
|
1385
1838
|
*
|
|
1386
1839
|
* Why: Server-initiated requests/notifications
|
|
1387
1840
|
*/
|
|
1388
|
-
sendToClient(sessionId, message) {
|
|
1389
|
-
const session = this.sessions.get(sessionId);
|
|
1841
|
+
sendToClient(profileId, sessionId, message) {
|
|
1842
|
+
const session = this.profileStates.get(profileId)?.sessions.get(sessionId);
|
|
1390
1843
|
if (!session) {
|
|
1391
|
-
this.logger.warn('Cannot send to client: session not found', { sessionId });
|
|
1844
|
+
this.logger.warn('Cannot send to client: session not found', { profileId, sessionId });
|
|
1392
1845
|
return;
|
|
1393
1846
|
}
|
|
1394
1847
|
const eventId = Date.now();
|
|
@@ -1437,12 +1890,38 @@ export class HttpTransport {
|
|
|
1437
1890
|
}
|
|
1438
1891
|
return 'unknown';
|
|
1439
1892
|
}
|
|
1893
|
+
getFilteringHeaderValue(req) {
|
|
1894
|
+
const headerValue = req.headers['x-mcp4-params'];
|
|
1895
|
+
if (Array.isArray(headerValue)) {
|
|
1896
|
+
if (headerValue.length === 0) {
|
|
1897
|
+
return undefined;
|
|
1898
|
+
}
|
|
1899
|
+
if (headerValue.length > 1) {
|
|
1900
|
+
throw new ValidationError('Invalid X-Mcp4-Params header. Expected comma-separated key=value pairs.');
|
|
1901
|
+
}
|
|
1902
|
+
return headerValue[0];
|
|
1903
|
+
}
|
|
1904
|
+
return headerValue;
|
|
1905
|
+
}
|
|
1906
|
+
getToolFilterHeaderValue(req) {
|
|
1907
|
+
const headerValue = req.headers['x-mcp4-tools'];
|
|
1908
|
+
if (Array.isArray(headerValue)) {
|
|
1909
|
+
if (headerValue.length === 0) {
|
|
1910
|
+
return undefined;
|
|
1911
|
+
}
|
|
1912
|
+
if (headerValue.length > 1) {
|
|
1913
|
+
throw new ValidationError('Invalid X-Mcp4-Tools header. Expected comma-separated tool names.');
|
|
1914
|
+
}
|
|
1915
|
+
return headerValue[0];
|
|
1916
|
+
}
|
|
1917
|
+
return headerValue;
|
|
1918
|
+
}
|
|
1440
1919
|
/**
|
|
1441
1920
|
* Create new session
|
|
1442
1921
|
*
|
|
1443
1922
|
* Why: Stateful sessions for MCP protocol
|
|
1444
1923
|
*/
|
|
1445
|
-
createSession(authToken, refreshToken, accessTokenExpiresAt, scopes, oauthClientId) {
|
|
1924
|
+
createSession(profileState, authToken, refreshToken, accessTokenExpiresAt, scopes, oauthClientId, filtering, filteringHeader, toolFilterRequest, toolFilterHeader) {
|
|
1446
1925
|
// Validate token if provided (defense in depth)
|
|
1447
1926
|
if (authToken) {
|
|
1448
1927
|
this.validateToken(authToken, 'Session auth token');
|
|
@@ -1458,9 +1937,14 @@ export class HttpTransport {
|
|
|
1458
1937
|
accessTokenExpiresAt,
|
|
1459
1938
|
scopes,
|
|
1460
1939
|
oauthClientId,
|
|
1940
|
+
filtering,
|
|
1941
|
+
filteringHeader,
|
|
1942
|
+
toolFilterRequest,
|
|
1943
|
+
toolFilterHeader,
|
|
1461
1944
|
};
|
|
1462
|
-
|
|
1945
|
+
profileState.sessions.set(sessionId, session);
|
|
1463
1946
|
this.logger.info('Session created', {
|
|
1947
|
+
profileId: profileState.profileId,
|
|
1464
1948
|
sessionId,
|
|
1465
1949
|
hasAuthToken: !!authToken,
|
|
1466
1950
|
hasRefreshToken: !!refreshToken,
|
|
@@ -1475,8 +1959,8 @@ export class HttpTransport {
|
|
|
1475
1959
|
/**
|
|
1476
1960
|
* Update session activity timestamp
|
|
1477
1961
|
*/
|
|
1478
|
-
updateSessionActivity(sessionId) {
|
|
1479
|
-
const session =
|
|
1962
|
+
updateSessionActivity(profileState, sessionId) {
|
|
1963
|
+
const session = profileState.sessions.get(sessionId);
|
|
1480
1964
|
if (session) {
|
|
1481
1965
|
session.lastActivityAt = Date.now();
|
|
1482
1966
|
}
|
|
@@ -1486,8 +1970,8 @@ export class HttpTransport {
|
|
|
1486
1970
|
*
|
|
1487
1971
|
* Why: Free memory, close streams
|
|
1488
1972
|
*/
|
|
1489
|
-
destroySession(sessionId) {
|
|
1490
|
-
const session =
|
|
1973
|
+
destroySession(profileState, sessionId) {
|
|
1974
|
+
const session = profileState.sessions.get(sessionId);
|
|
1491
1975
|
if (session) {
|
|
1492
1976
|
// Close all active SSE streams
|
|
1493
1977
|
for (const [, streamState] of session.sseStreams) {
|
|
@@ -1506,15 +1990,16 @@ export class HttpTransport {
|
|
|
1506
1990
|
session.sseStreams.clear();
|
|
1507
1991
|
// Clean up OAuth token from map if present
|
|
1508
1992
|
if (session.authToken) {
|
|
1509
|
-
|
|
1993
|
+
profileState.oauthTokensByAccessToken.delete(session.authToken);
|
|
1510
1994
|
}
|
|
1511
|
-
|
|
1512
|
-
this.logger.info('Session destroyed', { sessionId });
|
|
1995
|
+
profileState.sessions.delete(sessionId);
|
|
1996
|
+
this.logger.info('Session destroyed', { profileId: profileState.profileId, sessionId });
|
|
1513
1997
|
// Notify session destruction listeners (for cleanup in MCPServer)
|
|
1514
|
-
this.notifySessionDestroyed(sessionId);
|
|
1998
|
+
this.notifySessionDestroyed(profileState.profileId, sessionId);
|
|
1515
1999
|
// Record metrics
|
|
1516
2000
|
if (this.metrics) {
|
|
1517
2001
|
this.metrics.recordSessionDestroyed();
|
|
2002
|
+
this.metrics.clearToolsSession(sessionId);
|
|
1518
2003
|
}
|
|
1519
2004
|
}
|
|
1520
2005
|
}
|
|
@@ -1529,10 +2014,10 @@ export class HttpTransport {
|
|
|
1529
2014
|
/**
|
|
1530
2015
|
* Notify all listeners about session destruction
|
|
1531
2016
|
*/
|
|
1532
|
-
notifySessionDestroyed(sessionId) {
|
|
2017
|
+
notifySessionDestroyed(profileId, sessionId) {
|
|
1533
2018
|
for (const listener of this.sessionDestroyedListeners) {
|
|
1534
2019
|
try {
|
|
1535
|
-
listener(sessionId);
|
|
2020
|
+
listener(profileId, sessionId);
|
|
1536
2021
|
}
|
|
1537
2022
|
catch (error) {
|
|
1538
2023
|
this.logger.error('Session destroyed listener error', error);
|
|
@@ -1545,7 +2030,7 @@ export class HttpTransport {
|
|
|
1545
2030
|
* Why: Bridge between /oauth/token endpoint (where we see OAuthTokens)
|
|
1546
2031
|
* and session initialization (where we only see access token in Authorization header)
|
|
1547
2032
|
*/
|
|
1548
|
-
storeOAuthTokens(tokens, clientId, scopes) {
|
|
2033
|
+
storeOAuthTokens(profileState, tokens, clientId, scopes) {
|
|
1549
2034
|
if (!tokens.access_token) {
|
|
1550
2035
|
this.logger.warn('OAuth tokens missing access_token, skipping storage');
|
|
1551
2036
|
return;
|
|
@@ -1553,13 +2038,14 @@ export class HttpTransport {
|
|
|
1553
2038
|
const expiresAt = tokens.expires_in
|
|
1554
2039
|
? Date.now() + tokens.expires_in * 1000
|
|
1555
2040
|
: undefined;
|
|
1556
|
-
|
|
2041
|
+
profileState.oauthTokensByAccessToken.set(tokens.access_token, {
|
|
1557
2042
|
refreshToken: tokens.refresh_token,
|
|
1558
2043
|
expiresAt,
|
|
1559
2044
|
clientId,
|
|
1560
2045
|
scopes,
|
|
1561
2046
|
});
|
|
1562
2047
|
this.logger.debug('Stored OAuth tokens', {
|
|
2048
|
+
profileId: profileState.profileId,
|
|
1563
2049
|
hasRefreshToken: !!tokens.refresh_token,
|
|
1564
2050
|
expiresAt,
|
|
1565
2051
|
clientId,
|
|
@@ -1580,25 +2066,33 @@ export class HttpTransport {
|
|
|
1580
2066
|
// Default OAuth session timeout: 24 hours (or configurable)
|
|
1581
2067
|
const oauthSessionTimeoutMs = this.config.oauthSessionTimeoutMs
|
|
1582
2068
|
?? (24 * 60 * 60 * 1000); // 24 hours default
|
|
1583
|
-
for (const
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
// If oauthSessionTimeoutMs is 0 or negative, never expire OAuth sessions
|
|
1588
|
-
if (oauthSessionTimeoutMs > 0 && age > oauthSessionTimeoutMs) {
|
|
1589
|
-
expiredSessions.push(sessionId);
|
|
1590
|
-
}
|
|
1591
|
-
// Otherwise, keep the session alive (unlimited timeout)
|
|
2069
|
+
for (const profileState of this.profileStates.values()) {
|
|
2070
|
+
// Cleanup OAuth provider resources (states, codes, tokens)
|
|
2071
|
+
if (profileState.oauthProvider) {
|
|
2072
|
+
profileState.oauthProvider.cleanup();
|
|
1592
2073
|
}
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
2074
|
+
for (const [sessionId, session] of profileState.sessions) {
|
|
2075
|
+
const age = now - session.lastActivityAt;
|
|
2076
|
+
// OAuth sessions with refresh tokens: use extended timeout or never expire
|
|
2077
|
+
if (session.refreshToken) {
|
|
2078
|
+
// If oauthSessionTimeoutMs is 0 or negative, never expire OAuth sessions
|
|
2079
|
+
if (oauthSessionTimeoutMs > 0 && age > oauthSessionTimeoutMs) {
|
|
2080
|
+
expiredSessions.push({ profileId: profileState.profileId, sessionId });
|
|
2081
|
+
}
|
|
2082
|
+
}
|
|
2083
|
+
else {
|
|
2084
|
+
// Non-OAuth sessions: use standard timeout
|
|
2085
|
+
if (age > this.config.sessionTimeoutMs) {
|
|
2086
|
+
expiredSessions.push({ profileId: profileState.profileId, sessionId });
|
|
2087
|
+
}
|
|
1597
2088
|
}
|
|
1598
2089
|
}
|
|
1599
2090
|
}
|
|
1600
|
-
for (const
|
|
1601
|
-
this.
|
|
2091
|
+
for (const entry of expiredSessions) {
|
|
2092
|
+
const state = this.profileStates.get(entry.profileId);
|
|
2093
|
+
if (state) {
|
|
2094
|
+
this.destroySession(state, entry.sessionId);
|
|
2095
|
+
}
|
|
1602
2096
|
}
|
|
1603
2097
|
if (expiredSessions.length > 0) {
|
|
1604
2098
|
this.logger.info('Cleaned up expired sessions', { count: expiredSessions.length });
|
|
@@ -1609,18 +2103,71 @@ export class HttpTransport {
|
|
|
1609
2103
|
*
|
|
1610
2104
|
* Why public: Allows MCPServer to securely access session tokens without breaking encapsulation
|
|
1611
2105
|
*/
|
|
1612
|
-
getSessionToken(sessionId) {
|
|
1613
|
-
const session = this.sessions.get(sessionId);
|
|
2106
|
+
getSessionToken(profileId, sessionId) {
|
|
2107
|
+
const session = this.profileStates.get(profileId)?.sessions.get(sessionId);
|
|
1614
2108
|
return session?.authToken;
|
|
1615
2109
|
}
|
|
2110
|
+
getSessionFiltering(profileId, sessionId) {
|
|
2111
|
+
const session = this.profileStates.get(profileId)?.sessions.get(sessionId);
|
|
2112
|
+
return session?.filtering;
|
|
2113
|
+
}
|
|
2114
|
+
getSessionFilteringHeader(profileId, sessionId) {
|
|
2115
|
+
const session = this.profileStates.get(profileId)?.sessions.get(sessionId);
|
|
2116
|
+
return session?.filteringHeader;
|
|
2117
|
+
}
|
|
2118
|
+
getSessionToolFilterRequest(profileId, sessionId) {
|
|
2119
|
+
const session = this.profileStates.get(profileId)?.sessions.get(sessionId);
|
|
2120
|
+
return session?.toolFilterRequest;
|
|
2121
|
+
}
|
|
2122
|
+
getSessionToolFilter(profileId, sessionId) {
|
|
2123
|
+
const session = this.profileStates.get(profileId)?.sessions.get(sessionId);
|
|
2124
|
+
return session?.toolFilter;
|
|
2125
|
+
}
|
|
2126
|
+
getSessionToolFilterHeader(profileId, sessionId) {
|
|
2127
|
+
const session = this.profileStates.get(profileId)?.sessions.get(sessionId);
|
|
2128
|
+
return session?.toolFilterHeader;
|
|
2129
|
+
}
|
|
2130
|
+
setSessionToolFilter(profileId, sessionId, toolFilter) {
|
|
2131
|
+
const session = this.profileStates.get(profileId)?.sessions.get(sessionId);
|
|
2132
|
+
if (!session) {
|
|
2133
|
+
return;
|
|
2134
|
+
}
|
|
2135
|
+
session.toolFilter = toolFilter;
|
|
2136
|
+
}
|
|
2137
|
+
recordGlobalToolFilterMetrics(summary) {
|
|
2138
|
+
if (!this.metrics) {
|
|
2139
|
+
return;
|
|
2140
|
+
}
|
|
2141
|
+
this.metrics.recordToolsTotal('profile', summary.originalCount);
|
|
2142
|
+
this.metrics.recordToolsFiltered('global_env', 'allowed', summary.allowedCount);
|
|
2143
|
+
this.metrics.recordToolsFiltered('global_env', 'denied', summary.removedCount);
|
|
2144
|
+
for (const [type, count] of Object.entries(summary.patternCounts)) {
|
|
2145
|
+
this.metrics.recordToolFilterPatternCount(type, count);
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
recordSessionToolFilterMetrics(sessionId, allowedCount, request) {
|
|
2149
|
+
if (!this.metrics) {
|
|
2150
|
+
return;
|
|
2151
|
+
}
|
|
2152
|
+
this.metrics.recordToolsSession(sessionId, allowedCount);
|
|
2153
|
+
this.metrics.recordToolFilterPatternCount('session_allow_list', request.exactNames.size);
|
|
2154
|
+
this.metrics.recordToolFilterPatternCount('session_allow_regex', request.regexPatterns.length);
|
|
2155
|
+
}
|
|
2156
|
+
recordToolFilterRejection(tool, source) {
|
|
2157
|
+
if (!this.metrics) {
|
|
2158
|
+
return;
|
|
2159
|
+
}
|
|
2160
|
+
this.metrics.recordToolFilterRejection(tool, source);
|
|
2161
|
+
}
|
|
1616
2162
|
/**
|
|
1617
2163
|
* Ensure session has a valid access token, refreshing if necessary
|
|
1618
2164
|
*
|
|
1619
2165
|
* Why: Transparently refresh expired OAuth tokens before making API calls
|
|
1620
2166
|
* Returns true if token is valid (or was successfully refreshed), false otherwise
|
|
1621
2167
|
*/
|
|
1622
|
-
async ensureValidSessionToken(sessionId) {
|
|
1623
|
-
const
|
|
2168
|
+
async ensureValidSessionToken(profileId, sessionId) {
|
|
2169
|
+
const profileState = this.profileStates.get(profileId);
|
|
2170
|
+
const session = profileState?.sessions.get(sessionId);
|
|
1624
2171
|
if (!session) {
|
|
1625
2172
|
return false;
|
|
1626
2173
|
}
|
|
@@ -1634,11 +2181,12 @@ export class HttpTransport {
|
|
|
1634
2181
|
// If token is expired or about to expire, refresh it
|
|
1635
2182
|
if (timeUntilExpiration <= refreshThresholdMs) {
|
|
1636
2183
|
this.logger.debug('Access token expired or expiring soon, refreshing', {
|
|
2184
|
+
profileId,
|
|
1637
2185
|
sessionId,
|
|
1638
2186
|
expiresAt: new Date(session.accessTokenExpiresAt).toISOString(),
|
|
1639
2187
|
timeUntilExpiration,
|
|
1640
2188
|
});
|
|
1641
|
-
return await this.refreshAccessToken(sessionId);
|
|
2189
|
+
return await this.refreshAccessToken(profileId, sessionId);
|
|
1642
2190
|
}
|
|
1643
2191
|
return true;
|
|
1644
2192
|
}
|
|
@@ -1648,14 +2196,16 @@ export class HttpTransport {
|
|
|
1648
2196
|
* Why: Automatically renew expired OAuth access tokens without user intervention
|
|
1649
2197
|
* Returns true on success, false on failure
|
|
1650
2198
|
*/
|
|
1651
|
-
async refreshAccessToken(sessionId) {
|
|
1652
|
-
const
|
|
1653
|
-
|
|
2199
|
+
async refreshAccessToken(profileId, sessionId) {
|
|
2200
|
+
const profileState = this.profileStates.get(profileId);
|
|
2201
|
+
const session = profileState?.sessions.get(sessionId);
|
|
2202
|
+
if (!profileState || !session || !session.refreshToken || !profileState.oauthProvider) {
|
|
1654
2203
|
this.logger.warn('Cannot refresh token: missing session, refreshToken, or OAuth provider', {
|
|
2204
|
+
profileId,
|
|
1655
2205
|
sessionId,
|
|
1656
2206
|
hasSession: !!session,
|
|
1657
2207
|
hasRefreshToken: !!session?.refreshToken,
|
|
1658
|
-
hasOAuthProvider: !!
|
|
2208
|
+
hasOAuthProvider: !!profileState?.oauthProvider,
|
|
1659
2209
|
});
|
|
1660
2210
|
return false;
|
|
1661
2211
|
}
|
|
@@ -1664,32 +2214,33 @@ export class HttpTransport {
|
|
|
1664
2214
|
// Try to find client by clientId stored in session, or use default client
|
|
1665
2215
|
let client;
|
|
1666
2216
|
if (session.oauthClientId) {
|
|
1667
|
-
await
|
|
1668
|
-
client = await
|
|
2217
|
+
await profileState.oauthProvider.ensureEndpointsInitialized();
|
|
2218
|
+
client = await profileState.oauthProvider.clientsStore.getClient(session.oauthClientId);
|
|
1669
2219
|
}
|
|
1670
2220
|
// Fallback to default client from config if session client not found
|
|
1671
|
-
if (!client &&
|
|
1672
|
-
await
|
|
2221
|
+
if (!client && profileState.oauthProvider) {
|
|
2222
|
+
await profileState.oauthProvider.ensureEndpointsInitialized();
|
|
1673
2223
|
// Try common client IDs
|
|
1674
2224
|
const defaultClientIds = ['mcp-proxy-client'];
|
|
1675
|
-
if (
|
|
1676
|
-
defaultClientIds.unshift(
|
|
2225
|
+
if (profileState.context.oauthConfig?.client_id) {
|
|
2226
|
+
defaultClientIds.unshift(profileState.context.oauthConfig.client_id);
|
|
1677
2227
|
}
|
|
1678
2228
|
for (const clientId of defaultClientIds) {
|
|
1679
|
-
client = await
|
|
2229
|
+
client = await profileState.oauthProvider.clientsStore.getClient(clientId);
|
|
1680
2230
|
if (client)
|
|
1681
2231
|
break;
|
|
1682
2232
|
}
|
|
1683
2233
|
}
|
|
1684
2234
|
if (!client) {
|
|
1685
2235
|
this.logger.error('Cannot refresh token: OAuth client not found', undefined, {
|
|
2236
|
+
profileId,
|
|
1686
2237
|
sessionId,
|
|
1687
2238
|
oauthClientId: session.oauthClientId,
|
|
1688
2239
|
});
|
|
1689
2240
|
return false;
|
|
1690
2241
|
}
|
|
1691
2242
|
// Exchange refresh token for new tokens
|
|
1692
|
-
const tokens = await
|
|
2243
|
+
const tokens = await profileState.oauthProvider.exchangeRefreshToken(client, session.refreshToken, session.scopes);
|
|
1693
2244
|
// Update session with new tokens
|
|
1694
2245
|
const oldAccessToken = session.authToken;
|
|
1695
2246
|
session.authToken = tokens.access_token;
|
|
@@ -1699,10 +2250,11 @@ export class HttpTransport {
|
|
|
1699
2250
|
: undefined;
|
|
1700
2251
|
// Update token map: remove old token, add new one
|
|
1701
2252
|
if (oldAccessToken) {
|
|
1702
|
-
|
|
2253
|
+
profileState.oauthTokensByAccessToken.delete(oldAccessToken);
|
|
1703
2254
|
}
|
|
1704
|
-
this.storeOAuthTokens(tokens, client.client_id, session.scopes || []);
|
|
2255
|
+
this.storeOAuthTokens(profileState, tokens, client.client_id, session.scopes || []);
|
|
1705
2256
|
this.logger.info('Access token refreshed successfully', {
|
|
2257
|
+
profileId,
|
|
1706
2258
|
sessionId,
|
|
1707
2259
|
newExpiresAt: session.accessTokenExpiresAt ? new Date(session.accessTokenExpiresAt).toISOString() : undefined,
|
|
1708
2260
|
});
|
|
@@ -1710,6 +2262,7 @@ export class HttpTransport {
|
|
|
1710
2262
|
}
|
|
1711
2263
|
catch (error) {
|
|
1712
2264
|
this.logger.error('Token refresh failed', error instanceof Error ? error : new Error(String(error)), {
|
|
2265
|
+
profileId,
|
|
1713
2266
|
sessionId,
|
|
1714
2267
|
});
|
|
1715
2268
|
return false;
|
|
@@ -1724,41 +2277,49 @@ export class HttpTransport {
|
|
|
1724
2277
|
/**
|
|
1725
2278
|
* Check if OAuth provider is configured
|
|
1726
2279
|
*/
|
|
1727
|
-
hasOAuthProvider() {
|
|
1728
|
-
|
|
2280
|
+
hasOAuthProvider(profileId) {
|
|
2281
|
+
if (!profileId) {
|
|
2282
|
+
const defaultProfileId = this.getDefaultProfileId();
|
|
2283
|
+
if (!defaultProfileId) {
|
|
2284
|
+
return false;
|
|
2285
|
+
}
|
|
2286
|
+
const state = this.profileStates.get(defaultProfileId);
|
|
2287
|
+
return state ? state.oauthProvider !== null : false;
|
|
2288
|
+
}
|
|
2289
|
+
const state = this.profileStates.get(profileId);
|
|
2290
|
+
return state ? state.oauthProvider !== null : false;
|
|
1729
2291
|
}
|
|
1730
2292
|
/**
|
|
1731
2293
|
* Get server URL
|
|
1732
2294
|
*/
|
|
1733
|
-
getServerUrl() {
|
|
1734
|
-
|
|
1735
|
-
// This ensures consistency with the public address used for OAuth callbacks
|
|
1736
|
-
if (this.oauthProvider?.redirectUri) {
|
|
1737
|
-
try {
|
|
1738
|
-
return new URL(this.oauthProvider.redirectUri).origin;
|
|
1739
|
-
}
|
|
1740
|
-
catch (e) {
|
|
1741
|
-
// Ignore invalid URL format
|
|
1742
|
-
}
|
|
1743
|
-
}
|
|
1744
|
-
// Fallback to configured host/port
|
|
1745
|
-
// If configured with 0.0.0.0, this will return http://0.0.0.0:port
|
|
1746
|
-
// which is usually fine for internal communication but not for external clients
|
|
1747
|
-
const protocol = this.config.host.includes('://') ? '' : 'http://';
|
|
1748
|
-
const host = this.config.host.includes('://') ? this.config.host : this.config.host;
|
|
1749
|
-
return `${protocol}${host}:${this.config.port}`;
|
|
2295
|
+
getServerUrl(profileId) {
|
|
2296
|
+
return this.getServerOrigin(profileId);
|
|
1750
2297
|
}
|
|
1751
2298
|
/**
|
|
1752
2299
|
* Get OAuth authorization URL
|
|
1753
2300
|
*/
|
|
1754
|
-
getOAuthAuthorizationUrl() {
|
|
1755
|
-
|
|
2301
|
+
getOAuthAuthorizationUrl(profileId) {
|
|
2302
|
+
if (!profileId) {
|
|
2303
|
+
const defaultProfileId = this.getDefaultProfileId();
|
|
2304
|
+
if (!defaultProfileId) {
|
|
2305
|
+
return '';
|
|
2306
|
+
}
|
|
2307
|
+
return this.profileStates.get(defaultProfileId)?.oauthProvider?.authorizationEndpoint || '';
|
|
2308
|
+
}
|
|
2309
|
+
return this.profileStates.get(profileId)?.oauthProvider?.authorizationEndpoint || '';
|
|
1756
2310
|
}
|
|
1757
2311
|
/**
|
|
1758
2312
|
* Get OAuth scopes
|
|
1759
2313
|
*/
|
|
1760
|
-
getOAuthScopes() {
|
|
1761
|
-
|
|
2314
|
+
getOAuthScopes(profileId) {
|
|
2315
|
+
if (!profileId) {
|
|
2316
|
+
const defaultProfileId = this.getDefaultProfileId();
|
|
2317
|
+
if (!defaultProfileId) {
|
|
2318
|
+
return [];
|
|
2319
|
+
}
|
|
2320
|
+
return this.profileStates.get(defaultProfileId)?.oauthProvider?.scopes || [];
|
|
2321
|
+
}
|
|
2322
|
+
return this.profileStates.get(profileId)?.oauthProvider?.scopes || [];
|
|
1762
2323
|
}
|
|
1763
2324
|
/**
|
|
1764
2325
|
* Start HTTP server
|
|
@@ -1829,8 +2390,10 @@ export class HttpTransport {
|
|
|
1829
2390
|
this.cleanupInterval = null;
|
|
1830
2391
|
}
|
|
1831
2392
|
// Destroy all sessions
|
|
1832
|
-
for (const
|
|
1833
|
-
|
|
2393
|
+
for (const profileState of this.profileStates.values()) {
|
|
2394
|
+
for (const sessionId of profileState.sessions.keys()) {
|
|
2395
|
+
this.destroySession(profileState, sessionId);
|
|
2396
|
+
}
|
|
1834
2397
|
}
|
|
1835
2398
|
if (this.server) {
|
|
1836
2399
|
return new Promise((resolve, reject) => {
|