runtime.py 3.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111
  1. """Fail-closed stdio bootstrap for DataOps MCP servers."""
  2. from __future__ import annotations
  3. import argparse
  4. import importlib
  5. import os
  6. import re
  7. import uuid
  8. from collections.abc import Mapping
  9. from app.core.mcp.identity import AgentIdentity
  10. from app.core.mcp.servers import create_context_mcp, create_scheduling_mcp
  11. ALLOWED_ROLES = {"viewer", "editor", "scheduler", "admin"}
  12. TOKEN_PATTERN = re.compile(r"^[A-Za-z0-9_.:*@-]{1,200}$")
  13. def _required(environ, name):
  14. value = str(environ.get(name) or "").strip()
  15. if not value:
  16. raise ValueError(f"{name} is required")
  17. return value
  18. def _bounded_set(environ, name, *, allowed=None):
  19. values = {
  20. item.strip() for item in _required(environ, name).split(",") if item.strip()
  21. }
  22. if not values or len(values) > 50:
  23. raise ValueError(f"{name} must contain between 1 and 50 values")
  24. if any(not TOKEN_PATTERN.fullmatch(value) for value in values):
  25. raise ValueError(f"{name} contains an invalid value")
  26. if allowed is not None and not values <= allowed:
  27. raise ValueError(f"{name} contains an unsupported role")
  28. return frozenset(values)
  29. def identity_from_env(environ: Mapping[str, str] | None = None):
  30. source = environ if environ is not None else os.environ
  31. subject = _required(source, "DATAOPS_MCP_SUBJECT")
  32. if not TOKEN_PATTERN.fullmatch(subject):
  33. raise ValueError("DATAOPS_MCP_SUBJECT contains an invalid value")
  34. correlation_id = _required(source, "DATAOPS_MCP_CORRELATION_ID")
  35. try:
  36. correlation_id = str(uuid.UUID(correlation_id))
  37. except ValueError as exc:
  38. raise ValueError("DATAOPS_MCP_CORRELATION_ID must be a UUID") from exc
  39. return AgentIdentity(
  40. subject=subject,
  41. roles=_bounded_set(source, "DATAOPS_MCP_ROLES", allowed=ALLOWED_ROLES),
  42. business_domains=_bounded_set(source, "DATAOPS_MCP_BUSINESS_DOMAINS"),
  43. environments=_bounded_set(source, "DATAOPS_MCP_ENVIRONMENTS"),
  44. correlation_id=correlation_id,
  45. )
  46. def load_factory(path, *, allow_test_factory=False):
  47. if not isinstance(path, str) or ":" not in path:
  48. raise ValueError("MCP factory must use module:function syntax")
  49. module_name, function_name = path.rsplit(":", 1)
  50. allowed_prefixes = ("app.",)
  51. if allow_test_factory:
  52. allowed_prefixes += ("tests.mcp.",)
  53. if not module_name.startswith(allowed_prefixes):
  54. raise ValueError("MCP factory module is outside the allowlist")
  55. if not function_name.isidentifier():
  56. raise ValueError("MCP factory function is invalid")
  57. module = importlib.import_module(module_name)
  58. factory = getattr(module, function_name, None)
  59. if not callable(factory):
  60. raise ValueError("MCP factory is not callable")
  61. return factory
  62. def build_server(
  63. *,
  64. kind,
  65. factory_path,
  66. environ=None,
  67. allow_test_factory=False,
  68. ):
  69. identity = identity_from_env(environ)
  70. service = load_factory(factory_path, allow_test_factory=allow_test_factory)()
  71. if kind == "context":
  72. return create_context_mcp(service, identity=identity)
  73. if kind == "scheduling":
  74. return create_scheduling_mcp(service, identity=identity)
  75. raise ValueError("MCP kind must be context or scheduling")
  76. def main(argv=None):
  77. parser = argparse.ArgumentParser(description="Run a DataOps MCP server")
  78. parser.add_argument("--kind", choices=("context", "scheduling"), required=True)
  79. parser.add_argument(
  80. "--factory",
  81. default=os.getenv("DATAOPS_MCP_FACTORY"),
  82. required=os.getenv("DATAOPS_MCP_FACTORY") is None,
  83. )
  84. parser.add_argument(
  85. "--allow-test-factory",
  86. action="store_true",
  87. help=argparse.SUPPRESS,
  88. )
  89. arguments = parser.parse_args(argv)
  90. server = build_server(
  91. kind=arguments.kind,
  92. factory_path=arguments.factory,
  93. allow_test_factory=arguments.allow_test_factory,
  94. )
  95. server.run(transport="stdio")