multi-agent-platform 0.1.0__py3-none-any.whl

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.
Files changed (144) hide show
  1. cli/__init__.py +0 -0
  2. cli/action_item_escalation.py +177 -0
  3. cli/agent_client.py +554 -0
  4. cli/bridge_state.py +43 -0
  5. cli/commands/__init__.py +13 -0
  6. cli/commands/action.py +142 -0
  7. cli/commands/agent.py +117 -0
  8. cli/commands/audit.py +68 -0
  9. cli/commands/docs.py +179 -0
  10. cli/commands/experiment.py +755 -0
  11. cli/commands/feedback.py +106 -0
  12. cli/commands/notification.py +213 -0
  13. cli/commands/persona.py +63 -0
  14. cli/commands/project.py +87 -0
  15. cli/commands/runtime.py +105 -0
  16. cli/commands/topic.py +361 -0
  17. cli/e2e_collab.py +602 -0
  18. cli/git_checkpoint.py +68 -0
  19. cli/host_worker_types.py +151 -0
  20. cli/main.py +1553 -0
  21. cli/map_command_client.py +497 -0
  22. cli/participant_worker.py +255 -0
  23. cli/reviewer_worker.py +263 -0
  24. cli/runtime/__init__.py +5 -0
  25. cli/runtime/run_lock.py +497 -0
  26. cli/runtime_chat.py +317 -0
  27. cli/session_wake_log.py +235 -0
  28. cli/simple_waker.py +950 -0
  29. cli/table_render.py +113 -0
  30. cli/wake_backend.py +236 -0
  31. cli/worker_cycle_log.py +36 -0
  32. map_client/__init__.py +37 -0
  33. map_client/bootstrap.py +193 -0
  34. map_client/client.py +1045 -0
  35. map_client/config.py +21 -0
  36. map_client/errors.py +283 -0
  37. map_client/exceptions.py +130 -0
  38. map_client/plan_evidence.py +159 -0
  39. map_client/project_config.py +153 -0
  40. map_client/result_template.py +167 -0
  41. map_client/testing.py +27 -0
  42. map_mcp/__init__.py +4 -0
  43. map_mcp/_utils.py +28 -0
  44. map_mcp/auth.py +34 -0
  45. map_mcp/config.py +50 -0
  46. map_mcp/context.py +39 -0
  47. map_mcp/main.py +75 -0
  48. map_mcp/server.py +573 -0
  49. map_mcp/session.py +79 -0
  50. map_sdk/__init__.py +29 -0
  51. map_sdk/evidence.py +68 -0
  52. map_types/__init__.py +203 -0
  53. map_types/enums.py +199 -0
  54. map_types/schemas.py +1351 -0
  55. multi_agent_platform-0.1.0.dist-info/METADATA +298 -0
  56. multi_agent_platform-0.1.0.dist-info/RECORD +144 -0
  57. multi_agent_platform-0.1.0.dist-info/WHEEL +5 -0
  58. multi_agent_platform-0.1.0.dist-info/entry_points.txt +6 -0
  59. multi_agent_platform-0.1.0.dist-info/licenses/LICENSE +21 -0
  60. multi_agent_platform-0.1.0.dist-info/top_level.txt +6 -0
  61. server/__init__.py +0 -0
  62. server/__version__.py +14 -0
  63. server/api/__init__.py +0 -0
  64. server/api/action_items.py +138 -0
  65. server/api/agents.py +412 -0
  66. server/api/audit.py +54 -0
  67. server/api/background_tasks.py +18 -0
  68. server/api/common.py +117 -0
  69. server/api/deps.py +30 -0
  70. server/api/experiments.py +858 -0
  71. server/api/feedback.py +75 -0
  72. server/api/notifications.py +22 -0
  73. server/api/projects.py +209 -0
  74. server/api/router.py +25 -0
  75. server/api/status.py +33 -0
  76. server/api/topics.py +302 -0
  77. server/api/webhooks.py +74 -0
  78. server/auth/__init__.py +8 -0
  79. server/auth/experiment_access.py +66 -0
  80. server/config.py +38 -0
  81. server/db/__init__.py +3 -0
  82. server/db/base.py +5 -0
  83. server/db/deadlock_retry.py +146 -0
  84. server/db/session.py +41 -0
  85. server/domain/__init__.py +3 -0
  86. server/domain/encrypted_types.py +63 -0
  87. server/domain/models.py +713 -0
  88. server/domain/schemas.py +3 -0
  89. server/domain/state_machine.py +79 -0
  90. server/domain/topic_ack_constants.py +9 -0
  91. server/main.py +148 -0
  92. server/scripts/__init__.py +0 -0
  93. server/scripts/migrate_notification_unique.py +231 -0
  94. server/scripts/purge_audit_pollution.py +116 -0
  95. server/services/__init__.py +0 -0
  96. server/services/_lookups.py +26 -0
  97. server/services/acceptance_service.py +90 -0
  98. server/services/action_item_migration_service.py +190 -0
  99. server/services/action_item_service.py +200 -0
  100. server/services/agent_work_service.py +405 -0
  101. server/services/archive_lint_service.py +156 -0
  102. server/services/audit_service.py +457 -0
  103. server/services/auth.py +66 -0
  104. server/services/comment_service.py +173 -0
  105. server/services/errors.py +65 -0
  106. server/services/escalation_resolver.py +248 -0
  107. server/services/evidence_service.py +88 -0
  108. server/services/experiment_capabilities_service.py +277 -0
  109. server/services/inbound_event_service.py +111 -0
  110. server/services/lock_service.py +273 -0
  111. server/services/log_service.py +202 -0
  112. server/services/mention_service.py +730 -0
  113. server/services/notification_service.py +939 -0
  114. server/services/notification_stream.py +138 -0
  115. server/services/permissions.py +147 -0
  116. server/services/persona_activity_service.py +108 -0
  117. server/services/phase_owner_resolver.py +95 -0
  118. server/services/phase_service.py +381 -0
  119. server/services/plan_marker_service.py +235 -0
  120. server/services/plan_service.py +186 -0
  121. server/services/platform_feedback_service.py +114 -0
  122. server/services/project_service.py +534 -0
  123. server/services/project_status_service.py +132 -0
  124. server/services/review_service.py +707 -0
  125. server/services/secret_encryption.py +97 -0
  126. server/services/similarity_service.py +119 -0
  127. server/services/sse_event_schemas.py +17 -0
  128. server/services/status_service.py +68 -0
  129. server/services/template_service.py +134 -0
  130. server/services/text_utils.py +19 -0
  131. server/services/thread_activity.py +180 -0
  132. server/services/todo_persona_filter.py +73 -0
  133. server/services/todo_service.py +604 -0
  134. server/services/topic_ack_service.py +312 -0
  135. server/services/topic_action_item_ops.py +538 -0
  136. server/services/topic_comment_kind.py +14 -0
  137. server/services/topic_comment_service.py +237 -0
  138. server/services/topic_helpers.py +32 -0
  139. server/services/topic_lifecycle_service.py +478 -0
  140. server/services/topic_progress_service.py +40 -0
  141. server/services/topic_resolve_service.py +234 -0
  142. server/services/topic_service.py +102 -0
  143. server/services/topic_work_item_service.py +570 -0
  144. server/services/webhook_service.py +273 -0
