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
map_mcp/server.py ADDED
@@ -0,0 +1,573 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Annotated, Any
4
+
5
+ import httpx
6
+ from map_client.client import MAPClient
7
+ from map_client.config import load_config
8
+ from map_types import (
9
+ CommentAnchorType,
10
+ CommentCreate,
11
+ ExperimentComplete,
12
+ ExperimentCreate,
13
+ ExperimentLogCreate,
14
+ ExperimentPhase,
15
+ ExperimentResultDecision,
16
+ PlanInput,
17
+ PlanRevise,
18
+ ProjectStatusRevise,
19
+ ReviewCreate,
20
+ ReviewItemStatus,
21
+ TopicCommentCreate,
22
+ TopicCreate,
23
+ TopicStatus,
24
+ )
25
+ from mcp.server.fastmcp import FastMCP
26
+ from mcp.server.transport_security import TransportSecuritySettings
27
+ from starlette.requests import Request
28
+ from starlette.responses import JSONResponse
29
+
30
+ from map_mcp._utils import dump, dumps_json, parse_uuid
31
+ from map_mcp.auth import BearerTokenMiddleware
32
+ from map_mcp.session import ClientResolver, token_param
33
+
34
+ Token = Annotated[str | None, token_param()]
35
+
36
+ _INSTRUCTIONS = """\
37
+ Multi-Agent Platform (MAP) — experiment collaboration for AI agents.
38
+
39
+ Authentication (transport layer):
40
+ - HTTP: set Authorization: Bearer <MAP agent token> on the MCP connection (e.g. Cursor mcp.json headers).
41
+ - stdio: set MAP_TOKEN in the MCP server process environment.
42
+ - Use separate MCP entries (map-admin / map-agent) for admin vs project-bound agent roles.
43
+
44
+ Start with get_me to confirm role and bound project_key, then read project context via get_project_status.
45
+
46
+ Typical workflow:
47
+ 1. get_project_status for project context
48
+ 2. create_experiment with a plan (optionally submit_for_review; topic_id only if you are the topic host)
49
+ 3. create_review with reasonable and unreasonable items (often a second MCP connection / reviewer token)
50
+ 4. revise_plan or create_comment to address disputes; update_review_item to resolve items
51
+ 5. approve_experiment → start_experiment → complete_experiment to submit results for review
52
+ 6. a different reviewer/admin calls accept_experiment_result or reject_experiment_result
53
+
54
+ Admin-only tools (create_project, list_projects, etc.) require an admin token on the connection.
55
+
56
+ Read-only experiment context: map://experiment/{experiment_id}
57
+ """
58
+
59
+
60
+ def build_server(
61
+ client: MAPClient | None = None,
62
+ *,
63
+ api_url: str | None = None,
64
+ transport: httpx.BaseTransport | None = None,
65
+ host: str = "127.0.0.1",
66
+ port: int = 8080,
67
+ path: str = "/mcp",
68
+ ) -> FastMCP:
69
+ """Build a FastMCP server. HTTP auth via Authorization Bearer; stdio may use a default client."""
70
+ resolved_api_url = api_url or (client.base_url if client is not None else load_config()["api_url"])
71
+ resolved_transport = transport or (client._transport if client is not None else None)
72
+ resolver = ClientResolver(resolved_api_url, client, transport=resolved_transport)
73
+
74
+ transport_security: TransportSecuritySettings | None = None
75
+ if host in ("127.0.0.1", "localhost", "::1"):
76
+ transport_security = TransportSecuritySettings(
77
+ enable_dns_rebinding_protection=True,
78
+ allowed_hosts=["127.0.0.1:*", "localhost:*", "[::1]:*"],
79
+ allowed_origins=["http://127.0.0.1:*", "http://localhost:*", "http://[::1]:*"],
80
+ )
81
+
82
+ mcp = FastMCP(
83
+ "Multi-Agent Platform",
84
+ instructions=_INSTRUCTIONS,
85
+ host=host,
86
+ port=port,
87
+ streamable_http_path=path,
88
+ transport_security=transport_security,
89
+ )
90
+
91
+ @mcp.custom_route("/health", methods=["GET"], name="health")
92
+ async def health(_: Request) -> JSONResponse:
93
+ return JSONResponse({"status": "ok", "service": "map-mcp", "auth": "bearer"})
94
+
95
+ @mcp.tool()
96
+ def get_todos(token: Token = None) -> dict[str, Any]:
97
+ """Return the current agent's to-do list: pending reviews, pending replies, own open experiments and topics."""
98
+ with resolver.use(token) as (c, _ctx):
99
+ return dump(c.get_todos())
100
+
101
+ @mcp.tool()
102
+ def list_notifications(
103
+ unread_only: bool = False,
104
+ category: str | None = None,
105
+ target_type: str | None = None,
106
+ limit: int = 50,
107
+ offset: int = 0,
108
+ token: Token = None,
109
+ ) -> dict[str, Any]:
110
+ """List in-app notifications for the current agent (paginated, optional unread filter)."""
111
+ with resolver.use(token) as (c, _ctx):
112
+ return dump(
113
+ c.list_notifications(
114
+ unread_only=unread_only,
115
+ category=category,
116
+ target_type=target_type,
117
+ limit=limit,
118
+ offset=offset,
119
+ )
120
+ )
121
+
122
+ @mcp.tool()
123
+ def mark_notification_read(notification_id: str, token: Token = None) -> dict[str, Any]:
124
+ """Mark a single in-app notification as read."""
125
+ with resolver.use(token) as (c, _ctx):
126
+ return dump(c.mark_notification_read(parse_uuid(notification_id, "notification_id")))
127
+
128
+ @mcp.tool()
129
+ def mark_all_notifications_read(token: Token = None) -> dict[str, Any]:
130
+ """Mark all in-app notifications as read for the current agent."""
131
+ with resolver.use(token) as (c, _ctx):
132
+ return dump(c.mark_all_notifications_read())
133
+
134
+ @mcp.tool()
135
+ def get_audit_history(target_type: str, target_id: str, token: Token = None) -> list[dict[str, Any]]:
136
+ """Return the audit trail for a given object (e.g. target_type='experiment', 'topic')."""
137
+ with resolver.use(token) as (c, _ctx):
138
+ return dump(c.list_audit_for_target(target_type, parse_uuid(target_id, "target_id")))
139
+
140
+ @mcp.tool()
141
+ def get_me(token: Token = None) -> dict[str, Any]:
142
+ """Return the authenticated agent profile (role, project_id, project_key)."""
143
+ with resolver.use(token) as (c, _ctx):
144
+ return dump(c.get_me())
145
+
146
+ @mcp.tool()
147
+ def get_project_status(project_id: str | None = None, token: Token = None) -> dict[str, Any]:
148
+ """Get project status: structured snapshot (active_experiments, open_topics, experiment_counts_by_phase, etc.) plus narrative status_md. Prefer snapshot fields for experiment/topic lists; use status_md for goals, blockers, and next steps only."""
149
+ with resolver.use(token) as (c, ctx):
150
+ pid = ctx.resolve_project_id(project_id)
151
+ return dump(c.get_project_status(pid))
152
+
153
+ @mcp.tool()
154
+ def list_project_status_versions(project_id: str | None = None, token: Token = None) -> list[dict[str, Any]]:
155
+ """List Current Status markdown revisions for a project (newest first)."""
156
+ with resolver.use(token) as (c, ctx):
157
+ pid = ctx.resolve_project_id(project_id)
158
+ return dump(c.list_project_status_versions(pid))
159
+
160
+ @mcp.tool()
161
+ def get_project_status_version(
162
+ project_id: str | None,
163
+ version: int,
164
+ token: Token = None,
165
+ ) -> dict[str, Any]:
166
+ """Get a specific Current Status markdown revision by version number."""
167
+ with resolver.use(token) as (c, ctx):
168
+ pid = ctx.resolve_project_id(project_id)
169
+ return dump(c.get_project_status_version(pid, version))
170
+
171
+ @mcp.tool()
172
+ def list_experiments(
173
+ project_id: str | None = None,
174
+ phase: str | None = None,
175
+ creator_agent_id: str | None = None,
176
+ q: str | None = None,
177
+ page: int = 1,
178
+ page_size: int = 100,
179
+ include_archived: bool = False,
180
+ token: Token = None,
181
+ ) -> list[dict[str, Any]]:
182
+ """List experiments in a project, with optional phase/search/pagination/archive filters."""
183
+ with resolver.use(token) as (c, ctx):
184
+ pid = ctx.resolve_project_id(project_id)
185
+ phase_enum = ExperimentPhase(phase) if phase else None
186
+ creator_id = parse_uuid(creator_agent_id, "creator_agent_id") if creator_agent_id else None
187
+ return dump(
188
+ c.list_experiments(
189
+ pid,
190
+ phase=phase_enum,
191
+ creator_agent_id=creator_id,
192
+ q=q,
193
+ page=page,
194
+ page_size=page_size,
195
+ include_archived=include_archived,
196
+ )
197
+ )
198
+
199
+ @mcp.tool()
200
+ def list_topics(
201
+ project_id: str | None = None,
202
+ status: str | None = None,
203
+ creator_agent_id: str | None = None,
204
+ q: str | None = None,
205
+ page: int = 1,
206
+ page_size: int = 100,
207
+ include_archived: bool = False,
208
+ token: Token = None,
209
+ ) -> list[dict[str, Any]]:
210
+ """List discussion topics in a project, with optional status/search/pagination/archive filters."""
211
+ with resolver.use(token) as (c, ctx):
212
+ pid = ctx.resolve_project_id(project_id)
213
+ st = TopicStatus(status) if status else None
214
+ creator_id = parse_uuid(creator_agent_id, "creator_agent_id") if creator_agent_id else None
215
+ return dump(
216
+ c.list_topics(
217
+ pid,
218
+ status=st,
219
+ creator_agent_id=creator_id,
220
+ q=q,
221
+ page=page,
222
+ page_size=page_size,
223
+ include_archived=include_archived,
224
+ )
225
+ )
226
+
227
+ @mcp.tool()
228
+ def get_topic(topic_id: str, token: Token = None) -> dict[str, Any]:
229
+ """Get a topic detail: discussion thread, linked experiments, and counts."""
230
+ with resolver.use(token) as (c, _ctx):
231
+ return dump(c.get_topic(parse_uuid(topic_id, "topic_id")))
232
+
233
+ @mcp.tool()
234
+ def create_topic(
235
+ title: str,
236
+ project_id: str | None = None,
237
+ description: str | None = None,
238
+ token: Token = None,
239
+ ) -> dict[str, Any]:
240
+ """Create a lightweight discussion topic (no plan required) in the bound or specified project."""
241
+ with resolver.use(token) as (c, ctx):
242
+ pid = ctx.resolve_project_id(project_id)
243
+ payload = TopicCreate(title=title, description=description)
244
+ return dump(c.create_topic(pid, payload))
245
+
246
+ @mcp.tool()
247
+ def create_topic_comment(
248
+ topic_id: str,
249
+ body: str,
250
+ parent_id: str | None = None,
251
+ token: Token = None,
252
+ ) -> dict[str, Any]:
253
+ """Add a comment to a topic discussion thread."""
254
+ with resolver.use(token) as (c, _ctx):
255
+ payload = TopicCommentCreate(
256
+ body=body,
257
+ parent_id=parse_uuid(parent_id, "parent_id") if parent_id else None,
258
+ )
259
+ return dump(c.create_topic_comment(parse_uuid(topic_id, "topic_id"), payload))
260
+
261
+ @mcp.tool()
262
+ def close_topic(topic_id: str, token: Token = None) -> dict[str, Any]:
263
+ """Close a topic (open → closed)."""
264
+ with resolver.use(token) as (c, _ctx):
265
+ return dump(c.close_topic(parse_uuid(topic_id, "topic_id")))
266
+
267
+ @mcp.tool()
268
+ def reopen_topic(topic_id: str, token: Token = None) -> dict[str, Any]:
269
+ """Reopen a closed topic (closed → open)."""
270
+ with resolver.use(token) as (c, _ctx):
271
+ return dump(c.reopen_topic(parse_uuid(topic_id, "topic_id")))
272
+
273
+ @mcp.tool()
274
+ def get_experiment(experiment_id: str, token: Token = None) -> dict[str, Any]:
275
+ """Get experiment details including current plan summary."""
276
+ with resolver.use(token) as (c, _ctx):
277
+ return dump(c.get_experiment(parse_uuid(experiment_id, "experiment_id")))
278
+
279
+ @mcp.tool()
280
+ def create_experiment(
281
+ title: str,
282
+ plan_content_md: str,
283
+ project_id: str | None = None,
284
+ description: str | None = None,
285
+ submit_for_review: bool = False,
286
+ topic_id: str | None = None,
287
+ token: Token = None,
288
+ ) -> dict[str, Any]:
289
+ """Create an experiment with an initial plan. When topic_id is set, only the topic host (or admin) may call this."""
290
+ with resolver.use(token) as (c, ctx):
291
+ pid = ctx.resolve_project_id(project_id)
292
+ payload = ExperimentCreate(
293
+ title=title,
294
+ description=description,
295
+ plan=PlanInput(content_md=plan_content_md),
296
+ submit_for_review=submit_for_review,
297
+ topic_id=parse_uuid(topic_id, "topic_id") if topic_id else None,
298
+ )
299
+ return dump(c.create_experiment(pid, payload))
300
+
301
+ @mcp.tool()
302
+ def submit_for_review(experiment_id: str, token: Token = None) -> dict[str, Any]:
303
+ """Move experiment from draft to review phase."""
304
+ with resolver.use(token) as (c, _ctx):
305
+ return dump(c.submit_for_review(parse_uuid(experiment_id, "experiment_id")))
306
+
307
+ @mcp.tool()
308
+ def approve_experiment(experiment_id: str, token: Token = None) -> dict[str, Any]:
309
+ """Approve experiment when all unreasonable review items are resolved."""
310
+ with resolver.use(token) as (c, _ctx):
311
+ return dump(c.approve_experiment(parse_uuid(experiment_id, "experiment_id")))
312
+
313
+ @mcp.tool()
314
+ def withdraw_from_review(experiment_id: str, token: Token = None) -> dict[str, Any]:
315
+ """Withdraw experiment from review back to draft."""
316
+ with resolver.use(token) as (c, _ctx):
317
+ return dump(c.withdraw_from_review(parse_uuid(experiment_id, "experiment_id")))
318
+
319
+ @mcp.tool()
320
+ def cancel_experiment(experiment_id: str, token: Token = None) -> dict[str, Any]:
321
+ """Cancel an experiment."""
322
+ with resolver.use(token) as (c, _ctx):
323
+ return dump(c.cancel_experiment(parse_uuid(experiment_id, "experiment_id")))
324
+
325
+ @mcp.tool()
326
+ def start_experiment(experiment_id: str, token: Token = None) -> dict[str, Any]:
327
+ """Start an approved experiment (approved → running)."""
328
+ with resolver.use(token) as (c, _ctx):
329
+ return dump(c.start_experiment(parse_uuid(experiment_id, "experiment_id")))
330
+
331
+ @mcp.tool()
332
+ def complete_experiment(
333
+ experiment_id: str,
334
+ summary: str,
335
+ content_md: str,
336
+ metadata: dict[str, Any] | None = None,
337
+ token: Token = None,
338
+ ) -> dict[str, Any]:
339
+ """Submit running experiment results for review; metadata must include deployment/test evidence."""
340
+ with resolver.use(token) as (c, _ctx):
341
+ payload = ExperimentComplete(summary=summary, content_md=content_md, metadata=metadata)
342
+ return dump(c.complete_experiment(parse_uuid(experiment_id, "experiment_id"), payload))
343
+
344
+ @mcp.tool()
345
+ def accept_experiment_result(
346
+ experiment_id: str,
347
+ summary: str,
348
+ content_md: str,
349
+ metadata: dict[str, Any] | None = None,
350
+ token: Token = None,
351
+ ) -> dict[str, Any]:
352
+ """Approve submitted experiment results (result_review → done). Must be a different agent or admin."""
353
+ with resolver.use(token) as (c, _ctx):
354
+ payload = ExperimentResultDecision(summary=summary, content_md=content_md, metadata=metadata)
355
+ return dump(c.accept_experiment_result(parse_uuid(experiment_id, "experiment_id"), payload))
356
+
357
+ @mcp.tool()
358
+ def reject_experiment_result(
359
+ experiment_id: str,
360
+ summary: str,
361
+ content_md: str,
362
+ metadata: dict[str, Any] | None = None,
363
+ token: Token = None,
364
+ ) -> dict[str, Any]:
365
+ """Reject submitted experiment results and return the experiment to running for rework."""
366
+ with resolver.use(token) as (c, _ctx):
367
+ payload = ExperimentResultDecision(summary=summary, content_md=content_md, metadata=metadata)
368
+ return dump(c.reject_experiment_result(parse_uuid(experiment_id, "experiment_id"), payload))
369
+
370
+ @mcp.tool()
371
+ def list_plans(experiment_id: str, token: Token = None) -> list[dict[str, Any]]:
372
+ """List all plan versions for an experiment."""
373
+ with resolver.use(token) as (c, _ctx):
374
+ return dump(c.list_plans(parse_uuid(experiment_id, "experiment_id")))
375
+
376
+ @mcp.tool()
377
+ def get_plan(experiment_id: str, version: int, token: Token = None) -> dict[str, Any]:
378
+ """Get a specific plan version by number."""
379
+ with resolver.use(token) as (c, _ctx):
380
+ return dump(c.get_plan(parse_uuid(experiment_id, "experiment_id"), version))
381
+
382
+ @mcp.tool()
383
+ def revise_plan(
384
+ experiment_id: str,
385
+ content_md: str,
386
+ change_note: str | None = None,
387
+ addressed_item_ids: list[str] | None = None,
388
+ token: Token = None,
389
+ ) -> dict[str, Any]:
390
+ """Create a new plan revision; optionally link addressed unreasonable review items."""
391
+ with resolver.use(token) as (c, _ctx):
392
+ item_ids = [parse_uuid(item_id, "addressed_item_id") for item_id in (addressed_item_ids or [])]
393
+ payload = PlanRevise(content_md=content_md, change_note=change_note, addressed_item_ids=item_ids)
394
+ return dump(c.revise_plan(parse_uuid(experiment_id, "experiment_id"), payload))
395
+
396
+ @mcp.tool()
397
+ def create_review(
398
+ experiment_id: str,
399
+ reasonable_items: list[str] | None = None,
400
+ unreasonable_items: list[str] | None = None,
401
+ token: Token = None,
402
+ ) -> dict[str, Any]:
403
+ """Submit a structured review with reasonable and unreasonable items."""
404
+ with resolver.use(token) as (c, _ctx):
405
+ payload = ReviewCreate(
406
+ reasonable_items=reasonable_items or [],
407
+ unreasonable_items=unreasonable_items or [],
408
+ )
409
+ return dump(c.create_review(parse_uuid(experiment_id, "experiment_id"), payload))
410
+
411
+ @mcp.tool()
412
+ def list_reviews(experiment_id: str, token: Token = None) -> list[dict[str, Any]]:
413
+ """List all reviews on an experiment."""
414
+ with resolver.use(token) as (c, _ctx):
415
+ return dump(c.list_reviews(parse_uuid(experiment_id, "experiment_id")))
416
+
417
+ @mcp.tool()
418
+ def update_review_item(item_id: str, status: str, token: Token = None) -> dict[str, Any]:
419
+ """Update a review item status (open, addressed, rebutted, resolved, withdrawn, escalated)."""
420
+ with resolver.use(token) as (c, _ctx):
421
+ return dump(
422
+ c.update_review_item(
423
+ parse_uuid(item_id, "item_id"),
424
+ ReviewItemStatus(status),
425
+ )
426
+ )
427
+
428
+ @mcp.tool()
429
+ def create_comment(
430
+ experiment_id: str,
431
+ anchor_type: str,
432
+ anchor_id: str,
433
+ body: str,
434
+ parent_id: str | None = None,
435
+ token: Token = None,
436
+ ) -> dict[str, Any]:
437
+ """Create a comment on a plan, review, review item, or another comment."""
438
+ with resolver.use(token) as (c, _ctx):
439
+ payload = CommentCreate(
440
+ anchor_type=CommentAnchorType(anchor_type),
441
+ anchor_id=parse_uuid(anchor_id, "anchor_id"),
442
+ parent_id=parse_uuid(parent_id, "parent_id") if parent_id else None,
443
+ body=body,
444
+ )
445
+ return dump(c.create_comment(parse_uuid(experiment_id, "experiment_id"), payload))
446
+
447
+ @mcp.tool()
448
+ def list_comments(experiment_id: str, tree: bool = False, token: Token = None) -> list[dict[str, Any]]:
449
+ """List comments on an experiment; set tree=true for nested structure."""
450
+ with resolver.use(token) as (c, _ctx):
451
+ return dump(c.list_comments(parse_uuid(experiment_id, "experiment_id"), tree=tree))
452
+
453
+ @mcp.tool()
454
+ def create_log(
455
+ experiment_id: str,
456
+ summary: str,
457
+ content_md: str,
458
+ metadata: dict[str, Any] | None = None,
459
+ token: Token = None,
460
+ ) -> dict[str, Any]:
461
+ """Append an execution log entry to an experiment."""
462
+ with resolver.use(token) as (c, _ctx):
463
+ payload = ExperimentLogCreate(summary=summary, content_md=content_md, metadata=metadata)
464
+ return dump(c.create_log(parse_uuid(experiment_id, "experiment_id"), payload))
465
+
466
+ @mcp.tool()
467
+ def list_logs(experiment_id: str, token: Token = None) -> list[dict[str, Any]]:
468
+ """List execution logs for an experiment."""
469
+ with resolver.use(token) as (c, _ctx):
470
+ return dump(c.list_logs(parse_uuid(experiment_id, "experiment_id")))
471
+
472
+ @mcp.tool()
473
+ def get_global_status(project_id: str | None = None, token: Token = None) -> dict[str, Any]:
474
+ """Return the global status board, optionally filtered by project UUID (Admin only)."""
475
+ with resolver.use(token) as (c, ctx):
476
+ ctx.require_admin()
477
+ pid = parse_uuid(project_id, "project_id") if project_id else None
478
+ return dump(c.get_global_status(pid))
479
+
480
+ @mcp.tool()
481
+ def list_projects(include_archived: bool = False, token: Token = None) -> list[dict[str, Any]]:
482
+ """List all projects (Admin only)."""
483
+ with resolver.use(token) as (c, ctx):
484
+ ctx.require_admin()
485
+ return dump(c.list_projects(include_archived=include_archived))
486
+
487
+ @mcp.tool()
488
+ def get_project(project_id: str, token: Token = None) -> dict[str, Any]:
489
+ """Get a single project by UUID (Admin only)."""
490
+ with resolver.use(token) as (c, ctx):
491
+ ctx.require_admin()
492
+ return dump(c.get_project(parse_uuid(project_id, "project_id")))
493
+
494
+ @mcp.tool()
495
+ def revise_project_status(
496
+ content_md: str,
497
+ project_id: str | None = None,
498
+ change_note: str | None = None,
499
+ token: Token = None,
500
+ ) -> dict[str, Any]:
501
+ """Revise the project Current Status markdown (Admin or project-bound agent)."""
502
+ with resolver.use(token) as (c, ctx):
503
+ pid = ctx.resolve_project_id(project_id)
504
+ payload = ProjectStatusRevise(content_md=content_md, change_note=change_note)
505
+ return dump(c.revise_project_status(pid, payload))
506
+
507
+ @mcp.tool()
508
+ def create_project(
509
+ name: str,
510
+ workspace_path: str,
511
+ description: str | None = None,
512
+ project_key: str | None = None,
513
+ token: Token = None,
514
+ ) -> dict[str, Any]:
515
+ """Create a new project bound to a workspace path (Admin only)."""
516
+ with resolver.use(token) as (c, ctx):
517
+ ctx.require_admin()
518
+ key = project_key or name.lower().replace(" ", "-")
519
+ return dump(c.create_project(key, name, workspace_path, description))
520
+
521
+ @mcp.resource("map://project/{project_id}/status")
522
+ def project_status_resource(project_id: str) -> str:
523
+ """Read-only project status board snapshot (UUID form, Admin)."""
524
+ with resolver.use(None) as (c, ctx):
525
+ if not ctx.is_admin:
526
+ raise ValueError("Admin role required for this resource")
527
+ return dumps_json(c.get_project_status(parse_uuid(project_id, "project_id")))
528
+
529
+ @mcp.resource("map://experiment/{experiment_id}")
530
+ def experiment_context(experiment_id: str) -> str:
531
+ """Read-only snapshot: experiment detail, plans, reviews, disputes, comments."""
532
+ with resolver.use(None) as (c, _ctx):
533
+ eid = parse_uuid(experiment_id, "experiment_id")
534
+ detail = c.get_experiment(eid)
535
+ plans = c.list_plans(eid)
536
+ reviews = c.list_reviews(eid)
537
+ comments = c.list_comments(eid, tree=True)
538
+ logs = c.list_logs(eid)
539
+
540
+ open_items: list[dict[str, Any]] = []
541
+ for review in reviews:
542
+ for item in review.items:
543
+ if item.kind.value == "unreasonable" and item.status and item.status.value == "open":
544
+ open_items.append(dump(item))
545
+
546
+ context = {
547
+ "experiment": dump(detail),
548
+ "current_plan": dump(detail.current_plan),
549
+ "plan_versions": dump(plans),
550
+ "reviews": dump(reviews),
551
+ "open_unreasonable_items": open_items,
552
+ "comments_tree": dump(comments),
553
+ "logs": dump(logs),
554
+ }
555
+ return dumps_json(context)
556
+
557
+ @mcp.resource("map://project/{project_key}/current-status")
558
+ def project_current_status_resource(project_key: str) -> str:
559
+ """Read-only project Current Status."""
560
+ with resolver.use(None) as (c, _ctx):
561
+ project = c.get_project_by_key(project_key)
562
+ return dumps_json(c.get_project_status(project.id))
563
+
564
+ _original_streamable_http_app = mcp.streamable_http_app
565
+
566
+ def streamable_http_app_with_bearer():
567
+ app = _original_streamable_http_app()
568
+ app.add_middleware(BearerTokenMiddleware)
569
+ return app
570
+
571
+ mcp.streamable_http_app = streamable_http_app_with_bearer # type: ignore[method-assign]
572
+
573
+ return mcp
map_mcp/session.py ADDED
@@ -0,0 +1,79 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterator
4
+ from contextlib import contextmanager
5
+ from typing import TYPE_CHECKING
6
+
7
+ from map_client.client import MAPClient
8
+
9
+ from map_mcp.auth import get_request_bearer
10
+ from map_mcp.context import AgentContext
11
+
12
+ if TYPE_CHECKING:
13
+ import httpx
14
+
15
+ _TOKEN_PARAM_HELP = (
16
+ "Deprecated. Prefer Authorization: Bearer <MAP agent token> on the MCP HTTP connection, "
17
+ "or MAP_TOKEN in env for stdio. Overrides transport auth when set."
18
+ )
19
+
20
+
21
+ def token_param() -> str:
22
+ """Shared description for the optional token tool parameter."""
23
+ return _TOKEN_PARAM_HELP
24
+
25
+
26
+ class ClientResolver:
27
+ """Resolve MAPClient + AgentContext from transport Bearer, stdio default, or per-call token."""
28
+
29
+ def __init__(
30
+ self,
31
+ api_url: str,
32
+ default_client: MAPClient | None = None,
33
+ *,
34
+ transport: httpx.BaseTransport | None = None,
35
+ ) -> None:
36
+ self.api_url = api_url.rstrip("/")
37
+ self._default_client = default_client
38
+ self._default_ctx = AgentContext.from_client(default_client) if default_client else None
39
+ if transport is not None:
40
+ self._transport = transport
41
+ elif default_client is not None:
42
+ self._transport = default_client._transport
43
+ else:
44
+ self._transport = None
45
+
46
+ @property
47
+ def default_ctx(self) -> AgentContext | None:
48
+ return self._default_ctx
49
+
50
+ def _resolve_token(self, token: str | None) -> str | None:
51
+ if token:
52
+ return token
53
+ bearer = get_request_bearer()
54
+ if bearer:
55
+ return bearer
56
+ if self._default_client is not None:
57
+ return self._default_client.token
58
+ return None
59
+
60
+ @contextmanager
61
+ def use(self, token: str | None) -> Iterator[tuple[MAPClient, AgentContext]]:
62
+ effective = self._resolve_token(token)
63
+ if not effective:
64
+ raise ValueError(
65
+ "Authentication required: set Authorization: Bearer <MAP agent token> "
66
+ "(HTTP) or MAP_TOKEN (stdio)"
67
+ )
68
+ if (
69
+ self._default_client is not None
70
+ and effective == self._default_client.token
71
+ and self._default_ctx is not None
72
+ ):
73
+ yield self._default_client, self._default_ctx
74
+ return
75
+ client = MAPClient(self.api_url, effective, transport=self._transport)
76
+ try:
77
+ yield client, AgentContext.from_client(client)
78
+ finally:
79
+ client.close()
map_sdk/__init__.py ADDED
@@ -0,0 +1,29 @@
1
+ """map_sdk — shared, server-agnostic utilities for CLI + SDK.
2
+
3
+ arch experiment (0519e2a3) PR1: skeleton. PR2 added ``map_sdk.evidence``
4
+ with ``metadata_has_completion_evidence`` + ``EVIDENCE_METADATA_KEYS``
5
+ moved out of ``server.services.evidence_service``.
6
+
7
+ Hard boundary: this package MUST NOT import from ``server.*``. CI
8
+ asserts that via grep + unit test.
9
+ """
10
+
11
+ from map_sdk.evidence import (
12
+ EVIDENCE_METADATA_KEYS,
13
+ metadata_has_completion_evidence,
14
+ )
15
+
16
+ __version__ = "0.1.0"
17
+
18
+
19
+ def hello() -> str:
20
+ """Smoke function so the import surface has at least one symbol."""
21
+ return "map_sdk ok"
22
+
23
+
24
+ __all__ = [
25
+ "__version__",
26
+ "hello",
27
+ "EVIDENCE_METADATA_KEYS",
28
+ "metadata_has_completion_evidence",
29
+ ]