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.
- cli/__init__.py +0 -0
- cli/action_item_escalation.py +177 -0
- cli/agent_client.py +554 -0
- cli/bridge_state.py +43 -0
- cli/commands/__init__.py +13 -0
- cli/commands/action.py +142 -0
- cli/commands/agent.py +117 -0
- cli/commands/audit.py +68 -0
- cli/commands/docs.py +179 -0
- cli/commands/experiment.py +755 -0
- cli/commands/feedback.py +106 -0
- cli/commands/notification.py +213 -0
- cli/commands/persona.py +63 -0
- cli/commands/project.py +87 -0
- cli/commands/runtime.py +105 -0
- cli/commands/topic.py +361 -0
- cli/e2e_collab.py +602 -0
- cli/git_checkpoint.py +68 -0
- cli/host_worker_types.py +151 -0
- cli/main.py +1553 -0
- cli/map_command_client.py +497 -0
- cli/participant_worker.py +255 -0
- cli/reviewer_worker.py +263 -0
- cli/runtime/__init__.py +5 -0
- cli/runtime/run_lock.py +497 -0
- cli/runtime_chat.py +317 -0
- cli/session_wake_log.py +235 -0
- cli/simple_waker.py +950 -0
- cli/table_render.py +113 -0
- cli/wake_backend.py +236 -0
- cli/worker_cycle_log.py +36 -0
- map_client/__init__.py +37 -0
- map_client/bootstrap.py +193 -0
- map_client/client.py +1045 -0
- map_client/config.py +21 -0
- map_client/errors.py +283 -0
- map_client/exceptions.py +130 -0
- map_client/plan_evidence.py +159 -0
- map_client/project_config.py +153 -0
- map_client/result_template.py +167 -0
- map_client/testing.py +27 -0
- map_mcp/__init__.py +4 -0
- map_mcp/_utils.py +28 -0
- map_mcp/auth.py +34 -0
- map_mcp/config.py +50 -0
- map_mcp/context.py +39 -0
- map_mcp/main.py +75 -0
- map_mcp/server.py +573 -0
- map_mcp/session.py +79 -0
- map_sdk/__init__.py +29 -0
- map_sdk/evidence.py +68 -0
- map_types/__init__.py +203 -0
- map_types/enums.py +199 -0
- map_types/schemas.py +1351 -0
- multi_agent_platform-0.1.0.dist-info/METADATA +298 -0
- multi_agent_platform-0.1.0.dist-info/RECORD +144 -0
- multi_agent_platform-0.1.0.dist-info/WHEEL +5 -0
- multi_agent_platform-0.1.0.dist-info/entry_points.txt +6 -0
- multi_agent_platform-0.1.0.dist-info/licenses/LICENSE +21 -0
- multi_agent_platform-0.1.0.dist-info/top_level.txt +6 -0
- server/__init__.py +0 -0
- server/__version__.py +14 -0
- server/api/__init__.py +0 -0
- server/api/action_items.py +138 -0
- server/api/agents.py +412 -0
- server/api/audit.py +54 -0
- server/api/background_tasks.py +18 -0
- server/api/common.py +117 -0
- server/api/deps.py +30 -0
- server/api/experiments.py +858 -0
- server/api/feedback.py +75 -0
- server/api/notifications.py +22 -0
- server/api/projects.py +209 -0
- server/api/router.py +25 -0
- server/api/status.py +33 -0
- server/api/topics.py +302 -0
- server/api/webhooks.py +74 -0
- server/auth/__init__.py +8 -0
- server/auth/experiment_access.py +66 -0
- server/config.py +38 -0
- server/db/__init__.py +3 -0
- server/db/base.py +5 -0
- server/db/deadlock_retry.py +146 -0
- server/db/session.py +41 -0
- server/domain/__init__.py +3 -0
- server/domain/encrypted_types.py +63 -0
- server/domain/models.py +713 -0
- server/domain/schemas.py +3 -0
- server/domain/state_machine.py +79 -0
- server/domain/topic_ack_constants.py +9 -0
- server/main.py +148 -0
- server/scripts/__init__.py +0 -0
- server/scripts/migrate_notification_unique.py +231 -0
- server/scripts/purge_audit_pollution.py +116 -0
- server/services/__init__.py +0 -0
- server/services/_lookups.py +26 -0
- server/services/acceptance_service.py +90 -0
- server/services/action_item_migration_service.py +190 -0
- server/services/action_item_service.py +200 -0
- server/services/agent_work_service.py +405 -0
- server/services/archive_lint_service.py +156 -0
- server/services/audit_service.py +457 -0
- server/services/auth.py +66 -0
- server/services/comment_service.py +173 -0
- server/services/errors.py +65 -0
- server/services/escalation_resolver.py +248 -0
- server/services/evidence_service.py +88 -0
- server/services/experiment_capabilities_service.py +277 -0
- server/services/inbound_event_service.py +111 -0
- server/services/lock_service.py +273 -0
- server/services/log_service.py +202 -0
- server/services/mention_service.py +730 -0
- server/services/notification_service.py +939 -0
- server/services/notification_stream.py +138 -0
- server/services/permissions.py +147 -0
- server/services/persona_activity_service.py +108 -0
- server/services/phase_owner_resolver.py +95 -0
- server/services/phase_service.py +381 -0
- server/services/plan_marker_service.py +235 -0
- server/services/plan_service.py +186 -0
- server/services/platform_feedback_service.py +114 -0
- server/services/project_service.py +534 -0
- server/services/project_status_service.py +132 -0
- server/services/review_service.py +707 -0
- server/services/secret_encryption.py +97 -0
- server/services/similarity_service.py +119 -0
- server/services/sse_event_schemas.py +17 -0
- server/services/status_service.py +68 -0
- server/services/template_service.py +134 -0
- server/services/text_utils.py +19 -0
- server/services/thread_activity.py +180 -0
- server/services/todo_persona_filter.py +73 -0
- server/services/todo_service.py +604 -0
- server/services/topic_ack_service.py +312 -0
- server/services/topic_action_item_ops.py +538 -0
- server/services/topic_comment_kind.py +14 -0
- server/services/topic_comment_service.py +237 -0
- server/services/topic_helpers.py +32 -0
- server/services/topic_lifecycle_service.py +478 -0
- server/services/topic_progress_service.py +40 -0
- server/services/topic_resolve_service.py +234 -0
- server/services/topic_service.py +102 -0
- server/services/topic_work_item_service.py +570 -0
- 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
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()
|