@@ -0,0 +1,153 @@
1
+ """Project-local MAP identity: `.map/` config + persona tokens."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import yaml
11
+
12
+ from map_client.client import MAPClient
13
+
14
+ MAP_DIR_NAME = ".map"
15
+ CONFIG_FILE = "config.yaml"
16
+ AGENTS_FILE = "agents.yaml"
17
+ AGENTS_LOCAL_FILE = "agents.local.yaml"
18
+ DEFAULT_PERSONA = "host"
19
+
20
+ BOOTSTRAP_HINT = (
21
+ "Run:\n"
22
+ " map bootstrap --key <project-key> --name \"<Project Name>\" --api-url http://localhost:8001\n"
23
+ f"Or copy from {MAP_DIR_NAME}/config.yaml.example."
24
+ )
25
+
26
+
27
+ def missing_map_config_message() -> str:
28
+ return f"No {MAP_DIR_NAME}/{CONFIG_FILE} found. {BOOTSTRAP_HINT}"
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class PersonaInfo:
33
+ name: str
34
+ agent_name: str
35
+ description: str | None = None
36
+ role: str | None = None
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class ProjectMapConfig:
41
+ map_dir: Path
42
+ api_url: str
43
+ project_key: str
44
+ project_id: str | None
45
+ default_persona: str
46
+ personas: dict[str, PersonaInfo]
47
+ tokens: dict[str, str]
48
+
49
+ def token_for(self, persona: str) -> str:
50
+ if persona not in self.tokens:
51
+ known = ", ".join(sorted(self.tokens)) or "(none)"
52
+ raise ValueError(f"Unknown or missing token for persona '{persona}'. Known: {known}")
53
+ return self.tokens[persona]
54
+
55
+ def client_for(self, persona: str | None = None, *, transport: Any = None) -> MAPClient:
56
+ effective = persona or self.default_persona
57
+ return MAPClient(self.api_url, self.token_for(effective), transport=transport)
58
+
59
+
60
+ def find_map_dir(start: Path | None = None) -> Path | None:
61
+ current = (start or Path.cwd()).resolve()
62
+ for path in [current, *current.parents]:
63
+ map_dir = path / MAP_DIR_NAME
64
+ if (map_dir / CONFIG_FILE).is_file():
65
+ return map_dir
66
+ return None
67
+
68
+
69
+ def _read_yaml(path: Path) -> dict[str, Any]:
70
+ if not path.is_file():
71
+ return {}
72
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
73
+ return data if isinstance(data, dict) else {}
74
+
75
+
76
+ def load_project_map_config(
77
+ project_root: Path | None = None,
78
+ *,
79
+ map_dir: Path | None = None,
80
+ ) -> ProjectMapConfig:
81
+ resolved_map_dir = map_dir or find_map_dir(project_root)
82
+ if resolved_map_dir is None:
83
+ raise ValueError(missing_map_config_message())
84
+
85
+ config = _read_yaml(resolved_map_dir / CONFIG_FILE)
86
+ agents_meta = _read_yaml(resolved_map_dir / AGENTS_FILE)
87
+ agents_local = _read_yaml(resolved_map_dir / AGENTS_LOCAL_FILE)
88
+
89
+ api_url = str(
90
+ config.get("api_url")
91
+ or os.environ.get("MAP_API_URL")
92
+ or "http://localhost:8000"
93
+ ).rstrip("/")
94
+ project_key = config.get("project_key")
95
+ if not project_key:
96
+ raise ValueError(f"{resolved_map_dir / CONFIG_FILE} must set project_key")
97
+
98
+ default_persona = str(config.get("default_persona") or DEFAULT_PERSONA)
99
+ raw_personas = agents_meta.get("personas") or {}
100
+ personas: dict[str, PersonaInfo] = {}
101
+ for key, value in raw_personas.items():
102
+ if not isinstance(value, dict):
103
+ continue
104
+ agent_name = value.get("agent_name")
105
+ if not agent_name:
106
+ raise ValueError(f"personas.{key}.agent_name is required in agents.yaml")
107
+ personas[key] = PersonaInfo(
108
+ name=key,
109
+ agent_name=str(agent_name),
110
+ description=value.get("description"),
111
+ role=value.get("role"),
112
+ )
113
+
114
+ raw_tokens = agents_local.get("personas") or {}
115
+ tokens: dict[str, str] = {}
116
+ for key, value in raw_tokens.items():
117
+ if isinstance(value, dict) and value.get("token"):
118
+ tokens[key] = str(value["token"])
119
+ elif isinstance(value, str) and value:
120
+ tokens[key] = value
121
+
122
+ return ProjectMapConfig(
123
+ map_dir=resolved_map_dir,
124
+ api_url=api_url,
125
+ project_key=str(project_key),
126
+ project_id=str(config["project_id"]) if config.get("project_id") else None,
127
+ default_persona=default_persona,
128
+ personas=personas,
129
+ tokens=tokens,
130
+ )
131
+
132
+
133
+ def resolve_client(
134
+ *,
135
+ persona: str | None = None,
136
+ project_root: Path | None = None,
137
+ transport: Any = None,
138
+ ) -> MAPClient:
139
+ """Prefer project `.map/` persona; fall back to MAP_TOKEN / ~/.map/config.yaml."""
140
+ map_dir = find_map_dir(project_root)
141
+ if persona is not None or map_dir is not None:
142
+ cfg = load_project_map_config(project_root=project_root, map_dir=map_dir)
143
+ return cfg.client_for(persona, transport=transport)
144
+
145
+ from map_client.config import load_config
146
+
147
+ env_cfg = load_config()
148
+ if not env_cfg.get("token"):
149
+ raise ValueError(
150
+ f"No MAP credentials: add .map/agents.local.yaml ({BOOTSTRAP_HINT.strip()}) "
151
+ "or set MAP_TOKEN / ~/.map/config.yaml"
152
+ )
153
+ return MAPClient(env_cfg["api_url"], env_cfg["token"], transport=transport)
@@ -0,0 +1,167 @@
1
+ """b72d0542 I1.a — Result submission 4-段 template parser.
2
+
3
+ Extracts 4 required sections from a result submission markdown body so the
4
+ service layer can soft-warn on missing or malformed sections. Soft
5
+ validation only — host is expected to follow up; the validator never
6
+ blocks ``experiment complete``.
7
+
8
+ The 4 required sections (markdown ``##`` H2 headings; case-insensitive
9
+ substring match against the section name):
10
+
11
+ * ``## summary`` — one-line result summary
12
+ * ``## 实施 log`` — running logs, must contain at least one markdown link
13
+ * ``## 风险`` — risks section
14
+ * ``## acceptance`` — acceptance criteria checklist
15
+
16
+ Behavior contract (pinned by ``docs/MAP-RESULT-SUBMISSION-TEMPLATE.md``):
17
+
18
+ * No content (empty/None) → empty result (nothing to validate).
19
+ * All 4 sections present + each with valid markdown link (where required)
20
+ → empty warnings list.
21
+ * Missing any of the 4 sections → ``MISSING_TEMPLATE_SECTION`` warning.
22
+ * ``## 实施 log`` section present but no ``[text](url)`` markdown link
23
+ → ``NO_LINK_IN_LOG_SECTION`` warning.
24
+ * ``## 实施 log`` section has a malformed link (e.g. ``[text`` without
25
+ closing ``]`` or ``(`` without ``)``) → ``MALFORMED_MARKDOWN_LINK``
26
+ warning with the offending link text.
27
+
28
+ Parsing helpers live here; service-level validation (warn codes,
29
+ dataclass wrapper, valid=True invariant) lives in
30
+ ``server/services/template_service.py``.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import re
36
+ from dataclasses import dataclass
37
+
38
+ # 4 required section names (substring, lowercase compare).
39
+ REQUIRED_SECTION_NAMES: tuple[str, ...] = (
40
+ "summary",
41
+ "实施 log",
42
+ "风险",
43
+ "acceptance",
44
+ )
45
+
46
+ # Match a ``## <heading>`` line. Section names may include spaces / CJK.
47
+ _SECTION_HEADING_RE = re.compile(r"^##\s+(?P<name>.+?)\s*$", re.MULTILINE)
48
+
49
+ # Match ``[text](url)`` markdown link. Greedy on text/url up to 1/2 parens
50
+ # balanced; this is best-effort (markdown spec is more permissive than
51
+ # this regex). Captures: 1=text, 2=url.
52
+ _MARKDOWN_LINK_RE = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
53
+
54
+ # Match an unbalanced markdown-link-like fragment: ``[text`` without ``]``
55
+ # or ``(url`` without ``)``. Used to surface malformed links as warnings.
56
+ _UNBALANCED_OPEN_BRACKET_RE = re.compile(r"\[[^\]\n]*$", re.MULTILINE)
57
+ _UNBALANCED_OPEN_PAREN_RE = re.compile(r"\([^)\n]*$", re.MULTILINE)
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class ResultTemplateSections:
62
+ """Parsed sections of a result submission markdown body.
63
+
64
+ Each ``<section>_present`` is True iff a heading matching the
65
+ canonical name appears anywhere in the body. ``log_links`` is the
66
+ list of (text, url) tuples extracted from inside the ``## 实施 log``
67
+ section body (empty if section missing).
68
+ """
69
+
70
+ summary_present: bool
71
+ log_present: bool
72
+ risk_present: bool
73
+ acceptance_present: bool
74
+ log_links: tuple[tuple[str, str], ...] = ()
75
+
76
+ @property
77
+ def present(self) -> dict[str, bool]:
78
+ return {
79
+ "summary": self.summary_present,
80
+ "实施 log": self.log_present,
81
+ "风险": self.risk_present,
82
+ "acceptance": self.acceptance_present,
83
+ }
84
+
85
+
86
+ def _extract_section_names(content: str) -> list[str]:
87
+ """Return the section names (text after ``##``) in document order."""
88
+ return [m.group("name").strip() for m in _SECTION_HEADING_RE.finditer(content)]
89
+
90
+
91
+ def _slice_section_body(content: str, section_name: str) -> str | None:
92
+ """Return the body of the named ``##`` section (text after the heading
93
+ line until the next ``##`` heading or end of document), or None if
94
+ the section is not found.
95
+ """
96
+ headings = list(_SECTION_HEADING_RE.finditer(content))
97
+ target_idx: int | None = None
98
+ target_match_pos: int = -1
99
+ for idx, m in enumerate(headings):
100
+ if m.group("name").strip().lower() == section_name.lower():
101
+ target_idx = idx
102
+ target_match_pos = m.end()
103
+ break
104
+ if target_idx is None:
105
+ return None
106
+ if target_idx + 1 < len(headings):
107
+ return content[target_match_pos : headings[target_idx + 1].start()]
108
+ return content[target_match_pos:]
109
+
110
+
111
+ def parse_result_submission(content: str | None) -> ResultTemplateSections:
112
+ """Parse a result submission markdown body.
113
+
114
+ Returns a :class:`ResultTemplateSections` summarising which of the 4
115
+ required sections are present and the markdown links found inside
116
+ the ``## 实施 log`` section.
117
+
118
+ Section detection is case-insensitive substring match (canonical
119
+ names compared via ``str.lower()``). The 4 canonical names are:
120
+
121
+ * ``summary``
122
+ * ``实施 log``
123
+ * ``风险``
124
+ * ``acceptance``
125
+ """
126
+ if not content:
127
+ return ResultTemplateSections(False, False, False, False, ())
128
+
129
+ sections = _extract_section_names(content)
130
+ section_set_lower = {s.lower() for s in sections}
131
+
132
+ summary_present = "summary" in section_set_lower
133
+ log_present = "实施 log" in section_set_lower
134
+ risk_present = "风险" in section_set_lower
135
+ acceptance_present = "acceptance" in section_set_lower
136
+
137
+ log_links: tuple[tuple[str, str], ...] = ()
138
+ if log_present:
139
+ log_body = _slice_section_body(content, "实施 log") or ""
140
+ log_links = tuple(
141
+ (m.group(1).strip(), m.group(2).strip())
142
+ for m in _MARKDOWN_LINK_RE.finditer(log_body)
143
+ )
144
+
145
+ return ResultTemplateSections(
146
+ summary_present=summary_present,
147
+ log_present=log_present,
148
+ risk_present=risk_present,
149
+ acceptance_present=acceptance_present,
150
+ log_links=log_links,
151
+ )
152
+
153
+
154
+ def extract_malformed_link_fragment(log_body: str) -> str | None:
155
+ """Return the offending unbalanced fragment in ``## 实施 log`` body, if any.
156
+
157
+ Surfaces a hint for ``MALFORMED_MARKDOWN_LINK`` warnings. Returns
158
+ None when no unbalanced ``[...`` or ``(...`` tail is found (i.e. all
159
+ link-like fragments are well-formed or absent).
160
+ """
161
+ open_bracket = _UNBALANCED_OPEN_BRACKET_RE.search(log_body)
162
+ open_paren = _UNBALANCED_OPEN_PAREN_RE.search(log_body)
163
+ if open_bracket:
164
+ return open_bracket.group(0).strip()
165
+ if open_paren:
166
+ return open_paren.group(0).strip()
167
+ return None
map_client/testing.py ADDED
@@ -0,0 +1,27 @@
1
+ """Test utilities for in-process SDK/CLI tests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import httpx
6
+ from starlette.testclient import TestClient
7
+
8
+
9
+ class MAPTestClientTransport(httpx.BaseTransport):
10
+ def __init__(self, test_client: TestClient) -> None:
11
+ self._client = test_client
12
+
13
+ def handle_request(self, request: httpx.Request) -> httpx.Response:
14
+ path = request.url.path
15
+ if request.url.query:
16
+ path = f"{path}?{request.url.query.decode()}"
17
+ response = self._client.request(
18
+ request.method,
19
+ path,
20
+ headers=dict(request.headers),
21
+ content=request.content,
22
+ )
23
+ return httpx.Response(
24
+ status_code=response.status_code,
25
+ headers=response.headers,
26
+ content=response.content,
27
+ )
map_mcp/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ from map_mcp.config import MCPServerSettings
2
+ from map_mcp.server import build_server
3
+
4
+ __all__ = ["build_server", "MCPServerSettings"]
map_mcp/_utils.py ADDED
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import uuid
5
+ from typing import Any
6
+
7
+ from pydantic import BaseModel
8
+
9
+
10
+ def dump(value: Any) -> Any:
11
+ if isinstance(value, BaseModel):
12
+ return value.model_dump(mode="json")
13
+ if isinstance(value, list):
14
+ return [dump(item) for item in value]
15
+ if isinstance(value, dict):
16
+ return {key: dump(item) for key, item in value.items()}
17
+ return value
18
+
19
+
20
+ def parse_uuid(value: str, field: str) -> uuid.UUID:
21
+ try:
22
+ return uuid.UUID(value)
23
+ except ValueError as exc:
24
+ raise ValueError(f"Invalid UUID for {field}: {value}") from exc
25
+
26
+
27
+ def dumps_json(value: Any) -> str:
28
+ return json.dumps(dump(value), ensure_ascii=False, indent=2)
map_mcp/auth.py ADDED
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ from contextvars import ContextVar
4
+
5
+ from starlette.middleware.base import BaseHTTPMiddleware
6
+ from starlette.requests import Request
7
+ from starlette.responses import Response
8
+
9
+ _request_bearer: ContextVar[str | None] = ContextVar("map_mcp_request_bearer", default=None)
10
+
11
+
12
+ def get_request_bearer() -> str | None:
13
+ return _request_bearer.get()
14
+
15
+
16
+ def parse_bearer_token(authorization: str | None) -> str | None:
17
+ if not authorization:
18
+ return None
19
+ scheme, _, credentials = authorization.partition(" ")
20
+ if scheme.lower() != "bearer" or not credentials:
21
+ return None
22
+ return credentials.strip() or None
23
+
24
+
25
+ class BearerTokenMiddleware(BaseHTTPMiddleware):
26
+ """Extract Authorization: Bearer from each HTTP request into a context variable."""
27
+
28
+ async def dispatch(self, request: Request, call_next) -> Response:
29
+ token = parse_bearer_token(request.headers.get("authorization"))
30
+ reset = _request_bearer.set(token)
31
+ try:
32
+ return await call_next(request)
33
+ finally:
34
+ _request_bearer.reset(reset)
map_mcp/config.py ADDED
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+ from typing import Literal
6
+
7
+ TransportName = Literal["stdio", "streamable-http", "sse"]
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class MCPServerSettings:
12
+ transport: TransportName
13
+ host: str
14
+ port: int
15
+ path: str
16
+
17
+ @classmethod
18
+ def from_env(
19
+ cls,
20
+ *,
21
+ transport: str | None = None,
22
+ host: str | None = None,
23
+ port: int | None = None,
24
+ path: str | None = None,
25
+ ) -> MCPServerSettings:
26
+ resolved_transport = (transport or os.environ.get("MAP_MCP_TRANSPORT", "stdio")).strip()
27
+ if resolved_transport not in ("stdio", "streamable-http", "sse"):
28
+ raise ValueError("transport must be one of: stdio, streamable-http, sse")
29
+
30
+ default_host = "127.0.0.1" if resolved_transport == "stdio" else "0.0.0.0"
31
+ resolved_host = (host or os.environ.get("MAP_MCP_HOST", default_host)).strip()
32
+ resolved_port = port or int(os.environ.get("MAP_MCP_PORT", "8080"))
33
+ resolved_path = (path or os.environ.get("MAP_MCP_PATH", "/mcp")).strip() or "/mcp"
34
+ if not resolved_path.startswith("/"):
35
+ resolved_path = f"/{resolved_path}"
36
+
37
+ return cls(
38
+ transport=resolved_transport, # type: ignore[arg-type]
39
+ host=resolved_host,
40
+ port=resolved_port,
41
+ path=resolved_path,
42
+ )
43
+
44
+ @property
45
+ def url(self) -> str:
46
+ return f"http://{self.public_host}:{self.port}{self.path}"
47
+
48
+ @property
49
+ def public_host(self) -> str:
50
+ return "127.0.0.1" if self.host in {"0.0.0.0", "::"} else self.host
map_mcp/context.py ADDED
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+ from dataclasses import dataclass
5
+
6
+ from map_client.client import MAPClient
7
+ from map_types import AgentRole
8
+
9
+ from map_mcp._utils import parse_uuid
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class AgentContext:
14
+ role: AgentRole
15
+ project_id: uuid.UUID | None
16
+ project_key: str | None
17
+
18
+ @property
19
+ def is_admin(self) -> bool:
20
+ return self.role == AgentRole.admin
21
+
22
+ @classmethod
23
+ def from_client(cls, client: MAPClient) -> AgentContext:
24
+ me = client.get_me()
25
+ return cls(role=me.role, project_id=me.project_id, project_key=me.project_key)
26
+
27
+ def resolve_project_id(self, project_id: str | None) -> uuid.UUID:
28
+ if project_id:
29
+ pid = parse_uuid(project_id, "project_id")
30
+ if not self.is_admin and self.project_id and pid != self.project_id:
31
+ raise ValueError("Access denied: project_id does not match bound project")
32
+ return pid
33
+ if self.project_id:
34
+ return self.project_id
35
+ raise ValueError("project_id is required for admin agents without a bound project")
36
+
37
+ def require_admin(self) -> None:
38
+ if not self.is_admin:
39
+ raise ValueError("Admin role required for this tool")
map_mcp/main.py ADDED
@@ -0,0 +1,75 @@
1
+ from __future__ import annotations
2
+
3
+ import typer
4
+
5
+ from map_mcp.config import MCPServerSettings
6
+
7
+ cli = typer.Typer(
8
+ add_completion=False,
9
+ no_args_is_help=False,
10
+ invoke_without_command=True,
11
+ help="Multi-Agent Platform MCP server (stdio or HTTP).",
12
+ )
13
+
14
+
15
+ def _ensure_mcp_installed() -> None:
16
+ try:
17
+ from mcp.server.fastmcp import FastMCP # noqa: F401
18
+ except ImportError as exc:
19
+ typer.echo(
20
+ "MCP support requires the 'mcp' package. Install with: pip install -e \".[mcp]\"",
21
+ err=True,
22
+ )
23
+ raise typer.Exit(code=1) from exc
24
+
25
+
26
+ def _run_server(settings: MCPServerSettings) -> None:
27
+ from map_client import MAPClient
28
+ from map_client.config import load_config
29
+
30
+ from map_mcp.server import build_server
31
+
32
+ cfg = load_config()
33
+ api_url = cfg["api_url"]
34
+ # HTTP: identity comes from Authorization Bearer per connection.
35
+ # stdio: use MAP_TOKEN from env/config as the default client.
36
+ token = cfg.get("token")
37
+ client = MAPClient(api_url, token) if token and settings.transport == "stdio" else None
38
+ mcp = build_server(client, api_url=api_url, host=settings.host, port=settings.port, path=settings.path)
39
+
40
+ if settings.transport == "stdio":
41
+ mcp.run(transport="stdio")
42
+ return
43
+
44
+ if settings.transport == "streamable-http":
45
+ typer.echo(f"MAP MCP listening at {settings.url}", err=True)
46
+ mcp.run(transport="streamable-http")
47
+ return
48
+
49
+ typer.echo(f"MAP MCP SSE listening at http://{settings.public_host}:{settings.port}/sse", err=True)
50
+ mcp.run(transport="sse")
51
+
52
+
53
+ @cli.callback()
54
+ def main(
55
+ ctx: typer.Context,
56
+ transport: str | None = typer.Option(
57
+ None,
58
+ "--transport",
59
+ "-t",
60
+ help="Transport: stdio (default), streamable-http, or sse",
61
+ ),
62
+ host: str | None = typer.Option(None, "--host", help="Bind host for HTTP transports"),
63
+ port: int | None = typer.Option(None, "--port", "-p", help="Bind port for HTTP transports"),
64
+ path: str | None = typer.Option(None, "--path", help="HTTP MCP endpoint path (default: /mcp)"),
65
+ ) -> None:
66
+ """Run the MAP MCP server."""
67
+ if ctx.invoked_subcommand is not None:
68
+ return
69
+
70
+ _ensure_mcp_installed()
71
+ _run_server(MCPServerSettings.from_env(transport=transport, host=host, port=port, path=path))
72
+
73
+
74
+ if __name__ == "__main__":
75
+ cli()