config.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247
  1. from dataclasses import dataclass
  2. import os
  3. @dataclass
  4. class GatewayConfig:
  5. auth_base_url: str
  6. tools_base_url: str
  7. client_type: str = 'workbuddy'
  8. timeout_seconds: int = 10
  9. refresh_skew_seconds: int = 120
  10. log_level: str = 'info'
  11. token_store_path: str = '.mcp_token.json'
  12. token_store_type: str = 'file'
  13. redis_host: str = '127.0.0.1'
  14. redis_port: int = 6379
  15. redis_db: int = 0
  16. redis_password: str = ''
  17. redis_prefix: str = 'fms:mcp:workbuddy:'
  18. session_key: str = ''
  19. gateway_mode: str = 'local'
  20. gateway_session_ttl_seconds: int = 2592000
  21. rate_limit_enabled: bool = True
  22. rate_limit_max_requests: int = 60
  23. rate_limit_window_seconds: int = 60
  24. max_in_flight_per_tool: int = 2
  25. diagnosis_enabled: bool = False
  26. diagnosis_url: str = ''
  27. diagnosis_key_id: str = ''
  28. diagnosis_secret: str = ''
  29. diagnosis_allow_insecure_http: bool = False
  30. diagnosis_queue_size: int = 1000
  31. diagnosis_batch_size: int = 100
  32. diagnosis_timeout_seconds: float = 0.5
  33. diagnosis_initial_backoff_seconds: float = 0.25
  34. diagnosis_max_backoff_seconds: float = 5.0
  35. @classmethod
  36. def from_env(cls, env=None, dotenv_path=''):
  37. primary_env = os.environ if env is None else env
  38. dotenv_path = str(dotenv_path or '').strip() or os.path.join(os.path.dirname(__file__), '.env')
  39. dotenv_env = cls._load_dotenv(dotenv_path)
  40. dotenv_shared_base = (dotenv_env.get('FMS_API_BASE') or '').rstrip('/')
  41. env_shared_base = (primary_env.get('FMS_API_BASE') or '').rstrip('/')
  42. auth_base_url = cls._resolve_base_url(
  43. dotenv_env,
  44. primary_env,
  45. dotenv_shared_base,
  46. env_shared_base,
  47. 'MCP_AUTH_BASE_URL',
  48. 'FMS_AUTH_BASE',
  49. )
  50. tools_base_url = cls._resolve_base_url(
  51. dotenv_env,
  52. primary_env,
  53. dotenv_shared_base,
  54. env_shared_base,
  55. 'MCP_TOOLS_BASE_URL',
  56. 'FMS_TOOLS_BASE',
  57. )
  58. timeout_seconds = cls._resolve_timeout_seconds(dotenv_env, primary_env)
  59. session_key = cls._pick(dotenv_env, primary_env, 'MCP_SESSION_KEY', 'FMS_SESSION_KEY')
  60. gateway_mode = (cls._pick(dotenv_env, primary_env, 'FMS_GATEWAY_MODE', 'MCP_GATEWAY_MODE') or 'local').lower()
  61. redis_prefix = cls._pick(dotenv_env, primary_env, 'MCP_REDIS_PREFIX', 'FMS_REDIS_PREFIX')
  62. if not redis_prefix:
  63. redis_prefix = 'fms:mcp:gateway:' if gateway_mode == 'public' else 'fms:mcp:workbuddy:'
  64. return cls(
  65. auth_base_url=auth_base_url,
  66. tools_base_url=tools_base_url,
  67. client_type=cls._pick(dotenv_env, primary_env, 'MCP_CLIENT_TYPE', 'FMS_CLIENT_TYPE') or 'workbuddy',
  68. timeout_seconds=timeout_seconds,
  69. refresh_skew_seconds=int(cls._pick(dotenv_env, primary_env, 'MCP_REFRESH_SKEW_SECONDS', 'FMS_REFRESH_SKEW_SECONDS') or '120'),
  70. log_level=(cls._pick(dotenv_env, primary_env, 'MCP_LOG_LEVEL', 'FMS_LOG_LEVEL') or 'info').lower(),
  71. token_store_path=cls._pick(dotenv_env, primary_env, 'MCP_TOKEN_STORE_PATH', 'FMS_TOKEN_STORE_PATH') or '.mcp_token.json',
  72. token_store_type=(cls._pick(dotenv_env, primary_env, 'MCP_TOKEN_STORE', 'FMS_TOKEN_STORE') or 'file').lower(),
  73. redis_host=cls._pick(dotenv_env, primary_env, 'MCP_REDIS_HOST', 'FMS_REDIS_HOST') or '127.0.0.1',
  74. redis_port=int(cls._pick(dotenv_env, primary_env, 'MCP_REDIS_PORT', 'FMS_REDIS_PORT') or '6379'),
  75. redis_db=int(cls._pick(dotenv_env, primary_env, 'MCP_REDIS_DB', 'FMS_REDIS_DB') or '0'),
  76. redis_password=cls._pick(dotenv_env, primary_env, 'MCP_REDIS_PASSWORD', 'FMS_REDIS_PASSWORD') or '',
  77. redis_prefix=redis_prefix,
  78. session_key=session_key or cls._build_default_session_key(primary_env),
  79. gateway_mode=gateway_mode,
  80. gateway_session_ttl_seconds=int(cls._pick(dotenv_env, primary_env, 'FMS_GATEWAY_SESSION_TTL_SECONDS', 'MCP_GATEWAY_SESSION_TTL_SECONDS') or '2592000'),
  81. rate_limit_enabled=cls._parse_bool(cls._pick(dotenv_env, primary_env, 'FMS_RATE_LIMIT_ENABLED', 'MCP_RATE_LIMIT_ENABLED'), default=True),
  82. rate_limit_max_requests=cls._parse_int(cls._pick(dotenv_env, primary_env, 'FMS_RATE_LIMIT_MAX_REQUESTS', 'MCP_RATE_LIMIT_MAX_REQUESTS'), default=60),
  83. rate_limit_window_seconds=cls._parse_int(cls._pick(dotenv_env, primary_env, 'FMS_RATE_LIMIT_WINDOW_SECONDS', 'MCP_RATE_LIMIT_WINDOW_SECONDS'), default=60),
  84. max_in_flight_per_tool=cls._parse_int(cls._pick(dotenv_env, primary_env, 'FMS_MAX_IN_FLIGHT_PER_TOOL', 'MCP_MAX_IN_FLIGHT_PER_TOOL'), default=2),
  85. diagnosis_enabled=cls._parse_bool(
  86. cls._pick(dotenv_env, primary_env, 'MCP_DIAGNOSIS_ENABLED'),
  87. default=False,
  88. ),
  89. diagnosis_url=cls._pick(
  90. dotenv_env, primary_env, 'MCP_DIAGNOSIS_URL'
  91. ).rstrip('/'),
  92. diagnosis_key_id=cls._pick(
  93. dotenv_env, primary_env, 'MCP_DIAGNOSIS_KEY_ID'
  94. ),
  95. diagnosis_secret=cls._pick(
  96. dotenv_env, primary_env, 'MCP_DIAGNOSIS_SECRET'
  97. ),
  98. diagnosis_allow_insecure_http=cls._parse_bool(
  99. cls._pick(
  100. dotenv_env,
  101. primary_env,
  102. 'MCP_DIAGNOSIS_ALLOW_INSECURE_HTTP',
  103. ),
  104. default=False,
  105. ),
  106. diagnosis_queue_size=cls._parse_int(
  107. cls._pick(dotenv_env, primary_env, 'MCP_DIAGNOSIS_QUEUE_SIZE'),
  108. default=1000,
  109. ),
  110. diagnosis_batch_size=cls._parse_int(
  111. cls._pick(dotenv_env, primary_env, 'MCP_DIAGNOSIS_BATCH_SIZE'),
  112. default=100,
  113. ),
  114. diagnosis_timeout_seconds=cls._parse_float(
  115. cls._pick(dotenv_env, primary_env, 'MCP_DIAGNOSIS_TIMEOUT_SECONDS'),
  116. default=0.5,
  117. ),
  118. diagnosis_initial_backoff_seconds=cls._parse_float(
  119. cls._pick(
  120. dotenv_env,
  121. primary_env,
  122. 'MCP_DIAGNOSIS_INITIAL_BACKOFF_SECONDS',
  123. ),
  124. default=0.25,
  125. ),
  126. diagnosis_max_backoff_seconds=cls._parse_float(
  127. cls._pick(
  128. dotenv_env,
  129. primary_env,
  130. 'MCP_DIAGNOSIS_MAX_BACKOFF_SECONDS',
  131. ),
  132. default=5.0,
  133. ),
  134. )
  135. @staticmethod
  136. def _resolve_base_url(preferred_env, fallback_env, preferred_shared_base, fallback_shared_base, *specific_keys):
  137. for key in specific_keys:
  138. value = preferred_env.get(key)
  139. if value:
  140. return value.rstrip('/')
  141. if preferred_shared_base:
  142. return preferred_shared_base
  143. for key in specific_keys:
  144. value = fallback_env.get(key)
  145. if value:
  146. return value.rstrip('/')
  147. return fallback_shared_base
  148. @staticmethod
  149. def _resolve_timeout_seconds(preferred_env, fallback_env):
  150. if preferred_env.get('MCP_TIMEOUT_SECONDS'):
  151. return int(preferred_env['MCP_TIMEOUT_SECONDS'])
  152. if preferred_env.get('FMS_TIMEOUT_SECONDS'):
  153. return int(preferred_env['FMS_TIMEOUT_SECONDS'])
  154. if preferred_env.get('FMS_TIMEOUT_MS'):
  155. return max(1, int(preferred_env['FMS_TIMEOUT_MS']) // 1000)
  156. if fallback_env.get('MCP_TIMEOUT_SECONDS'):
  157. return int(fallback_env['MCP_TIMEOUT_SECONDS'])
  158. if fallback_env.get('FMS_TIMEOUT_SECONDS'):
  159. return int(fallback_env['FMS_TIMEOUT_SECONDS'])
  160. if fallback_env.get('FMS_TIMEOUT_MS'):
  161. return max(1, int(fallback_env['FMS_TIMEOUT_MS']) // 1000)
  162. return 10
  163. @staticmethod
  164. def _pick(preferred_env, fallback_env, *keys):
  165. for key in keys:
  166. value = preferred_env.get(key)
  167. if value:
  168. return value
  169. for key in keys:
  170. value = fallback_env.get(key)
  171. if value:
  172. return value
  173. return ''
  174. @staticmethod
  175. def _strip_comment(value):
  176. """Strip inline comment from a raw env value (space+# pattern)."""
  177. pos = str(value or '').find(' #')
  178. return value[:pos].strip() if pos >= 0 else value
  179. @staticmethod
  180. def _parse_int(raw, default):
  181. """Parse int from env value, tolerating inline comments from OS env vars."""
  182. value = GatewayConfig._strip_comment(str(raw or '').strip())
  183. if not value:
  184. return default
  185. try:
  186. return int(value)
  187. except ValueError:
  188. return default
  189. @staticmethod
  190. def _parse_bool(raw, default=True):
  191. """Parse bool from env value; empty / unset → default."""
  192. value = GatewayConfig._strip_comment(str(raw or '').strip()).lower()
  193. if not value:
  194. return default
  195. return value not in ('0', 'false', 'no', 'off')
  196. @staticmethod
  197. def _parse_float(raw, default):
  198. value = GatewayConfig._strip_comment(str(raw or '').strip())
  199. if not value:
  200. return default
  201. try:
  202. return float(value)
  203. except ValueError:
  204. return default
  205. @staticmethod
  206. def _build_default_session_key(env):
  207. computer = env.get('COMPUTERNAME') or env.get('HOSTNAME') or os.environ.get('COMPUTERNAME') or os.environ.get('HOSTNAME') or 'unknown-computer'
  208. user = env.get('USERNAME') or env.get('USER') or os.environ.get('USERNAME') or os.environ.get('USER') or 'unknown-user'
  209. domain = env.get('USERDOMAIN') or os.environ.get('USERDOMAIN') or ''
  210. parts = [domain, computer, user] if domain else [computer, user]
  211. return ':'.join(str(part).strip().replace(' ', '_') for part in parts if str(part).strip())
  212. @staticmethod
  213. def _load_dotenv(dotenv_path):
  214. data = {}
  215. if not dotenv_path or not os.path.exists(dotenv_path):
  216. return data
  217. with open(dotenv_path, 'r', encoding='utf-8') as file:
  218. for raw_line in file:
  219. line = raw_line.strip()
  220. if not line or line.startswith('#') or '=' not in line:
  221. continue
  222. key, value = line.split('=', 1)
  223. key = key.strip()
  224. value = value.strip()
  225. # Strip inline comments for unquoted values (e.g. KEY=123 # comment)
  226. if value and value[0] not in ('"', "'"):
  227. comment_pos = value.find(' #')
  228. if comment_pos >= 0:
  229. value = value[:comment_pos].strip()
  230. else:
  231. value = value.strip('"').strip("'")
  232. if key:
  233. data[key] = value
  234. return data