mcp4openapi 0.2.8 → 0.3.0
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/{oauth-provider.d.ts → auth/oauth-provider.d.ts} +7 -2
- package/dist/src/auth/oauth-provider.d.ts.map +1 -0
- package/dist/src/{oauth-provider.js → auth/oauth-provider.js} +30 -2
- package/dist/src/auth/oauth-provider.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 +124 -0
- package/dist/src/core/cli-config.js.map +1 -0
- package/dist/src/{constants.d.ts → core/constants.d.ts} +1 -0
- package/dist/src/core/constants.d.ts.map +1 -0
- package/dist/src/{constants.js → core/constants.js} +1 -0
- package/dist/src/core/constants.js.map +1 -0
- package/dist/src/{errors.d.ts → core/errors.d.ts} +6 -0
- package/dist/src/core/errors.d.ts.map +1 -0
- package/dist/src/{errors.js → core/errors.js} +15 -6
- 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 +275 -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/{logger.d.ts → core/logger.d.ts} +6 -1
- package/dist/src/core/logger.d.ts.map +1 -0
- package/dist/src/{logger.js → core/logger.js} +30 -2
- package/dist/src/core/logger.js.map +1 -0
- package/dist/src/{metrics.d.ts → core/metrics.d.ts} +11 -0
- package/dist/src/core/metrics.d.ts.map +1 -0
- package/dist/src/{metrics.js → core/metrics.js} +61 -0
- package/dist/src/core/metrics.js.map +1 -0
- package/dist/src/core/naming-warnings.d.ts.map +1 -0
- package/dist/src/core/naming-warnings.js.map +1 -0
- package/dist/src/core/naming.d.ts.map +1 -0
- package/dist/src/core/naming.js.map +1 -0
- package/dist/src/generated-schemas.d.ts +245 -79
- package/dist/src/generated-schemas.d.ts.map +1 -1
- package/dist/src/generated-schemas.js +14 -2
- package/dist/src/generated-schemas.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/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/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-server.d.ts → mcp/mcp-server.d.ts} +31 -1
- package/dist/src/mcp/mcp-server.d.ts.map +1 -0
- package/dist/src/{mcp-server.js → mcp/mcp-server.js} +547 -146
- package/dist/src/mcp/mcp-server.js.map +1 -0
- package/dist/src/{openapi-parser.d.ts → openapi/openapi-parser.d.ts} +1 -1
- package/dist/src/openapi/openapi-parser.d.ts.map +1 -0
- package/dist/src/{openapi-parser.js → openapi/openapi-parser.js} +2 -2
- package/dist/src/openapi/openapi-parser.js.map +1 -0
- package/dist/src/{profile-loader.d.ts → profile/profile-loader.d.ts} +3 -2
- package/dist/src/profile/profile-loader.d.ts.map +1 -0
- package/dist/src/{profile-loader.js → profile/profile-loader.js} +17 -6
- package/dist/src/profile/profile-loader.js.map +1 -0
- package/dist/src/profile/profile-registry.d.ts +18 -0
- package/dist/src/profile/profile-registry.d.ts.map +1 -0
- package/dist/src/profile/profile-registry.js +26 -0
- package/dist/src/profile/profile-registry.js.map +1 -0
- package/dist/src/profile/profile-resolver.d.ts +25 -0
- package/dist/src/profile/profile-resolver.d.ts.map +1 -0
- package/dist/src/profile/profile-resolver.js +204 -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/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/{composite-executor.d.ts → tooling/composite-executor.d.ts} +3 -3
- package/dist/src/tooling/composite-executor.d.ts.map +1 -0
- package/dist/src/{composite-executor.js → tooling/composite-executor.js} +1 -1
- package/dist/src/tooling/composite-executor.js.map +1 -0
- package/dist/src/{dag-executor.d.ts → tooling/dag-executor.d.ts} +1 -1
- package/dist/src/tooling/dag-executor.d.ts.map +1 -0
- package/dist/src/tooling/dag-executor.js.map +1 -0
- package/dist/src/{proxy-executor.d.ts → tooling/proxy-executor.d.ts} +2 -2
- package/dist/src/tooling/proxy-executor.d.ts.map +1 -0
- package/dist/src/{proxy-executor.js → tooling/proxy-executor.js} +8 -1
- package/dist/src/tooling/proxy-executor.js.map +1 -0
- package/dist/src/{tool-generator.d.ts → tooling/tool-generator.d.ts} +4 -3
- package/dist/src/tooling/tool-generator.d.ts.map +1 -0
- package/dist/src/{tool-generator.js → tooling/tool-generator.js} +23 -7
- package/dist/src/tooling/tool-generator.js.map +1 -0
- package/dist/src/{http-client-factory.d.ts → transport/http-client-factory.d.ts} +4 -1
- package/dist/src/transport/http-client-factory.d.ts.map +1 -0
- package/dist/src/{http-client-factory.js → transport/http-client-factory.js} +13 -3
- 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 +62 -0
- package/dist/src/transport/http-transport-config.js.map +1 -0
- package/dist/src/{http-transport.d.ts → transport/http-transport.d.ts} +72 -14
- package/dist/src/transport/http-transport.d.ts.map +1 -0
- package/dist/src/{http-transport.js → transport/http-transport.js} +1166 -493
- package/dist/src/transport/http-transport.js.map +1 -0
- package/dist/src/{interceptors.d.ts → transport/interceptors.d.ts} +6 -2
- package/dist/src/transport/interceptors.d.ts.map +1 -0
- package/dist/src/{interceptors.js → transport/interceptors.js} +72 -41
- package/dist/src/transport/interceptors.js.map +1 -0
- package/dist/src/types/http-transport.d.ts +25 -0
- package/dist/src/types/http-transport.d.ts.map +1 -1
- package/dist/src/types/profile.d.ts +13 -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.map +1 -0
- package/dist/src/validation/jsonrpc-validator.js.map +1 -0
- package/dist/src/{schema-validator.d.ts → validation/schema-validator.d.ts} +2 -2
- package/dist/src/validation/schema-validator.d.ts.map +1 -0
- package/dist/src/validation/schema-validator.js.map +1 -0
- package/dist/src/validation/validation-utils.d.ts.map +1 -0
- package/dist/src/validation/validation-utils.js.map +1 -0
- package/package.json +9 -3
- package/profile-schema.json +63 -3
- package/profiles/gitlab/developer-profile-oauth.json +1520 -0
- package/profiles/gitlab/developer-profile-oauth.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/composite-executor.d.ts.map +0 -1
- package/dist/src/composite-executor.js.map +0 -1
- package/dist/src/constants.d.ts.map +0 -1
- package/dist/src/constants.js.map +0 -1
- package/dist/src/dag-executor.d.ts.map +0 -1
- package/dist/src/dag-executor.js.map +0 -1
- package/dist/src/errors.d.ts.map +0 -1
- package/dist/src/errors.js.map +0 -1
- package/dist/src/http-client-factory.d.ts.map +0 -1
- package/dist/src/http-client-factory.js.map +0 -1
- package/dist/src/http-transport.d.ts.map +0 -1
- package/dist/src/http-transport.js.map +0 -1
- package/dist/src/interceptors.d.ts.map +0 -1
- package/dist/src/interceptors.js.map +0 -1
- package/dist/src/jsonrpc-validator.d.ts.map +0 -1
- package/dist/src/jsonrpc-validator.js.map +0 -1
- package/dist/src/logger.d.ts.map +0 -1
- package/dist/src/logger.js.map +0 -1
- package/dist/src/mcp-server.d.ts.map +0 -1
- package/dist/src/mcp-server.js.map +0 -1
- package/dist/src/metrics.d.ts.map +0 -1
- package/dist/src/metrics.js.map +0 -1
- package/dist/src/naming-warnings.d.ts.map +0 -1
- package/dist/src/naming-warnings.js.map +0 -1
- package/dist/src/naming.d.ts.map +0 -1
- package/dist/src/naming.js.map +0 -1
- package/dist/src/oauth-provider.d.ts.map +0 -1
- package/dist/src/oauth-provider.js.map +0 -1
- package/dist/src/openapi-parser.d.ts.map +0 -1
- package/dist/src/openapi-parser.js.map +0 -1
- package/dist/src/profile-loader.d.ts.map +0 -1
- package/dist/src/profile-loader.js.map +0 -1
- package/dist/src/proxy-executor.d.ts.map +0 -1
- package/dist/src/proxy-executor.js.map +0 -1
- package/dist/src/schema-validator.d.ts.map +0 -1
- package/dist/src/schema-validator.js.map +0 -1
- 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
- package/dist/src/tool-generator.d.ts.map +0 -1
- package/dist/src/tool-generator.js.map +0 -1
- package/dist/src/validation-utils.d.ts.map +0 -1
- package/dist/src/validation-utils.js.map +0 -1
- /package/dist/src/{naming-warnings.d.ts → core/naming-warnings.d.ts} +0 -0
- /package/dist/src/{naming-warnings.js → core/naming-warnings.js} +0 -0
- /package/dist/src/{naming.d.ts → core/naming.d.ts} +0 -0
- /package/dist/src/{naming.js → core/naming.js} +0 -0
- /package/dist/src/{dag-executor.js → tooling/dag-executor.js} +0 -0
- /package/dist/src/{jsonrpc-validator.d.ts → validation/jsonrpc-validator.d.ts} +0 -0
- /package/dist/src/{jsonrpc-validator.js → validation/jsonrpc-validator.js} +0 -0
- /package/dist/src/{schema-validator.js → validation/schema-validator.js} +0 -0
- /package/dist/src/{validation-utils.d.ts → validation/validation-utils.d.ts} +0 -0
- /package/dist/src/{validation-utils.js → validation/validation-utils.js} +0 -0
|
@@ -13,25 +13,27 @@ import fs from 'fs';
|
|
|
13
13
|
import crypto from 'crypto';
|
|
14
14
|
import { isIP } from 'node:net';
|
|
15
15
|
import rateLimit from 'express-rate-limit';
|
|
16
|
-
import { isInitializeRequest } from '
|
|
17
|
-
import { MetricsCollector } from '
|
|
18
|
-
import { ExternalOAuthProvider } from '
|
|
19
|
-
import { HTTP_STATUS, MIME_TYPES, OAUTH_PATHS, TIMEOUTS, OAUTH_RATE_LIMIT } from '
|
|
20
|
-
import { escapeHtmlSafe } from '
|
|
21
|
-
import { AuthenticationError, AuthorizationError, RateLimitError, ValidationError, generateCorrelationId, } from '
|
|
22
|
-
|
|
16
|
+
import { isInitializeRequest } from '../validation/jsonrpc-validator.js';
|
|
17
|
+
import { MetricsCollector } from '../core/metrics.js';
|
|
18
|
+
import { ExternalOAuthProvider } from '../auth/oauth-provider.js';
|
|
19
|
+
import { HTTP_STATUS, MIME_TYPES, OAUTH_PATHS, TIMEOUTS, OAUTH_RATE_LIMIT } from '../core/constants.js';
|
|
20
|
+
import { escapeHtmlSafe, isSafePropertyName } from '../validation/validation-utils.js';
|
|
21
|
+
import { AuthenticationError, AuthorizationError, ConfigurationError, RateLimitError, ValidationError, generateCorrelationId, } from '../core/errors.js';
|
|
22
|
+
import { parseFilteringHeader, normalizeFilteringHeaderValue } from '../core/filtering.js';
|
|
23
|
+
import { ToolFilterService, EnvConfigParser, HeaderConfigParser, RegexCompiler, RegexValidator, OperationClassifier, OpenAPIOperationResolver, OperationDetector, normalizeToolFilterHeaderValue, parseSessionToolFilterHeader, } from '../tool-filter/index.js';
|
|
23
24
|
const DEFAULT_MAX_TOKEN_LENGTH = 1000;
|
|
24
25
|
export class HttpTransport {
|
|
26
|
+
static { this.PROFILE_HINT_TTL_MS = 10 * 60 * 1000; }
|
|
25
27
|
constructor(config, logger) {
|
|
26
28
|
this.server = null;
|
|
27
|
-
this.sessions = new Map();
|
|
28
29
|
this.metrics = null;
|
|
29
30
|
this.cleanupInterval = null;
|
|
30
31
|
this.messageHandler = null;
|
|
31
|
-
this.
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
this.
|
|
32
|
+
this.profileContextProvider = null;
|
|
33
|
+
this.profileStates = new Map();
|
|
34
|
+
this.oauthRedirectHostCache = new Map();
|
|
35
|
+
this.warnedMissingOAuthRedirectEnvVars = new Set();
|
|
36
|
+
this.profileHintsByClient = new Map();
|
|
35
37
|
this.hasWarnedAboutBinding = false;
|
|
36
38
|
/**
|
|
37
39
|
* Session destruction listeners for cleanup in other components
|
|
@@ -47,30 +49,37 @@ export class HttpTransport {
|
|
|
47
49
|
prefix: 'mcp_',
|
|
48
50
|
});
|
|
49
51
|
}
|
|
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
52
|
this.app = express();
|
|
53
|
+
if (this.config.trustProxy !== undefined) {
|
|
54
|
+
this.app.set('trust proxy', this.config.trustProxy);
|
|
55
|
+
}
|
|
65
56
|
this.setupMiddleware();
|
|
66
57
|
this.setupRoutes();
|
|
67
58
|
}
|
|
59
|
+
getMetricsCollector() {
|
|
60
|
+
return this.metrics;
|
|
61
|
+
}
|
|
68
62
|
/**
|
|
69
63
|
* Setup Express middleware
|
|
70
64
|
*
|
|
71
65
|
* Why: Security (Origin validation, rate limiting), JSON parsing, session extraction, metrics
|
|
72
66
|
*/
|
|
73
67
|
setupMiddleware() {
|
|
68
|
+
// Security: standard headers
|
|
69
|
+
this.app.disable('x-powered-by');
|
|
70
|
+
this.app.use((req, res, next) => {
|
|
71
|
+
res.setHeader('Content-Security-Policy', "default-src 'self'; frame-ancestors 'none'");
|
|
72
|
+
res.setHeader('X-Frame-Options', 'DENY');
|
|
73
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
74
|
+
res.setHeader('Referrer-Policy', 'no-referrer');
|
|
75
|
+
res.setHeader('Permissions-Policy', 'geolocation=(), microphone=(), camera=(), payment=()');
|
|
76
|
+
// Additional security headers
|
|
77
|
+
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
|
|
78
|
+
res.setHeader('Cross-Origin-Resource-Policy', 'same-origin');
|
|
79
|
+
res.setHeader('X-Permitted-Cross-Domain-Policies', 'none');
|
|
80
|
+
res.setHeader('X-DNS-Prefetch-Control', 'off');
|
|
81
|
+
next();
|
|
82
|
+
});
|
|
74
83
|
// Request logging (before any middleware)
|
|
75
84
|
this.app.use((req, res, next) => {
|
|
76
85
|
this.logger.debug('Request received', {
|
|
@@ -103,7 +112,9 @@ export class HttpTransport {
|
|
|
103
112
|
next();
|
|
104
113
|
});
|
|
105
114
|
// JSON body parser
|
|
106
|
-
|
|
115
|
+
// Limit set to 10MB to support large tool inputs/results (e.g. file content)
|
|
116
|
+
// while preventing massive DoS attacks. Default is 100kb.
|
|
117
|
+
this.app.use(express.json({ limit: '10mb' }));
|
|
107
118
|
// Metrics: Track request start time
|
|
108
119
|
this.app.use((req, res, next) => {
|
|
109
120
|
req.startTime = Date.now();
|
|
@@ -149,6 +160,16 @@ export class HttpTransport {
|
|
|
149
160
|
});
|
|
150
161
|
next();
|
|
151
162
|
});
|
|
163
|
+
// Capture profile hints for clients using profile routing
|
|
164
|
+
this.app.use((req, _res, next) => {
|
|
165
|
+
if (this.config.profileRoutingEnabled) {
|
|
166
|
+
const profileId = this.resolveProfileIdFromPath(req.path);
|
|
167
|
+
if (profileId) {
|
|
168
|
+
this.storeProfileHint(req, profileId);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
next();
|
|
172
|
+
});
|
|
152
173
|
// Security: Origin validation (DNS rebinding protection)
|
|
153
174
|
this.app.use((req, res, next) => {
|
|
154
175
|
const origin = req.headers.origin;
|
|
@@ -157,19 +178,31 @@ export class HttpTransport {
|
|
|
157
178
|
this.logger.warn('HTTP transport bound to 0.0.0.0 - accessible from network. Ensure firewall protection.');
|
|
158
179
|
this.hasWarnedAboutBinding = true;
|
|
159
180
|
}
|
|
160
|
-
//
|
|
161
|
-
|
|
162
|
-
|
|
181
|
+
// Validate Origin header
|
|
182
|
+
// We do not skip this check for localhost, as requests to localhost can still be CSRF targets
|
|
183
|
+
if (!origin) {
|
|
184
|
+
next();
|
|
185
|
+
return;
|
|
163
186
|
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
187
|
+
this.isAllowedOriginForRequest(origin, req)
|
|
188
|
+
.then((allowed) => {
|
|
189
|
+
if (!allowed) {
|
|
190
|
+
this.logger.warn('Rejected request from disallowed origin', { origin, ip: req.ip });
|
|
191
|
+
res.status(HTTP_STATUS.FORBIDDEN).json({
|
|
192
|
+
error: 'Forbidden',
|
|
193
|
+
message: 'Origin not allowed'
|
|
194
|
+
});
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
next();
|
|
198
|
+
})
|
|
199
|
+
.catch((error) => {
|
|
200
|
+
this.logger.error('Origin validation failed', error instanceof Error ? error : new Error(String(error)));
|
|
201
|
+
res.status(HTTP_STATUS.FORBIDDEN).json({
|
|
168
202
|
error: 'Forbidden',
|
|
169
203
|
message: 'Origin not allowed'
|
|
170
204
|
});
|
|
171
|
-
}
|
|
172
|
-
next();
|
|
205
|
+
});
|
|
173
206
|
});
|
|
174
207
|
// Extract session ID from header
|
|
175
208
|
this.app.use((req, res, next) => {
|
|
@@ -180,6 +213,101 @@ export class HttpTransport {
|
|
|
180
213
|
next();
|
|
181
214
|
});
|
|
182
215
|
}
|
|
216
|
+
setProfileContextProvider(provider) {
|
|
217
|
+
this.profileContextProvider = provider;
|
|
218
|
+
}
|
|
219
|
+
getDefaultProfileId() {
|
|
220
|
+
if (this.config.defaultProfileId) {
|
|
221
|
+
return this.config.defaultProfileId;
|
|
222
|
+
}
|
|
223
|
+
if (this.config.profileRoutingEnabled) {
|
|
224
|
+
return undefined;
|
|
225
|
+
}
|
|
226
|
+
return 'default';
|
|
227
|
+
}
|
|
228
|
+
buildDefaultProfileContext() {
|
|
229
|
+
const profileId = this.getDefaultProfileId();
|
|
230
|
+
if (!profileId) {
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
234
|
+
profileId,
|
|
235
|
+
oauthConfig: this.config.oauthConfig,
|
|
236
|
+
authConfigs: this.config.authConfigs,
|
|
237
|
+
baseUrl: this.config.baseUrl,
|
|
238
|
+
rateLimitOAuthMax: this.config.rateLimitOAuthMax,
|
|
239
|
+
rateLimitOAuthWindowMs: this.config.rateLimitOAuthWindowMs,
|
|
240
|
+
resourceName: this.config.resourceName,
|
|
241
|
+
resourceDocumentation: this.config.resourceDocumentation,
|
|
242
|
+
parser: this.config.parser,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
async getProfileState(profileId) {
|
|
246
|
+
const existing = this.profileStates.get(profileId);
|
|
247
|
+
if (existing) {
|
|
248
|
+
return existing;
|
|
249
|
+
}
|
|
250
|
+
let context = null;
|
|
251
|
+
if (this.profileContextProvider) {
|
|
252
|
+
try {
|
|
253
|
+
context = await this.profileContextProvider(profileId);
|
|
254
|
+
}
|
|
255
|
+
catch (error) {
|
|
256
|
+
if (error instanceof ConfigurationError && error.message === 'Profile not found') {
|
|
257
|
+
this.logger.warn('Profile not found during request', { profileId });
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
throw error;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
else {
|
|
264
|
+
const defaultContext = this.buildDefaultProfileContext();
|
|
265
|
+
if (defaultContext?.profileId === profileId) {
|
|
266
|
+
context = defaultContext;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if (!context) {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
let oauthProvider = null;
|
|
273
|
+
if (context.oauthConfig) {
|
|
274
|
+
this.logger.info('Initializing OAuth provider with config', {
|
|
275
|
+
profileId,
|
|
276
|
+
hasClientId: !!context.oauthConfig.client_id,
|
|
277
|
+
});
|
|
278
|
+
oauthProvider = new ExternalOAuthProvider(context.oauthConfig, this.logger);
|
|
279
|
+
this.logger.info('OAuth provider initialized', {
|
|
280
|
+
profileId,
|
|
281
|
+
endpoint: oauthProvider.authorizationEndpoint || '(to be derived from issuer)',
|
|
282
|
+
hasIssuer: !!context.oauthConfig.issuer,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
this.logger.info('No OAuth config provided - OAuth provider not initialized', { profileId });
|
|
287
|
+
}
|
|
288
|
+
const state = {
|
|
289
|
+
profileId,
|
|
290
|
+
context,
|
|
291
|
+
oauthProvider,
|
|
292
|
+
oauthTokensByAccessToken: new Map(),
|
|
293
|
+
sessions: new Map(),
|
|
294
|
+
};
|
|
295
|
+
this.profileStates.set(profileId, state);
|
|
296
|
+
return state;
|
|
297
|
+
}
|
|
298
|
+
getProfileIdForRequest(req) {
|
|
299
|
+
if (req.profileId) {
|
|
300
|
+
return req.profileId;
|
|
301
|
+
}
|
|
302
|
+
return this.getDefaultProfileId();
|
|
303
|
+
}
|
|
304
|
+
async getProfileStateForRequest(req) {
|
|
305
|
+
const profileId = this.getProfileIdForRequest(req);
|
|
306
|
+
if (!profileId) {
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
return await this.getProfileState(profileId);
|
|
310
|
+
}
|
|
183
311
|
/**
|
|
184
312
|
* Check if origin is allowed
|
|
185
313
|
*
|
|
@@ -203,16 +331,10 @@ export class HttpTransport {
|
|
|
203
331
|
if (hostname === this.config.host) {
|
|
204
332
|
return true;
|
|
205
333
|
}
|
|
206
|
-
// Allow OAuth redirect URI
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
if (hostname === redirectUrl.hostname) {
|
|
211
|
-
return true;
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
catch {
|
|
215
|
-
// Invalid URL, ignore
|
|
334
|
+
// Allow OAuth redirect URI hosts (initialized + cached + configured)
|
|
335
|
+
for (const redirectHost of this.getOAuthRedirectHostPatterns()) {
|
|
336
|
+
if (this.matchOrigin(hostname, redirectHost)) {
|
|
337
|
+
return true;
|
|
216
338
|
}
|
|
217
339
|
}
|
|
218
340
|
// Check custom allowed origins
|
|
@@ -229,6 +351,159 @@ export class HttpTransport {
|
|
|
229
351
|
return false;
|
|
230
352
|
}
|
|
231
353
|
}
|
|
354
|
+
getOAuthRedirectHostPatterns() {
|
|
355
|
+
const hosts = new Set();
|
|
356
|
+
const defaultProfileId = this.getDefaultProfileId() ?? 'default';
|
|
357
|
+
for (const state of this.profileStates.values()) {
|
|
358
|
+
const redirectUri = state.oauthProvider?.redirectUri;
|
|
359
|
+
if (!redirectUri) {
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
try {
|
|
363
|
+
const redirectUrl = new URL(redirectUri);
|
|
364
|
+
hosts.add(redirectUrl.hostname);
|
|
365
|
+
}
|
|
366
|
+
catch {
|
|
367
|
+
// Ignore invalid URL
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
if (this.config.oauthConfig) {
|
|
371
|
+
for (const pattern of this.extractRedirectHostPatterns(this.config.oauthConfig, defaultProfileId)) {
|
|
372
|
+
hosts.add(pattern);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
for (const patterns of this.oauthRedirectHostCache.values()) {
|
|
376
|
+
for (const pattern of patterns) {
|
|
377
|
+
hosts.add(pattern);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return Array.from(hosts);
|
|
381
|
+
}
|
|
382
|
+
extractRedirectHostPatterns(oauthConfig, profileId) {
|
|
383
|
+
if (!oauthConfig?.redirect_uri) {
|
|
384
|
+
return [];
|
|
385
|
+
}
|
|
386
|
+
const resolvedRedirectUri = this.resolveRedirectUriFromEnv(oauthConfig.redirect_uri, profileId);
|
|
387
|
+
if (!resolvedRedirectUri) {
|
|
388
|
+
return [];
|
|
389
|
+
}
|
|
390
|
+
try {
|
|
391
|
+
const redirectUrl = new URL(resolvedRedirectUri);
|
|
392
|
+
return [redirectUrl.hostname];
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
return [];
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
resolveRedirectUriFromEnv(value, profileId) {
|
|
399
|
+
const match = value.match(/^\$\{env:([^}]+)\}$/);
|
|
400
|
+
if (!match) {
|
|
401
|
+
return value;
|
|
402
|
+
}
|
|
403
|
+
const envVar = match[1];
|
|
404
|
+
const envValue = process.env[envVar];
|
|
405
|
+
if (!envValue || envValue.trim().length === 0) {
|
|
406
|
+
const warningKey = `${profileId}:${envVar}`;
|
|
407
|
+
if (!this.warnedMissingOAuthRedirectEnvVars.has(warningKey)) {
|
|
408
|
+
this.warnedMissingOAuthRedirectEnvVars.add(warningKey);
|
|
409
|
+
this.logger.warn('OAuth redirect_uri environment variable is empty', {
|
|
410
|
+
profileId,
|
|
411
|
+
envVar,
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
return undefined;
|
|
415
|
+
}
|
|
416
|
+
return envValue;
|
|
417
|
+
}
|
|
418
|
+
resolveProfileIdFromPath(pathname) {
|
|
419
|
+
if (!this.config.profileRoutingEnabled) {
|
|
420
|
+
return null;
|
|
421
|
+
}
|
|
422
|
+
const match = /^\/profile\/([^/]+)/.exec(pathname);
|
|
423
|
+
if (!match) {
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
try {
|
|
427
|
+
return decodeURIComponent(match[1]);
|
|
428
|
+
}
|
|
429
|
+
catch {
|
|
430
|
+
return null;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
getClientHintKey(req) {
|
|
434
|
+
const userAgent = req.get('user-agent') || '';
|
|
435
|
+
return `${req.ip}|${userAgent}`;
|
|
436
|
+
}
|
|
437
|
+
storeProfileHint(req, profileId) {
|
|
438
|
+
const key = this.getClientHintKey(req);
|
|
439
|
+
this.profileHintsByClient.set(key, { profileId, lastSeen: Date.now() });
|
|
440
|
+
}
|
|
441
|
+
resolveProfileIdFromHint(req) {
|
|
442
|
+
const key = this.getClientHintKey(req);
|
|
443
|
+
const hint = this.profileHintsByClient.get(key);
|
|
444
|
+
if (!hint) {
|
|
445
|
+
return null;
|
|
446
|
+
}
|
|
447
|
+
if (Date.now() - hint.lastSeen > HttpTransport.PROFILE_HINT_TTL_MS) {
|
|
448
|
+
this.profileHintsByClient.delete(key);
|
|
449
|
+
return null;
|
|
450
|
+
}
|
|
451
|
+
return hint.profileId;
|
|
452
|
+
}
|
|
453
|
+
resolveProfileIdForOriginCheck(req) {
|
|
454
|
+
const pathProfileId = this.resolveProfileIdFromPath(req.path);
|
|
455
|
+
if (pathProfileId) {
|
|
456
|
+
return pathProfileId;
|
|
457
|
+
}
|
|
458
|
+
const resource = req.query?.resource;
|
|
459
|
+
if (typeof resource === 'string') {
|
|
460
|
+
const info = this.resolveProfileInfoFromResourceUrl(resource);
|
|
461
|
+
if (info?.profileId) {
|
|
462
|
+
return info.profileId;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
return this.getDefaultProfileId() ?? this.resolveProfileIdFromHint(req) ?? null;
|
|
466
|
+
}
|
|
467
|
+
async primeOAuthRedirectHosts(profileId) {
|
|
468
|
+
if (!this.profileContextProvider) {
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
if (this.oauthRedirectHostCache.has(profileId)) {
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
try {
|
|
475
|
+
const context = await this.profileContextProvider(profileId);
|
|
476
|
+
if (!context?.oauthConfig) {
|
|
477
|
+
this.oauthRedirectHostCache.set(profileId, []);
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
const patterns = this.extractRedirectHostPatterns(context.oauthConfig, profileId);
|
|
481
|
+
this.oauthRedirectHostCache.set(profileId, patterns);
|
|
482
|
+
}
|
|
483
|
+
catch (error) {
|
|
484
|
+
if (error instanceof ConfigurationError && error.message === 'Profile not found') {
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
this.logger.warn('Failed to preload OAuth redirect hosts', {
|
|
488
|
+
profileId,
|
|
489
|
+
error: String(error),
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
async isAllowedOriginForRequest(origin, req) {
|
|
494
|
+
if (this.isAllowedOrigin(origin)) {
|
|
495
|
+
return true;
|
|
496
|
+
}
|
|
497
|
+
if (!this.config.profileRoutingEnabled) {
|
|
498
|
+
return false;
|
|
499
|
+
}
|
|
500
|
+
const profileId = this.resolveProfileIdForOriginCheck(req);
|
|
501
|
+
if (!profileId) {
|
|
502
|
+
return false;
|
|
503
|
+
}
|
|
504
|
+
await this.primeOAuthRedirectHosts(profileId);
|
|
505
|
+
return this.isAllowedOrigin(origin);
|
|
506
|
+
}
|
|
232
507
|
/**
|
|
233
508
|
* Match hostname against allowed origin pattern
|
|
234
509
|
*
|
|
@@ -280,9 +555,12 @@ export class HttpTransport {
|
|
|
280
555
|
}
|
|
281
556
|
const ipInt = this.ipv4ToInt(ip);
|
|
282
557
|
const rangeInt = this.ipv4ToInt(range);
|
|
558
|
+
/* c8 ignore start - defensive check for edge cases where isIP() passes but parsing fails
|
|
559
|
+
* This should never happen in practice, but serves as a fail-safe */
|
|
283
560
|
if (ipInt === null || rangeInt === null) {
|
|
284
561
|
return false;
|
|
285
562
|
}
|
|
563
|
+
/* c8 ignore end */
|
|
286
564
|
const mask = (0xFFFFFFFF << (32 - maskBits)) >>> 0;
|
|
287
565
|
return (ipInt & mask) === (rangeInt & mask);
|
|
288
566
|
}
|
|
@@ -292,9 +570,12 @@ export class HttpTransport {
|
|
|
292
570
|
}
|
|
293
571
|
const ipInt = this.ipv6ToBigInt(ip);
|
|
294
572
|
const rangeInt = this.ipv6ToBigInt(range);
|
|
573
|
+
/* c8 ignore start - defensive check for edge cases where isIP() passes but parsing fails
|
|
574
|
+
* This should never happen in practice, but serves as a fail-safe */
|
|
295
575
|
if (ipInt === null || rangeInt === null) {
|
|
296
576
|
return false;
|
|
297
577
|
}
|
|
578
|
+
/* c8 ignore end */
|
|
298
579
|
const mask = this.ipv6Mask(maskBits);
|
|
299
580
|
return (ipInt & mask) === (rangeInt & mask);
|
|
300
581
|
}
|
|
@@ -368,17 +649,21 @@ export class HttpTransport {
|
|
|
368
649
|
return null;
|
|
369
650
|
}
|
|
370
651
|
segments = [...headVals, ...Array(missing).fill(0), ...tailVals];
|
|
652
|
+
/* c8 ignore start - defensive check that should never trigger if logic above is correct */
|
|
371
653
|
if (segments.length !== totalSegmentsNeeded) {
|
|
372
654
|
return null;
|
|
373
655
|
}
|
|
656
|
+
/* c8 ignore end */
|
|
374
657
|
if (ipv4Tail !== null) {
|
|
375
658
|
const high = (ipv4Tail >>> 16) & 0xFFFF;
|
|
376
659
|
const low = ipv4Tail & 0xFFFF;
|
|
377
660
|
segments.push(high, low);
|
|
378
661
|
}
|
|
662
|
+
/* c8 ignore start - defensive check that should never trigger if logic above is correct */
|
|
379
663
|
if (segments.length !== 8) {
|
|
380
664
|
return null;
|
|
381
665
|
}
|
|
666
|
+
/* c8 ignore end */
|
|
382
667
|
let value = 0n;
|
|
383
668
|
for (const part of segments) {
|
|
384
669
|
value = (value << 16n) + BigInt(part);
|
|
@@ -428,6 +713,90 @@ export class HttpTransport {
|
|
|
428
713
|
formatRateLimitMessage(scope, maxRequests, windowMs) {
|
|
429
714
|
return `Rate limit exceeded for ${scope}. Max ${maxRequests} requests per ${windowMs / 1000} seconds.`;
|
|
430
715
|
}
|
|
716
|
+
getProfilePrefix(profileId, options) {
|
|
717
|
+
if (!this.config.profileRoutingEnabled) {
|
|
718
|
+
return '';
|
|
719
|
+
}
|
|
720
|
+
if (options?.forceProfilePrefix && profileId) {
|
|
721
|
+
return `/profile/${encodeURIComponent(profileId)}`;
|
|
722
|
+
}
|
|
723
|
+
const defaultProfileId = this.getDefaultProfileId();
|
|
724
|
+
if (!profileId || (defaultProfileId && profileId === defaultProfileId)) {
|
|
725
|
+
return '';
|
|
726
|
+
}
|
|
727
|
+
return `/profile/${encodeURIComponent(profileId)}`;
|
|
728
|
+
}
|
|
729
|
+
buildProfilePath(profileId, path, options) {
|
|
730
|
+
const prefix = this.getProfilePrefix(profileId, options);
|
|
731
|
+
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
|
732
|
+
return `${prefix}${normalizedPath}`;
|
|
733
|
+
}
|
|
734
|
+
getServerOrigin(profileId) {
|
|
735
|
+
if (profileId) {
|
|
736
|
+
const state = this.profileStates.get(profileId);
|
|
737
|
+
if (state?.oauthProvider?.redirectUri) {
|
|
738
|
+
try {
|
|
739
|
+
return new URL(state.oauthProvider.redirectUri).origin;
|
|
740
|
+
}
|
|
741
|
+
catch {
|
|
742
|
+
// Ignore invalid URL
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
const protocol = this.config.host.includes('://') ? '' : 'http://';
|
|
747
|
+
const host = this.config.host.includes('://') ? this.config.host : this.config.host;
|
|
748
|
+
return `${protocol}${host}:${this.config.port}`;
|
|
749
|
+
}
|
|
750
|
+
buildProfileUrl(profileId, path, options) {
|
|
751
|
+
return `${this.getServerOrigin(profileId)}${this.buildProfilePath(profileId, path, options)}`;
|
|
752
|
+
}
|
|
753
|
+
normalizeResourcePath(pathname) {
|
|
754
|
+
const normalized = pathname.replace(/\/+$/, '');
|
|
755
|
+
return normalized === '' ? '/' : normalized;
|
|
756
|
+
}
|
|
757
|
+
resolveProfileInfoFromResourceUrl(resource) {
|
|
758
|
+
let url;
|
|
759
|
+
try {
|
|
760
|
+
url = new URL(resource);
|
|
761
|
+
}
|
|
762
|
+
catch {
|
|
763
|
+
return null;
|
|
764
|
+
}
|
|
765
|
+
const path = this.normalizeResourcePath(url.pathname);
|
|
766
|
+
if (path === '/mcp') {
|
|
767
|
+
const defaultProfileId = this.getDefaultProfileId();
|
|
768
|
+
if (!defaultProfileId) {
|
|
769
|
+
return null;
|
|
770
|
+
}
|
|
771
|
+
return { profileId: defaultProfileId, forceProfilePrefix: false };
|
|
772
|
+
}
|
|
773
|
+
if (!this.config.profileRoutingEnabled) {
|
|
774
|
+
return null;
|
|
775
|
+
}
|
|
776
|
+
const match = /^\/profile\/([^/]+)\/mcp$/.exec(path);
|
|
777
|
+
if (!match) {
|
|
778
|
+
return null;
|
|
779
|
+
}
|
|
780
|
+
try {
|
|
781
|
+
return { profileId: decodeURIComponent(match[1]), forceProfilePrefix: true };
|
|
782
|
+
}
|
|
783
|
+
catch {
|
|
784
|
+
return null;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
resolveProfileIdFromResourceUrl(resource) {
|
|
788
|
+
return this.resolveProfileInfoFromResourceUrl(resource)?.profileId ?? null;
|
|
789
|
+
}
|
|
790
|
+
getOAuthProtectedResourceUrl(profileId) {
|
|
791
|
+
const effectiveProfileId = profileId ?? this.getDefaultProfileId();
|
|
792
|
+
return this.buildProfileUrl(effectiveProfileId, OAUTH_PATHS.WELL_KNOWN_PROTECTED_RESOURCE);
|
|
793
|
+
}
|
|
794
|
+
respondProfileNotFound(res, profileId) {
|
|
795
|
+
res.status(HTTP_STATUS.NOT_FOUND).json({
|
|
796
|
+
error: 'Not Found',
|
|
797
|
+
message: profileId ? `Profile '${profileId}' not found` : 'Profile not found',
|
|
798
|
+
});
|
|
799
|
+
}
|
|
431
800
|
/**
|
|
432
801
|
* Setup MCP endpoint routes
|
|
433
802
|
*
|
|
@@ -437,303 +806,130 @@ export class HttpTransport {
|
|
|
437
806
|
this.logger.info('Setting up HTTP routes');
|
|
438
807
|
// Security: Rate limiting setup (needed for OAuth routes)
|
|
439
808
|
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);
|
|
809
|
+
const profileRoutingEnabled = this.config.profileRoutingEnabled === true;
|
|
810
|
+
const defaultProfileId = this.getDefaultProfileId();
|
|
811
|
+
const attachProfileId = (req, _res, next) => {
|
|
812
|
+
req.profileId = req.params.profileId;
|
|
813
|
+
next();
|
|
814
|
+
};
|
|
815
|
+
const oauthRateLimiterByProfile = new Map();
|
|
816
|
+
const getOAuthRateLimiter = (profileState) => {
|
|
817
|
+
const existing = oauthRateLimiterByProfile.get(profileState.profileId);
|
|
818
|
+
if (existing) {
|
|
819
|
+
return existing;
|
|
481
820
|
}
|
|
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
|
-
}
|
|
635
|
-
});
|
|
636
|
-
// OAuth callback endpoint to receive tokens from authorization server
|
|
637
|
-
this.app.get(OAUTH_PATHS.CALLBACK, oauthRateLimiter, async (req, res) => {
|
|
638
|
-
try {
|
|
639
|
-
const { code, state, error, error_description } = req.query;
|
|
640
|
-
this.logger.info('OAuth callback received', {
|
|
641
|
-
hasCode: !!code,
|
|
642
|
-
hasState: !!state,
|
|
643
|
-
error: error,
|
|
644
|
-
errorDescription: error_description
|
|
645
|
-
});
|
|
646
|
-
if (error) {
|
|
647
|
-
// Sanitize error messages to prevent XSS
|
|
648
|
-
const safeError = escapeHtmlSafe(error);
|
|
649
|
-
const safeErrorDesc = escapeHtmlSafe(error_description);
|
|
650
|
-
res.status(HTTP_STATUS.BAD_REQUEST).json({
|
|
651
|
-
error: safeError,
|
|
652
|
-
error_description: safeErrorDesc || safeError
|
|
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
|
-
}
|
|
821
|
+
const oauthWindowMs = profileState.context.rateLimitOAuthWindowMs || OAUTH_RATE_LIMIT.WINDOW_MS;
|
|
822
|
+
const oauthMaxRequests = profileState.context.rateLimitOAuthMax || OAUTH_RATE_LIMIT.MAX_REQUESTS;
|
|
823
|
+
const limiter = this.createRateLimiter({
|
|
824
|
+
enabled: rateLimitEnabled,
|
|
825
|
+
windowMs: oauthWindowMs,
|
|
826
|
+
maxRequests: oauthMaxRequests,
|
|
827
|
+
logMessage: 'Rate limit exceeded for OAuth',
|
|
828
|
+
responseMessage: `Too many OAuth requests. Limit: ${oauthMaxRequests} requests per ${Math.round(oauthWindowMs / 60000)} minutes. Please try again later.`,
|
|
695
829
|
});
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
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' });
|
|
830
|
+
oauthRateLimiterByProfile.set(profileState.profileId, limiter);
|
|
831
|
+
return limiter;
|
|
832
|
+
};
|
|
833
|
+
const oauthRateLimiter = async (req, res, next) => {
|
|
834
|
+
const profileState = await this.getProfileStateForRequest(req);
|
|
835
|
+
if (!profileState) {
|
|
836
|
+
this.respondProfileNotFound(res, req.profileId);
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
const limiter = getOAuthRateLimiter(profileState);
|
|
840
|
+
return limiter(req, res, next);
|
|
841
|
+
};
|
|
842
|
+
const withProfileState = (handler) => {
|
|
843
|
+
return async (req, res) => {
|
|
844
|
+
const profileState = await this.getProfileStateForRequest(req);
|
|
845
|
+
if (!profileState) {
|
|
846
|
+
this.respondProfileNotFound(res, req.profileId);
|
|
847
|
+
return;
|
|
734
848
|
}
|
|
849
|
+
await handler(req, res, profileState);
|
|
850
|
+
};
|
|
851
|
+
};
|
|
852
|
+
const registerOAuthRoutes = (basePath, includeProfileParam, includeProtectedResource) => {
|
|
853
|
+
const middlewares = includeProfileParam ? [attachProfileId] : [];
|
|
854
|
+
const withOAuthRateLimit = [...middlewares, oauthRateLimiter];
|
|
855
|
+
const withProfile = (handler) => {
|
|
856
|
+
return [...middlewares, oauthRateLimiter, withProfileState(handler)];
|
|
857
|
+
};
|
|
858
|
+
if (includeProtectedResource) {
|
|
859
|
+
this.app.get(`${basePath}${OAUTH_PATHS.WELL_KNOWN_PROTECTED_RESOURCE}`, ...withProfile((req, res, profileState) => this.handleOAuthProtectedResource(req, res, profileState)));
|
|
860
|
+
}
|
|
861
|
+
this.app.get(`${basePath}${OAUTH_PATHS.AUTHORIZE}`, ...withProfile((req, res, profileState) => this.handleOAuthAuthorize(req, res, profileState)));
|
|
862
|
+
this.app.post(`${basePath}${OAUTH_PATHS.TOKEN}`, ...middlewares, oauthRateLimiter, express.urlencoded({ extended: false, limit: '50kb' }), withProfileState((req, res, profileState) => this.handleOAuthToken(req, res, profileState)));
|
|
863
|
+
this.app.get(`${basePath}${OAUTH_PATHS.CALLBACK}`, ...withProfile((req, res, profileState) => this.handleOAuthCallback(req, res, profileState)));
|
|
864
|
+
this.app.get(`${basePath}${OAUTH_PATHS.WELL_KNOWN_AUTHORIZATION_SERVER}`, ...withProfile((req, res, profileState) => this.handleOAuthAuthorizationServerMetadata(req, res, profileState)));
|
|
865
|
+
this.app.get(`${basePath}${OAUTH_PATHS.WELL_KNOWN_OPENID_CONFIGURATION}`, ...withProfile((req, res, profileState) => this.handleOAuthAuthorizationServerMetadata(req, res, profileState)));
|
|
866
|
+
this.app.post(`${basePath}${OAUTH_PATHS.REGISTER}`, ...middlewares, oauthRateLimiter, express.json(), withProfileState((req, res, profileState) => this.handleOAuthRegister(req, res, profileState)));
|
|
867
|
+
this.logger.info('OAuth routes registered', {
|
|
868
|
+
basePath,
|
|
869
|
+
profileRoutingEnabled,
|
|
735
870
|
});
|
|
736
|
-
|
|
871
|
+
};
|
|
872
|
+
if (defaultProfileId) {
|
|
873
|
+
registerOAuthRoutes('', false, false);
|
|
874
|
+
}
|
|
875
|
+
if (profileRoutingEnabled) {
|
|
876
|
+
registerOAuthRoutes('/profile/:profileId', true, true);
|
|
877
|
+
}
|
|
878
|
+
const attachProfileFromResourceQuery = (req, res, next) => {
|
|
879
|
+
const { resource } = req.query;
|
|
880
|
+
if (resource === undefined) {
|
|
881
|
+
next();
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
if (typeof resource !== 'string') {
|
|
885
|
+
res.status(HTTP_STATUS.BAD_REQUEST).json({
|
|
886
|
+
error: 'invalid_request',
|
|
887
|
+
message: 'Invalid resource query parameter',
|
|
888
|
+
});
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
const info = this.resolveProfileInfoFromResourceUrl(resource);
|
|
892
|
+
if (!info?.profileId) {
|
|
893
|
+
res.status(HTTP_STATUS.NOT_FOUND).json({
|
|
894
|
+
error: 'Not Found',
|
|
895
|
+
message: 'OAuth metadata unavailable for requested resource',
|
|
896
|
+
});
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
req.profileId = info.profileId;
|
|
900
|
+
req.forceProfilePrefix = info.forceProfilePrefix;
|
|
901
|
+
this.storeProfileHint(req, info.profileId);
|
|
902
|
+
next();
|
|
903
|
+
};
|
|
904
|
+
const attachProfileFromHint = (req, res, next) => {
|
|
905
|
+
const profileId = this.resolveProfileIdFromHint(req);
|
|
906
|
+
if (!profileId) {
|
|
907
|
+
res.status(HTTP_STATUS.NOT_FOUND).json({
|
|
908
|
+
error: 'Not Found',
|
|
909
|
+
message: 'OAuth metadata unavailable for requested resource',
|
|
910
|
+
});
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
913
|
+
req.profileId = profileId;
|
|
914
|
+
req.forceProfilePrefix = true;
|
|
915
|
+
next();
|
|
916
|
+
};
|
|
917
|
+
if (defaultProfileId || profileRoutingEnabled) {
|
|
918
|
+
this.app.get(OAUTH_PATHS.WELL_KNOWN_PROTECTED_RESOURCE, attachProfileFromResourceQuery, oauthRateLimiter, withProfileState((req, res, profileState) => this.handleOAuthProtectedResource(req, res, profileState)));
|
|
919
|
+
}
|
|
920
|
+
if (profileRoutingEnabled) {
|
|
921
|
+
this.app.get('/.well-known/oauth-protected-resource', attachProfileFromResourceQuery, oauthRateLimiter, withProfileState((req, res, profileState) => this.handleOAuthProtectedResource(req, res, profileState)));
|
|
922
|
+
this.app.get('/.well-known/oauth-protected-resource/profile/:profileId/mcp', attachProfileId, oauthRateLimiter, withProfileState((req, res, profileState) => this.handleOAuthProtectedResource(req, res, profileState)));
|
|
923
|
+
if (!defaultProfileId) {
|
|
924
|
+
this.app.get(OAUTH_PATHS.WELL_KNOWN_AUTHORIZATION_SERVER, attachProfileFromHint, oauthRateLimiter, withProfileState((req, res, profileState) => this.handleOAuthAuthorizationServerMetadata(req, res, profileState)));
|
|
925
|
+
this.app.get(OAUTH_PATHS.WELL_KNOWN_OPENID_CONFIGURATION, attachProfileFromHint, oauthRateLimiter, withProfileState((req, res, profileState) => this.handleOAuthAuthorizationServerMetadata(req, res, profileState)));
|
|
926
|
+
this.app.post(OAUTH_PATHS.REGISTER, attachProfileFromHint, oauthRateLimiter, express.json(), withProfileState((req, res, profileState) => this.handleOAuthRegister(req, res, profileState)));
|
|
927
|
+
this.app.get(OAUTH_PATHS.AUTHORIZE, attachProfileFromHint, oauthRateLimiter, withProfileState((req, res, profileState) => this.handleOAuthAuthorize(req, res, profileState)));
|
|
928
|
+
this.app.post(OAUTH_PATHS.TOKEN, attachProfileFromHint, oauthRateLimiter, express.urlencoded({ extended: false, limit: '50kb' }), withProfileState((req, res, profileState) => this.handleOAuthToken(req, res, profileState)));
|
|
929
|
+
this.app.get(OAUTH_PATHS.CALLBACK, attachProfileFromHint, oauthRateLimiter, withProfileState((req, res, profileState) => this.handleOAuthCallback(req, res, profileState)));
|
|
930
|
+
}
|
|
931
|
+
this.app.get(`${OAUTH_PATHS.WELL_KNOWN_AUTHORIZATION_SERVER}/profile/:profileId`, attachProfileId, oauthRateLimiter, withProfileState((req, res, profileState) => this.handleOAuthAuthorizationServerMetadata(req, res, profileState)));
|
|
932
|
+
this.app.get(`${OAUTH_PATHS.WELL_KNOWN_OPENID_CONFIGURATION}/profile/:profileId`, attachProfileId, oauthRateLimiter, withProfileState((req, res, profileState) => this.handleOAuthAuthorizationServerMetadata(req, res, profileState)));
|
|
737
933
|
}
|
|
738
934
|
// Security: Rate limiting setup (for MCP endpoints)
|
|
739
935
|
const windowMs = this.config.rateLimitWindowMs || TIMEOUTS.RATE_LIMIT_WINDOW_MS;
|
|
@@ -761,47 +957,67 @@ export class HttpTransport {
|
|
|
761
957
|
logMessage: 'Rate limit exceeded for metrics',
|
|
762
958
|
responseMessage: this.formatRateLimitMessage('metrics', metricsMaxRequests, windowMs),
|
|
763
959
|
});
|
|
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
|
-
|
|
960
|
+
const registerMcpRoutes = (basePath, includeProfileParam, isDefault) => {
|
|
961
|
+
const middlewares = includeProfileParam ? [attachProfileId] : [];
|
|
962
|
+
const pathPrefix = basePath || '';
|
|
963
|
+
// Main MCP endpoint - POST for sending messages
|
|
964
|
+
this.app.post(`${pathPrefix}/mcp`, ...middlewares, mcpRateLimiter, this.handlePost.bind(this));
|
|
965
|
+
// CORS preflight handler
|
|
966
|
+
this.app.options(`${pathPrefix}/mcp`, ...middlewares, async (req, res) => {
|
|
967
|
+
const origin = req.headers.origin;
|
|
968
|
+
try {
|
|
969
|
+
// Only send CORS headers for explicitly allowed origins; otherwise reject
|
|
970
|
+
if (origin && await this.isAllowedOriginForRequest(origin, req)) {
|
|
971
|
+
// nosemgrep: javascript.express.security.cors-misconfiguration.cors-misconfiguration
|
|
972
|
+
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
973
|
+
res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS');
|
|
974
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Accept, Mcp-Session-Id');
|
|
975
|
+
// We do not allow credentials; prevents cookie-based attacks by default
|
|
976
|
+
res.setHeader('Access-Control-Allow-Credentials', 'false');
|
|
977
|
+
res.setHeader('Access-Control-Max-Age', '86400'); // 24 hours cache
|
|
978
|
+
return res.status(HTTP_STATUS.OK).send();
|
|
979
|
+
}
|
|
980
|
+
// Disallowed origin: do not echo origin or emit permissive headers
|
|
981
|
+
res.status(HTTP_STATUS.FORBIDDEN).json({ error: 'Forbidden', message: 'Origin not allowed' });
|
|
982
|
+
}
|
|
983
|
+
catch (error) {
|
|
984
|
+
this.logger.error('Origin validation failed', error instanceof Error ? error : new Error(String(error)));
|
|
985
|
+
res.status(HTTP_STATUS.FORBIDDEN).json({ error: 'Forbidden', message: 'Origin not allowed' });
|
|
986
|
+
}
|
|
987
|
+
});
|
|
988
|
+
// Main MCP endpoint - GET for SSE streaming
|
|
989
|
+
this.app.get(`${pathPrefix}/mcp`, ...middlewares, mcpRateLimiter, this.handleGet.bind(this));
|
|
990
|
+
// Session termination
|
|
991
|
+
this.app.delete(`${pathPrefix}/mcp`, ...middlewares, mcpRateLimiter, this.handleDelete.bind(this));
|
|
992
|
+
// Legacy alias endpoints - deprecated
|
|
993
|
+
// Why: Backward compatibility for clients using /sse during migration
|
|
994
|
+
this.app.post(`${pathPrefix}/sse`, ...middlewares, mcpRateLimiter, (req, res, next) => {
|
|
995
|
+
this.logger.warn('Deprecated endpoint used: POST /sse. Please migrate to POST /mcp');
|
|
996
|
+
this.logger.info('Handling POST /sse request');
|
|
997
|
+
return this.handlePost(req, res, next);
|
|
998
|
+
});
|
|
999
|
+
this.app.get(`${pathPrefix}/sse`, ...middlewares, mcpRateLimiter, (req, res, next) => {
|
|
1000
|
+
this.logger.warn('Deprecated endpoint used: GET /sse. Please migrate to GET /mcp');
|
|
1001
|
+
this.logger.info(`Handling GET /sse request from: ${req.ip}`);
|
|
1002
|
+
return this.handleGet(req, res, next);
|
|
1003
|
+
});
|
|
1004
|
+
this.app.delete(`${pathPrefix}/sse`, ...middlewares, mcpRateLimiter, (req, res, next) => {
|
|
1005
|
+
this.logger.warn('Deprecated endpoint used: DELETE /sse. Please migrate to DELETE /mcp');
|
|
1006
|
+
return this.handleDelete(req, res, next);
|
|
1007
|
+
});
|
|
1008
|
+
if (isDefault) {
|
|
1009
|
+
this.logger.info('Registered MCP routes for default profile', { pathPrefix: pathPrefix || '/mcp' });
|
|
1010
|
+
}
|
|
1011
|
+
else {
|
|
1012
|
+
this.logger.info('Registered MCP routes for profile routing', { pathPrefix: `${pathPrefix}/mcp` });
|
|
1013
|
+
}
|
|
1014
|
+
};
|
|
1015
|
+
if (defaultProfileId) {
|
|
1016
|
+
registerMcpRoutes('', false, true);
|
|
1017
|
+
}
|
|
1018
|
+
if (profileRoutingEnabled) {
|
|
1019
|
+
registerMcpRoutes('/profile/:profileId', true, false);
|
|
1020
|
+
}
|
|
805
1021
|
// Metrics endpoint (if enabled)
|
|
806
1022
|
if (this.config.metricsEnabled) {
|
|
807
1023
|
this.app.get(this.config.metricsPath, metricsRateLimiter, this.handleMetrics.bind(this));
|
|
@@ -809,7 +1025,11 @@ export class HttpTransport {
|
|
|
809
1025
|
// Health check (with rate limiting)
|
|
810
1026
|
this.app.get('/health', mcpRateLimiter, (req, res) => {
|
|
811
1027
|
const startTime = Date.now();
|
|
812
|
-
|
|
1028
|
+
let totalSessions = 0;
|
|
1029
|
+
for (const state of this.profileStates.values()) {
|
|
1030
|
+
totalSessions += state.sessions.size;
|
|
1031
|
+
}
|
|
1032
|
+
res.json({ status: 'ok', sessions: totalSessions });
|
|
813
1033
|
if (this.metrics) {
|
|
814
1034
|
const duration = (Date.now() - startTime) / 1000;
|
|
815
1035
|
this.metrics.recordHttpRequest(req.method, req.path, res.statusCode, duration);
|
|
@@ -833,6 +1053,245 @@ export class HttpTransport {
|
|
|
833
1053
|
});
|
|
834
1054
|
});
|
|
835
1055
|
}
|
|
1056
|
+
getProfileIssuerUrl(profileId, options) {
|
|
1057
|
+
const origin = this.getServerOrigin(profileId);
|
|
1058
|
+
const prefix = this.getProfilePrefix(profileId, options);
|
|
1059
|
+
return `${origin}${prefix}`;
|
|
1060
|
+
}
|
|
1061
|
+
async handleOAuthProtectedResource(req, res, profileState) {
|
|
1062
|
+
if (!profileState.oauthProvider) {
|
|
1063
|
+
res.status(HTTP_STATUS.NOT_FOUND).json({ error: 'Not Found', message: 'OAuth not configured for this profile' });
|
|
1064
|
+
return;
|
|
1065
|
+
}
|
|
1066
|
+
const profileId = profileState.profileId;
|
|
1067
|
+
const isProfileScoped = typeof req.params?.profileId === 'string';
|
|
1068
|
+
const forceProfilePrefix = isProfileScoped || req.forceProfilePrefix === true;
|
|
1069
|
+
const urlOptions = forceProfilePrefix ? { forceProfilePrefix: true } : undefined;
|
|
1070
|
+
const serverUrl = new URL(this.buildProfileUrl(profileId, '/mcp', urlOptions));
|
|
1071
|
+
const issuerUrl = this.getProfileIssuerUrl(profileId, urlOptions);
|
|
1072
|
+
const metadata = {
|
|
1073
|
+
resource: serverUrl.href,
|
|
1074
|
+
authorization_servers: [issuerUrl],
|
|
1075
|
+
bearer_methods_supported: ['header'],
|
|
1076
|
+
};
|
|
1077
|
+
if (profileState.oauthProvider.scopes && profileState.oauthProvider.scopes.length > 0) {
|
|
1078
|
+
metadata.scopes_supported = profileState.oauthProvider.scopes;
|
|
1079
|
+
}
|
|
1080
|
+
if (profileState.context.resourceName) {
|
|
1081
|
+
metadata.resource_name = profileState.context.resourceName;
|
|
1082
|
+
}
|
|
1083
|
+
if (profileState.context.resourceDocumentation) {
|
|
1084
|
+
metadata.resource_documentation = profileState.context.resourceDocumentation;
|
|
1085
|
+
}
|
|
1086
|
+
res.json(metadata);
|
|
1087
|
+
}
|
|
1088
|
+
async handleOAuthAuthorize(req, res, profileState) {
|
|
1089
|
+
if (!profileState.oauthProvider) {
|
|
1090
|
+
res.status(HTTP_STATUS.NOT_FOUND).send('OAuth not configured for this profile');
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
try {
|
|
1094
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
1095
|
+
const { response_type, client_id, redirect_uri, scope, state, code_challenge, code_challenge_method } = req.query;
|
|
1096
|
+
if (!client_id || typeof client_id !== 'string') {
|
|
1097
|
+
res.status(HTTP_STATUS.BAD_REQUEST).send('Missing client_id');
|
|
1098
|
+
return;
|
|
1099
|
+
}
|
|
1100
|
+
if (!response_type || typeof response_type !== 'string') {
|
|
1101
|
+
res.status(HTTP_STATUS.BAD_REQUEST).send('Missing response_type');
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
1104
|
+
if (response_type !== 'code') {
|
|
1105
|
+
res.status(HTTP_STATUS.BAD_REQUEST).send('Unsupported response_type');
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1108
|
+
if (!redirect_uri || typeof redirect_uri !== 'string') {
|
|
1109
|
+
res.status(HTTP_STATUS.BAD_REQUEST).send('Missing redirect_uri');
|
|
1110
|
+
return;
|
|
1111
|
+
}
|
|
1112
|
+
await profileState.oauthProvider.ensureEndpointsInitialized();
|
|
1113
|
+
const client = await profileState.oauthProvider.clientsStore.getClient(client_id);
|
|
1114
|
+
if (!client) {
|
|
1115
|
+
res.status(HTTP_STATUS.BAD_REQUEST).send('Invalid client_id');
|
|
1116
|
+
return;
|
|
1117
|
+
}
|
|
1118
|
+
const scopeStr = (scope || '').trim();
|
|
1119
|
+
const params = {
|
|
1120
|
+
responseType: response_type,
|
|
1121
|
+
clientId: client_id,
|
|
1122
|
+
redirectUri: redirect_uri,
|
|
1123
|
+
scope: scopeStr ? scopeStr.split(' ') : [],
|
|
1124
|
+
state: state,
|
|
1125
|
+
codeChallenge: code_challenge,
|
|
1126
|
+
codeChallengeMethod: code_challenge_method,
|
|
1127
|
+
scopes: scopeStr ? scopeStr.split(' ') : [],
|
|
1128
|
+
};
|
|
1129
|
+
await profileState.oauthProvider.authorize(client, params, res);
|
|
1130
|
+
}
|
|
1131
|
+
catch (error) {
|
|
1132
|
+
this.logger.error('OAuth authorize error', error instanceof Error ? error : new Error(String(error)));
|
|
1133
|
+
res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send('OAuth authorization failed');
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
async handleOAuthToken(req, res, profileState) {
|
|
1137
|
+
if (!profileState.oauthProvider) {
|
|
1138
|
+
res.status(HTTP_STATUS.NOT_FOUND).json({ error: 'server_error', error_description: 'OAuth provider not initialized' });
|
|
1139
|
+
return;
|
|
1140
|
+
}
|
|
1141
|
+
try {
|
|
1142
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
1143
|
+
const { grant_type, code, redirect_uri, client_id, code_verifier, refresh_token } = req.body;
|
|
1144
|
+
this.logger.debug('OAuth token request', {
|
|
1145
|
+
profileId: profileState.profileId,
|
|
1146
|
+
grant_type,
|
|
1147
|
+
client_id,
|
|
1148
|
+
has_code: !!code,
|
|
1149
|
+
has_code_verifier: !!code_verifier,
|
|
1150
|
+
redirect_uri,
|
|
1151
|
+
});
|
|
1152
|
+
if (grant_type === 'authorization_code') {
|
|
1153
|
+
if (!code) {
|
|
1154
|
+
res.status(HTTP_STATUS.BAD_REQUEST).json({ error: 'invalid_request', error_description: 'Missing code' });
|
|
1155
|
+
return;
|
|
1156
|
+
}
|
|
1157
|
+
await profileState.oauthProvider.ensureEndpointsInitialized();
|
|
1158
|
+
const client = await profileState.oauthProvider.clientsStore.getClient(client_id);
|
|
1159
|
+
if (!client) {
|
|
1160
|
+
res.status(HTTP_STATUS.BAD_REQUEST).json({ error: 'invalid_client' });
|
|
1161
|
+
return;
|
|
1162
|
+
}
|
|
1163
|
+
const tokens = await profileState.oauthProvider.exchangeAuthorizationCode(client, code, code_verifier, redirect_uri);
|
|
1164
|
+
this.storeOAuthTokens(profileState, tokens, client.client_id, client.scope?.split(' ') || []);
|
|
1165
|
+
res.json(tokens);
|
|
1166
|
+
return;
|
|
1167
|
+
}
|
|
1168
|
+
if (grant_type === 'refresh_token') {
|
|
1169
|
+
if (!refresh_token) {
|
|
1170
|
+
res.status(HTTP_STATUS.BAD_REQUEST).json({ error: 'invalid_request', error_description: 'Missing refresh_token' });
|
|
1171
|
+
return;
|
|
1172
|
+
}
|
|
1173
|
+
await profileState.oauthProvider.ensureEndpointsInitialized();
|
|
1174
|
+
const client = await profileState.oauthProvider.clientsStore.getClient(client_id);
|
|
1175
|
+
if (!client) {
|
|
1176
|
+
res.status(HTTP_STATUS.BAD_REQUEST).json({ error: 'invalid_client' });
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
1179
|
+
const tokens = await profileState.oauthProvider.exchangeRefreshToken(client, refresh_token);
|
|
1180
|
+
this.storeOAuthTokens(profileState, tokens, client.client_id, client.scope?.split(' ') || []);
|
|
1181
|
+
res.json(tokens);
|
|
1182
|
+
return;
|
|
1183
|
+
}
|
|
1184
|
+
this.logger.warn('Unsupported grant type', { grant_type, expected: 'authorization_code or refresh_token' });
|
|
1185
|
+
res.status(HTTP_STATUS.BAD_REQUEST).json({ error: 'unsupported_grant_type' });
|
|
1186
|
+
}
|
|
1187
|
+
catch (error) {
|
|
1188
|
+
this.logger.error('OAuth token exchange error', error instanceof Error ? error : new Error(String(error)));
|
|
1189
|
+
res.status(HTTP_STATUS.BAD_REQUEST).json({
|
|
1190
|
+
error: 'invalid_grant',
|
|
1191
|
+
error_description: 'Token exchange failed',
|
|
1192
|
+
});
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
async handleOAuthCallback(req, res, profileState) {
|
|
1196
|
+
if (!profileState.oauthProvider) {
|
|
1197
|
+
res.status(HTTP_STATUS.NOT_FOUND).send('OAuth provider not initialized');
|
|
1198
|
+
return;
|
|
1199
|
+
}
|
|
1200
|
+
try {
|
|
1201
|
+
const { code, state, error, error_description } = req.query;
|
|
1202
|
+
this.logger.info('OAuth callback received', {
|
|
1203
|
+
profileId: profileState.profileId,
|
|
1204
|
+
hasCode: !!code,
|
|
1205
|
+
hasState: !!state,
|
|
1206
|
+
error: error,
|
|
1207
|
+
errorDescription: error_description,
|
|
1208
|
+
});
|
|
1209
|
+
if (error) {
|
|
1210
|
+
const safeError = escapeHtmlSafe(error);
|
|
1211
|
+
const safeErrorDesc = escapeHtmlSafe(error_description);
|
|
1212
|
+
res.status(HTTP_STATUS.BAD_REQUEST).json({
|
|
1213
|
+
error: safeError,
|
|
1214
|
+
error_description: safeErrorDesc || safeError,
|
|
1215
|
+
});
|
|
1216
|
+
return;
|
|
1217
|
+
}
|
|
1218
|
+
if (!code || typeof code !== 'string') {
|
|
1219
|
+
res.status(HTTP_STATUS.BAD_REQUEST).send('Missing authorization code');
|
|
1220
|
+
return;
|
|
1221
|
+
}
|
|
1222
|
+
await profileState.oauthProvider.handleCallback(req, res);
|
|
1223
|
+
}
|
|
1224
|
+
catch (error) {
|
|
1225
|
+
this.logger.error('OAuth callback error', error instanceof Error ? error : new Error(String(error)));
|
|
1226
|
+
if (!res.headersSent) {
|
|
1227
|
+
res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send('OAuth callback failed');
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
async handleOAuthAuthorizationServerMetadata(req, res, profileState) {
|
|
1232
|
+
if (!profileState.oauthProvider) {
|
|
1233
|
+
res.status(HTTP_STATUS.NOT_FOUND).send('OAuth metadata unavailable');
|
|
1234
|
+
return;
|
|
1235
|
+
}
|
|
1236
|
+
try {
|
|
1237
|
+
const profileId = profileState.profileId;
|
|
1238
|
+
const isProfileScoped = typeof req.params?.profileId === 'string';
|
|
1239
|
+
const forceProfilePrefix = isProfileScoped || req.forceProfilePrefix === true;
|
|
1240
|
+
const urlOptions = forceProfilePrefix ? { forceProfilePrefix: true } : undefined;
|
|
1241
|
+
const issuer = this.getProfileIssuerUrl(profileId, urlOptions);
|
|
1242
|
+
res.json({
|
|
1243
|
+
issuer,
|
|
1244
|
+
authorization_endpoint: this.buildProfileUrl(profileId, OAUTH_PATHS.AUTHORIZE, urlOptions),
|
|
1245
|
+
token_endpoint: this.buildProfileUrl(profileId, OAUTH_PATHS.TOKEN, urlOptions),
|
|
1246
|
+
registration_endpoint: this.buildProfileUrl(profileId, OAUTH_PATHS.REGISTER, urlOptions),
|
|
1247
|
+
response_types_supported: ['code'],
|
|
1248
|
+
code_challenge_methods_supported: ['S256'],
|
|
1249
|
+
grant_types_supported: ['authorization_code', 'refresh_token'],
|
|
1250
|
+
scopes_supported: profileState.oauthProvider.scopes || ['api'],
|
|
1251
|
+
});
|
|
1252
|
+
}
|
|
1253
|
+
catch (error) {
|
|
1254
|
+
this.logger.error('OAuth authorization server metadata error', error instanceof Error ? error : new Error(String(error)));
|
|
1255
|
+
res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send('OAuth metadata failed');
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
async handleOAuthRegister(req, res, profileState) {
|
|
1259
|
+
if (!profileState.oauthProvider) {
|
|
1260
|
+
res.status(HTTP_STATUS.NOT_FOUND).json({ error: 'server_error', error_description: 'Registration unavailable' });
|
|
1261
|
+
return;
|
|
1262
|
+
}
|
|
1263
|
+
try {
|
|
1264
|
+
const { redirect_uris } = req.body;
|
|
1265
|
+
this.logger.info('Dynamic client registration request', {
|
|
1266
|
+
profileId: profileState.profileId,
|
|
1267
|
+
redirect_uris,
|
|
1268
|
+
});
|
|
1269
|
+
const clientId = 'mcp-proxy-client';
|
|
1270
|
+
const clientSecret = 'mcp-proxy-secret';
|
|
1271
|
+
const client = {
|
|
1272
|
+
client_id: clientId,
|
|
1273
|
+
client_secret: clientSecret,
|
|
1274
|
+
redirect_uris: redirect_uris || [],
|
|
1275
|
+
grant_types: ['authorization_code', 'refresh_token'],
|
|
1276
|
+
response_types: ['code'],
|
|
1277
|
+
scope: (profileState.oauthProvider.scopes || []).join(' '),
|
|
1278
|
+
};
|
|
1279
|
+
await profileState.oauthProvider.clientsStore.registerClient(client);
|
|
1280
|
+
res.status(HTTP_STATUS.CREATED).json({
|
|
1281
|
+
client_id: clientId,
|
|
1282
|
+
client_secret: clientSecret,
|
|
1283
|
+
redirect_uris: redirect_uris,
|
|
1284
|
+
grant_types: ['authorization_code', 'refresh_token'],
|
|
1285
|
+
response_types: ['code'],
|
|
1286
|
+
scope: (profileState.oauthProvider.scopes || []).join(' '),
|
|
1287
|
+
token_endpoint_auth_method: 'client_secret_post',
|
|
1288
|
+
});
|
|
1289
|
+
}
|
|
1290
|
+
catch (error) {
|
|
1291
|
+
this.logger.error('Client registration failed', error instanceof Error ? error : new Error(String(error)));
|
|
1292
|
+
res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({ error: 'server_error', error_description: 'Registration failed' });
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
836
1295
|
/**
|
|
837
1296
|
* Handle metrics endpoint
|
|
838
1297
|
*
|
|
@@ -851,8 +1310,14 @@ export class HttpTransport {
|
|
|
851
1310
|
// Don't record metrics call in metrics (avoid recursion)
|
|
852
1311
|
}
|
|
853
1312
|
catch (error) {
|
|
854
|
-
|
|
855
|
-
|
|
1313
|
+
const correlationId = generateCorrelationId();
|
|
1314
|
+
this.logger.error('Metrics endpoint error', error, { correlationId });
|
|
1315
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
1316
|
+
res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({
|
|
1317
|
+
error: 'Internal Server Error',
|
|
1318
|
+
message: `Internal error (correlation ID: ${correlationId})`,
|
|
1319
|
+
correlationId
|
|
1320
|
+
});
|
|
856
1321
|
}
|
|
857
1322
|
}
|
|
858
1323
|
/**
|
|
@@ -969,16 +1434,13 @@ export class HttpTransport {
|
|
|
969
1434
|
*
|
|
970
1435
|
* Returns: { type: 'bearer' | 'oauth' | 'api-token', token: string, sessionId?: string }
|
|
971
1436
|
*/
|
|
972
|
-
extractAuthToken(req) {
|
|
973
|
-
// 1. Check for OAuth session first (highest priority for authenticated sessions)
|
|
1437
|
+
extractAuthToken(req, profileState) {
|
|
974
1438
|
const sessionId = req.sessionId || req.headers['mcp-session-id'];
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
}
|
|
981
|
-
// 2. Check Authorization: Bearer header
|
|
1439
|
+
const session = sessionId ? profileState.sessions.get(sessionId) : undefined;
|
|
1440
|
+
const authConfigs = profileState.context.authConfigs;
|
|
1441
|
+
const configs = authConfigs ? (Array.isArray(authConfigs) ? authConfigs : [authConfigs]) : [];
|
|
1442
|
+
const hasOAuth = !!profileState.oauthProvider || configs.some(config => config.type === 'oauth');
|
|
1443
|
+
// 1. Check Authorization: Bearer header
|
|
982
1444
|
const authHeader = req.headers.authorization;
|
|
983
1445
|
if (authHeader) {
|
|
984
1446
|
// Defense against ReDoS: Check length before regex
|
|
@@ -997,6 +1459,25 @@ export class HttpTransport {
|
|
|
997
1459
|
this.validateToken(token, 'Authorization token');
|
|
998
1460
|
return { type: 'bearer', token };
|
|
999
1461
|
}
|
|
1462
|
+
// 2. Check configured custom header (if any)
|
|
1463
|
+
if (configs.length > 0) {
|
|
1464
|
+
const sortedConfigs = configs.sort((a, b) => (a.priority || 0) - (b.priority || 0));
|
|
1465
|
+
const customHeaderConfig = sortedConfigs.find(c => c.type === 'custom-header' && c.header_name);
|
|
1466
|
+
if (customHeaderConfig && customHeaderConfig.header_name) {
|
|
1467
|
+
if (!isSafePropertyName(customHeaderConfig.header_name)) {
|
|
1468
|
+
throw new ValidationError(`Invalid custom auth header name: ${customHeaderConfig.header_name}`);
|
|
1469
|
+
}
|
|
1470
|
+
const headerKey = customHeaderConfig.header_name.toLowerCase();
|
|
1471
|
+
const headerValue = req.headers[headerKey];
|
|
1472
|
+
if (headerValue) {
|
|
1473
|
+
if (typeof headerValue !== 'string') {
|
|
1474
|
+
throw new ValidationError(`${customHeaderConfig.header_name} must be a string`);
|
|
1475
|
+
}
|
|
1476
|
+
this.validateToken(headerValue, customHeaderConfig.header_name);
|
|
1477
|
+
return { type: 'api-token', token: headerValue };
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1000
1481
|
// 3. Check X-API-Token header (for custom implementations)
|
|
1001
1482
|
const apiTokenHeader = req.headers['x-api-token'];
|
|
1002
1483
|
if (apiTokenHeader) {
|
|
@@ -1006,8 +1487,32 @@ export class HttpTransport {
|
|
|
1006
1487
|
this.validateToken(apiTokenHeader, 'X-API-Token');
|
|
1007
1488
|
return { type: 'api-token', token: apiTokenHeader };
|
|
1008
1489
|
}
|
|
1490
|
+
// 4. Fall back to session token (OAuth only when configured)
|
|
1491
|
+
if (session && session.authToken) {
|
|
1492
|
+
return { type: hasOAuth ? 'oauth' : 'api-token', token: session.authToken, sessionId };
|
|
1493
|
+
}
|
|
1009
1494
|
return { type: 'none' };
|
|
1010
1495
|
}
|
|
1496
|
+
/**
|
|
1497
|
+
* Lazy initialization of ToolFilterService
|
|
1498
|
+
*/
|
|
1499
|
+
getToolFilterService(profileState) {
|
|
1500
|
+
if (!profileState.toolFilterService) {
|
|
1501
|
+
const validator = new RegexValidator();
|
|
1502
|
+
const compiler = new RegexCompiler(validator);
|
|
1503
|
+
const envParser = new EnvConfigParser(compiler);
|
|
1504
|
+
const headerParser = new HeaderConfigParser(compiler);
|
|
1505
|
+
// Create OperationDetector for category filtering (if parser available)
|
|
1506
|
+
let detector;
|
|
1507
|
+
if (profileState.context.parser) {
|
|
1508
|
+
const classifier = new OperationClassifier();
|
|
1509
|
+
const resolver = new OpenAPIOperationResolver(profileState.context.parser);
|
|
1510
|
+
detector = new OperationDetector(classifier, resolver);
|
|
1511
|
+
}
|
|
1512
|
+
profileState.toolFilterService = new ToolFilterService(envParser, headerParser, this.logger, detector);
|
|
1513
|
+
}
|
|
1514
|
+
return profileState.toolFilterService;
|
|
1515
|
+
}
|
|
1011
1516
|
/**
|
|
1012
1517
|
* Handle POST requests - Client sending messages to server
|
|
1013
1518
|
*
|
|
@@ -1017,8 +1522,19 @@ export class HttpTransport {
|
|
|
1017
1522
|
const startTime = Date.now();
|
|
1018
1523
|
try {
|
|
1019
1524
|
this.logger.debug('handlePost called', { method: req.method, path: req.path, sessionId: req.sessionId, accept: req.headers.accept });
|
|
1525
|
+
const profileState = await this.getProfileStateForRequest(req);
|
|
1526
|
+
if (!profileState) {
|
|
1527
|
+
this.respondProfileNotFound(res, req.profileId);
|
|
1528
|
+
return;
|
|
1529
|
+
}
|
|
1530
|
+
const requestProfileId = req.profileId ?? profileState.profileId;
|
|
1020
1531
|
const sessionId = req.sessionId;
|
|
1021
1532
|
const body = req.body;
|
|
1533
|
+
const filteringHeader = normalizeFilteringHeaderValue(this.getFilteringHeaderValue(req));
|
|
1534
|
+
const parsedFiltering = filteringHeader ? parseFilteringHeader(filteringHeader) : undefined;
|
|
1535
|
+
const toolFilterHeader = normalizeToolFilterHeaderValue(this.getToolFilterHeaderValue(req));
|
|
1536
|
+
const parsedToolFilter = toolFilterHeader !== undefined ? parseSessionToolFilterHeader(toolFilterHeader) : undefined;
|
|
1537
|
+
const normalizedToolFilterHeader = parsedToolFilter?.normalizedHeader;
|
|
1022
1538
|
// Validate Accept header per MCP Streamable HTTP specification
|
|
1023
1539
|
const accept = req.headers.accept || '';
|
|
1024
1540
|
// POST requests can return either JSON or SSE, so must accept both if specified
|
|
@@ -1047,12 +1563,35 @@ export class HttpTransport {
|
|
|
1047
1563
|
this.logger.debug('Session validation', { isInitialization, sessionId, bodyMethod: body?.method });
|
|
1048
1564
|
// Validate session (except for initialization)
|
|
1049
1565
|
if (!isInitialization && sessionId) {
|
|
1050
|
-
const session =
|
|
1566
|
+
const session = profileState.sessions.get(sessionId);
|
|
1051
1567
|
if (!session) {
|
|
1052
1568
|
res.status(HTTP_STATUS.NOT_FOUND).json({ error: 'Not Found', message: 'Session not found or expired' });
|
|
1053
1569
|
return;
|
|
1054
1570
|
}
|
|
1055
|
-
|
|
1571
|
+
if (filteringHeader !== undefined) {
|
|
1572
|
+
if (!session.filteringHeader || session.filteringHeader !== filteringHeader) {
|
|
1573
|
+
throw new ValidationError('X-Mcp4-Params header mismatch for existing session.');
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
if (normalizedToolFilterHeader !== undefined) {
|
|
1577
|
+
if (!session.toolFilterHeader || session.toolFilterHeader !== normalizedToolFilterHeader) {
|
|
1578
|
+
throw new ValidationError(`X-Mcp4-Tools header mismatch for existing session. Expected: '${session.toolFilterHeader ?? ''}', Got: '${normalizedToolFilterHeader}'.`);
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
this.updateSessionActivity(profileState, sessionId);
|
|
1582
|
+
const authInfo = this.extractAuthToken(req, profileState);
|
|
1583
|
+
if (authInfo.token && authInfo.type !== 'oauth') {
|
|
1584
|
+
const previousToken = session.authToken;
|
|
1585
|
+
if (!previousToken || previousToken !== authInfo.token) {
|
|
1586
|
+
session.authToken = authInfo.token;
|
|
1587
|
+
this.logger.info('Session auth token updated', {
|
|
1588
|
+
profileId: profileState.profileId,
|
|
1589
|
+
sessionId,
|
|
1590
|
+
authType: authInfo.type,
|
|
1591
|
+
replaced: !!previousToken,
|
|
1592
|
+
});
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1056
1595
|
}
|
|
1057
1596
|
else if (!isInitialization && !sessionId) {
|
|
1058
1597
|
this.logger.debug('Session validation failed: non-init request without sessionId');
|
|
@@ -1065,7 +1604,7 @@ export class HttpTransport {
|
|
|
1065
1604
|
// If only notifications/responses, return 202 Accepted
|
|
1066
1605
|
if (messageType === 'notification-only' || messageType === 'response-only') {
|
|
1067
1606
|
if (this.messageHandler) {
|
|
1068
|
-
await this.messageHandler(body);
|
|
1607
|
+
await this.messageHandler(body, undefined, requestProfileId);
|
|
1069
1608
|
}
|
|
1070
1609
|
res.status(HTTP_STATUS.ACCEPTED).send();
|
|
1071
1610
|
return;
|
|
@@ -1080,14 +1619,14 @@ export class HttpTransport {
|
|
|
1080
1619
|
let newSessionId;
|
|
1081
1620
|
if (isInitialization) {
|
|
1082
1621
|
// Extract and validate auth token from headers
|
|
1083
|
-
const authInfo = this.extractAuthToken(req);
|
|
1622
|
+
const authInfo = this.extractAuthToken(req, profileState);
|
|
1084
1623
|
this.logger.debug('Auth token extracted', { authType: authInfo?.type, hasToken: !!authInfo?.token });
|
|
1085
1624
|
// If OAuth is configured, require authentication for initialization
|
|
1086
1625
|
// This ensures clients like Cursor properly handle OAuth flow
|
|
1087
|
-
if (
|
|
1626
|
+
if (profileState.oauthProvider && !authInfo.token) {
|
|
1088
1627
|
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="${
|
|
1628
|
+
const resourceMetadataUrl = this.getOAuthProtectedResourceUrl(requestProfileId);
|
|
1629
|
+
res.setHeader('WWW-Authenticate', `Bearer resource_metadata="${resourceMetadataUrl}", scope="${profileState.oauthProvider.scopes.join(' ')}"`);
|
|
1091
1630
|
res.status(HTTP_STATUS.UNAUTHORIZED).json({
|
|
1092
1631
|
error: 'Unauthorized',
|
|
1093
1632
|
message: 'Authentication required for OAuth'
|
|
@@ -1096,17 +1635,17 @@ export class HttpTransport {
|
|
|
1096
1635
|
}
|
|
1097
1636
|
// Allow initialization without token for non-OAuth scenarios
|
|
1098
1637
|
// Validate token if auth is configured and token is provided
|
|
1099
|
-
if (authInfo && authInfo.token &&
|
|
1638
|
+
if (authInfo && authInfo.token && profileState.context.authConfigs && profileState.context.baseUrl) {
|
|
1100
1639
|
// Find matching auth config based on priority (authConfigs is sorted)
|
|
1101
1640
|
// For 'bearer' token type, 'oauth' config is also a match
|
|
1102
|
-
const authConfig =
|
|
1641
|
+
const authConfig = profileState.context.authConfigs.find(c => c.type === authInfo.type ||
|
|
1103
1642
|
(authInfo.type === 'bearer' && c.type === 'oauth'));
|
|
1104
1643
|
if (authConfig && authConfig.validation_endpoint) {
|
|
1105
1644
|
this.logger.info('Validating auth token during initialization', {
|
|
1106
1645
|
authType: authConfig.type, // Use config type for logging
|
|
1107
1646
|
endpoint: authConfig.validation_endpoint,
|
|
1108
1647
|
});
|
|
1109
|
-
const isValid = await this.validateAuthToken(authConfig, authInfo.token,
|
|
1648
|
+
const isValid = await this.validateAuthToken(authConfig, authInfo.token, profileState.context.baseUrl);
|
|
1110
1649
|
if (!isValid) {
|
|
1111
1650
|
this.logger.warn('Auth token validation failed during initialization', {
|
|
1112
1651
|
authType: authInfo.type,
|
|
@@ -1126,7 +1665,7 @@ export class HttpTransport {
|
|
|
1126
1665
|
let scopes;
|
|
1127
1666
|
let oauthClientId;
|
|
1128
1667
|
if (authInfo.token && (authInfo.type === 'oauth' || authInfo.type === 'bearer')) {
|
|
1129
|
-
const tokenData =
|
|
1668
|
+
const tokenData = profileState.oauthTokensByAccessToken.get(authInfo.token);
|
|
1130
1669
|
if (tokenData) {
|
|
1131
1670
|
refreshToken = tokenData.refreshToken;
|
|
1132
1671
|
accessTokenExpiresAt = tokenData.expiresAt;
|
|
@@ -1144,21 +1683,21 @@ export class HttpTransport {
|
|
|
1144
1683
|
});
|
|
1145
1684
|
}
|
|
1146
1685
|
}
|
|
1147
|
-
newSessionId = this.createSession(authInfo.token, refreshToken, accessTokenExpiresAt, scopes, oauthClientId);
|
|
1686
|
+
newSessionId = this.createSession(profileState, authInfo.token, refreshToken, accessTokenExpiresAt, scopes, oauthClientId, parsedFiltering?.filtering, parsedFiltering?.normalizedHeader, parsedToolFilter, normalizedToolFilterHeader);
|
|
1148
1687
|
}
|
|
1149
1688
|
this.logger.debug('Calling messageHandler', { body, sessionId: isInitialization ? newSessionId : sessionId });
|
|
1150
|
-
const response = await this.messageHandler(body, isInitialization ? newSessionId : sessionId);
|
|
1689
|
+
const response = await this.messageHandler(body, isInitialization ? newSessionId : sessionId, requestProfileId);
|
|
1151
1690
|
this.logger.debug('MessageHandler response', { response });
|
|
1152
1691
|
// Debug: Check OAuth conditions
|
|
1153
1692
|
this.logger.debug('Checking OAuth conditions', {
|
|
1154
1693
|
responseError: response.error,
|
|
1155
|
-
hasOAuthProvider: !!
|
|
1156
|
-
oauthProviderType: typeof
|
|
1694
|
+
hasOAuthProvider: !!profileState.oauthProvider,
|
|
1695
|
+
oauthProviderType: typeof profileState.oauthProvider
|
|
1157
1696
|
});
|
|
1158
1697
|
// Check if response contains OAuth error and add WWW-Authenticate header
|
|
1159
1698
|
const responseObj = response;
|
|
1160
1699
|
if (responseObj.error && responseObj.error.data && responseObj.error.data.oauth_required) {
|
|
1161
|
-
const resourceMetadataUrl =
|
|
1700
|
+
const resourceMetadataUrl = this.getOAuthProtectedResourceUrl(requestProfileId);
|
|
1162
1701
|
res.setHeader('WWW-Authenticate', `Bearer resource_metadata="${resourceMetadataUrl}", scope="api"`);
|
|
1163
1702
|
res.status(HTTP_STATUS.UNAUTHORIZED); // Set 401 status for OAuth errors
|
|
1164
1703
|
}
|
|
@@ -1175,6 +1714,8 @@ export class HttpTransport {
|
|
|
1175
1714
|
if (newSessionId) {
|
|
1176
1715
|
res.setHeader('Mcp-Session-Id', newSessionId);
|
|
1177
1716
|
}
|
|
1717
|
+
// Security: Prevent caching of sensitive API responses
|
|
1718
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
1178
1719
|
this.logger.debug('Sending JSON response', { response, newSessionId });
|
|
1179
1720
|
res.json(response);
|
|
1180
1721
|
}
|
|
@@ -1185,6 +1726,7 @@ export class HttpTransport {
|
|
|
1185
1726
|
catch (error) {
|
|
1186
1727
|
const correlationId = generateCorrelationId();
|
|
1187
1728
|
this.logger.error('POST request error', error, { correlationId });
|
|
1729
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
1188
1730
|
let status = 500;
|
|
1189
1731
|
let errorLabel = 'Internal Server Error';
|
|
1190
1732
|
let message = `Internal error (correlation ID: ${correlationId})`;
|
|
@@ -1231,6 +1773,11 @@ export class HttpTransport {
|
|
|
1231
1773
|
async handleGet(req, res) {
|
|
1232
1774
|
const startTime = Date.now();
|
|
1233
1775
|
try {
|
|
1776
|
+
const profileState = await this.getProfileStateForRequest(req);
|
|
1777
|
+
if (!profileState) {
|
|
1778
|
+
this.respondProfileNotFound(res, req.profileId);
|
|
1779
|
+
return;
|
|
1780
|
+
}
|
|
1234
1781
|
const sessionId = req.sessionId;
|
|
1235
1782
|
const lastEventId = req.headers['last-event-id'];
|
|
1236
1783
|
// Validate Accept header
|
|
@@ -1244,14 +1791,14 @@ export class HttpTransport {
|
|
|
1244
1791
|
res.status(HTTP_STATUS.BAD_REQUEST).json({ error: 'Bad Request', message: 'Mcp-Session-Id header required' });
|
|
1245
1792
|
return;
|
|
1246
1793
|
}
|
|
1247
|
-
const session =
|
|
1794
|
+
const session = profileState.sessions.get(sessionId);
|
|
1248
1795
|
if (!session) {
|
|
1249
1796
|
res.status(HTTP_STATUS.NOT_FOUND).json({ error: 'Not Found', message: 'Session not found or expired' });
|
|
1250
1797
|
return;
|
|
1251
1798
|
}
|
|
1252
|
-
this.updateSessionActivity(sessionId);
|
|
1799
|
+
this.updateSessionActivity(profileState, sessionId);
|
|
1253
1800
|
// Start SSE stream
|
|
1254
|
-
this.startSSEStream(res, sessionId, lastEventId);
|
|
1801
|
+
this.startSSEStream(res, sessionId, lastEventId, profileState);
|
|
1255
1802
|
// Record metrics for successful SSE start
|
|
1256
1803
|
if (this.metrics) {
|
|
1257
1804
|
const duration = (Date.now() - startTime) / 1000;
|
|
@@ -1259,10 +1806,16 @@ export class HttpTransport {
|
|
|
1259
1806
|
}
|
|
1260
1807
|
}
|
|
1261
1808
|
catch (error) {
|
|
1262
|
-
|
|
1809
|
+
const correlationId = generateCorrelationId();
|
|
1810
|
+
this.logger.error('GET request error', error, { correlationId });
|
|
1263
1811
|
const status = 500;
|
|
1264
1812
|
if (!res.headersSent) {
|
|
1265
|
-
res.
|
|
1813
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
1814
|
+
res.status(status).json({
|
|
1815
|
+
error: 'Internal Server Error',
|
|
1816
|
+
message: `Internal error (correlation ID: ${correlationId})`,
|
|
1817
|
+
correlationId
|
|
1818
|
+
});
|
|
1266
1819
|
}
|
|
1267
1820
|
// Record error metrics
|
|
1268
1821
|
if (this.metrics) {
|
|
@@ -1279,6 +1832,11 @@ export class HttpTransport {
|
|
|
1279
1832
|
handleDelete(req, res) {
|
|
1280
1833
|
const startTime = Date.now();
|
|
1281
1834
|
const sessionId = req.sessionId;
|
|
1835
|
+
const profileId = this.getProfileIdForRequest(req);
|
|
1836
|
+
if (!profileId) {
|
|
1837
|
+
this.respondProfileNotFound(res, req.profileId);
|
|
1838
|
+
return;
|
|
1839
|
+
}
|
|
1282
1840
|
if (!sessionId) {
|
|
1283
1841
|
const status = 400;
|
|
1284
1842
|
res.status(status).json({ error: 'Bad Request', message: 'Mcp-Session-Id header required' });
|
|
@@ -1288,7 +1846,12 @@ export class HttpTransport {
|
|
|
1288
1846
|
}
|
|
1289
1847
|
return;
|
|
1290
1848
|
}
|
|
1291
|
-
const
|
|
1849
|
+
const profileState = this.profileStates.get(profileId);
|
|
1850
|
+
if (!profileState) {
|
|
1851
|
+
this.respondProfileNotFound(res, profileId);
|
|
1852
|
+
return;
|
|
1853
|
+
}
|
|
1854
|
+
const session = profileState.sessions.get(sessionId);
|
|
1292
1855
|
if (!session) {
|
|
1293
1856
|
const status = 404;
|
|
1294
1857
|
res.status(status).json({ error: 'Not Found', message: 'Session not found' });
|
|
@@ -1298,7 +1861,7 @@ export class HttpTransport {
|
|
|
1298
1861
|
}
|
|
1299
1862
|
return;
|
|
1300
1863
|
}
|
|
1301
|
-
this.destroySession(sessionId);
|
|
1864
|
+
this.destroySession(profileState, sessionId);
|
|
1302
1865
|
const status = 204;
|
|
1303
1866
|
res.status(status).send();
|
|
1304
1867
|
if (this.metrics) {
|
|
@@ -1330,12 +1893,12 @@ export class HttpTransport {
|
|
|
1330
1893
|
*
|
|
1331
1894
|
* Why: Allows server to send requests/notifications to client
|
|
1332
1895
|
*/
|
|
1333
|
-
startSSEStream(res, sessionId, lastEventId) {
|
|
1896
|
+
startSSEStream(res, sessionId, lastEventId, profileState) {
|
|
1334
1897
|
res.setHeader('Content-Type', MIME_TYPES.EVENT_STREAM);
|
|
1335
1898
|
res.setHeader('Cache-Control', 'no-cache');
|
|
1336
1899
|
res.setHeader('Connection', 'keep-alive');
|
|
1337
1900
|
const streamId = crypto.randomBytes(16).toString('hex');
|
|
1338
|
-
const session =
|
|
1901
|
+
const session = profileState.sessions.get(sessionId);
|
|
1339
1902
|
const streamState = {
|
|
1340
1903
|
streamId,
|
|
1341
1904
|
lastEventId: lastEventId ? parseInt(lastEventId, 10) : 0,
|
|
@@ -1385,10 +1948,10 @@ export class HttpTransport {
|
|
|
1385
1948
|
*
|
|
1386
1949
|
* Why: Server-initiated requests/notifications
|
|
1387
1950
|
*/
|
|
1388
|
-
sendToClient(sessionId, message) {
|
|
1389
|
-
const session = this.sessions.get(sessionId);
|
|
1951
|
+
sendToClient(profileId, sessionId, message) {
|
|
1952
|
+
const session = this.profileStates.get(profileId)?.sessions.get(sessionId);
|
|
1390
1953
|
if (!session) {
|
|
1391
|
-
this.logger.warn('Cannot send to client: session not found', { sessionId });
|
|
1954
|
+
this.logger.warn('Cannot send to client: session not found', { profileId, sessionId });
|
|
1392
1955
|
return;
|
|
1393
1956
|
}
|
|
1394
1957
|
const eventId = Date.now();
|
|
@@ -1437,12 +2000,38 @@ export class HttpTransport {
|
|
|
1437
2000
|
}
|
|
1438
2001
|
return 'unknown';
|
|
1439
2002
|
}
|
|
2003
|
+
getFilteringHeaderValue(req) {
|
|
2004
|
+
const headerValue = req.headers['x-mcp4-params'];
|
|
2005
|
+
if (Array.isArray(headerValue)) {
|
|
2006
|
+
if (headerValue.length === 0) {
|
|
2007
|
+
return undefined;
|
|
2008
|
+
}
|
|
2009
|
+
if (headerValue.length > 1) {
|
|
2010
|
+
throw new ValidationError('Invalid X-Mcp4-Params header. Expected comma-separated key=value pairs.');
|
|
2011
|
+
}
|
|
2012
|
+
return headerValue[0];
|
|
2013
|
+
}
|
|
2014
|
+
return headerValue;
|
|
2015
|
+
}
|
|
2016
|
+
getToolFilterHeaderValue(req) {
|
|
2017
|
+
const headerValue = req.headers['x-mcp4-tools'];
|
|
2018
|
+
if (Array.isArray(headerValue)) {
|
|
2019
|
+
if (headerValue.length === 0) {
|
|
2020
|
+
return undefined;
|
|
2021
|
+
}
|
|
2022
|
+
if (headerValue.length > 1) {
|
|
2023
|
+
throw new ValidationError('Invalid X-Mcp4-Tools header. Expected comma-separated tool names.');
|
|
2024
|
+
}
|
|
2025
|
+
return headerValue[0];
|
|
2026
|
+
}
|
|
2027
|
+
return headerValue;
|
|
2028
|
+
}
|
|
1440
2029
|
/**
|
|
1441
2030
|
* Create new session
|
|
1442
2031
|
*
|
|
1443
2032
|
* Why: Stateful sessions for MCP protocol
|
|
1444
2033
|
*/
|
|
1445
|
-
createSession(authToken, refreshToken, accessTokenExpiresAt, scopes, oauthClientId) {
|
|
2034
|
+
createSession(profileState, authToken, refreshToken, accessTokenExpiresAt, scopes, oauthClientId, filtering, filteringHeader, toolFilterRequest, toolFilterHeader) {
|
|
1446
2035
|
// Validate token if provided (defense in depth)
|
|
1447
2036
|
if (authToken) {
|
|
1448
2037
|
this.validateToken(authToken, 'Session auth token');
|
|
@@ -1458,9 +2047,14 @@ export class HttpTransport {
|
|
|
1458
2047
|
accessTokenExpiresAt,
|
|
1459
2048
|
scopes,
|
|
1460
2049
|
oauthClientId,
|
|
2050
|
+
filtering,
|
|
2051
|
+
filteringHeader,
|
|
2052
|
+
toolFilterRequest,
|
|
2053
|
+
toolFilterHeader,
|
|
1461
2054
|
};
|
|
1462
|
-
|
|
2055
|
+
profileState.sessions.set(sessionId, session);
|
|
1463
2056
|
this.logger.info('Session created', {
|
|
2057
|
+
profileId: profileState.profileId,
|
|
1464
2058
|
sessionId,
|
|
1465
2059
|
hasAuthToken: !!authToken,
|
|
1466
2060
|
hasRefreshToken: !!refreshToken,
|
|
@@ -1475,8 +2069,8 @@ export class HttpTransport {
|
|
|
1475
2069
|
/**
|
|
1476
2070
|
* Update session activity timestamp
|
|
1477
2071
|
*/
|
|
1478
|
-
updateSessionActivity(sessionId) {
|
|
1479
|
-
const session =
|
|
2072
|
+
updateSessionActivity(profileState, sessionId) {
|
|
2073
|
+
const session = profileState.sessions.get(sessionId);
|
|
1480
2074
|
if (session) {
|
|
1481
2075
|
session.lastActivityAt = Date.now();
|
|
1482
2076
|
}
|
|
@@ -1486,8 +2080,8 @@ export class HttpTransport {
|
|
|
1486
2080
|
*
|
|
1487
2081
|
* Why: Free memory, close streams
|
|
1488
2082
|
*/
|
|
1489
|
-
destroySession(sessionId) {
|
|
1490
|
-
const session =
|
|
2083
|
+
destroySession(profileState, sessionId) {
|
|
2084
|
+
const session = profileState.sessions.get(sessionId);
|
|
1491
2085
|
if (session) {
|
|
1492
2086
|
// Close all active SSE streams
|
|
1493
2087
|
for (const [, streamState] of session.sseStreams) {
|
|
@@ -1506,15 +2100,16 @@ export class HttpTransport {
|
|
|
1506
2100
|
session.sseStreams.clear();
|
|
1507
2101
|
// Clean up OAuth token from map if present
|
|
1508
2102
|
if (session.authToken) {
|
|
1509
|
-
|
|
2103
|
+
profileState.oauthTokensByAccessToken.delete(session.authToken);
|
|
1510
2104
|
}
|
|
1511
|
-
|
|
1512
|
-
this.logger.info('Session destroyed', { sessionId });
|
|
2105
|
+
profileState.sessions.delete(sessionId);
|
|
2106
|
+
this.logger.info('Session destroyed', { profileId: profileState.profileId, sessionId });
|
|
1513
2107
|
// Notify session destruction listeners (for cleanup in MCPServer)
|
|
1514
|
-
this.notifySessionDestroyed(sessionId);
|
|
2108
|
+
this.notifySessionDestroyed(profileState.profileId, sessionId);
|
|
1515
2109
|
// Record metrics
|
|
1516
2110
|
if (this.metrics) {
|
|
1517
2111
|
this.metrics.recordSessionDestroyed();
|
|
2112
|
+
this.metrics.clearToolsSession(sessionId);
|
|
1518
2113
|
}
|
|
1519
2114
|
}
|
|
1520
2115
|
}
|
|
@@ -1529,10 +2124,10 @@ export class HttpTransport {
|
|
|
1529
2124
|
/**
|
|
1530
2125
|
* Notify all listeners about session destruction
|
|
1531
2126
|
*/
|
|
1532
|
-
notifySessionDestroyed(sessionId) {
|
|
2127
|
+
notifySessionDestroyed(profileId, sessionId) {
|
|
1533
2128
|
for (const listener of this.sessionDestroyedListeners) {
|
|
1534
2129
|
try {
|
|
1535
|
-
listener(sessionId);
|
|
2130
|
+
listener(profileId, sessionId);
|
|
1536
2131
|
}
|
|
1537
2132
|
catch (error) {
|
|
1538
2133
|
this.logger.error('Session destroyed listener error', error);
|
|
@@ -1545,7 +2140,7 @@ export class HttpTransport {
|
|
|
1545
2140
|
* Why: Bridge between /oauth/token endpoint (where we see OAuthTokens)
|
|
1546
2141
|
* and session initialization (where we only see access token in Authorization header)
|
|
1547
2142
|
*/
|
|
1548
|
-
storeOAuthTokens(tokens, clientId, scopes) {
|
|
2143
|
+
storeOAuthTokens(profileState, tokens, clientId, scopes) {
|
|
1549
2144
|
if (!tokens.access_token) {
|
|
1550
2145
|
this.logger.warn('OAuth tokens missing access_token, skipping storage');
|
|
1551
2146
|
return;
|
|
@@ -1553,13 +2148,14 @@ export class HttpTransport {
|
|
|
1553
2148
|
const expiresAt = tokens.expires_in
|
|
1554
2149
|
? Date.now() + tokens.expires_in * 1000
|
|
1555
2150
|
: undefined;
|
|
1556
|
-
|
|
2151
|
+
profileState.oauthTokensByAccessToken.set(tokens.access_token, {
|
|
1557
2152
|
refreshToken: tokens.refresh_token,
|
|
1558
2153
|
expiresAt,
|
|
1559
2154
|
clientId,
|
|
1560
2155
|
scopes,
|
|
1561
2156
|
});
|
|
1562
2157
|
this.logger.debug('Stored OAuth tokens', {
|
|
2158
|
+
profileId: profileState.profileId,
|
|
1563
2159
|
hasRefreshToken: !!tokens.refresh_token,
|
|
1564
2160
|
expiresAt,
|
|
1565
2161
|
clientId,
|
|
@@ -1580,25 +2176,33 @@ export class HttpTransport {
|
|
|
1580
2176
|
// Default OAuth session timeout: 24 hours (or configurable)
|
|
1581
2177
|
const oauthSessionTimeoutMs = this.config.oauthSessionTimeoutMs
|
|
1582
2178
|
?? (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)
|
|
2179
|
+
for (const profileState of this.profileStates.values()) {
|
|
2180
|
+
// Cleanup OAuth provider resources (states, codes, tokens)
|
|
2181
|
+
if (profileState.oauthProvider) {
|
|
2182
|
+
profileState.oauthProvider.cleanup();
|
|
1592
2183
|
}
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
2184
|
+
for (const [sessionId, session] of profileState.sessions) {
|
|
2185
|
+
const age = now - session.lastActivityAt;
|
|
2186
|
+
// OAuth sessions with refresh tokens: use extended timeout or never expire
|
|
2187
|
+
if (session.refreshToken) {
|
|
2188
|
+
// If oauthSessionTimeoutMs is 0 or negative, never expire OAuth sessions
|
|
2189
|
+
if (oauthSessionTimeoutMs > 0 && age > oauthSessionTimeoutMs) {
|
|
2190
|
+
expiredSessions.push({ profileId: profileState.profileId, sessionId });
|
|
2191
|
+
}
|
|
2192
|
+
}
|
|
2193
|
+
else {
|
|
2194
|
+
// Non-OAuth sessions: use standard timeout
|
|
2195
|
+
if (age > this.config.sessionTimeoutMs) {
|
|
2196
|
+
expiredSessions.push({ profileId: profileState.profileId, sessionId });
|
|
2197
|
+
}
|
|
1597
2198
|
}
|
|
1598
2199
|
}
|
|
1599
2200
|
}
|
|
1600
|
-
for (const
|
|
1601
|
-
this.
|
|
2201
|
+
for (const entry of expiredSessions) {
|
|
2202
|
+
const state = this.profileStates.get(entry.profileId);
|
|
2203
|
+
if (state) {
|
|
2204
|
+
this.destroySession(state, entry.sessionId);
|
|
2205
|
+
}
|
|
1602
2206
|
}
|
|
1603
2207
|
if (expiredSessions.length > 0) {
|
|
1604
2208
|
this.logger.info('Cleaned up expired sessions', { count: expiredSessions.length });
|
|
@@ -1609,18 +2213,71 @@ export class HttpTransport {
|
|
|
1609
2213
|
*
|
|
1610
2214
|
* Why public: Allows MCPServer to securely access session tokens without breaking encapsulation
|
|
1611
2215
|
*/
|
|
1612
|
-
getSessionToken(sessionId) {
|
|
1613
|
-
const session = this.sessions.get(sessionId);
|
|
2216
|
+
getSessionToken(profileId, sessionId) {
|
|
2217
|
+
const session = this.profileStates.get(profileId)?.sessions.get(sessionId);
|
|
1614
2218
|
return session?.authToken;
|
|
1615
2219
|
}
|
|
2220
|
+
getSessionFiltering(profileId, sessionId) {
|
|
2221
|
+
const session = this.profileStates.get(profileId)?.sessions.get(sessionId);
|
|
2222
|
+
return session?.filtering;
|
|
2223
|
+
}
|
|
2224
|
+
getSessionFilteringHeader(profileId, sessionId) {
|
|
2225
|
+
const session = this.profileStates.get(profileId)?.sessions.get(sessionId);
|
|
2226
|
+
return session?.filteringHeader;
|
|
2227
|
+
}
|
|
2228
|
+
getSessionToolFilterRequest(profileId, sessionId) {
|
|
2229
|
+
const session = this.profileStates.get(profileId)?.sessions.get(sessionId);
|
|
2230
|
+
return session?.toolFilterRequest;
|
|
2231
|
+
}
|
|
2232
|
+
getSessionToolFilter(profileId, sessionId) {
|
|
2233
|
+
const session = this.profileStates.get(profileId)?.sessions.get(sessionId);
|
|
2234
|
+
return session?.toolFilter;
|
|
2235
|
+
}
|
|
2236
|
+
getSessionToolFilterHeader(profileId, sessionId) {
|
|
2237
|
+
const session = this.profileStates.get(profileId)?.sessions.get(sessionId);
|
|
2238
|
+
return session?.toolFilterHeader;
|
|
2239
|
+
}
|
|
2240
|
+
setSessionToolFilter(profileId, sessionId, toolFilter) {
|
|
2241
|
+
const session = this.profileStates.get(profileId)?.sessions.get(sessionId);
|
|
2242
|
+
if (!session) {
|
|
2243
|
+
return;
|
|
2244
|
+
}
|
|
2245
|
+
session.toolFilter = toolFilter;
|
|
2246
|
+
}
|
|
2247
|
+
recordGlobalToolFilterMetrics(summary) {
|
|
2248
|
+
if (!this.metrics) {
|
|
2249
|
+
return;
|
|
2250
|
+
}
|
|
2251
|
+
this.metrics.recordToolsTotal('profile', summary.originalCount);
|
|
2252
|
+
this.metrics.recordToolsFiltered('global_env', 'allowed', summary.allowedCount);
|
|
2253
|
+
this.metrics.recordToolsFiltered('global_env', 'denied', summary.removedCount);
|
|
2254
|
+
for (const [type, count] of Object.entries(summary.patternCounts)) {
|
|
2255
|
+
this.metrics.recordToolFilterPatternCount(type, count);
|
|
2256
|
+
}
|
|
2257
|
+
}
|
|
2258
|
+
recordSessionToolFilterMetrics(sessionId, allowedCount, request) {
|
|
2259
|
+
if (!this.metrics) {
|
|
2260
|
+
return;
|
|
2261
|
+
}
|
|
2262
|
+
this.metrics.recordToolsSession(sessionId, allowedCount);
|
|
2263
|
+
this.metrics.recordToolFilterPatternCount('session_allow_list', request.exactNames.size);
|
|
2264
|
+
this.metrics.recordToolFilterPatternCount('session_allow_regex', request.regexPatterns.length);
|
|
2265
|
+
}
|
|
2266
|
+
recordToolFilterRejection(tool, source) {
|
|
2267
|
+
if (!this.metrics) {
|
|
2268
|
+
return;
|
|
2269
|
+
}
|
|
2270
|
+
this.metrics.recordToolFilterRejection(tool, source);
|
|
2271
|
+
}
|
|
1616
2272
|
/**
|
|
1617
2273
|
* Ensure session has a valid access token, refreshing if necessary
|
|
1618
2274
|
*
|
|
1619
2275
|
* Why: Transparently refresh expired OAuth tokens before making API calls
|
|
1620
2276
|
* Returns true if token is valid (or was successfully refreshed), false otherwise
|
|
1621
2277
|
*/
|
|
1622
|
-
async ensureValidSessionToken(sessionId) {
|
|
1623
|
-
const
|
|
2278
|
+
async ensureValidSessionToken(profileId, sessionId) {
|
|
2279
|
+
const profileState = this.profileStates.get(profileId);
|
|
2280
|
+
const session = profileState?.sessions.get(sessionId);
|
|
1624
2281
|
if (!session) {
|
|
1625
2282
|
return false;
|
|
1626
2283
|
}
|
|
@@ -1634,11 +2291,12 @@ export class HttpTransport {
|
|
|
1634
2291
|
// If token is expired or about to expire, refresh it
|
|
1635
2292
|
if (timeUntilExpiration <= refreshThresholdMs) {
|
|
1636
2293
|
this.logger.debug('Access token expired or expiring soon, refreshing', {
|
|
2294
|
+
profileId,
|
|
1637
2295
|
sessionId,
|
|
1638
2296
|
expiresAt: new Date(session.accessTokenExpiresAt).toISOString(),
|
|
1639
2297
|
timeUntilExpiration,
|
|
1640
2298
|
});
|
|
1641
|
-
return await this.refreshAccessToken(sessionId);
|
|
2299
|
+
return await this.refreshAccessToken(profileId, sessionId);
|
|
1642
2300
|
}
|
|
1643
2301
|
return true;
|
|
1644
2302
|
}
|
|
@@ -1648,14 +2306,16 @@ export class HttpTransport {
|
|
|
1648
2306
|
* Why: Automatically renew expired OAuth access tokens without user intervention
|
|
1649
2307
|
* Returns true on success, false on failure
|
|
1650
2308
|
*/
|
|
1651
|
-
async refreshAccessToken(sessionId) {
|
|
1652
|
-
const
|
|
1653
|
-
|
|
2309
|
+
async refreshAccessToken(profileId, sessionId) {
|
|
2310
|
+
const profileState = this.profileStates.get(profileId);
|
|
2311
|
+
const session = profileState?.sessions.get(sessionId);
|
|
2312
|
+
if (!profileState || !session || !session.refreshToken || !profileState.oauthProvider) {
|
|
1654
2313
|
this.logger.warn('Cannot refresh token: missing session, refreshToken, or OAuth provider', {
|
|
2314
|
+
profileId,
|
|
1655
2315
|
sessionId,
|
|
1656
2316
|
hasSession: !!session,
|
|
1657
2317
|
hasRefreshToken: !!session?.refreshToken,
|
|
1658
|
-
hasOAuthProvider: !!
|
|
2318
|
+
hasOAuthProvider: !!profileState?.oauthProvider,
|
|
1659
2319
|
});
|
|
1660
2320
|
return false;
|
|
1661
2321
|
}
|
|
@@ -1664,32 +2324,33 @@ export class HttpTransport {
|
|
|
1664
2324
|
// Try to find client by clientId stored in session, or use default client
|
|
1665
2325
|
let client;
|
|
1666
2326
|
if (session.oauthClientId) {
|
|
1667
|
-
await
|
|
1668
|
-
client = await
|
|
2327
|
+
await profileState.oauthProvider.ensureEndpointsInitialized();
|
|
2328
|
+
client = await profileState.oauthProvider.clientsStore.getClient(session.oauthClientId);
|
|
1669
2329
|
}
|
|
1670
2330
|
// Fallback to default client from config if session client not found
|
|
1671
|
-
if (!client &&
|
|
1672
|
-
await
|
|
2331
|
+
if (!client && profileState.oauthProvider) {
|
|
2332
|
+
await profileState.oauthProvider.ensureEndpointsInitialized();
|
|
1673
2333
|
// Try common client IDs
|
|
1674
2334
|
const defaultClientIds = ['mcp-proxy-client'];
|
|
1675
|
-
if (
|
|
1676
|
-
defaultClientIds.unshift(
|
|
2335
|
+
if (profileState.context.oauthConfig?.client_id) {
|
|
2336
|
+
defaultClientIds.unshift(profileState.context.oauthConfig.client_id);
|
|
1677
2337
|
}
|
|
1678
2338
|
for (const clientId of defaultClientIds) {
|
|
1679
|
-
client = await
|
|
2339
|
+
client = await profileState.oauthProvider.clientsStore.getClient(clientId);
|
|
1680
2340
|
if (client)
|
|
1681
2341
|
break;
|
|
1682
2342
|
}
|
|
1683
2343
|
}
|
|
1684
2344
|
if (!client) {
|
|
1685
2345
|
this.logger.error('Cannot refresh token: OAuth client not found', undefined, {
|
|
2346
|
+
profileId,
|
|
1686
2347
|
sessionId,
|
|
1687
2348
|
oauthClientId: session.oauthClientId,
|
|
1688
2349
|
});
|
|
1689
2350
|
return false;
|
|
1690
2351
|
}
|
|
1691
2352
|
// Exchange refresh token for new tokens
|
|
1692
|
-
const tokens = await
|
|
2353
|
+
const tokens = await profileState.oauthProvider.exchangeRefreshToken(client, session.refreshToken, session.scopes);
|
|
1693
2354
|
// Update session with new tokens
|
|
1694
2355
|
const oldAccessToken = session.authToken;
|
|
1695
2356
|
session.authToken = tokens.access_token;
|
|
@@ -1699,10 +2360,11 @@ export class HttpTransport {
|
|
|
1699
2360
|
: undefined;
|
|
1700
2361
|
// Update token map: remove old token, add new one
|
|
1701
2362
|
if (oldAccessToken) {
|
|
1702
|
-
|
|
2363
|
+
profileState.oauthTokensByAccessToken.delete(oldAccessToken);
|
|
1703
2364
|
}
|
|
1704
|
-
this.storeOAuthTokens(tokens, client.client_id, session.scopes || []);
|
|
2365
|
+
this.storeOAuthTokens(profileState, tokens, client.client_id, session.scopes || []);
|
|
1705
2366
|
this.logger.info('Access token refreshed successfully', {
|
|
2367
|
+
profileId,
|
|
1706
2368
|
sessionId,
|
|
1707
2369
|
newExpiresAt: session.accessTokenExpiresAt ? new Date(session.accessTokenExpiresAt).toISOString() : undefined,
|
|
1708
2370
|
});
|
|
@@ -1710,6 +2372,7 @@ export class HttpTransport {
|
|
|
1710
2372
|
}
|
|
1711
2373
|
catch (error) {
|
|
1712
2374
|
this.logger.error('Token refresh failed', error instanceof Error ? error : new Error(String(error)), {
|
|
2375
|
+
profileId,
|
|
1713
2376
|
sessionId,
|
|
1714
2377
|
});
|
|
1715
2378
|
return false;
|
|
@@ -1724,41 +2387,49 @@ export class HttpTransport {
|
|
|
1724
2387
|
/**
|
|
1725
2388
|
* Check if OAuth provider is configured
|
|
1726
2389
|
*/
|
|
1727
|
-
hasOAuthProvider() {
|
|
1728
|
-
|
|
2390
|
+
hasOAuthProvider(profileId) {
|
|
2391
|
+
if (!profileId) {
|
|
2392
|
+
const defaultProfileId = this.getDefaultProfileId();
|
|
2393
|
+
if (!defaultProfileId) {
|
|
2394
|
+
return false;
|
|
2395
|
+
}
|
|
2396
|
+
const state = this.profileStates.get(defaultProfileId);
|
|
2397
|
+
return state ? state.oauthProvider !== null : false;
|
|
2398
|
+
}
|
|
2399
|
+
const state = this.profileStates.get(profileId);
|
|
2400
|
+
return state ? state.oauthProvider !== null : false;
|
|
1729
2401
|
}
|
|
1730
2402
|
/**
|
|
1731
2403
|
* Get server URL
|
|
1732
2404
|
*/
|
|
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}`;
|
|
2405
|
+
getServerUrl(profileId) {
|
|
2406
|
+
return this.getServerOrigin(profileId);
|
|
1750
2407
|
}
|
|
1751
2408
|
/**
|
|
1752
2409
|
* Get OAuth authorization URL
|
|
1753
2410
|
*/
|
|
1754
|
-
getOAuthAuthorizationUrl() {
|
|
1755
|
-
|
|
2411
|
+
getOAuthAuthorizationUrl(profileId) {
|
|
2412
|
+
if (!profileId) {
|
|
2413
|
+
const defaultProfileId = this.getDefaultProfileId();
|
|
2414
|
+
if (!defaultProfileId) {
|
|
2415
|
+
return '';
|
|
2416
|
+
}
|
|
2417
|
+
return this.profileStates.get(defaultProfileId)?.oauthProvider?.authorizationEndpoint || '';
|
|
2418
|
+
}
|
|
2419
|
+
return this.profileStates.get(profileId)?.oauthProvider?.authorizationEndpoint || '';
|
|
1756
2420
|
}
|
|
1757
2421
|
/**
|
|
1758
2422
|
* Get OAuth scopes
|
|
1759
2423
|
*/
|
|
1760
|
-
getOAuthScopes() {
|
|
1761
|
-
|
|
2424
|
+
getOAuthScopes(profileId) {
|
|
2425
|
+
if (!profileId) {
|
|
2426
|
+
const defaultProfileId = this.getDefaultProfileId();
|
|
2427
|
+
if (!defaultProfileId) {
|
|
2428
|
+
return [];
|
|
2429
|
+
}
|
|
2430
|
+
return this.profileStates.get(defaultProfileId)?.oauthProvider?.scopes || [];
|
|
2431
|
+
}
|
|
2432
|
+
return this.profileStates.get(profileId)?.oauthProvider?.scopes || [];
|
|
1762
2433
|
}
|
|
1763
2434
|
/**
|
|
1764
2435
|
* Start HTTP server
|
|
@@ -1829,8 +2500,10 @@ export class HttpTransport {
|
|
|
1829
2500
|
this.cleanupInterval = null;
|
|
1830
2501
|
}
|
|
1831
2502
|
// Destroy all sessions
|
|
1832
|
-
for (const
|
|
1833
|
-
|
|
2503
|
+
for (const profileState of this.profileStates.values()) {
|
|
2504
|
+
for (const sessionId of profileState.sessions.keys()) {
|
|
2505
|
+
this.destroySession(profileState, sessionId);
|
|
2506
|
+
}
|
|
1834
2507
|
}
|
|
1835
2508
|
if (this.server) {
|
|
1836
2509
|
return new Promise((resolve, reject) => {
|