mcp4openapi 0.2.7 → 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 +147 -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 -13
- package/dist/src/core/logger.d.ts.map +1 -0
- package/dist/src/core/logger.js +197 -0
- package/dist/src/core/logger.js.map +1 -0
- package/dist/src/{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/{naming-warnings.js → core/naming-warnings.js} +6 -6
- 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 +281 -79
- package/dist/src/generated-schemas.d.ts.map +1 -1
- package/dist/src/generated-schemas.js +17 -3
- 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 -156
- 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} +43 -3
- package/dist/src/mcp/mcp-server.d.ts.map +1 -0
- package/dist/src/{mcp-server.js → mcp/mcp-server.js} +639 -123
- 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/listen-support.d.ts +3 -0
- package/dist/src/testing/listen-support.d.ts.map +1 -0
- package/dist/src/testing/listen-support.js +50 -0
- package/dist/src/testing/listen-support.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} +19 -4
- package/dist/src/tooling/proxy-executor.d.ts.map +1 -0
- package/dist/src/tooling/proxy-executor.js +497 -0
- 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/transport/http-transport.js +2522 -0
- 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} +77 -46
- 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 +31 -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 +75 -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 +0 -1826
- 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 +0 -177
- 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 +0 -240
- 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 -138
- 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.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
|
@@ -1,1826 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* HTTP Streamable Transport for MCP
|
|
3
|
-
*
|
|
4
|
-
* Implements MCP Specification 2025-03-26
|
|
5
|
-
* https://modelcontextprotocol.io/specification/2025-03-26/basic/transports
|
|
6
|
-
*
|
|
7
|
-
* Why: Enables remote MCP server access with SSE streaming, session management,
|
|
8
|
-
* and resumability for reliable communication over HTTP.
|
|
9
|
-
*/
|
|
10
|
-
import express from 'express';
|
|
11
|
-
import https from 'https';
|
|
12
|
-
import fs from 'fs';
|
|
13
|
-
import crypto from 'crypto';
|
|
14
|
-
import { isIP } from 'node:net';
|
|
15
|
-
import rateLimit from 'express-rate-limit';
|
|
16
|
-
import { isInitializeRequest } from './jsonrpc-validator.js';
|
|
17
|
-
import { MetricsCollector } from './metrics.js';
|
|
18
|
-
import { ExternalOAuthProvider } from './oauth-provider.js';
|
|
19
|
-
import { HTTP_STATUS, MIME_TYPES, OAUTH_PATHS, TIMEOUTS, OAUTH_RATE_LIMIT } from './constants.js';
|
|
20
|
-
import { escapeHtmlSafe } from './validation-utils.js';
|
|
21
|
-
// Default maximum token length (1000 characters)
|
|
22
|
-
const DEFAULT_MAX_TOKEN_LENGTH = 1000;
|
|
23
|
-
export class HttpTransport {
|
|
24
|
-
constructor(config, logger) {
|
|
25
|
-
this.server = null;
|
|
26
|
-
this.sessions = new Map();
|
|
27
|
-
this.metrics = null;
|
|
28
|
-
this.cleanupInterval = null;
|
|
29
|
-
this.messageHandler = null;
|
|
30
|
-
this.oauthProvider = null;
|
|
31
|
-
// Map access_token -> { refreshToken, expiresAt, clientId, scopes }
|
|
32
|
-
// Used to bridge /oauth/token endpoint (where we see OAuthTokens) and session initialization (where we only see access token)
|
|
33
|
-
this.oauthTokensByAccessToken = new Map();
|
|
34
|
-
this.hasWarnedAboutBinding = false;
|
|
35
|
-
/**
|
|
36
|
-
* Session destruction listeners for cleanup in other components
|
|
37
|
-
*/
|
|
38
|
-
this.sessionDestroyedListeners = [];
|
|
39
|
-
// Freeze config to prevent runtime mutation of security-critical settings (allowedOrigins, rate limits, etc.)
|
|
40
|
-
this.config = Object.freeze({ ...config });
|
|
41
|
-
this.logger = logger;
|
|
42
|
-
// Initialize metrics if enabled
|
|
43
|
-
if (config.metricsEnabled) {
|
|
44
|
-
this.metrics = new MetricsCollector({
|
|
45
|
-
enabled: true,
|
|
46
|
-
prefix: 'mcp_',
|
|
47
|
-
});
|
|
48
|
-
}
|
|
49
|
-
// Initialize OAuth provider if configured
|
|
50
|
-
if (config.oauthConfig) {
|
|
51
|
-
this.logger.info('Initializing OAuth provider with config', { hasClientId: !!config.oauthConfig.client_id });
|
|
52
|
-
this.oauthProvider = new ExternalOAuthProvider(config.oauthConfig, logger);
|
|
53
|
-
// Note: authorizationEndpoint may be undefined at this point if config uses issuer-based discovery
|
|
54
|
-
// It will be resolved lazily on first OAuth operation (authorize/token)
|
|
55
|
-
this.logger.info('OAuth provider initialized', {
|
|
56
|
-
endpoint: this.oauthProvider.authorizationEndpoint || '(to be derived from issuer)',
|
|
57
|
-
hasIssuer: !!config.oauthConfig.issuer,
|
|
58
|
-
});
|
|
59
|
-
}
|
|
60
|
-
else {
|
|
61
|
-
this.logger.info('No OAuth config provided - OAuth provider not initialized');
|
|
62
|
-
}
|
|
63
|
-
this.app = express();
|
|
64
|
-
this.setupMiddleware();
|
|
65
|
-
this.setupRoutes();
|
|
66
|
-
}
|
|
67
|
-
/**
|
|
68
|
-
* Setup Express middleware
|
|
69
|
-
*
|
|
70
|
-
* Why: Security (Origin validation, rate limiting), JSON parsing, session extraction, metrics
|
|
71
|
-
*/
|
|
72
|
-
setupMiddleware() {
|
|
73
|
-
// Request logging (before any middleware)
|
|
74
|
-
this.app.use((req, res, next) => {
|
|
75
|
-
this.logger.debug('Request received', {
|
|
76
|
-
method: req.method,
|
|
77
|
-
url: req.url,
|
|
78
|
-
path: req.path,
|
|
79
|
-
userAgent: req.get('user-agent'),
|
|
80
|
-
ip: req.ip,
|
|
81
|
-
});
|
|
82
|
-
next();
|
|
83
|
-
});
|
|
84
|
-
// DNS rebinding protection when binding to localhost
|
|
85
|
-
// Deny requests with mismatched Host headers to prevent DNS rebinding attacks
|
|
86
|
-
// Applies when server host is localhost/127.0.0.1, regardless of auth configuration
|
|
87
|
-
this.app.use((req, res, next) => {
|
|
88
|
-
const hostCfg = this.config.host?.toLowerCase();
|
|
89
|
-
if (hostCfg === 'localhost' || hostCfg === '127.0.0.1') {
|
|
90
|
-
const hostHeader = (req.headers['host'] || '').toString().toLowerCase();
|
|
91
|
-
const expectedHosts = new Set(['localhost', '127.0.0.1']);
|
|
92
|
-
const headerHostOnly = hostHeader.split(':')[0];
|
|
93
|
-
if (!expectedHosts.has(headerHostOnly)) {
|
|
94
|
-
this.logger.warn('DNS rebinding protection: invalid Host header', {
|
|
95
|
-
hostHeader,
|
|
96
|
-
expected: Array.from(expectedHosts),
|
|
97
|
-
});
|
|
98
|
-
res.status(403).json({ error: 'Forbidden' });
|
|
99
|
-
return;
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
next();
|
|
103
|
-
});
|
|
104
|
-
// JSON body parser
|
|
105
|
-
this.app.use(express.json());
|
|
106
|
-
// Metrics: Track request start time
|
|
107
|
-
this.app.use((req, res, next) => {
|
|
108
|
-
req.startTime = Date.now();
|
|
109
|
-
// Log response
|
|
110
|
-
const originalSend = res.send;
|
|
111
|
-
const originalJson = res.json;
|
|
112
|
-
const logger = this.logger;
|
|
113
|
-
res.send = function (body) {
|
|
114
|
-
logger.debug('Outgoing response', {
|
|
115
|
-
method: req.method,
|
|
116
|
-
url: req.url,
|
|
117
|
-
status: res.statusCode,
|
|
118
|
-
contentType: res.get('content-type'),
|
|
119
|
-
bodyLength: body ? body.length : 0,
|
|
120
|
-
bodyPreview: typeof body === 'string' ? body.substring(0, 200) : '[object]'
|
|
121
|
-
});
|
|
122
|
-
return originalSend.call(this, body);
|
|
123
|
-
};
|
|
124
|
-
res.json = function (body) {
|
|
125
|
-
logger.debug('Outgoing JSON response', {
|
|
126
|
-
method: req.method,
|
|
127
|
-
url: req.url,
|
|
128
|
-
status: res.statusCode,
|
|
129
|
-
body
|
|
130
|
-
});
|
|
131
|
-
return originalJson.call(this, body);
|
|
132
|
-
};
|
|
133
|
-
next();
|
|
134
|
-
});
|
|
135
|
-
// Debug: Log all requests
|
|
136
|
-
this.app.use((req, res, next) => {
|
|
137
|
-
this.logger.debug('Incoming request', {
|
|
138
|
-
method: req.method,
|
|
139
|
-
url: req.url,
|
|
140
|
-
path: req.path,
|
|
141
|
-
headers: {
|
|
142
|
-
'user-agent': req.headers['user-agent'],
|
|
143
|
-
'accept': req.headers.accept,
|
|
144
|
-
'content-type': req.headers['content-type'],
|
|
145
|
-
'authorization': req.headers.authorization ? '[REDACTED]' : undefined
|
|
146
|
-
},
|
|
147
|
-
ip: req.ip
|
|
148
|
-
});
|
|
149
|
-
next();
|
|
150
|
-
});
|
|
151
|
-
// Security: Origin validation (DNS rebinding protection)
|
|
152
|
-
this.app.use((req, res, next) => {
|
|
153
|
-
const origin = req.headers.origin;
|
|
154
|
-
// Warn if binding to 0.0.0.0
|
|
155
|
-
if (this.config.host === '0.0.0.0' && !this.hasWarnedAboutBinding) {
|
|
156
|
-
this.logger.warn('HTTP transport bound to 0.0.0.0 - accessible from network. Ensure firewall protection.');
|
|
157
|
-
this.hasWarnedAboutBinding = true;
|
|
158
|
-
}
|
|
159
|
-
// Skip Origin check for localhost
|
|
160
|
-
if (req.hostname === 'localhost' || req.hostname === '127.0.0.1') {
|
|
161
|
-
return next();
|
|
162
|
-
}
|
|
163
|
-
// Validate Origin header for non-localhost
|
|
164
|
-
if (origin && !this.isAllowedOrigin(origin)) {
|
|
165
|
-
this.logger.warn('Rejected request from disallowed origin', { origin, ip: req.ip });
|
|
166
|
-
return res.status(HTTP_STATUS.FORBIDDEN).json({
|
|
167
|
-
error: 'Forbidden',
|
|
168
|
-
message: 'Origin not allowed'
|
|
169
|
-
});
|
|
170
|
-
}
|
|
171
|
-
next();
|
|
172
|
-
});
|
|
173
|
-
// Extract session ID from header
|
|
174
|
-
this.app.use((req, res, next) => {
|
|
175
|
-
const sessionId = req.headers['mcp-session-id'];
|
|
176
|
-
if (sessionId) {
|
|
177
|
-
req.sessionId = sessionId;
|
|
178
|
-
}
|
|
179
|
-
next();
|
|
180
|
-
});
|
|
181
|
-
}
|
|
182
|
-
/**
|
|
183
|
-
* Check if origin is allowed
|
|
184
|
-
*
|
|
185
|
-
* Why: Prevent DNS rebinding attacks
|
|
186
|
-
*
|
|
187
|
-
* Supports:
|
|
188
|
-
* - Exact hostname: 'example.com', 'api.example.com'
|
|
189
|
-
* - Wildcard subdomain: '*.example.com'
|
|
190
|
-
* - IPv4 CIDR: '192.168.1.0/24', '10.0.0.0/8'
|
|
191
|
-
* - IPv4 exact: '192.168.1.100'
|
|
192
|
-
*/
|
|
193
|
-
isAllowedOrigin(origin) {
|
|
194
|
-
try {
|
|
195
|
-
const url = new URL(origin);
|
|
196
|
-
const hostname = url.hostname;
|
|
197
|
-
// Always allow localhost
|
|
198
|
-
if (hostname === 'localhost' || hostname === '127.0.0.1') {
|
|
199
|
-
return true;
|
|
200
|
-
}
|
|
201
|
-
// Allow configured host
|
|
202
|
-
if (hostname === this.config.host) {
|
|
203
|
-
return true;
|
|
204
|
-
}
|
|
205
|
-
// Allow OAuth redirect URI host if configured
|
|
206
|
-
if (this.oauthProvider?.redirectUri) {
|
|
207
|
-
try {
|
|
208
|
-
const redirectUrl = new URL(this.oauthProvider.redirectUri);
|
|
209
|
-
if (hostname === redirectUrl.hostname) {
|
|
210
|
-
return true;
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
catch {
|
|
214
|
-
// Invalid URL, ignore
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
// Check custom allowed origins
|
|
218
|
-
if (this.config.allowedOrigins && this.config.allowedOrigins.length > 0) {
|
|
219
|
-
for (const allowed of this.config.allowedOrigins) {
|
|
220
|
-
if (this.matchOrigin(hostname, allowed)) {
|
|
221
|
-
return true;
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
return false;
|
|
226
|
-
}
|
|
227
|
-
catch {
|
|
228
|
-
return false;
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
/**
|
|
232
|
-
* Match hostname against allowed origin pattern
|
|
233
|
-
*
|
|
234
|
-
* Supports:
|
|
235
|
-
* - Exact match: 'example.com' === 'example.com'
|
|
236
|
-
* - Wildcard: '*.example.com' matches 'api.example.com', 'web.example.com'
|
|
237
|
-
* - CIDR: '192.168.1.0/24' matches '192.168.1.1' through '192.168.1.254'
|
|
238
|
-
*/
|
|
239
|
-
matchOrigin(hostname, pattern) {
|
|
240
|
-
const normalizedHost = this.stripIpv6Brackets(hostname);
|
|
241
|
-
const normalizedPattern = this.stripIpv6Brackets(pattern);
|
|
242
|
-
// Exact match
|
|
243
|
-
if (normalizedHost === normalizedPattern) {
|
|
244
|
-
return true;
|
|
245
|
-
}
|
|
246
|
-
// Wildcard subdomain match (*.example.com)
|
|
247
|
-
if (normalizedPattern.startsWith('*.')) {
|
|
248
|
-
const domain = normalizedPattern.substring(2); // Remove '*.'
|
|
249
|
-
return normalizedHost.endsWith('.' + domain) || normalizedHost === domain;
|
|
250
|
-
}
|
|
251
|
-
// CIDR match (IPv4/IPv6)
|
|
252
|
-
if (normalizedPattern.includes('/')) {
|
|
253
|
-
return this.matchCIDR(normalizedHost, normalizedPattern);
|
|
254
|
-
}
|
|
255
|
-
return false;
|
|
256
|
-
}
|
|
257
|
-
/**
|
|
258
|
-
* Check if IP address is within CIDR range (IPv4 or IPv6)
|
|
259
|
-
*
|
|
260
|
-
* Example: '192.168.1.50' matches '192.168.1.0/24'
|
|
261
|
-
* '2001:db8::1' matches '2001:db8::/32'
|
|
262
|
-
*/
|
|
263
|
-
matchCIDR(ip, cidr) {
|
|
264
|
-
const [rawRange, bits] = cidr.split('/');
|
|
265
|
-
const range = this.stripIpv6Brackets(rawRange);
|
|
266
|
-
const maskBits = parseInt(bits, 10);
|
|
267
|
-
if (isNaN(maskBits)) {
|
|
268
|
-
return false;
|
|
269
|
-
}
|
|
270
|
-
const ipVersion = isIP(ip);
|
|
271
|
-
const rangeVersion = isIP(range);
|
|
272
|
-
if (ipVersion === 0 || rangeVersion === 0 || ipVersion !== rangeVersion) {
|
|
273
|
-
return false;
|
|
274
|
-
}
|
|
275
|
-
if (ipVersion === 4) {
|
|
276
|
-
if (maskBits < 0 || maskBits > 32) {
|
|
277
|
-
this.logger.warn('Invalid CIDR mask bits', { cidr });
|
|
278
|
-
return false;
|
|
279
|
-
}
|
|
280
|
-
const ipInt = this.ipv4ToInt(ip);
|
|
281
|
-
const rangeInt = this.ipv4ToInt(range);
|
|
282
|
-
if (ipInt === null || rangeInt === null) {
|
|
283
|
-
return false;
|
|
284
|
-
}
|
|
285
|
-
const mask = (0xFFFFFFFF << (32 - maskBits)) >>> 0;
|
|
286
|
-
return (ipInt & mask) === (rangeInt & mask);
|
|
287
|
-
}
|
|
288
|
-
if (maskBits < 0 || maskBits > 128) {
|
|
289
|
-
this.logger.warn('Invalid IPv6 CIDR mask bits', { cidr });
|
|
290
|
-
return false;
|
|
291
|
-
}
|
|
292
|
-
const ipInt = this.ipv6ToBigInt(ip);
|
|
293
|
-
const rangeInt = this.ipv6ToBigInt(range);
|
|
294
|
-
if (ipInt === null || rangeInt === null) {
|
|
295
|
-
return false;
|
|
296
|
-
}
|
|
297
|
-
const mask = this.ipv6Mask(maskBits);
|
|
298
|
-
return (ipInt & mask) === (rangeInt & mask);
|
|
299
|
-
}
|
|
300
|
-
/**
|
|
301
|
-
* Convert IPv4 address to 32-bit integer
|
|
302
|
-
*
|
|
303
|
-
* Example: '192.168.1.1' -> 3232235777
|
|
304
|
-
*/
|
|
305
|
-
ipv4ToInt(ip) {
|
|
306
|
-
const parts = ip.split('.');
|
|
307
|
-
if (parts.length !== 4) {
|
|
308
|
-
return null;
|
|
309
|
-
}
|
|
310
|
-
let result = 0;
|
|
311
|
-
for (let i = 0; i < 4; i++) {
|
|
312
|
-
const octet = parseInt(parts[i], 10);
|
|
313
|
-
if (isNaN(octet) || octet < 0 || octet > 255) {
|
|
314
|
-
return null;
|
|
315
|
-
}
|
|
316
|
-
result = (result << 8) | octet;
|
|
317
|
-
}
|
|
318
|
-
return result >>> 0; // Unsigned
|
|
319
|
-
}
|
|
320
|
-
/**
|
|
321
|
-
* Convert IPv6 address to 128-bit BigInt
|
|
322
|
-
*/
|
|
323
|
-
ipv6ToBigInt(ip) {
|
|
324
|
-
const cleaned = this.stripIpv6Brackets(ip);
|
|
325
|
-
// Handle IPv4-mapped IPv6 (e.g., ::ffff:192.168.0.1)
|
|
326
|
-
let ipv4Tail = null;
|
|
327
|
-
let base = cleaned;
|
|
328
|
-
if (cleaned.includes('.')) {
|
|
329
|
-
const lastColon = cleaned.lastIndexOf(':');
|
|
330
|
-
if (lastColon === -1)
|
|
331
|
-
return null;
|
|
332
|
-
const ipv4Part = cleaned.slice(lastColon + 1);
|
|
333
|
-
ipv4Tail = this.ipv4ToInt(ipv4Part);
|
|
334
|
-
if (ipv4Tail === null)
|
|
335
|
-
return null;
|
|
336
|
-
base = cleaned.slice(0, lastColon);
|
|
337
|
-
}
|
|
338
|
-
const parts = base.split('::');
|
|
339
|
-
if (parts.length > 2) {
|
|
340
|
-
return null;
|
|
341
|
-
}
|
|
342
|
-
const head = parts[0] ? parts[0].split(':') : [];
|
|
343
|
-
const tail = parts.length === 2 && parts[1] ? parts[1].split(':') : [];
|
|
344
|
-
if (head.some(p => p === '') || tail.some(p => p === '')) {
|
|
345
|
-
return null;
|
|
346
|
-
}
|
|
347
|
-
const totalSegmentsNeeded = 8 - (ipv4Tail !== null ? 2 : 0);
|
|
348
|
-
let segments = [];
|
|
349
|
-
const parseHextets = (items) => {
|
|
350
|
-
const result = [];
|
|
351
|
-
for (const part of items) {
|
|
352
|
-
const num = parseInt(part || '0', 16);
|
|
353
|
-
if (isNaN(num) || num < 0 || num > 0xFFFF) {
|
|
354
|
-
return null;
|
|
355
|
-
}
|
|
356
|
-
result.push(num);
|
|
357
|
-
}
|
|
358
|
-
return result;
|
|
359
|
-
};
|
|
360
|
-
const headVals = parseHextets(head);
|
|
361
|
-
const tailVals = parseHextets(tail);
|
|
362
|
-
if (!headVals || !tailVals) {
|
|
363
|
-
return null;
|
|
364
|
-
}
|
|
365
|
-
const missing = totalSegmentsNeeded - (headVals.length + tailVals.length);
|
|
366
|
-
if (missing < 0) {
|
|
367
|
-
return null;
|
|
368
|
-
}
|
|
369
|
-
segments = [...headVals, ...Array(missing).fill(0), ...tailVals];
|
|
370
|
-
if (segments.length !== totalSegmentsNeeded) {
|
|
371
|
-
return null;
|
|
372
|
-
}
|
|
373
|
-
if (ipv4Tail !== null) {
|
|
374
|
-
const high = (ipv4Tail >>> 16) & 0xFFFF;
|
|
375
|
-
const low = ipv4Tail & 0xFFFF;
|
|
376
|
-
segments.push(high, low);
|
|
377
|
-
}
|
|
378
|
-
if (segments.length !== 8) {
|
|
379
|
-
return null;
|
|
380
|
-
}
|
|
381
|
-
let value = 0n;
|
|
382
|
-
for (const part of segments) {
|
|
383
|
-
value = (value << 16n) + BigInt(part);
|
|
384
|
-
}
|
|
385
|
-
return value;
|
|
386
|
-
}
|
|
387
|
-
ipv6Mask(maskBits) {
|
|
388
|
-
if (maskBits === 0) {
|
|
389
|
-
return 0n;
|
|
390
|
-
}
|
|
391
|
-
const ones = (1n << BigInt(maskBits)) - 1n;
|
|
392
|
-
return BigInt.asUintN(128, ones << BigInt(128 - maskBits));
|
|
393
|
-
}
|
|
394
|
-
stripIpv6Brackets(value) {
|
|
395
|
-
return value.replace(/^\[/, '').replace(/\]$/, '');
|
|
396
|
-
}
|
|
397
|
-
/**
|
|
398
|
-
* Create configured rate limiter or a passthrough handler when disabled
|
|
399
|
-
*
|
|
400
|
-
* Why: Both MCP and metrics endpoints share the same rate limiting setup logic.
|
|
401
|
-
* Centralizing it keeps behaviour consistent and avoids drifting configuration.
|
|
402
|
-
*/
|
|
403
|
-
createRateLimiter(options) {
|
|
404
|
-
if (!options.enabled) {
|
|
405
|
-
return (_req, _res, next) => next();
|
|
406
|
-
}
|
|
407
|
-
const message = options.responseMessage ??
|
|
408
|
-
`Rate limit exceeded. Max ${options.maxRequests} requests per ${options.windowMs / 1000} seconds.`;
|
|
409
|
-
return rateLimit({
|
|
410
|
-
windowMs: options.windowMs,
|
|
411
|
-
max: options.maxRequests,
|
|
412
|
-
standardHeaders: true, // Return rate limit info in `RateLimit-*` headers
|
|
413
|
-
legacyHeaders: false, // Disable deprecated `X-RateLimit-*` headers
|
|
414
|
-
handler: (req, res) => {
|
|
415
|
-
this.logger.warn(options.logMessage, {
|
|
416
|
-
ip: req.ip,
|
|
417
|
-
path: req.path,
|
|
418
|
-
method: req.method,
|
|
419
|
-
});
|
|
420
|
-
res.status(HTTP_STATUS.TOO_MANY_REQUESTS).json({
|
|
421
|
-
error: 'Too Many Requests',
|
|
422
|
-
message,
|
|
423
|
-
});
|
|
424
|
-
},
|
|
425
|
-
});
|
|
426
|
-
}
|
|
427
|
-
formatRateLimitMessage(scope, maxRequests, windowMs) {
|
|
428
|
-
return `Rate limit exceeded for ${scope}. Max ${maxRequests} requests per ${windowMs / 1000} seconds.`;
|
|
429
|
-
}
|
|
430
|
-
/**
|
|
431
|
-
* Setup MCP endpoint routes
|
|
432
|
-
*
|
|
433
|
-
* Why: Single endpoint for POST (client→server) and GET (SSE stream)
|
|
434
|
-
*/
|
|
435
|
-
setupRoutes() {
|
|
436
|
-
this.logger.info('Setting up HTTP routes');
|
|
437
|
-
// Security: Rate limiting setup (needed for OAuth routes)
|
|
438
|
-
const rateLimitEnabled = this.config.rateLimitEnabled !== false; // default: true
|
|
439
|
-
// Rate limiter for OAuth endpoints (stricter limits for security)
|
|
440
|
-
// OAuth endpoints are sensitive and should have lower limits than general API
|
|
441
|
-
// Configuration priority: profile > env vars > defaults
|
|
442
|
-
const oauthWindowMs = this.config.rateLimitOAuthWindowMs || OAUTH_RATE_LIMIT.WINDOW_MS;
|
|
443
|
-
const oauthMaxRequests = this.config.rateLimitOAuthMax || OAUTH_RATE_LIMIT.MAX_REQUESTS;
|
|
444
|
-
const oauthRateLimiter = this.createRateLimiter({
|
|
445
|
-
enabled: rateLimitEnabled,
|
|
446
|
-
windowMs: oauthWindowMs,
|
|
447
|
-
maxRequests: oauthMaxRequests,
|
|
448
|
-
logMessage: 'Rate limit exceeded for OAuth',
|
|
449
|
-
responseMessage: `Too many OAuth requests. Limit: ${oauthMaxRequests} requests per ${Math.round(oauthWindowMs / 60000)} minutes. Please try again later.`,
|
|
450
|
-
});
|
|
451
|
-
// OAuth 2.0 routes (if configured)
|
|
452
|
-
if (this.oauthProvider) {
|
|
453
|
-
// Build redirect URI
|
|
454
|
-
const redirectUri = this.oauthProvider.redirectUri ||
|
|
455
|
-
`http://${this.config.host}:${this.config.port}/oauth/callback`;
|
|
456
|
-
// Derive serverUrl from redirectUri
|
|
457
|
-
const baseUrl = new URL(redirectUri).origin;
|
|
458
|
-
const serverUrl = new URL(`${baseUrl}/mcp`);
|
|
459
|
-
// issuerUrl should be the base URL of the authorization server (e.g. https://gitlab.com),
|
|
460
|
-
// NOT the authorization endpoint (e.g. https://gitlab.com/oauth/authorize)
|
|
461
|
-
// We try to derive it from authorizationEndpoint if not explicitly configured
|
|
462
|
-
// Note: authorizationEndpoint might not be ready yet (async initialization),
|
|
463
|
-
// so we use serverUrl.origin as fallback
|
|
464
|
-
let issuerUrl;
|
|
465
|
-
try {
|
|
466
|
-
const authEndpoint = this.oauthProvider.authorizationEndpoint;
|
|
467
|
-
if (authEndpoint) {
|
|
468
|
-
// Try to extract base URL from auth endpoint
|
|
469
|
-
const authUrl = new URL(authEndpoint);
|
|
470
|
-
issuerUrl = new URL(authUrl.origin);
|
|
471
|
-
}
|
|
472
|
-
else {
|
|
473
|
-
// Fallback: use server origin (will be updated after async init)
|
|
474
|
-
issuerUrl = new URL(serverUrl.origin);
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
catch (e) {
|
|
478
|
-
// Fallback: use server origin
|
|
479
|
-
issuerUrl = new URL(serverUrl.origin);
|
|
480
|
-
}
|
|
481
|
-
this.logger.info('Setting up OAuth routes', {
|
|
482
|
-
serverUrl: serverUrl.toString(),
|
|
483
|
-
issuerUrl: issuerUrl.toString(),
|
|
484
|
-
redirectUri,
|
|
485
|
-
});
|
|
486
|
-
// Install MCP OAuth router
|
|
487
|
-
// This adds standard OAuth endpoints:
|
|
488
|
-
// - /.well-known/oauth-authorization-server
|
|
489
|
-
// - /.well-known/oauth-protected-resource
|
|
490
|
-
// - /oauth/authorize
|
|
491
|
-
// - /oauth/token
|
|
492
|
-
// - /oauth/register (dynamic client registration)
|
|
493
|
-
// - /oauth/revoke (token revocation)
|
|
494
|
-
// Only register resource server endpoints, not authorization server endpoints
|
|
495
|
-
// since our MCP server is not an OAuth authorization server
|
|
496
|
-
this.app.get(OAUTH_PATHS.WELL_KNOWN_PROTECTED_RESOURCE, oauthRateLimiter, (req, res) => {
|
|
497
|
-
// Build metadata object with only defined fields (RFC 8707)
|
|
498
|
-
const metadata = {
|
|
499
|
-
resource: serverUrl.href,
|
|
500
|
-
authorization_servers: [serverUrl.origin], // We are the authorization server (proxy)
|
|
501
|
-
bearer_methods_supported: ['header'],
|
|
502
|
-
};
|
|
503
|
-
// Optional: scopes_supported (only if scopes are defined)
|
|
504
|
-
if (this.oauthProvider?.scopes && this.oauthProvider.scopes.length > 0) {
|
|
505
|
-
metadata.scopes_supported = this.oauthProvider.scopes;
|
|
506
|
-
}
|
|
507
|
-
// Optional: resource_name (from config, already has fallback in mcp-server.ts)
|
|
508
|
-
if (this.config.resourceName) {
|
|
509
|
-
metadata.resource_name = this.config.resourceName;
|
|
510
|
-
}
|
|
511
|
-
// Optional: resource_documentation (from config, may be undefined)
|
|
512
|
-
if (this.config.resourceDocumentation) {
|
|
513
|
-
metadata.resource_documentation = this.config.resourceDocumentation;
|
|
514
|
-
}
|
|
515
|
-
res.json(metadata);
|
|
516
|
-
});
|
|
517
|
-
// Authorization endpoint
|
|
518
|
-
// Initiates the OAuth flow by redirecting the user to the external provider
|
|
519
|
-
this.app.get(OAUTH_PATHS.AUTHORIZE, oauthRateLimiter, async (req, res) => {
|
|
520
|
-
try {
|
|
521
|
-
const { response_type, client_id, redirect_uri, scope, state, code_challenge, code_challenge_method } = req.query;
|
|
522
|
-
if (!client_id || typeof client_id !== 'string') {
|
|
523
|
-
res.status(HTTP_STATUS.BAD_REQUEST).send('Missing client_id');
|
|
524
|
-
return;
|
|
525
|
-
}
|
|
526
|
-
if (!redirect_uri || typeof redirect_uri !== 'string') {
|
|
527
|
-
res.status(HTTP_STATUS.BAD_REQUEST).send('Missing redirect_uri');
|
|
528
|
-
return;
|
|
529
|
-
}
|
|
530
|
-
if (this.oauthProvider) {
|
|
531
|
-
// Ensure provider is initialized before client validation
|
|
532
|
-
// This registers configured client_id if present
|
|
533
|
-
await this.oauthProvider.ensureEndpointsInitialized();
|
|
534
|
-
// Find the client to validate configuration
|
|
535
|
-
const client = await this.oauthProvider.clientsStore.getClient(client_id);
|
|
536
|
-
if (!client) {
|
|
537
|
-
res.status(HTTP_STATUS.BAD_REQUEST).send('Invalid client_id');
|
|
538
|
-
return;
|
|
539
|
-
}
|
|
540
|
-
// Prepare parameters for provider authorization
|
|
541
|
-
const scopeStr = (scope || '').trim();
|
|
542
|
-
const params = {
|
|
543
|
-
responseType: response_type || 'code',
|
|
544
|
-
clientId: client_id,
|
|
545
|
-
redirectUri: redirect_uri,
|
|
546
|
-
scope: scopeStr ? scopeStr.split(' ') : [],
|
|
547
|
-
state: state,
|
|
548
|
-
codeChallenge: code_challenge,
|
|
549
|
-
codeChallengeMethod: code_challenge_method,
|
|
550
|
-
scopes: scopeStr ? scopeStr.split(' ') : [],
|
|
551
|
-
};
|
|
552
|
-
// Call provider authorize method which handles the redirect logic
|
|
553
|
-
await this.oauthProvider.authorize(client, params, res);
|
|
554
|
-
}
|
|
555
|
-
else {
|
|
556
|
-
res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send('OAuth provider not initialized');
|
|
557
|
-
}
|
|
558
|
-
}
|
|
559
|
-
catch (error) {
|
|
560
|
-
this.logger.error('OAuth authorize error', error instanceof Error ? error : new Error(String(error)));
|
|
561
|
-
res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send('OAuth authorization failed');
|
|
562
|
-
}
|
|
563
|
-
});
|
|
564
|
-
// Token endpoint
|
|
565
|
-
// Exchanges authorization code or refresh token for access token
|
|
566
|
-
this.app.post(OAUTH_PATHS.TOKEN, oauthRateLimiter, express.urlencoded({ extended: false }), async (req, res) => {
|
|
567
|
-
try {
|
|
568
|
-
const { grant_type, code, redirect_uri, client_id, code_verifier, refresh_token } = req.body;
|
|
569
|
-
this.logger.debug('OAuth token request', {
|
|
570
|
-
grant_type,
|
|
571
|
-
client_id,
|
|
572
|
-
has_code: !!code,
|
|
573
|
-
has_code_verifier: !!code_verifier,
|
|
574
|
-
redirect_uri,
|
|
575
|
-
});
|
|
576
|
-
if (grant_type === 'authorization_code') {
|
|
577
|
-
// Authorization Code Flow
|
|
578
|
-
if (!code) {
|
|
579
|
-
res.status(HTTP_STATUS.BAD_REQUEST).json({ error: 'invalid_request', error_description: 'Missing code' });
|
|
580
|
-
return;
|
|
581
|
-
}
|
|
582
|
-
if (this.oauthProvider) {
|
|
583
|
-
// Ensure provider is initialized before client validation
|
|
584
|
-
await this.oauthProvider.ensureEndpointsInitialized();
|
|
585
|
-
const client = await this.oauthProvider.clientsStore.getClient(client_id);
|
|
586
|
-
if (!client) {
|
|
587
|
-
res.status(HTTP_STATUS.BAD_REQUEST).json({ error: 'invalid_client' });
|
|
588
|
-
return;
|
|
589
|
-
}
|
|
590
|
-
const tokens = await this.oauthProvider.exchangeAuthorizationCode(client, code, code_verifier, redirect_uri);
|
|
591
|
-
// Store OAuth tokens for later session initialization
|
|
592
|
-
this.storeOAuthTokens(tokens, client.client_id, client.scope?.split(' ') || []);
|
|
593
|
-
res.json(tokens);
|
|
594
|
-
}
|
|
595
|
-
else {
|
|
596
|
-
res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({ error: 'server_error', error_description: 'OAuth provider not initialized' });
|
|
597
|
-
}
|
|
598
|
-
}
|
|
599
|
-
else if (grant_type === 'refresh_token') {
|
|
600
|
-
// Refresh Token Flow
|
|
601
|
-
if (!refresh_token) {
|
|
602
|
-
res.status(HTTP_STATUS.BAD_REQUEST).json({ error: 'invalid_request', error_description: 'Missing refresh_token' });
|
|
603
|
-
return;
|
|
604
|
-
}
|
|
605
|
-
if (this.oauthProvider) {
|
|
606
|
-
// Ensure provider is initialized before client validation
|
|
607
|
-
await this.oauthProvider.ensureEndpointsInitialized();
|
|
608
|
-
const client = await this.oauthProvider.clientsStore.getClient(client_id);
|
|
609
|
-
if (!client) {
|
|
610
|
-
res.status(HTTP_STATUS.BAD_REQUEST).json({ error: 'invalid_client' });
|
|
611
|
-
return;
|
|
612
|
-
}
|
|
613
|
-
const tokens = await this.oauthProvider.exchangeRefreshToken(client, refresh_token);
|
|
614
|
-
// Store OAuth tokens for later session initialization
|
|
615
|
-
// Note: When refreshing, the old access token should be invalidated
|
|
616
|
-
// but we don't track it here - the new token replaces it in the map
|
|
617
|
-
this.storeOAuthTokens(tokens, client.client_id, client.scope?.split(' ') || []);
|
|
618
|
-
res.json(tokens);
|
|
619
|
-
}
|
|
620
|
-
else {
|
|
621
|
-
res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({ error: 'server_error', error_description: 'OAuth provider not initialized' });
|
|
622
|
-
}
|
|
623
|
-
}
|
|
624
|
-
else {
|
|
625
|
-
this.logger.warn('Unsupported grant type', { grant_type, expected: 'authorization_code or refresh_token' });
|
|
626
|
-
res.status(HTTP_STATUS.BAD_REQUEST).json({ error: 'unsupported_grant_type' });
|
|
627
|
-
return;
|
|
628
|
-
}
|
|
629
|
-
}
|
|
630
|
-
catch (error) {
|
|
631
|
-
this.logger.error('OAuth token exchange error', error instanceof Error ? error : new Error(String(error)));
|
|
632
|
-
res.status(HTTP_STATUS.BAD_REQUEST).json({ error: 'invalid_grant', error_description: String(error) });
|
|
633
|
-
}
|
|
634
|
-
});
|
|
635
|
-
// OAuth callback endpoint to receive tokens from authorization server
|
|
636
|
-
this.app.get(OAUTH_PATHS.CALLBACK, oauthRateLimiter, async (req, res) => {
|
|
637
|
-
try {
|
|
638
|
-
const { code, state, error, error_description } = req.query;
|
|
639
|
-
this.logger.info('OAuth callback received', {
|
|
640
|
-
hasCode: !!code,
|
|
641
|
-
hasState: !!state,
|
|
642
|
-
error: error,
|
|
643
|
-
errorDescription: error_description
|
|
644
|
-
});
|
|
645
|
-
if (error) {
|
|
646
|
-
// Sanitize error messages to prevent XSS
|
|
647
|
-
const safeError = escapeHtmlSafe(error);
|
|
648
|
-
const safeErrorDesc = escapeHtmlSafe(error_description);
|
|
649
|
-
res.status(HTTP_STATUS.BAD_REQUEST).json({
|
|
650
|
-
error: safeError,
|
|
651
|
-
error_description: safeErrorDesc || safeError
|
|
652
|
-
});
|
|
653
|
-
return;
|
|
654
|
-
}
|
|
655
|
-
if (!code || typeof code !== 'string') {
|
|
656
|
-
res.status(HTTP_STATUS.BAD_REQUEST).send('Missing authorization code');
|
|
657
|
-
return;
|
|
658
|
-
}
|
|
659
|
-
// Delegate to OAuth provider to handle token exchange and redirect back to client (Cursor)
|
|
660
|
-
if (this.oauthProvider) {
|
|
661
|
-
await this.oauthProvider.handleCallback(req, res);
|
|
662
|
-
}
|
|
663
|
-
else {
|
|
664
|
-
res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send('OAuth provider not initialized');
|
|
665
|
-
}
|
|
666
|
-
}
|
|
667
|
-
catch (error) {
|
|
668
|
-
this.logger.error('OAuth callback error', error instanceof Error ? error : new Error(String(error)));
|
|
669
|
-
if (!res.headersSent) {
|
|
670
|
-
res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send('OAuth callback failed');
|
|
671
|
-
}
|
|
672
|
-
}
|
|
673
|
-
});
|
|
674
|
-
// Provide authorization server metadata
|
|
675
|
-
// We advertise the MCP server itself as the authorization server (Proxy Mode)
|
|
676
|
-
// This allows us to handle the redirect dance between Cursor -> MCP -> GitLab -> MCP -> Cursor
|
|
677
|
-
this.app.get(OAUTH_PATHS.WELL_KNOWN_AUTHORIZATION_SERVER, async (req, res) => {
|
|
678
|
-
try {
|
|
679
|
-
res.json({
|
|
680
|
-
issuer: serverUrl.origin, // We are the issuer for the client
|
|
681
|
-
authorization_endpoint: new URL(OAUTH_PATHS.AUTHORIZE, serverUrl.origin).href,
|
|
682
|
-
token_endpoint: new URL(OAUTH_PATHS.TOKEN, serverUrl.origin).href,
|
|
683
|
-
registration_endpoint: new URL(OAUTH_PATHS.REGISTER, serverUrl.origin).href,
|
|
684
|
-
response_types_supported: ['code'],
|
|
685
|
-
code_challenge_methods_supported: ['S256'],
|
|
686
|
-
grant_types_supported: ['authorization_code', 'refresh_token'],
|
|
687
|
-
scopes_supported: this.oauthProvider?.scopes || ['api'],
|
|
688
|
-
});
|
|
689
|
-
}
|
|
690
|
-
catch (error) {
|
|
691
|
-
this.logger.error('OAuth authorization server metadata error', error instanceof Error ? error : new Error(String(error)));
|
|
692
|
-
res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).send('OAuth metadata failed');
|
|
693
|
-
}
|
|
694
|
-
});
|
|
695
|
-
// Dynamic Client Registration endpoint
|
|
696
|
-
// Cursor requires this to register itself with a redirect URI
|
|
697
|
-
this.app.post(OAUTH_PATHS.REGISTER, express.json(), async (req, res) => {
|
|
698
|
-
try {
|
|
699
|
-
const { redirect_uris } = req.body;
|
|
700
|
-
this.logger.info('Dynamic client registration request', { redirect_uris });
|
|
701
|
-
// We don't actually strictly enforce registration in this proxy mode,
|
|
702
|
-
// but we return a valid client configuration to satisfy the client.
|
|
703
|
-
// We use a static client ID for the internal mapping.
|
|
704
|
-
const clientId = 'mcp-proxy-client';
|
|
705
|
-
const clientSecret = 'mcp-proxy-secret';
|
|
706
|
-
// Register this client in our internal store so authorize requests pass validation
|
|
707
|
-
if (this.oauthProvider) {
|
|
708
|
-
const client = {
|
|
709
|
-
client_id: clientId,
|
|
710
|
-
client_secret: clientSecret,
|
|
711
|
-
redirect_uris: redirect_uris || [],
|
|
712
|
-
grant_types: ['authorization_code', 'refresh_token'],
|
|
713
|
-
response_types: ['code'],
|
|
714
|
-
scope: (this.oauthProvider.scopes || []).join(' '),
|
|
715
|
-
};
|
|
716
|
-
// We need to cast to any because registerClient might not be exposed on the interface
|
|
717
|
-
// but we know ExternalOAuthProvider uses InMemoryClientsStore
|
|
718
|
-
await this.oauthProvider.clientsStore.registerClient(client);
|
|
719
|
-
}
|
|
720
|
-
res.status(HTTP_STATUS.CREATED).json({
|
|
721
|
-
client_id: clientId,
|
|
722
|
-
client_secret: clientSecret,
|
|
723
|
-
redirect_uris: redirect_uris,
|
|
724
|
-
grant_types: ['authorization_code', 'refresh_token'],
|
|
725
|
-
response_types: ['code'],
|
|
726
|
-
scope: (this.oauthProvider?.scopes || []).join(' '),
|
|
727
|
-
token_endpoint_auth_method: 'client_secret_post'
|
|
728
|
-
});
|
|
729
|
-
}
|
|
730
|
-
catch (error) {
|
|
731
|
-
this.logger.error('Client registration failed', error instanceof Error ? error : new Error(String(error)));
|
|
732
|
-
res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({ error: 'server_error', error_description: 'Registration failed' });
|
|
733
|
-
}
|
|
734
|
-
});
|
|
735
|
-
this.logger.info('OAuth routes registered');
|
|
736
|
-
}
|
|
737
|
-
// Security: Rate limiting setup (for MCP endpoints)
|
|
738
|
-
const windowMs = this.config.rateLimitWindowMs || TIMEOUTS.RATE_LIMIT_WINDOW_MS;
|
|
739
|
-
const maxRequests = this.config.rateLimitMaxRequests || 100; // 100 req/min
|
|
740
|
-
const metricsMaxRequests = this.config.rateLimitMetricsMax || 10; // 10 req/min for metrics
|
|
741
|
-
if (rateLimitEnabled) {
|
|
742
|
-
this.logger.info('Rate limiting enabled', {
|
|
743
|
-
windowMs,
|
|
744
|
-
maxRequests,
|
|
745
|
-
metricsMaxRequests,
|
|
746
|
-
});
|
|
747
|
-
}
|
|
748
|
-
// Rate limiter for MCP/SSE endpoints (100 req/min by default)
|
|
749
|
-
const mcpRateLimiter = this.createRateLimiter({
|
|
750
|
-
enabled: rateLimitEnabled,
|
|
751
|
-
windowMs,
|
|
752
|
-
maxRequests,
|
|
753
|
-
logMessage: 'Rate limit exceeded',
|
|
754
|
-
});
|
|
755
|
-
// Rate limiter for metrics endpoint (10 req/min by default)
|
|
756
|
-
const metricsRateLimiter = this.createRateLimiter({
|
|
757
|
-
enabled: rateLimitEnabled,
|
|
758
|
-
windowMs,
|
|
759
|
-
maxRequests: metricsMaxRequests,
|
|
760
|
-
logMessage: 'Rate limit exceeded for metrics',
|
|
761
|
-
responseMessage: this.formatRateLimitMessage('metrics', metricsMaxRequests, windowMs),
|
|
762
|
-
});
|
|
763
|
-
// Main MCP endpoint - POST for sending messages
|
|
764
|
-
this.app.post('/mcp', mcpRateLimiter, this.handlePost.bind(this));
|
|
765
|
-
// CORS preflight handler
|
|
766
|
-
this.app.options('/mcp', (req, res) => {
|
|
767
|
-
const origin = req.headers.origin;
|
|
768
|
-
// Only send CORS headers for explicitly allowed origins; otherwise reject
|
|
769
|
-
if (origin && this.isAllowedOrigin(origin)) {
|
|
770
|
-
// nosemgrep: javascript.express.security.cors-misconfiguration.cors-misconfiguration
|
|
771
|
-
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
772
|
-
res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS');
|
|
773
|
-
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Accept, Mcp-Session-Id');
|
|
774
|
-
// We do not allow credentials; prevents cookie-based attacks by default
|
|
775
|
-
res.setHeader('Access-Control-Allow-Credentials', 'false');
|
|
776
|
-
res.setHeader('Access-Control-Max-Age', '86400'); // 24 hours cache
|
|
777
|
-
return res.status(HTTP_STATUS.OK).send();
|
|
778
|
-
}
|
|
779
|
-
// Disallowed origin: do not echo origin or emit permissive headers
|
|
780
|
-
res.status(HTTP_STATUS.FORBIDDEN).json({ error: 'Forbidden', message: 'Origin not allowed' });
|
|
781
|
-
});
|
|
782
|
-
this.logger.info('Registered POST /mcp route');
|
|
783
|
-
// Main MCP endpoint - GET for SSE streaming
|
|
784
|
-
this.app.get('/mcp', mcpRateLimiter, this.handleGet.bind(this));
|
|
785
|
-
// Session termination
|
|
786
|
-
this.app.delete('/mcp', mcpRateLimiter, this.handleDelete.bind(this));
|
|
787
|
-
// Legacy alias endpoints - deprecated
|
|
788
|
-
// Why: Backward compatibility for clients using /sse during migration
|
|
789
|
-
this.app.post('/sse', mcpRateLimiter, (req, res, next) => {
|
|
790
|
-
this.logger.warn('Deprecated endpoint used: POST /sse. Please migrate to POST /mcp');
|
|
791
|
-
this.logger.info('Handling POST /sse request');
|
|
792
|
-
return this.handlePost(req, res, next);
|
|
793
|
-
});
|
|
794
|
-
this.app.get('/sse', mcpRateLimiter, (req, res, next) => {
|
|
795
|
-
console.log('=== SSE GET handler called for path:', req.path);
|
|
796
|
-
this.logger.warn('Deprecated endpoint used: GET /sse. Please migrate to GET /mcp');
|
|
797
|
-
this.logger.info(`Handling GET /sse request from: ${req.ip}`);
|
|
798
|
-
return this.handleGet(req, res, next);
|
|
799
|
-
});
|
|
800
|
-
this.logger.info('Registered SSE routes: POST/GET/DELETE /sse');
|
|
801
|
-
this.app.delete('/sse', mcpRateLimiter, (req, res, next) => {
|
|
802
|
-
this.logger.warn('Deprecated endpoint used: DELETE /sse. Please migrate to DELETE /mcp');
|
|
803
|
-
return this.handleDelete(req, res, next);
|
|
804
|
-
});
|
|
805
|
-
// Metrics endpoint (if enabled)
|
|
806
|
-
if (this.config.metricsEnabled) {
|
|
807
|
-
this.app.get(this.config.metricsPath, metricsRateLimiter, this.handleMetrics.bind(this));
|
|
808
|
-
}
|
|
809
|
-
// Health check (with rate limiting)
|
|
810
|
-
this.app.get('/health', mcpRateLimiter, (req, res) => {
|
|
811
|
-
const startTime = Date.now();
|
|
812
|
-
res.json({ status: 'ok', sessions: this.sessions.size });
|
|
813
|
-
if (this.metrics) {
|
|
814
|
-
const duration = (Date.now() - startTime) / 1000;
|
|
815
|
-
this.metrics.recordHttpRequest(req.method, req.path, res.statusCode, duration);
|
|
816
|
-
}
|
|
817
|
-
});
|
|
818
|
-
// Debug: SSE route registered
|
|
819
|
-
this.logger.info('SSE routes registered successfully');
|
|
820
|
-
// Default 404 handler - MUST be last route registered
|
|
821
|
-
// This will catch all unmatched requests
|
|
822
|
-
this.app.use((req, res) => {
|
|
823
|
-
this.logger.warn('Unhandled request (404)', {
|
|
824
|
-
method: req.method,
|
|
825
|
-
url: req.url,
|
|
826
|
-
path: req.path,
|
|
827
|
-
headers: req.headers,
|
|
828
|
-
ip: req.ip
|
|
829
|
-
});
|
|
830
|
-
res.status(HTTP_STATUS.NOT_FOUND).json({
|
|
831
|
-
error: 'Not Found',
|
|
832
|
-
message: `Endpoint ${req.method} ${req.path} not found`
|
|
833
|
-
});
|
|
834
|
-
});
|
|
835
|
-
}
|
|
836
|
-
/**
|
|
837
|
-
* Handle metrics endpoint
|
|
838
|
-
*
|
|
839
|
-
* Why: Prometheus scraping endpoint
|
|
840
|
-
*/
|
|
841
|
-
async handleMetrics(req, res) {
|
|
842
|
-
const startTime = Date.now();
|
|
843
|
-
try {
|
|
844
|
-
if (!this.metrics) {
|
|
845
|
-
res.status(HTTP_STATUS.NOT_FOUND).json({ error: 'Not Found', message: 'Metrics disabled' });
|
|
846
|
-
return;
|
|
847
|
-
}
|
|
848
|
-
const metrics = await this.metrics.getMetrics();
|
|
849
|
-
res.set('Content-Type', 'text/plain; version=0.0.4; charset=utf-8');
|
|
850
|
-
res.send(metrics);
|
|
851
|
-
// Don't record metrics call in metrics (avoid recursion)
|
|
852
|
-
}
|
|
853
|
-
catch (error) {
|
|
854
|
-
this.logger.error('Metrics endpoint error', error);
|
|
855
|
-
res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({ error: 'Internal Server Error', message: error.message });
|
|
856
|
-
}
|
|
857
|
-
}
|
|
858
|
-
/**
|
|
859
|
-
* Validate authentication token by making a probe request to the API
|
|
860
|
-
*
|
|
861
|
-
* Supports all auth types: bearer, query, custom-header
|
|
862
|
-
* Returns true if token is valid, false otherwise
|
|
863
|
-
*/
|
|
864
|
-
/**
|
|
865
|
-
* Builds a URL by intelligently combining base URL and endpoint
|
|
866
|
-
* Handles absolute URLs, absolute paths, and relative paths correctly
|
|
867
|
-
*/
|
|
868
|
-
buildUrl(endpoint, baseUrl) {
|
|
869
|
-
// If endpoint is already an absolute URL, use it as-is
|
|
870
|
-
if (endpoint.startsWith('http://') || endpoint.startsWith('https://')) {
|
|
871
|
-
return new URL(endpoint);
|
|
872
|
-
}
|
|
873
|
-
// If endpoint is an absolute path (starts with /), combine with origin of baseUrl
|
|
874
|
-
if (endpoint.startsWith('/')) {
|
|
875
|
-
const baseUrlObj = new URL(baseUrl);
|
|
876
|
-
return new URL(endpoint, baseUrlObj.origin);
|
|
877
|
-
}
|
|
878
|
-
// Otherwise, treat as relative path and append to baseUrl
|
|
879
|
-
// Ensure baseUrl ends with '/' for proper URL construction
|
|
880
|
-
const normalizedBaseUrl = baseUrl.endsWith('/') ? baseUrl : baseUrl + '/';
|
|
881
|
-
return new URL(endpoint, normalizedBaseUrl);
|
|
882
|
-
}
|
|
883
|
-
async validateAuthToken(authConfig, token, baseUrl) {
|
|
884
|
-
if (!authConfig.validation_endpoint) {
|
|
885
|
-
return true; // Skip validation if not configured
|
|
886
|
-
}
|
|
887
|
-
const url = this.buildUrl(authConfig.validation_endpoint, baseUrl);
|
|
888
|
-
const headers = {};
|
|
889
|
-
const method = authConfig.validation_method || 'GET';
|
|
890
|
-
const timeout = authConfig.validation_timeout_ms || 5000;
|
|
891
|
-
const urlString = url.toString();
|
|
892
|
-
// Apply auth based on type
|
|
893
|
-
switch (authConfig.type) {
|
|
894
|
-
case 'oauth':
|
|
895
|
-
case 'bearer':
|
|
896
|
-
headers['Authorization'] = `Bearer ${token}`;
|
|
897
|
-
break;
|
|
898
|
-
case 'custom-header':
|
|
899
|
-
if (authConfig.header_name) {
|
|
900
|
-
headers[authConfig.header_name] = token;
|
|
901
|
-
}
|
|
902
|
-
break;
|
|
903
|
-
case 'query':
|
|
904
|
-
if (authConfig.query_param) {
|
|
905
|
-
url.searchParams.set(authConfig.query_param, token);
|
|
906
|
-
}
|
|
907
|
-
break;
|
|
908
|
-
}
|
|
909
|
-
try {
|
|
910
|
-
this.logger.debug('Validating auth token', {
|
|
911
|
-
endpoint: urlString,
|
|
912
|
-
method,
|
|
913
|
-
authType: authConfig.type,
|
|
914
|
-
});
|
|
915
|
-
const controller = new AbortController();
|
|
916
|
-
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
917
|
-
const response = await fetch(url.toString(), {
|
|
918
|
-
method,
|
|
919
|
-
headers,
|
|
920
|
-
signal: controller.signal,
|
|
921
|
-
});
|
|
922
|
-
clearTimeout(timeoutId);
|
|
923
|
-
const isValid = response.status >= 200 && response.status < 300;
|
|
924
|
-
this.logger.debug('Auth token validation result', {
|
|
925
|
-
status: response.status,
|
|
926
|
-
isValid,
|
|
927
|
-
});
|
|
928
|
-
return isValid;
|
|
929
|
-
}
|
|
930
|
-
catch (error) {
|
|
931
|
-
this.logger.warn(`Auth token validation failed: ${error.message}`, {
|
|
932
|
-
endpoint: authConfig.validation_endpoint,
|
|
933
|
-
});
|
|
934
|
-
return false;
|
|
935
|
-
}
|
|
936
|
-
}
|
|
937
|
-
/**
|
|
938
|
-
* Validate token format and length
|
|
939
|
-
*
|
|
940
|
-
* Why centralized: Single source of truth for token validation rules
|
|
941
|
-
*
|
|
942
|
-
* Relaxed validation: Allow common API token characters including colons,
|
|
943
|
-
* to support various token formats (GitLab glpat-, YouTrack perm:, etc.)
|
|
944
|
-
*/
|
|
945
|
-
validateToken(token, source) {
|
|
946
|
-
const maxLength = this.config.maxTokenLength ?? DEFAULT_MAX_TOKEN_LENGTH;
|
|
947
|
-
if (token.length > maxLength) {
|
|
948
|
-
throw new Error(`${source} too long (max ${maxLength} characters)`);
|
|
949
|
-
}
|
|
950
|
-
if (token.length === 0) {
|
|
951
|
-
throw new Error(`${source} is empty`);
|
|
952
|
-
}
|
|
953
|
-
// RFC 6750 Bearer token characters + common API token chars (including colons for YouTrack)
|
|
954
|
-
// Allow: alphanumeric, dash, underscore, dot, tilde, plus, slash, equals, colon
|
|
955
|
-
// Note: dash at end of character class to avoid being interpreted as range
|
|
956
|
-
if (!/^[A-Za-z0-9._~+/:=-]+$/.test(token)) {
|
|
957
|
-
throw new Error(`Invalid ${source} format: ...${(token || "").slice(-4)}`);
|
|
958
|
-
}
|
|
959
|
-
}
|
|
960
|
-
/**
|
|
961
|
-
* Extract and validate auth token from request headers
|
|
962
|
-
*
|
|
963
|
-
* Supports:
|
|
964
|
-
* - Authorization: Bearer <token>
|
|
965
|
-
* - X-API-Token: <token>
|
|
966
|
-
* - OAuth session (via mcp-session-id header)
|
|
967
|
-
*
|
|
968
|
-
* Why strict validation: Prevents header injection attacks
|
|
969
|
-
*
|
|
970
|
-
* Returns: { type: 'bearer' | 'oauth' | 'api-token', token: string, sessionId?: string }
|
|
971
|
-
*/
|
|
972
|
-
extractAuthToken(req) {
|
|
973
|
-
// 1. Check for OAuth session first (highest priority for authenticated sessions)
|
|
974
|
-
const sessionId = req.sessionId || req.headers['mcp-session-id'];
|
|
975
|
-
if (sessionId && this.sessions.has(sessionId)) {
|
|
976
|
-
const session = this.sessions.get(sessionId);
|
|
977
|
-
if (session && session.authToken) {
|
|
978
|
-
return { type: 'oauth', token: session.authToken, sessionId };
|
|
979
|
-
}
|
|
980
|
-
}
|
|
981
|
-
// 2. Check Authorization: Bearer header
|
|
982
|
-
const authHeader = req.headers.authorization;
|
|
983
|
-
if (authHeader) {
|
|
984
|
-
// Defense against ReDoS: Check length before regex
|
|
985
|
-
const maxHeaderLength = (this.config.maxTokenLength ?? DEFAULT_MAX_TOKEN_LENGTH) + 10; // Bearer + spaces + margin
|
|
986
|
-
if (authHeader.length > maxHeaderLength) {
|
|
987
|
-
throw new Error(`Authorization header too long (max ${maxHeaderLength} characters)`);
|
|
988
|
-
}
|
|
989
|
-
// Relaxed Bearer token format validation - allow flexible whitespace
|
|
990
|
-
// Trim whitespace to handle client variations (IntelliJ, VSCode, etc.)
|
|
991
|
-
const trimmed = authHeader.trim();
|
|
992
|
-
const match = trimmed.match(/^Bearer\s+(.+)$/);
|
|
993
|
-
if (!match) {
|
|
994
|
-
throw new Error('Invalid Authorization header format. Expected: Bearer <token>');
|
|
995
|
-
}
|
|
996
|
-
const token = match[1].trim();
|
|
997
|
-
this.validateToken(token, 'Authorization token');
|
|
998
|
-
return { type: 'bearer', token };
|
|
999
|
-
}
|
|
1000
|
-
// 3. Check X-API-Token header (for custom implementations)
|
|
1001
|
-
const apiTokenHeader = req.headers['x-api-token'];
|
|
1002
|
-
if (apiTokenHeader) {
|
|
1003
|
-
if (typeof apiTokenHeader !== 'string') {
|
|
1004
|
-
throw new Error('X-API-Token must be a string');
|
|
1005
|
-
}
|
|
1006
|
-
this.validateToken(apiTokenHeader, 'X-API-Token');
|
|
1007
|
-
return { type: 'api-token', token: apiTokenHeader };
|
|
1008
|
-
}
|
|
1009
|
-
return { type: 'none' };
|
|
1010
|
-
}
|
|
1011
|
-
/**
|
|
1012
|
-
* Handle POST requests - Client sending messages to server
|
|
1013
|
-
*
|
|
1014
|
-
* MCP Spec: POST can contain requests, notifications, or responses
|
|
1015
|
-
*/
|
|
1016
|
-
async handlePost(req, res) {
|
|
1017
|
-
const startTime = Date.now();
|
|
1018
|
-
try {
|
|
1019
|
-
this.logger.debug('handlePost called', { method: req.method, path: req.path, sessionId: req.sessionId, accept: req.headers.accept });
|
|
1020
|
-
const sessionId = req.sessionId;
|
|
1021
|
-
const body = req.body;
|
|
1022
|
-
// Validate Accept header per MCP Streamable HTTP specification
|
|
1023
|
-
const accept = req.headers.accept || '';
|
|
1024
|
-
// POST requests can return either JSON or SSE, so must accept both if specified
|
|
1025
|
-
// GET requests return SSE, so must accept text/event-stream
|
|
1026
|
-
const acceptsJson = accept.includes(MIME_TYPES.JSON) || accept === '*/*' || accept === '';
|
|
1027
|
-
const acceptsEventStream = accept.includes(MIME_TYPES.EVENT_STREAM) || accept === '*/*' || accept === '';
|
|
1028
|
-
if (req.method === 'GET' && accept && !acceptsEventStream) {
|
|
1029
|
-
this.logger.debug('Accept header validation failed for GET, returning 406');
|
|
1030
|
-
res.status(HTTP_STATUS.NOT_ACCEPTABLE).json({
|
|
1031
|
-
error: 'Not Acceptable',
|
|
1032
|
-
message: `GET requests must accept ${MIME_TYPES.EVENT_STREAM}`
|
|
1033
|
-
});
|
|
1034
|
-
return;
|
|
1035
|
-
}
|
|
1036
|
-
// For POST, be more flexible - allow if client accepts either JSON or SSE
|
|
1037
|
-
if (req.method === 'POST' && accept && !acceptsJson && !acceptsEventStream) {
|
|
1038
|
-
this.logger.debug('Accept header validation failed for POST, returning 406');
|
|
1039
|
-
res.status(HTTP_STATUS.NOT_ACCEPTABLE).json({
|
|
1040
|
-
error: 'Not Acceptable',
|
|
1041
|
-
message: `POST requests must accept ${MIME_TYPES.JSON} or ${MIME_TYPES.EVENT_STREAM}`
|
|
1042
|
-
});
|
|
1043
|
-
return;
|
|
1044
|
-
}
|
|
1045
|
-
// Check if this is initialization (no session ID yet)
|
|
1046
|
-
const isInitialization = isInitializeRequest(body);
|
|
1047
|
-
this.logger.debug('Session validation', { isInitialization, sessionId, bodyMethod: body?.method });
|
|
1048
|
-
// Validate session (except for initialization)
|
|
1049
|
-
if (!isInitialization && sessionId) {
|
|
1050
|
-
const session = this.sessions.get(sessionId);
|
|
1051
|
-
if (!session) {
|
|
1052
|
-
res.status(HTTP_STATUS.NOT_FOUND).json({ error: 'Not Found', message: 'Session not found or expired' });
|
|
1053
|
-
return;
|
|
1054
|
-
}
|
|
1055
|
-
this.updateSessionActivity(sessionId);
|
|
1056
|
-
}
|
|
1057
|
-
else if (!isInitialization && !sessionId) {
|
|
1058
|
-
this.logger.debug('Session validation failed: non-init request without sessionId');
|
|
1059
|
-
res.status(HTTP_STATUS.BAD_REQUEST).json({ error: 'Bad Request', message: 'Mcp-Session-Id header required (except for initialization)' });
|
|
1060
|
-
return;
|
|
1061
|
-
}
|
|
1062
|
-
// Determine message type
|
|
1063
|
-
const messageType = this.getMessageType(body);
|
|
1064
|
-
this.logger.debug('Message type determined', { messageType, hasMessageHandler: !!this.messageHandler });
|
|
1065
|
-
// If only notifications/responses, return 202 Accepted
|
|
1066
|
-
if (messageType === 'notification-only' || messageType === 'response-only') {
|
|
1067
|
-
if (this.messageHandler) {
|
|
1068
|
-
await this.messageHandler(body);
|
|
1069
|
-
}
|
|
1070
|
-
res.status(HTTP_STATUS.ACCEPTED).send();
|
|
1071
|
-
return;
|
|
1072
|
-
}
|
|
1073
|
-
// If contains requests, process and return response
|
|
1074
|
-
if (messageType === 'request') {
|
|
1075
|
-
if (!this.messageHandler) {
|
|
1076
|
-
res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({ error: 'Internal Server Error', message: 'Message handler not configured' });
|
|
1077
|
-
return;
|
|
1078
|
-
}
|
|
1079
|
-
// Create session on initialization
|
|
1080
|
-
let newSessionId;
|
|
1081
|
-
if (isInitialization) {
|
|
1082
|
-
// Extract and validate auth token from headers
|
|
1083
|
-
const authInfo = this.extractAuthToken(req);
|
|
1084
|
-
this.logger.debug('Auth token extracted', { authType: authInfo?.type, hasToken: !!authInfo?.token });
|
|
1085
|
-
// If OAuth is configured, require authentication for initialization
|
|
1086
|
-
// This ensures clients like Cursor properly handle OAuth flow
|
|
1087
|
-
if (this.oauthProvider && !authInfo.token) {
|
|
1088
|
-
this.logger.debug('OAuth configured but no token provided, triggering OAuth flow');
|
|
1089
|
-
const resourceMetadataUrl = new URL(OAUTH_PATHS.WELL_KNOWN_PROTECTED_RESOURCE, this.getServerUrl()).href;
|
|
1090
|
-
res.setHeader('WWW-Authenticate', `Bearer resource_metadata="${resourceMetadataUrl}", scope="${this.oauthProvider.scopes.join(' ')}"`);
|
|
1091
|
-
res.status(HTTP_STATUS.UNAUTHORIZED).json({
|
|
1092
|
-
error: 'Unauthorized',
|
|
1093
|
-
message: 'Authentication required for OAuth'
|
|
1094
|
-
});
|
|
1095
|
-
return;
|
|
1096
|
-
}
|
|
1097
|
-
// Allow initialization without token for non-OAuth scenarios
|
|
1098
|
-
// Validate token if auth is configured and token is provided
|
|
1099
|
-
if (authInfo && authInfo.token && this.config.authConfigs && this.config.baseUrl) {
|
|
1100
|
-
// Find matching auth config based on priority (authConfigs is sorted)
|
|
1101
|
-
// For 'bearer' token type, 'oauth' config is also a match
|
|
1102
|
-
const authConfig = this.config.authConfigs.find(c => c.type === authInfo.type ||
|
|
1103
|
-
(authInfo.type === 'bearer' && c.type === 'oauth'));
|
|
1104
|
-
if (authConfig && authConfig.validation_endpoint) {
|
|
1105
|
-
this.logger.info('Validating auth token during initialization', {
|
|
1106
|
-
authType: authConfig.type, // Use config type for logging
|
|
1107
|
-
endpoint: authConfig.validation_endpoint,
|
|
1108
|
-
});
|
|
1109
|
-
const isValid = await this.validateAuthToken(authConfig, authInfo.token, this.config.baseUrl);
|
|
1110
|
-
if (!isValid) {
|
|
1111
|
-
this.logger.warn('Auth token validation failed during initialization', {
|
|
1112
|
-
authType: authInfo.type,
|
|
1113
|
-
});
|
|
1114
|
-
res.status(HTTP_STATUS.UNAUTHORIZED).json({
|
|
1115
|
-
error: 'Unauthorized',
|
|
1116
|
-
message: 'Invalid or expired authentication token'
|
|
1117
|
-
});
|
|
1118
|
-
return;
|
|
1119
|
-
}
|
|
1120
|
-
this.logger.info('Auth token validation successful');
|
|
1121
|
-
}
|
|
1122
|
-
}
|
|
1123
|
-
// Look up OAuth tokens if this is an OAuth token
|
|
1124
|
-
let refreshToken;
|
|
1125
|
-
let accessTokenExpiresAt;
|
|
1126
|
-
let scopes;
|
|
1127
|
-
let oauthClientId;
|
|
1128
|
-
if (authInfo.token && (authInfo.type === 'oauth' || authInfo.type === 'bearer')) {
|
|
1129
|
-
const tokenData = this.oauthTokensByAccessToken.get(authInfo.token);
|
|
1130
|
-
if (tokenData) {
|
|
1131
|
-
refreshToken = tokenData.refreshToken;
|
|
1132
|
-
accessTokenExpiresAt = tokenData.expiresAt;
|
|
1133
|
-
scopes = tokenData.scopes;
|
|
1134
|
-
oauthClientId = tokenData.clientId;
|
|
1135
|
-
this.logger.debug('Found OAuth token data for session', {
|
|
1136
|
-
hasRefreshToken: !!refreshToken,
|
|
1137
|
-
hasExpiration: !!accessTokenExpiresAt,
|
|
1138
|
-
scopesCount: scopes.length,
|
|
1139
|
-
});
|
|
1140
|
-
}
|
|
1141
|
-
else {
|
|
1142
|
-
this.logger.debug('No OAuth token data found in map (may be non-OAuth bearer token)', {
|
|
1143
|
-
tokenPrefix: authInfo.token.substring(0, 10),
|
|
1144
|
-
});
|
|
1145
|
-
}
|
|
1146
|
-
}
|
|
1147
|
-
newSessionId = this.createSession(authInfo.token, refreshToken, accessTokenExpiresAt, scopes, oauthClientId);
|
|
1148
|
-
}
|
|
1149
|
-
this.logger.debug('Calling messageHandler', { body, sessionId: isInitialization ? newSessionId : sessionId });
|
|
1150
|
-
const response = await this.messageHandler(body, isInitialization ? newSessionId : sessionId);
|
|
1151
|
-
this.logger.debug('MessageHandler response', { response });
|
|
1152
|
-
// Debug: Check OAuth conditions
|
|
1153
|
-
this.logger.debug('Checking OAuth conditions', {
|
|
1154
|
-
responseError: response.error,
|
|
1155
|
-
hasOAuthProvider: !!this.oauthProvider,
|
|
1156
|
-
oauthProviderType: typeof this.oauthProvider
|
|
1157
|
-
});
|
|
1158
|
-
// Check if response contains OAuth error and add WWW-Authenticate header
|
|
1159
|
-
const responseObj = response;
|
|
1160
|
-
if (responseObj.error && responseObj.error.data && responseObj.error.data.oauth_required) {
|
|
1161
|
-
const resourceMetadataUrl = new URL(OAUTH_PATHS.WELL_KNOWN_PROTECTED_RESOURCE, this.getServerUrl()).href;
|
|
1162
|
-
res.setHeader('WWW-Authenticate', `Bearer resource_metadata="${resourceMetadataUrl}", scope="api"`);
|
|
1163
|
-
res.status(HTTP_STATUS.UNAUTHORIZED); // Set 401 status for OAuth errors
|
|
1164
|
-
}
|
|
1165
|
-
// Decide response format based on Accept header
|
|
1166
|
-
const accept = req.headers.accept || '';
|
|
1167
|
-
const wantsOnlySSE = accept.trim() === MIME_TYPES.EVENT_STREAM;
|
|
1168
|
-
if (wantsOnlySSE) {
|
|
1169
|
-
// Return SSE response only when client explicitly wants text/event-stream only
|
|
1170
|
-
this.logger.debug('Sending SSE response', { response, newSessionId });
|
|
1171
|
-
this.startSSEResponse(res, response, newSessionId, sessionId);
|
|
1172
|
-
}
|
|
1173
|
-
else {
|
|
1174
|
-
// Return JSON response (default for requests)
|
|
1175
|
-
if (newSessionId) {
|
|
1176
|
-
res.setHeader('Mcp-Session-Id', newSessionId);
|
|
1177
|
-
}
|
|
1178
|
-
this.logger.debug('Sending JSON response', { response, newSessionId });
|
|
1179
|
-
res.json(response);
|
|
1180
|
-
}
|
|
1181
|
-
return;
|
|
1182
|
-
}
|
|
1183
|
-
res.status(HTTP_STATUS.BAD_REQUEST).json({ error: 'Bad Request', message: 'Invalid message type' });
|
|
1184
|
-
}
|
|
1185
|
-
catch (error) {
|
|
1186
|
-
this.logger.error('POST request error', error);
|
|
1187
|
-
const status = 500;
|
|
1188
|
-
res.status(status).json({ error: 'Internal Server Error', message: error.message });
|
|
1189
|
-
// Record error metrics
|
|
1190
|
-
if (this.metrics) {
|
|
1191
|
-
const duration = (Date.now() - startTime) / 1000;
|
|
1192
|
-
this.metrics.recordHttpRequest(req.method, req.path, status, duration);
|
|
1193
|
-
}
|
|
1194
|
-
}
|
|
1195
|
-
finally {
|
|
1196
|
-
// Record success metrics (if not already recorded in catch)
|
|
1197
|
-
if (this.metrics && res.statusCode !== 500) {
|
|
1198
|
-
const duration = (Date.now() - startTime) / 1000;
|
|
1199
|
-
this.metrics.recordHttpRequest(req.method, req.path, res.statusCode, duration);
|
|
1200
|
-
}
|
|
1201
|
-
}
|
|
1202
|
-
}
|
|
1203
|
-
/**
|
|
1204
|
-
* Handle GET requests - Client opening SSE stream for server messages
|
|
1205
|
-
*
|
|
1206
|
-
* MCP Spec: GET opens SSE stream for server-initiated requests/notifications
|
|
1207
|
-
*/
|
|
1208
|
-
async handleGet(req, res) {
|
|
1209
|
-
const startTime = Date.now();
|
|
1210
|
-
try {
|
|
1211
|
-
const sessionId = req.sessionId;
|
|
1212
|
-
const lastEventId = req.headers['last-event-id'];
|
|
1213
|
-
// Validate Accept header
|
|
1214
|
-
const accept = req.headers.accept || '';
|
|
1215
|
-
if (!accept.includes(MIME_TYPES.EVENT_STREAM)) {
|
|
1216
|
-
res.status(HTTP_STATUS.METHOD_NOT_ALLOWED).json({ error: 'Method Not Allowed', message: `Must accept ${MIME_TYPES.EVENT_STREAM}` });
|
|
1217
|
-
return;
|
|
1218
|
-
}
|
|
1219
|
-
// Validate session
|
|
1220
|
-
if (!sessionId) {
|
|
1221
|
-
res.status(HTTP_STATUS.BAD_REQUEST).json({ error: 'Bad Request', message: 'Mcp-Session-Id header required' });
|
|
1222
|
-
return;
|
|
1223
|
-
}
|
|
1224
|
-
const session = this.sessions.get(sessionId);
|
|
1225
|
-
if (!session) {
|
|
1226
|
-
res.status(HTTP_STATUS.NOT_FOUND).json({ error: 'Not Found', message: 'Session not found or expired' });
|
|
1227
|
-
return;
|
|
1228
|
-
}
|
|
1229
|
-
this.updateSessionActivity(sessionId);
|
|
1230
|
-
// Start SSE stream
|
|
1231
|
-
this.startSSEStream(res, sessionId, lastEventId);
|
|
1232
|
-
// Record metrics for successful SSE start
|
|
1233
|
-
if (this.metrics) {
|
|
1234
|
-
const duration = (Date.now() - startTime) / 1000;
|
|
1235
|
-
this.metrics.recordHttpRequest(req.method, req.path, 200, duration);
|
|
1236
|
-
}
|
|
1237
|
-
}
|
|
1238
|
-
catch (error) {
|
|
1239
|
-
this.logger.error('GET request error', error);
|
|
1240
|
-
const status = 500;
|
|
1241
|
-
if (!res.headersSent) {
|
|
1242
|
-
res.status(status).json({ error: 'Internal Server Error', message: error.message });
|
|
1243
|
-
}
|
|
1244
|
-
// Record error metrics
|
|
1245
|
-
if (this.metrics) {
|
|
1246
|
-
const duration = (Date.now() - startTime) / 1000;
|
|
1247
|
-
this.metrics.recordHttpRequest(req.method, req.path, status, duration);
|
|
1248
|
-
}
|
|
1249
|
-
}
|
|
1250
|
-
}
|
|
1251
|
-
/**
|
|
1252
|
-
* Handle DELETE requests - Client terminating session
|
|
1253
|
-
*
|
|
1254
|
-
* MCP Spec: DELETE explicitly terminates session
|
|
1255
|
-
*/
|
|
1256
|
-
handleDelete(req, res) {
|
|
1257
|
-
const startTime = Date.now();
|
|
1258
|
-
const sessionId = req.sessionId;
|
|
1259
|
-
if (!sessionId) {
|
|
1260
|
-
const status = 400;
|
|
1261
|
-
res.status(status).json({ error: 'Bad Request', message: 'Mcp-Session-Id header required' });
|
|
1262
|
-
if (this.metrics) {
|
|
1263
|
-
const duration = (Date.now() - startTime) / 1000;
|
|
1264
|
-
this.metrics.recordHttpRequest(req.method, req.path, status, duration);
|
|
1265
|
-
}
|
|
1266
|
-
return;
|
|
1267
|
-
}
|
|
1268
|
-
const session = this.sessions.get(sessionId);
|
|
1269
|
-
if (!session) {
|
|
1270
|
-
const status = 404;
|
|
1271
|
-
res.status(status).json({ error: 'Not Found', message: 'Session not found' });
|
|
1272
|
-
if (this.metrics) {
|
|
1273
|
-
const duration = (Date.now() - startTime) / 1000;
|
|
1274
|
-
this.metrics.recordHttpRequest(req.method, req.path, status, duration);
|
|
1275
|
-
}
|
|
1276
|
-
return;
|
|
1277
|
-
}
|
|
1278
|
-
this.destroySession(sessionId);
|
|
1279
|
-
const status = 204;
|
|
1280
|
-
res.status(status).send();
|
|
1281
|
-
if (this.metrics) {
|
|
1282
|
-
const duration = (Date.now() - startTime) / 1000;
|
|
1283
|
-
this.metrics.recordHttpRequest(req.method, req.path, status, duration);
|
|
1284
|
-
}
|
|
1285
|
-
}
|
|
1286
|
-
/**
|
|
1287
|
-
* Start SSE response for a POST request
|
|
1288
|
-
*
|
|
1289
|
-
* Why: Returns response via SSE stream, allows server-initiated messages
|
|
1290
|
-
*/
|
|
1291
|
-
startSSEResponse(res, response, newSessionId, sessionId) {
|
|
1292
|
-
res.setHeader('Content-Type', MIME_TYPES.EVENT_STREAM);
|
|
1293
|
-
res.setHeader('Cache-Control', 'no-cache');
|
|
1294
|
-
res.setHeader('Connection', 'keep-alive');
|
|
1295
|
-
if (newSessionId) {
|
|
1296
|
-
res.setHeader('Mcp-Session-Id', newSessionId);
|
|
1297
|
-
}
|
|
1298
|
-
// Send response
|
|
1299
|
-
const eventId = Date.now();
|
|
1300
|
-
res.write(`id: ${eventId}\n`);
|
|
1301
|
-
res.write(`data: ${JSON.stringify(response)}\n\n`);
|
|
1302
|
-
// Close stream
|
|
1303
|
-
res.end();
|
|
1304
|
-
}
|
|
1305
|
-
/**
|
|
1306
|
-
* Start SSE stream for GET request
|
|
1307
|
-
*
|
|
1308
|
-
* Why: Allows server to send requests/notifications to client
|
|
1309
|
-
*/
|
|
1310
|
-
startSSEStream(res, sessionId, lastEventId) {
|
|
1311
|
-
res.setHeader('Content-Type', MIME_TYPES.EVENT_STREAM);
|
|
1312
|
-
res.setHeader('Cache-Control', 'no-cache');
|
|
1313
|
-
res.setHeader('Connection', 'keep-alive');
|
|
1314
|
-
const streamId = crypto.randomBytes(16).toString('hex');
|
|
1315
|
-
const session = this.sessions.get(sessionId);
|
|
1316
|
-
const streamState = {
|
|
1317
|
-
streamId,
|
|
1318
|
-
lastEventId: lastEventId ? parseInt(lastEventId, 10) : 0,
|
|
1319
|
-
messageQueue: [],
|
|
1320
|
-
active: true,
|
|
1321
|
-
response: res,
|
|
1322
|
-
};
|
|
1323
|
-
session.sseStreams.set(streamId, streamState);
|
|
1324
|
-
// Replay missed messages if resuming
|
|
1325
|
-
if (lastEventId) {
|
|
1326
|
-
this.replayMessages(res, streamState);
|
|
1327
|
-
}
|
|
1328
|
-
// Setup heartbeat if enabled
|
|
1329
|
-
let heartbeatInterval = null;
|
|
1330
|
-
if (this.config.heartbeatEnabled) {
|
|
1331
|
-
heartbeatInterval = setInterval(() => {
|
|
1332
|
-
if (streamState.active) {
|
|
1333
|
-
res.write(':ping\n\n');
|
|
1334
|
-
}
|
|
1335
|
-
}, this.config.heartbeatIntervalMs);
|
|
1336
|
-
}
|
|
1337
|
-
// Handle client disconnect
|
|
1338
|
-
res.on('close', () => {
|
|
1339
|
-
streamState.active = false;
|
|
1340
|
-
if (heartbeatInterval) {
|
|
1341
|
-
clearInterval(heartbeatInterval);
|
|
1342
|
-
}
|
|
1343
|
-
this.logger.info('SSE stream closed', { sessionId, streamId });
|
|
1344
|
-
});
|
|
1345
|
-
this.logger.info('SSE stream opened', { sessionId, streamId, resuming: !!lastEventId });
|
|
1346
|
-
}
|
|
1347
|
-
/**
|
|
1348
|
-
* Replay messages after Last-Event-ID
|
|
1349
|
-
*
|
|
1350
|
-
* Why: Resumability - client can reconnect and receive missed messages
|
|
1351
|
-
*/
|
|
1352
|
-
replayMessages(res, streamState) {
|
|
1353
|
-
const missedMessages = streamState.messageQueue.filter(msg => msg.eventId > streamState.lastEventId);
|
|
1354
|
-
for (const msg of missedMessages) {
|
|
1355
|
-
res.write(`id: ${msg.eventId}\n`);
|
|
1356
|
-
res.write(`data: ${JSON.stringify(msg.data)}\n\n`);
|
|
1357
|
-
}
|
|
1358
|
-
this.logger.info('Replayed messages', { count: missedMessages.length, streamId: streamState.streamId });
|
|
1359
|
-
}
|
|
1360
|
-
/**
|
|
1361
|
-
* Send message to client via SSE
|
|
1362
|
-
*
|
|
1363
|
-
* Why: Server-initiated requests/notifications
|
|
1364
|
-
*/
|
|
1365
|
-
sendToClient(sessionId, message) {
|
|
1366
|
-
const session = this.sessions.get(sessionId);
|
|
1367
|
-
if (!session) {
|
|
1368
|
-
this.logger.warn('Cannot send to client: session not found', { sessionId });
|
|
1369
|
-
return;
|
|
1370
|
-
}
|
|
1371
|
-
const eventId = Date.now();
|
|
1372
|
-
const queuedMessage = {
|
|
1373
|
-
eventId,
|
|
1374
|
-
data: message,
|
|
1375
|
-
timestamp: Date.now(),
|
|
1376
|
-
};
|
|
1377
|
-
// Send to all active streams for this session
|
|
1378
|
-
for (const [streamId, streamState] of session.sseStreams) {
|
|
1379
|
-
if (streamState.active) {
|
|
1380
|
-
// Queue for resumability
|
|
1381
|
-
streamState.messageQueue.push(queuedMessage);
|
|
1382
|
-
// Keep only last 100 messages
|
|
1383
|
-
if (streamState.messageQueue.length > 100) {
|
|
1384
|
-
streamState.messageQueue.shift();
|
|
1385
|
-
}
|
|
1386
|
-
}
|
|
1387
|
-
}
|
|
1388
|
-
}
|
|
1389
|
-
/**
|
|
1390
|
-
* Determine message type (request, notification, response)
|
|
1391
|
-
*/
|
|
1392
|
-
getMessageType(body) {
|
|
1393
|
-
if (Array.isArray(body)) {
|
|
1394
|
-
// Batch
|
|
1395
|
-
const hasRequest = body.some((msg) => typeof msg === 'object' && msg !== null && 'method' in msg && 'id' in msg);
|
|
1396
|
-
const hasNotification = body.some((msg) => typeof msg === 'object' && msg !== null && 'method' in msg && !('id' in msg));
|
|
1397
|
-
const hasResponse = body.some((msg) => typeof msg === 'object' && msg !== null && ('result' in msg || 'error' in msg));
|
|
1398
|
-
if (hasRequest)
|
|
1399
|
-
return 'request';
|
|
1400
|
-
if (hasNotification && !hasResponse)
|
|
1401
|
-
return 'notification-only';
|
|
1402
|
-
if (hasResponse && !hasNotification)
|
|
1403
|
-
return 'response-only';
|
|
1404
|
-
return 'mixed';
|
|
1405
|
-
}
|
|
1406
|
-
else if (typeof body === 'object' && body !== null) {
|
|
1407
|
-
const msg = body;
|
|
1408
|
-
if ('method' in msg) {
|
|
1409
|
-
return 'id' in msg ? 'request' : 'notification-only';
|
|
1410
|
-
}
|
|
1411
|
-
if ('result' in msg || 'error' in msg) {
|
|
1412
|
-
return 'response-only';
|
|
1413
|
-
}
|
|
1414
|
-
}
|
|
1415
|
-
return 'unknown';
|
|
1416
|
-
}
|
|
1417
|
-
/**
|
|
1418
|
-
* Create new session
|
|
1419
|
-
*
|
|
1420
|
-
* Why: Stateful sessions for MCP protocol
|
|
1421
|
-
*/
|
|
1422
|
-
createSession(authToken, refreshToken, accessTokenExpiresAt, scopes, oauthClientId) {
|
|
1423
|
-
// Validate token if provided (defense in depth)
|
|
1424
|
-
if (authToken) {
|
|
1425
|
-
this.validateToken(authToken, 'Session auth token');
|
|
1426
|
-
}
|
|
1427
|
-
const sessionId = crypto.randomUUID();
|
|
1428
|
-
const session = {
|
|
1429
|
-
id: sessionId,
|
|
1430
|
-
createdAt: Date.now(),
|
|
1431
|
-
lastActivityAt: Date.now(),
|
|
1432
|
-
sseStreams: new Map(),
|
|
1433
|
-
authToken,
|
|
1434
|
-
refreshToken,
|
|
1435
|
-
accessTokenExpiresAt,
|
|
1436
|
-
scopes,
|
|
1437
|
-
oauthClientId,
|
|
1438
|
-
};
|
|
1439
|
-
this.sessions.set(sessionId, session);
|
|
1440
|
-
this.logger.info('Session created', {
|
|
1441
|
-
sessionId,
|
|
1442
|
-
hasAuthToken: !!authToken,
|
|
1443
|
-
hasRefreshToken: !!refreshToken,
|
|
1444
|
-
hasExpiration: !!accessTokenExpiresAt,
|
|
1445
|
-
});
|
|
1446
|
-
// Record metrics
|
|
1447
|
-
if (this.metrics) {
|
|
1448
|
-
this.metrics.recordSessionCreated();
|
|
1449
|
-
}
|
|
1450
|
-
return sessionId;
|
|
1451
|
-
}
|
|
1452
|
-
/**
|
|
1453
|
-
* Update session activity timestamp
|
|
1454
|
-
*/
|
|
1455
|
-
updateSessionActivity(sessionId) {
|
|
1456
|
-
const session = this.sessions.get(sessionId);
|
|
1457
|
-
if (session) {
|
|
1458
|
-
session.lastActivityAt = Date.now();
|
|
1459
|
-
}
|
|
1460
|
-
}
|
|
1461
|
-
/**
|
|
1462
|
-
* Destroy session and cleanup resources
|
|
1463
|
-
*
|
|
1464
|
-
* Why: Free memory, close streams
|
|
1465
|
-
*/
|
|
1466
|
-
destroySession(sessionId) {
|
|
1467
|
-
const session = this.sessions.get(sessionId);
|
|
1468
|
-
if (session) {
|
|
1469
|
-
// Close all active SSE streams
|
|
1470
|
-
for (const [, streamState] of session.sseStreams) {
|
|
1471
|
-
streamState.active = false;
|
|
1472
|
-
// Close the HTTP response to terminate the SSE connection
|
|
1473
|
-
try {
|
|
1474
|
-
if (!streamState.response.headersSent || !streamState.response.writableEnded) {
|
|
1475
|
-
streamState.response.end();
|
|
1476
|
-
}
|
|
1477
|
-
}
|
|
1478
|
-
catch (error) {
|
|
1479
|
-
// Ignore errors if response is already closed
|
|
1480
|
-
this.logger.debug('Failed to close SSE response', { error: error.message });
|
|
1481
|
-
}
|
|
1482
|
-
}
|
|
1483
|
-
session.sseStreams.clear();
|
|
1484
|
-
// Clean up OAuth token from map if present
|
|
1485
|
-
if (session.authToken) {
|
|
1486
|
-
this.oauthTokensByAccessToken.delete(session.authToken);
|
|
1487
|
-
}
|
|
1488
|
-
this.sessions.delete(sessionId);
|
|
1489
|
-
this.logger.info('Session destroyed', { sessionId });
|
|
1490
|
-
// Notify session destruction listeners (for cleanup in MCPServer)
|
|
1491
|
-
this.notifySessionDestroyed(sessionId);
|
|
1492
|
-
// Record metrics
|
|
1493
|
-
if (this.metrics) {
|
|
1494
|
-
this.metrics.recordSessionDestroyed();
|
|
1495
|
-
}
|
|
1496
|
-
}
|
|
1497
|
-
}
|
|
1498
|
-
/**
|
|
1499
|
-
* Register listener for session destruction events
|
|
1500
|
-
*
|
|
1501
|
-
* Why: Allows MCPServer to cleanup per-session HTTP clients
|
|
1502
|
-
*/
|
|
1503
|
-
onSessionDestroyed(listener) {
|
|
1504
|
-
this.sessionDestroyedListeners.push(listener);
|
|
1505
|
-
}
|
|
1506
|
-
/**
|
|
1507
|
-
* Notify all listeners about session destruction
|
|
1508
|
-
*/
|
|
1509
|
-
notifySessionDestroyed(sessionId) {
|
|
1510
|
-
for (const listener of this.sessionDestroyedListeners) {
|
|
1511
|
-
try {
|
|
1512
|
-
listener(sessionId);
|
|
1513
|
-
}
|
|
1514
|
-
catch (error) {
|
|
1515
|
-
this.logger.error('Session destroyed listener error', error);
|
|
1516
|
-
}
|
|
1517
|
-
}
|
|
1518
|
-
}
|
|
1519
|
-
/**
|
|
1520
|
-
* Store OAuth tokens in internal map for later session initialization
|
|
1521
|
-
*
|
|
1522
|
-
* Why: Bridge between /oauth/token endpoint (where we see OAuthTokens)
|
|
1523
|
-
* and session initialization (where we only see access token in Authorization header)
|
|
1524
|
-
*/
|
|
1525
|
-
storeOAuthTokens(tokens, clientId, scopes) {
|
|
1526
|
-
if (!tokens.access_token) {
|
|
1527
|
-
this.logger.warn('OAuth tokens missing access_token, skipping storage');
|
|
1528
|
-
return;
|
|
1529
|
-
}
|
|
1530
|
-
const expiresAt = tokens.expires_in
|
|
1531
|
-
? Date.now() + tokens.expires_in * 1000
|
|
1532
|
-
: undefined;
|
|
1533
|
-
this.oauthTokensByAccessToken.set(tokens.access_token, {
|
|
1534
|
-
refreshToken: tokens.refresh_token,
|
|
1535
|
-
expiresAt,
|
|
1536
|
-
clientId,
|
|
1537
|
-
scopes,
|
|
1538
|
-
});
|
|
1539
|
-
this.logger.debug('Stored OAuth tokens', {
|
|
1540
|
-
hasRefreshToken: !!tokens.refresh_token,
|
|
1541
|
-
expiresAt,
|
|
1542
|
-
clientId,
|
|
1543
|
-
scopesCount: scopes.length,
|
|
1544
|
-
});
|
|
1545
|
-
}
|
|
1546
|
-
/**
|
|
1547
|
-
* Cleanup expired sessions
|
|
1548
|
-
*
|
|
1549
|
-
* Why: Prevent memory leaks, enforce session timeout
|
|
1550
|
-
*
|
|
1551
|
-
* OAuth sessions with refresh tokens have extended or unlimited timeout
|
|
1552
|
-
* to avoid forcing users to re-authenticate after periods of inactivity
|
|
1553
|
-
*/
|
|
1554
|
-
cleanupExpiredSessions() {
|
|
1555
|
-
const now = Date.now();
|
|
1556
|
-
const expiredSessions = [];
|
|
1557
|
-
// Default OAuth session timeout: 24 hours (or configurable)
|
|
1558
|
-
const oauthSessionTimeoutMs = this.config.oauthSessionTimeoutMs
|
|
1559
|
-
?? (24 * 60 * 60 * 1000); // 24 hours default
|
|
1560
|
-
for (const [sessionId, session] of this.sessions) {
|
|
1561
|
-
const age = now - session.lastActivityAt;
|
|
1562
|
-
// OAuth sessions with refresh tokens: use extended timeout or never expire
|
|
1563
|
-
if (session.refreshToken) {
|
|
1564
|
-
// If oauthSessionTimeoutMs is 0 or negative, never expire OAuth sessions
|
|
1565
|
-
if (oauthSessionTimeoutMs > 0 && age > oauthSessionTimeoutMs) {
|
|
1566
|
-
expiredSessions.push(sessionId);
|
|
1567
|
-
}
|
|
1568
|
-
// Otherwise, keep the session alive (unlimited timeout)
|
|
1569
|
-
}
|
|
1570
|
-
else {
|
|
1571
|
-
// Non-OAuth sessions: use standard timeout
|
|
1572
|
-
if (age > this.config.sessionTimeoutMs) {
|
|
1573
|
-
expiredSessions.push(sessionId);
|
|
1574
|
-
}
|
|
1575
|
-
}
|
|
1576
|
-
}
|
|
1577
|
-
for (const sessionId of expiredSessions) {
|
|
1578
|
-
this.destroySession(sessionId);
|
|
1579
|
-
}
|
|
1580
|
-
if (expiredSessions.length > 0) {
|
|
1581
|
-
this.logger.info('Cleaned up expired sessions', { count: expiredSessions.length });
|
|
1582
|
-
}
|
|
1583
|
-
}
|
|
1584
|
-
/**
|
|
1585
|
-
* Get auth token from session
|
|
1586
|
-
*
|
|
1587
|
-
* Why public: Allows MCPServer to securely access session tokens without breaking encapsulation
|
|
1588
|
-
*/
|
|
1589
|
-
getSessionToken(sessionId) {
|
|
1590
|
-
const session = this.sessions.get(sessionId);
|
|
1591
|
-
return session?.authToken;
|
|
1592
|
-
}
|
|
1593
|
-
/**
|
|
1594
|
-
* Ensure session has a valid access token, refreshing if necessary
|
|
1595
|
-
*
|
|
1596
|
-
* Why: Transparently refresh expired OAuth tokens before making API calls
|
|
1597
|
-
* Returns true if token is valid (or was successfully refreshed), false otherwise
|
|
1598
|
-
*/
|
|
1599
|
-
async ensureValidSessionToken(sessionId) {
|
|
1600
|
-
const session = this.sessions.get(sessionId);
|
|
1601
|
-
if (!session) {
|
|
1602
|
-
return false;
|
|
1603
|
-
}
|
|
1604
|
-
// If no expiration info, assume token is valid (non-OAuth scenarios)
|
|
1605
|
-
if (!session.accessTokenExpiresAt) {
|
|
1606
|
-
return true;
|
|
1607
|
-
}
|
|
1608
|
-
const now = Date.now();
|
|
1609
|
-
const refreshThresholdMs = this.config.oauthRefreshThresholdMs ?? (60 * 1000); // Default: 60 seconds before expiration
|
|
1610
|
-
const timeUntilExpiration = session.accessTokenExpiresAt - now;
|
|
1611
|
-
// If token is expired or about to expire, refresh it
|
|
1612
|
-
if (timeUntilExpiration <= refreshThresholdMs) {
|
|
1613
|
-
this.logger.debug('Access token expired or expiring soon, refreshing', {
|
|
1614
|
-
sessionId,
|
|
1615
|
-
expiresAt: new Date(session.accessTokenExpiresAt).toISOString(),
|
|
1616
|
-
timeUntilExpiration,
|
|
1617
|
-
});
|
|
1618
|
-
return await this.refreshAccessToken(sessionId);
|
|
1619
|
-
}
|
|
1620
|
-
return true;
|
|
1621
|
-
}
|
|
1622
|
-
/**
|
|
1623
|
-
* Refresh access token using refresh token
|
|
1624
|
-
*
|
|
1625
|
-
* Why: Automatically renew expired OAuth access tokens without user intervention
|
|
1626
|
-
* Returns true on success, false on failure
|
|
1627
|
-
*/
|
|
1628
|
-
async refreshAccessToken(sessionId) {
|
|
1629
|
-
const session = this.sessions.get(sessionId);
|
|
1630
|
-
if (!session || !session.refreshToken || !this.oauthProvider) {
|
|
1631
|
-
this.logger.warn('Cannot refresh token: missing session, refreshToken, or OAuth provider', {
|
|
1632
|
-
sessionId,
|
|
1633
|
-
hasSession: !!session,
|
|
1634
|
-
hasRefreshToken: !!session?.refreshToken,
|
|
1635
|
-
hasOAuthProvider: !!this.oauthProvider,
|
|
1636
|
-
});
|
|
1637
|
-
return false;
|
|
1638
|
-
}
|
|
1639
|
-
try {
|
|
1640
|
-
// Get client from OAuth provider
|
|
1641
|
-
// Try to find client by clientId stored in session, or use default client
|
|
1642
|
-
let client;
|
|
1643
|
-
if (session.oauthClientId) {
|
|
1644
|
-
await this.oauthProvider.ensureEndpointsInitialized();
|
|
1645
|
-
client = await this.oauthProvider.clientsStore.getClient(session.oauthClientId);
|
|
1646
|
-
}
|
|
1647
|
-
// Fallback to default client from config if session client not found
|
|
1648
|
-
if (!client && this.oauthProvider) {
|
|
1649
|
-
await this.oauthProvider.ensureEndpointsInitialized();
|
|
1650
|
-
// Try common client IDs
|
|
1651
|
-
const defaultClientIds = ['mcp-proxy-client'];
|
|
1652
|
-
if (this.config.oauthConfig?.client_id) {
|
|
1653
|
-
defaultClientIds.unshift(this.config.oauthConfig.client_id);
|
|
1654
|
-
}
|
|
1655
|
-
for (const clientId of defaultClientIds) {
|
|
1656
|
-
client = await this.oauthProvider.clientsStore.getClient(clientId);
|
|
1657
|
-
if (client)
|
|
1658
|
-
break;
|
|
1659
|
-
}
|
|
1660
|
-
}
|
|
1661
|
-
if (!client) {
|
|
1662
|
-
this.logger.error('Cannot refresh token: OAuth client not found', undefined, {
|
|
1663
|
-
sessionId,
|
|
1664
|
-
oauthClientId: session.oauthClientId,
|
|
1665
|
-
});
|
|
1666
|
-
return false;
|
|
1667
|
-
}
|
|
1668
|
-
// Exchange refresh token for new tokens
|
|
1669
|
-
const tokens = await this.oauthProvider.exchangeRefreshToken(client, session.refreshToken, session.scopes);
|
|
1670
|
-
// Update session with new tokens
|
|
1671
|
-
const oldAccessToken = session.authToken;
|
|
1672
|
-
session.authToken = tokens.access_token;
|
|
1673
|
-
session.refreshToken = tokens.refresh_token || session.refreshToken; // Keep old refresh token if new one not provided
|
|
1674
|
-
session.accessTokenExpiresAt = tokens.expires_in
|
|
1675
|
-
? Date.now() + tokens.expires_in * 1000
|
|
1676
|
-
: undefined;
|
|
1677
|
-
// Update token map: remove old token, add new one
|
|
1678
|
-
if (oldAccessToken) {
|
|
1679
|
-
this.oauthTokensByAccessToken.delete(oldAccessToken);
|
|
1680
|
-
}
|
|
1681
|
-
this.storeOAuthTokens(tokens, client.client_id, session.scopes || []);
|
|
1682
|
-
this.logger.info('Access token refreshed successfully', {
|
|
1683
|
-
sessionId,
|
|
1684
|
-
newExpiresAt: session.accessTokenExpiresAt ? new Date(session.accessTokenExpiresAt).toISOString() : undefined,
|
|
1685
|
-
});
|
|
1686
|
-
return true;
|
|
1687
|
-
}
|
|
1688
|
-
catch (error) {
|
|
1689
|
-
this.logger.error('Token refresh failed', error instanceof Error ? error : new Error(String(error)), {
|
|
1690
|
-
sessionId,
|
|
1691
|
-
});
|
|
1692
|
-
return false;
|
|
1693
|
-
}
|
|
1694
|
-
}
|
|
1695
|
-
/**
|
|
1696
|
-
* Set message handler for processing incoming JSON-RPC messages
|
|
1697
|
-
*/
|
|
1698
|
-
setMessageHandler(handler) {
|
|
1699
|
-
this.messageHandler = handler;
|
|
1700
|
-
}
|
|
1701
|
-
/**
|
|
1702
|
-
* Check if OAuth provider is configured
|
|
1703
|
-
*/
|
|
1704
|
-
hasOAuthProvider() {
|
|
1705
|
-
return this.oauthProvider !== null;
|
|
1706
|
-
}
|
|
1707
|
-
/**
|
|
1708
|
-
* Get server URL
|
|
1709
|
-
*/
|
|
1710
|
-
getServerUrl() {
|
|
1711
|
-
// Prefer base URL derived from OAuth redirect URI if available
|
|
1712
|
-
// This ensures consistency with the public address used for OAuth callbacks
|
|
1713
|
-
if (this.oauthProvider?.redirectUri) {
|
|
1714
|
-
try {
|
|
1715
|
-
return new URL(this.oauthProvider.redirectUri).origin;
|
|
1716
|
-
}
|
|
1717
|
-
catch (e) {
|
|
1718
|
-
// Ignore invalid URL format
|
|
1719
|
-
}
|
|
1720
|
-
}
|
|
1721
|
-
// Fallback to configured host/port
|
|
1722
|
-
// If configured with 0.0.0.0, this will return http://0.0.0.0:port
|
|
1723
|
-
// which is usually fine for internal communication but not for external clients
|
|
1724
|
-
const protocol = this.config.host.includes('://') ? '' : 'http://';
|
|
1725
|
-
const host = this.config.host.includes('://') ? this.config.host : this.config.host;
|
|
1726
|
-
return `${protocol}${host}:${this.config.port}`;
|
|
1727
|
-
}
|
|
1728
|
-
/**
|
|
1729
|
-
* Get OAuth authorization URL
|
|
1730
|
-
*/
|
|
1731
|
-
getOAuthAuthorizationUrl() {
|
|
1732
|
-
return this.oauthProvider?.authorizationEndpoint || '';
|
|
1733
|
-
}
|
|
1734
|
-
/**
|
|
1735
|
-
* Get OAuth scopes
|
|
1736
|
-
*/
|
|
1737
|
-
getOAuthScopes() {
|
|
1738
|
-
return this.oauthProvider?.scopes || [];
|
|
1739
|
-
}
|
|
1740
|
-
/**
|
|
1741
|
-
* Start HTTP server
|
|
1742
|
-
*/
|
|
1743
|
-
async start() {
|
|
1744
|
-
return new Promise((resolve, reject) => {
|
|
1745
|
-
try {
|
|
1746
|
-
// Check for SSL configuration from environment variables
|
|
1747
|
-
const sslCertFile = process.env.MCP4_SSL_CERT_FILE;
|
|
1748
|
-
const sslKeyFile = process.env.MCP4_SSL_KEY_FILE;
|
|
1749
|
-
if (sslCertFile && sslKeyFile) {
|
|
1750
|
-
// Start HTTPS server
|
|
1751
|
-
this.logger.info('SSL configuration detected, starting HTTPS server', {
|
|
1752
|
-
certFile: sslCertFile,
|
|
1753
|
-
keyFile: sslKeyFile,
|
|
1754
|
-
});
|
|
1755
|
-
try {
|
|
1756
|
-
const httpsOptions = {
|
|
1757
|
-
cert: fs.readFileSync(sslCertFile),
|
|
1758
|
-
key: fs.readFileSync(sslKeyFile),
|
|
1759
|
-
};
|
|
1760
|
-
this.server = https.createServer(httpsOptions, this.app);
|
|
1761
|
-
this.server.listen(this.config.port, this.config.host, () => {
|
|
1762
|
-
this.logger.info('HTTPS transport started', {
|
|
1763
|
-
host: this.config.host,
|
|
1764
|
-
port: this.config.port,
|
|
1765
|
-
heartbeat: this.config.heartbeatEnabled,
|
|
1766
|
-
metrics: this.config.metricsEnabled,
|
|
1767
|
-
});
|
|
1768
|
-
// Start session cleanup interval
|
|
1769
|
-
this.cleanupInterval = setInterval(() => this.cleanupExpiredSessions(), TIMEOUTS.CLEANUP_INTERVAL_MS);
|
|
1770
|
-
resolve();
|
|
1771
|
-
});
|
|
1772
|
-
}
|
|
1773
|
-
catch (sslError) {
|
|
1774
|
-
this.logger.error('Failed to start HTTPS server', sslError instanceof Error ? sslError : new Error(String(sslError)));
|
|
1775
|
-
reject(sslError);
|
|
1776
|
-
return;
|
|
1777
|
-
}
|
|
1778
|
-
}
|
|
1779
|
-
else {
|
|
1780
|
-
// Start HTTP server
|
|
1781
|
-
this.server = this.app.listen(this.config.port, this.config.host, () => {
|
|
1782
|
-
this.logger.info('HTTP transport started', {
|
|
1783
|
-
host: this.config.host,
|
|
1784
|
-
port: this.config.port,
|
|
1785
|
-
heartbeat: this.config.heartbeatEnabled,
|
|
1786
|
-
metrics: this.config.metricsEnabled,
|
|
1787
|
-
});
|
|
1788
|
-
// Start session cleanup interval
|
|
1789
|
-
this.cleanupInterval = setInterval(() => this.cleanupExpiredSessions(), TIMEOUTS.CLEANUP_INTERVAL_MS);
|
|
1790
|
-
resolve();
|
|
1791
|
-
});
|
|
1792
|
-
}
|
|
1793
|
-
this.server.on('error', reject);
|
|
1794
|
-
}
|
|
1795
|
-
catch (error) {
|
|
1796
|
-
reject(error);
|
|
1797
|
-
}
|
|
1798
|
-
});
|
|
1799
|
-
}
|
|
1800
|
-
/**
|
|
1801
|
-
* Stop HTTP server
|
|
1802
|
-
*/
|
|
1803
|
-
async stop() {
|
|
1804
|
-
if (this.cleanupInterval) {
|
|
1805
|
-
clearInterval(this.cleanupInterval);
|
|
1806
|
-
this.cleanupInterval = null;
|
|
1807
|
-
}
|
|
1808
|
-
// Destroy all sessions
|
|
1809
|
-
for (const sessionId of this.sessions.keys()) {
|
|
1810
|
-
this.destroySession(sessionId);
|
|
1811
|
-
}
|
|
1812
|
-
if (this.server) {
|
|
1813
|
-
return new Promise((resolve, reject) => {
|
|
1814
|
-
this.server.close((err) => {
|
|
1815
|
-
if (err)
|
|
1816
|
-
reject(err);
|
|
1817
|
-
else {
|
|
1818
|
-
this.logger.info('HTTP transport stopped');
|
|
1819
|
-
resolve();
|
|
1820
|
-
}
|
|
1821
|
-
});
|
|
1822
|
-
});
|
|
1823
|
-
}
|
|
1824
|
-
}
|
|
1825
|
-
}
|
|
1826
|
-
//# sourceMappingURL=http-transport.js.map
|