ssot-mcp 0.1.1.dev2__tar.gz

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.
@@ -0,0 +1,82 @@
1
+ Metadata-Version: 2.4
2
+ Name: ssot-mcp
3
+ Version: 0.1.1.dev2
4
+ Summary: MCP server for optional SSOT pull-worker control-plane coordination.
5
+ Author-email: Jacob Stewart <jacob@swarmauri.com>
6
+ License-Expression: Apache-2.0
7
+ Keywords: ssot,mcp,workers,leases,control-plane
8
+ Requires-Python: <3.15,>=3.10
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: mcp>=1.0
11
+ Requires-Dist: ssot-core<0.3.0,>=0.2.19.dev2
12
+
13
+ # ssot-mcp
14
+
15
+ Optional MCP server for SSOT pull-worker coordination. Workers still pull work
16
+ through `claim_next_maturation_slice`; pushed update notifications only wake,
17
+ pause, refresh, or stop workers.
18
+
19
+ The core `ssot` CLI and `.ssot/registry.json` workflows do not require this
20
+ package. Deploy `ssot-mcp` only when a Codex/MCP client should coordinate
21
+ campaigns, leases, worker events, and registry writes through MCP tools.
22
+
23
+ Run one pinned server per repository in normal use:
24
+
25
+ ```powershell
26
+ ssot-mcp --transport stdio --repo E:\swarmauri_github\ssot-registry
27
+ ```
28
+
29
+ Run global/dev mode only when callers must pass an explicit `repo` argument on
30
+ every tool/resource call:
31
+
32
+ ```powershell
33
+ ssot-mcp --transport stdio --repo-mode explicit
34
+ ```
35
+
36
+ See [Codex MCP configuration](../../docs/coordination/codex-mcp.md) for Codex
37
+ `config.toml` examples.
38
+
39
+ ## Registry write authority
40
+
41
+ Workers must not hand-edit `.ssot/registry.json`. When a worker needs SSOT
42
+ entity changes, it asks `ssot-mcp` to perform the mutation through one of the
43
+ registry tools:
44
+
45
+ - `get_blocked_transitions`
46
+ - `scaffold_target_claim_wiring`
47
+ - `repair_blocked_transition`
48
+ - `registry_entity_get`
49
+ - `registry_entity_list`
50
+ - `registry_entity_search`
51
+ - `registry_entity_upsert`
52
+ - `registry_entity_delete`
53
+ - `registry_entity_link`
54
+ - `registry_entity_unlink`
55
+ - `get_ssot_cli_surface`
56
+ - `run_ssot_cli`
57
+
58
+ The structured entity tools use the same core registry mutation APIs as the
59
+ CLI, validate the registry before saving, and emit `registry_updated` events.
60
+ `run_ssot_cli` delegates to the repo-local CLI parser in-process for command
61
+ coverage that is not yet exposed as a dedicated MCP tool. It supports global
62
+ flags, help/version requests, commands, subcommands, command flags, and
63
+ subcommand flags as argv tokens. `get_ssot_cli_surface` returns the live CLI
64
+ surface (`global_flags`, `top_level_commands`, `subcommand_paths`, and
65
+ `flags_by_path`) so workers can discover the exact supported command shape
66
+ before calling `run_ssot_cli`. Help and invalid-argument parser exits are
67
+ captured as normal tool results; they must not close the MCP transport.
68
+
69
+ Campaign claims can also be scoped instead of running over every active
70
+ in-bounds feature. `claim_next_maturation_slice` accepts `feature_ids`,
71
+ `profile_ids`, and `boundary_ids`; unscoped campaigns consider 25 in-bounds
72
+ active features by default, and operators can raise `feature_limit` explicitly
73
+ for broader campaigns. Out-of-bounds features are filtered from assignment and
74
+ campaign status output. It caps blocker discovery with `max_blockers_per_claim`;
75
+ and auto-scaffolding is enabled by default so
76
+ `ssot-mcp` attempts target-tier claim/test/evidence scaffolding before returning
77
+ a blocked result. Pass `auto_scaffold=false` only when intentionally testing or
78
+ observing raw blocked-transition behavior. When a claim response returns
79
+ `kind="blocked"`, it includes a top-level `reason` and a structured
80
+ `problem_detail` with blocker rows and recommended MCP tool calls such as
81
+ `repair_blocked_transition` or `scaffold_target_claim_wiring`; workers should
82
+ perform those repairs and then pull again.
@@ -0,0 +1,70 @@
1
+ # ssot-mcp
2
+
3
+ Optional MCP server for SSOT pull-worker coordination. Workers still pull work
4
+ through `claim_next_maturation_slice`; pushed update notifications only wake,
5
+ pause, refresh, or stop workers.
6
+
7
+ The core `ssot` CLI and `.ssot/registry.json` workflows do not require this
8
+ package. Deploy `ssot-mcp` only when a Codex/MCP client should coordinate
9
+ campaigns, leases, worker events, and registry writes through MCP tools.
10
+
11
+ Run one pinned server per repository in normal use:
12
+
13
+ ```powershell
14
+ ssot-mcp --transport stdio --repo E:\swarmauri_github\ssot-registry
15
+ ```
16
+
17
+ Run global/dev mode only when callers must pass an explicit `repo` argument on
18
+ every tool/resource call:
19
+
20
+ ```powershell
21
+ ssot-mcp --transport stdio --repo-mode explicit
22
+ ```
23
+
24
+ See [Codex MCP configuration](../../docs/coordination/codex-mcp.md) for Codex
25
+ `config.toml` examples.
26
+
27
+ ## Registry write authority
28
+
29
+ Workers must not hand-edit `.ssot/registry.json`. When a worker needs SSOT
30
+ entity changes, it asks `ssot-mcp` to perform the mutation through one of the
31
+ registry tools:
32
+
33
+ - `get_blocked_transitions`
34
+ - `scaffold_target_claim_wiring`
35
+ - `repair_blocked_transition`
36
+ - `registry_entity_get`
37
+ - `registry_entity_list`
38
+ - `registry_entity_search`
39
+ - `registry_entity_upsert`
40
+ - `registry_entity_delete`
41
+ - `registry_entity_link`
42
+ - `registry_entity_unlink`
43
+ - `get_ssot_cli_surface`
44
+ - `run_ssot_cli`
45
+
46
+ The structured entity tools use the same core registry mutation APIs as the
47
+ CLI, validate the registry before saving, and emit `registry_updated` events.
48
+ `run_ssot_cli` delegates to the repo-local CLI parser in-process for command
49
+ coverage that is not yet exposed as a dedicated MCP tool. It supports global
50
+ flags, help/version requests, commands, subcommands, command flags, and
51
+ subcommand flags as argv tokens. `get_ssot_cli_surface` returns the live CLI
52
+ surface (`global_flags`, `top_level_commands`, `subcommand_paths`, and
53
+ `flags_by_path`) so workers can discover the exact supported command shape
54
+ before calling `run_ssot_cli`. Help and invalid-argument parser exits are
55
+ captured as normal tool results; they must not close the MCP transport.
56
+
57
+ Campaign claims can also be scoped instead of running over every active
58
+ in-bounds feature. `claim_next_maturation_slice` accepts `feature_ids`,
59
+ `profile_ids`, and `boundary_ids`; unscoped campaigns consider 25 in-bounds
60
+ active features by default, and operators can raise `feature_limit` explicitly
61
+ for broader campaigns. Out-of-bounds features are filtered from assignment and
62
+ campaign status output. It caps blocker discovery with `max_blockers_per_claim`;
63
+ and auto-scaffolding is enabled by default so
64
+ `ssot-mcp` attempts target-tier claim/test/evidence scaffolding before returning
65
+ a blocked result. Pass `auto_scaffold=false` only when intentionally testing or
66
+ observing raw blocked-transition behavior. When a claim response returns
67
+ `kind="blocked"`, it includes a top-level `reason` and a structured
68
+ `problem_detail` with blocker rows and recommended MCP tool calls such as
69
+ `repair_blocked_transition` or `scaffold_target_claim_wiring`; workers should
70
+ perform those repairs and then pull again.
@@ -0,0 +1,26 @@
1
+ [build-system]
2
+ requires = ["setuptools>=69", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "ssot-mcp"
7
+ version = "0.1.1.dev2"
8
+ description = "MCP server for optional SSOT pull-worker control-plane coordination."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10,<3.15"
11
+ license = "Apache-2.0"
12
+ authors = [{ name = "Jacob Stewart", email = "jacob@swarmauri.com" }]
13
+ dependencies = [
14
+ "mcp>=1.0",
15
+ "ssot-core>=0.2.19.dev2,<0.3.0",
16
+ ]
17
+ keywords = ["ssot", "mcp", "workers", "leases", "control-plane"]
18
+
19
+ [project.scripts]
20
+ ssot-mcp = "ssot_mcp.server:main"
21
+
22
+ [tool.setuptools]
23
+ package-dir = {"" = "src"}
24
+
25
+ [tool.setuptools.packages.find]
26
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ from __future__ import annotations
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ from __future__ import annotations
2
+
3
+ from .server import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from ssot_registry.api.load import load_registry
7
+ from ssot_registry.control.service import ControlPlane
8
+ from ssot_registry.maturation.selector import next_maturation_slice
9
+
10
+ from .tools import resolve_repo
11
+
12
+
13
+ def registry_resource(repo: str | None = None) -> dict[str, Any]:
14
+ _registry_path, _repo_root, registry = load_registry(resolve_repo(repo))
15
+ return registry
16
+
17
+
18
+ def campaign_status_resource(repo: str | None = None, campaign_id: str = "") -> dict[str, Any]:
19
+ return ControlPlane(resolve_repo(repo)).get_campaign_status(campaign_id, target_tier="T2")
20
+
21
+
22
+ def maturation_queue_resource(repo: str | None = None) -> dict[str, Any]:
23
+ _registry_path, repo_root, registry = load_registry(resolve_repo(repo))
24
+ return {"next_slice": next_maturation_slice(registry, target_tier="T2", repo_root=repo_root)}
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ from typing import Any
5
+
6
+ from . import resources, tools
7
+
8
+
9
+ def build_server() -> Any:
10
+ try:
11
+ from mcp.server.fastmcp import FastMCP
12
+ except ModuleNotFoundError as exc: # pragma: no cover - exercised when optional dependency is absent.
13
+ raise RuntimeError("ssot-mcp requires the official `mcp` Python package. Install the ssot-mcp extra/package.") from exc
14
+
15
+ mcp = FastMCP("ssot-mcp")
16
+
17
+ mcp.tool()(tools.claim_next_maturation_slice)
18
+ mcp.tool()(tools.renew_lease)
19
+ mcp.tool()(tools.get_slice_context)
20
+ mcp.tool()(tools.complete_slice)
21
+ mcp.tool()(tools.abandon_slice)
22
+ mcp.tool()(tools.get_campaign_status)
23
+ mcp.tool()(tools.get_ssot_cli_surface)
24
+ mcp.tool()(tools.get_worker_events)
25
+ mcp.tool()(tools.ack_worker_events)
26
+ mcp.tool()(tools.get_conflicts)
27
+ mcp.tool()(tools.get_blocked_transitions)
28
+ mcp.tool()(tools.scaffold_target_claim_wiring)
29
+ mcp.tool()(tools.repair_blocked_transition)
30
+ mcp.tool()(tools.repair_blocked_transitions)
31
+ mcp.tool()(tools.registry_entity_get)
32
+ mcp.tool()(tools.registry_entity_list)
33
+ mcp.tool()(tools.registry_entity_search)
34
+ mcp.tool()(tools.registry_entity_upsert)
35
+ mcp.tool()(tools.registry_entity_delete)
36
+ mcp.tool()(tools.registry_entity_link)
37
+ mcp.tool()(tools.registry_entity_unlink)
38
+ mcp.tool()(tools.run_ssot_cli)
39
+
40
+ mcp.resource("ssot://registry/{repo}")(resources.registry_resource)
41
+ mcp.resource("ssot://campaign/{repo}/{campaign_id}")(resources.campaign_status_resource)
42
+ mcp.resource("ssot://maturation-queue/{repo}")(resources.maturation_queue_resource)
43
+
44
+ return mcp
45
+
46
+
47
+ def main(argv: list[str] | None = None) -> int:
48
+ parser = argparse.ArgumentParser(description="Run the optional SSOT pull-worker MCP server.")
49
+ parser.add_argument("--transport", default="stdio", choices=["stdio", "sse"])
50
+ parser.add_argument("--repo", default=None, help="Pin this MCP server instance to one SSOT repository root.")
51
+ parser.add_argument(
52
+ "--repo-mode",
53
+ choices=["explicit"],
54
+ default=None,
55
+ help="Run as a global/dev server where every tool call must pass an explicit repo argument.",
56
+ )
57
+ args = parser.parse_args(argv)
58
+ if args.repo is not None and args.repo_mode is not None:
59
+ parser.error("--repo and --repo-mode explicit are mutually exclusive")
60
+ if args.repo is None and args.repo_mode != "explicit":
61
+ parser.error("choose either --repo <path> for a pinned server or --repo-mode explicit for dev/testing")
62
+ tools.configure_repo(args.repo)
63
+ server = build_server()
64
+ server.run(transport=args.transport)
65
+ return 0
66
+
67
+
68
+ if __name__ == "__main__":
69
+ raise SystemExit(main())
@@ -0,0 +1,445 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ import io
5
+ import json
6
+ import os
7
+ import argparse
8
+ from pathlib import Path
9
+ from threading import RLock
10
+ from typing import Any
11
+
12
+ from ssot_cli.main import build_parser, main as ssot_cli_main
13
+ from ssot_registry.version import __version__ as SSOT_CORE_VERSION
14
+ from ssot_registry.api.entity_ops import (
15
+ SECTIONS,
16
+ create_entity,
17
+ delete_entity,
18
+ get_entity,
19
+ link_entities,
20
+ list_entities,
21
+ unlink_entities,
22
+ update_entity,
23
+ )
24
+ from ssot_registry.control.service import ControlPlane
25
+
26
+ _PINNED_REPO: Path | None = None
27
+ _CLI_LOCK = RLock()
28
+
29
+
30
+ def configure_repo(repo: str | Path | None) -> None:
31
+ global _PINNED_REPO
32
+ _PINNED_REPO = Path(repo).resolve() if repo is not None else None
33
+
34
+
35
+ def resolve_repo(repo: str | None = None) -> Path:
36
+ if _PINNED_REPO is None:
37
+ if repo is None:
38
+ raise ValueError("repo is required unless ssot-mcp is started with --repo")
39
+ return Path(repo).resolve()
40
+ if repo is not None and Path(repo).resolve() != _PINNED_REPO:
41
+ raise ValueError(f"ssot-mcp is pinned to {_PINNED_REPO}; refusing repo {Path(repo).resolve()}")
42
+ return _PINNED_REPO
43
+
44
+
45
+ def _plane(repo: str | None = None) -> ControlPlane:
46
+ return ControlPlane(resolve_repo(repo))
47
+
48
+
49
+ def _notify_registry_updated(repo_root: Path, payload: dict[str, Any]) -> None:
50
+ ControlPlane(repo_root).notify_registry_updated(payload=payload)
51
+
52
+
53
+ def _section_name(section: str) -> str:
54
+ normalized = section.strip().lower().replace("-", "_")
55
+ aliases = {
56
+ "feature": "features",
57
+ "profile": "profiles",
58
+ "test": "tests",
59
+ "claim": "claims",
60
+ "evidence": "evidence",
61
+ "issue": "issues",
62
+ "risk": "risks",
63
+ "boundary": "boundaries",
64
+ "release": "releases",
65
+ }
66
+ normalized = aliases.get(normalized, normalized)
67
+ if normalized not in SECTIONS:
68
+ raise ValueError(f"unsupported registry section: {section}")
69
+ return normalized
70
+
71
+
72
+ def _matches_query(row: dict[str, Any], query: str) -> bool:
73
+ needle = query.lower()
74
+ haystack = json.dumps(row, sort_keys=True, default=str).lower()
75
+ return needle in haystack
76
+
77
+
78
+ def _cli_root_command(args: list[str]) -> str:
79
+ skip_value_for = {"--output-format", "--output-file"}
80
+ skip_next = False
81
+ for token in args:
82
+ if skip_next:
83
+ skip_next = False
84
+ continue
85
+ if token in skip_value_for:
86
+ skip_next = True
87
+ continue
88
+ if token.startswith("-"):
89
+ continue
90
+ return token
91
+ return ""
92
+
93
+
94
+ def _option_flags(parser: argparse.ArgumentParser) -> list[str]:
95
+ flags: list[str] = []
96
+ for action in parser._actions:
97
+ if isinstance(action, argparse._HelpAction):
98
+ continue
99
+ for option in action.option_strings:
100
+ if option not in flags:
101
+ flags.append(option)
102
+ return sorted(flags)
103
+
104
+
105
+ def _walk_parser(
106
+ parser: argparse.ArgumentParser,
107
+ path: list[str],
108
+ subcommand_paths: list[str],
109
+ flags_by_path: dict[str, list[str]],
110
+ ) -> None:
111
+ if path:
112
+ path_key = " ".join(path)
113
+ subcommand_paths.append(path_key)
114
+ flags_by_path[path_key] = _option_flags(parser)
115
+
116
+ for action in parser._actions:
117
+ if not isinstance(action, argparse._SubParsersAction):
118
+ continue
119
+ for name, child_parser in sorted(action.choices.items()):
120
+ _walk_parser(child_parser, [*path, name], subcommand_paths, flags_by_path)
121
+
122
+
123
+ def _cli_surface() -> dict[str, Any]:
124
+ parser = build_parser(prog="ssot-registry")
125
+ top_level_commands: list[str] = []
126
+ for action in parser._actions:
127
+ if isinstance(action, argparse._SubParsersAction):
128
+ top_level_commands = sorted(action.choices.keys())
129
+ break
130
+
131
+ subcommand_paths: list[str] = []
132
+ flags_by_path: dict[str, list[str]] = {}
133
+ _walk_parser(parser, [], subcommand_paths, flags_by_path)
134
+ return {
135
+ "top_level_commands": top_level_commands,
136
+ "subcommand_paths": sorted(subcommand_paths),
137
+ "global_flags": _option_flags(parser),
138
+ "flags_by_path": {key: flags_by_path[key] for key in sorted(flags_by_path)},
139
+ }
140
+
141
+
142
+ def _is_cli_metadata_request(args: list[str]) -> bool:
143
+ return not args or any(token in {"-h", "--help", "--version"} for token in args)
144
+
145
+
146
+ def _resolve_repo_for_cli(repo: str | None, args: list[str]) -> Path:
147
+ if repo is not None or _PINNED_REPO is not None:
148
+ return resolve_repo(repo)
149
+ if _is_cli_metadata_request(args):
150
+ return Path.cwd()
151
+ return resolve_repo(repo)
152
+
153
+
154
+ def _normalize_mcp_cli_args(args: list[str]) -> tuple[list[str], list[str]]:
155
+ """Normalize delegated CLI calls that are unsafe or ambiguous for MCP workers."""
156
+
157
+ if _cli_root_command(args) != "upgrade":
158
+ return list(args), []
159
+
160
+ normalized: list[str] = []
161
+ warnings: list[str] = []
162
+ skip_next = False
163
+ saw_sync_docs = False
164
+ for token in args:
165
+ if skip_next:
166
+ skip_next = False
167
+ warnings.append(
168
+ "MCP upgrade ignores --target-version and uses the currently running ssot-mcp binary/runtime instead."
169
+ )
170
+ continue
171
+ if token == "--target-version":
172
+ skip_next = True
173
+ continue
174
+ if token.startswith("--target-version="):
175
+ warnings.append(
176
+ "MCP upgrade ignores --target-version and uses the currently running ssot-mcp binary/runtime instead."
177
+ )
178
+ continue
179
+ if token == "--sync-docs":
180
+ saw_sync_docs = True
181
+ normalized.append(token)
182
+
183
+ if not saw_sync_docs:
184
+ normalized.append("--sync-docs")
185
+ warnings.append("MCP upgrade added --sync-docs so packaged ADR/SPEC documents are refreshed with the current runtime.")
186
+
187
+ warnings.append(f"MCP upgrade is running with installed ssot-core version {SSOT_CORE_VERSION}.")
188
+ return normalized, warnings
189
+
190
+
191
+ @contextlib.contextmanager
192
+ def _cwd(path: Path):
193
+ previous = Path.cwd()
194
+ os.chdir(path)
195
+ try:
196
+ yield
197
+ finally:
198
+ os.chdir(previous)
199
+
200
+
201
+ def claim_next_maturation_slice(
202
+ repo: str | None = None,
203
+ worker_id: str = "",
204
+ campaign_id: str = "",
205
+ target_tier: str = "T2",
206
+ os_user: str | None = None,
207
+ ttl_seconds: int = 1800,
208
+ feature_ids: list[str] | None = None,
209
+ profile_ids: list[str] | None = None,
210
+ boundary_ids: list[str] | None = None,
211
+ max_blockers_per_claim: int = 5,
212
+ auto_scaffold: bool = True,
213
+ feature_limit: int | None = 25,
214
+ ) -> dict[str, Any]:
215
+ return _plane(repo).claim_next_maturation_slice(
216
+ worker_id=worker_id,
217
+ campaign_id=campaign_id,
218
+ target_tier=target_tier,
219
+ os_user=os_user,
220
+ ttl_seconds=ttl_seconds,
221
+ feature_ids=feature_ids,
222
+ profile_ids=profile_ids,
223
+ boundary_ids=boundary_ids,
224
+ max_blockers_per_claim=max_blockers_per_claim,
225
+ auto_scaffold=auto_scaffold,
226
+ feature_limit=feature_limit,
227
+ )
228
+
229
+
230
+ def renew_lease(repo: str | None = None, worker_id: str = "", lease_id: str = "", fencing_token: int = 0, ttl_seconds: int = 1800) -> dict[str, Any]:
231
+ return _plane(repo).renew_lease(
232
+ worker_id=worker_id,
233
+ lease_id=lease_id,
234
+ fencing_token=fencing_token,
235
+ ttl_seconds=ttl_seconds,
236
+ )
237
+
238
+
239
+ def get_slice_context(repo: str | None = None, lease_id: str = "") -> dict[str, Any]:
240
+ return _plane(repo).get_slice_context(lease_id)
241
+
242
+
243
+ def complete_slice(repo: str | None = None, worker_id: str = "", lease_id: str = "", fencing_token: int = 0, result: dict[str, Any] | None = None) -> dict[str, Any]:
244
+ if result is None:
245
+ result = {}
246
+ return _plane(repo).complete_slice(worker_id=worker_id, lease_id=lease_id, fencing_token=fencing_token, result=result)
247
+
248
+
249
+ def abandon_slice(repo: str | None = None, worker_id: str = "", lease_id: str = "", fencing_token: int = 0, reason: str = "") -> dict[str, Any]:
250
+ return _plane(repo).abandon_slice(worker_id=worker_id, lease_id=lease_id, fencing_token=fencing_token, reason=reason)
251
+
252
+
253
+ def get_campaign_status(repo: str | None = None, campaign_id: str = "", target_tier: str = "T2", feature_limit: int | None = None) -> dict[str, Any]:
254
+ return _plane(repo).get_campaign_status(campaign_id, target_tier=target_tier, feature_limit=feature_limit)
255
+
256
+
257
+ def get_worker_events(
258
+ repo: str | None = None,
259
+ worker_id: str | None = None,
260
+ campaign_id: str | None = None,
261
+ after_event_id: int = 0,
262
+ limit: int = 100,
263
+ ) -> dict[str, Any]:
264
+ return _plane(repo).get_worker_events(
265
+ worker_id=worker_id,
266
+ campaign_id=campaign_id,
267
+ after_event_id=after_event_id,
268
+ limit=limit,
269
+ )
270
+
271
+
272
+ def ack_worker_events(repo: str | None = None, worker_id: str = "", event_ids: list[int] | None = None, action: str = "processed") -> dict[str, Any]:
273
+ if event_ids is None:
274
+ event_ids = []
275
+ return _plane(repo).ack_worker_events(worker_id=worker_id, event_ids=event_ids, action=action)
276
+
277
+
278
+ def get_conflicts(repo: str | None = None, status: str | None = "open") -> dict[str, Any]:
279
+ return _plane(repo).get_conflicts(status=status)
280
+
281
+
282
+ def get_ssot_cli_surface(repo: str | None = None) -> dict[str, Any]:
283
+ if repo is not None or _PINNED_REPO is not None:
284
+ _resolve_repo_for_cli(repo, ["--help"])
285
+ surface = _cli_surface()
286
+ return {"passed": True, **surface}
287
+
288
+
289
+ def get_blocked_transitions(repo: str | None = None, campaign_id: str | None = None, status: str | None = "open") -> dict[str, Any]:
290
+ return {"passed": True, "blocked_transitions": _plane(repo).store.get_blocked_transitions(campaign_id=campaign_id, status=status)}
291
+
292
+
293
+ def scaffold_target_claim_wiring(repo: str | None = None, feature_id: str = "", target_tier: str = "T1") -> dict[str, Any]:
294
+ return _plane(repo).scaffold_target_claim_wiring(feature_id=feature_id, target_tier=target_tier)
295
+
296
+
297
+ def repair_blocked_transition(repo: str | None = None, blocked_id: str = "") -> dict[str, Any]:
298
+ return _plane(repo).repair_blocked_transition(blocked_id=blocked_id)
299
+
300
+
301
+ def repair_blocked_transitions(
302
+ repo: str | None = None,
303
+ campaign_id: str | None = None,
304
+ feature_ids: list[str] | None = None,
305
+ limit: int = 25,
306
+ ) -> dict[str, Any]:
307
+ return _plane(repo).repair_blocked_transitions(campaign_id=campaign_id, feature_ids=feature_ids, limit=limit)
308
+
309
+
310
+ def registry_entity_get(repo: str | None = None, section: str = "", entity_id: str = "") -> dict[str, Any]:
311
+ repo_root = resolve_repo(repo)
312
+ resolved_section = _section_name(section)
313
+ return {"passed": True, "section": resolved_section, "entity": get_entity(repo_root, resolved_section, entity_id)}
314
+
315
+
316
+ def registry_entity_list(
317
+ repo: str | None = None,
318
+ section: str = "",
319
+ ids: list[str] | None = None,
320
+ origin: str | None = None,
321
+ limit: int = 100,
322
+ offset: int = 0,
323
+ ) -> dict[str, Any]:
324
+ repo_root = resolve_repo(repo)
325
+ resolved_section = _section_name(section)
326
+ rows = list_entities(repo_root, resolved_section, ids=ids, origin=origin)
327
+ total = len(rows)
328
+ limited = rows[max(offset, 0) : max(offset, 0) + max(limit, 0)]
329
+ return {"passed": True, "section": resolved_section, "total": total, "offset": offset, "limit": limit, "entities": limited}
330
+
331
+
332
+ def registry_entity_search(repo: str | None = None, section: str = "", query: str = "", limit: int = 100, offset: int = 0) -> dict[str, Any]:
333
+ repo_root = resolve_repo(repo)
334
+ resolved_section = _section_name(section)
335
+ rows = [row for row in list_entities(repo_root, resolved_section) if _matches_query(row, query)]
336
+ total = len(rows)
337
+ limited = rows[max(offset, 0) : max(offset, 0) + max(limit, 0)]
338
+ return {"passed": True, "section": resolved_section, "query": query, "total": total, "offset": offset, "limit": limit, "entities": limited}
339
+
340
+
341
+ def registry_entity_upsert(repo: str | None = None, section: str = "", entity: dict[str, Any] | None = None) -> dict[str, Any]:
342
+ if entity is None:
343
+ entity = {}
344
+ repo_root = resolve_repo(repo)
345
+ resolved_section = _section_name(section)
346
+ entity_id = entity.get("id")
347
+ if not isinstance(entity_id, str) or not entity_id:
348
+ raise ValueError("entity.id is required")
349
+ try:
350
+ existing = get_entity(repo_root, resolved_section, entity_id)
351
+ except ValueError:
352
+ result = create_entity(repo_root, resolved_section, entity)
353
+ action = "create"
354
+ else:
355
+ result = update_entity(repo_root, resolved_section, entity_id, entity)
356
+ action = "update"
357
+ result["previous_entity"] = existing
358
+ _notify_registry_updated(repo_root, {"source": "ssot-mcp", "tool": "registry_entity_upsert", "section": resolved_section, "entity_id": entity_id, "action": action})
359
+ return result
360
+
361
+
362
+ def registry_entity_delete(repo: str | None = None, section: str = "", entity_id: str = "") -> dict[str, Any]:
363
+ repo_root = resolve_repo(repo)
364
+ resolved_section = _section_name(section)
365
+ result = delete_entity(repo_root, resolved_section, entity_id)
366
+ _notify_registry_updated(repo_root, {"source": "ssot-mcp", "tool": "registry_entity_delete", "section": resolved_section, "entity_id": entity_id})
367
+ return result
368
+
369
+
370
+ def registry_entity_link(repo: str | None = None, section: str = "", entity_id: str = "", links: dict[str, list[str]] | None = None) -> dict[str, Any]:
371
+ repo_root = resolve_repo(repo)
372
+ resolved_section = _section_name(section)
373
+ result = link_entities(repo_root, resolved_section, entity_id, links or {})
374
+ _notify_registry_updated(repo_root, {"source": "ssot-mcp", "tool": "registry_entity_link", "section": resolved_section, "entity_id": entity_id, "links": links or {}})
375
+ return result
376
+
377
+
378
+ def registry_entity_unlink(repo: str | None = None, section: str = "", entity_id: str = "", links: dict[str, list[str]] | None = None) -> dict[str, Any]:
379
+ repo_root = resolve_repo(repo)
380
+ resolved_section = _section_name(section)
381
+ result = unlink_entities(repo_root, resolved_section, entity_id, links or {})
382
+ _notify_registry_updated(repo_root, {"source": "ssot-mcp", "tool": "registry_entity_unlink", "section": resolved_section, "entity_id": entity_id, "links": links or {}})
383
+ return result
384
+
385
+
386
+ def run_ssot_cli(repo: str | None = None, args: list[str] | None = None) -> dict[str, Any]:
387
+ """Run the SSOT CLI in-process against the resolved repo root.
388
+
389
+ The server changes cwd to the target repo for the duration of the call so
390
+ normal CLI commands using the default `.` path operate on the MCP-selected
391
+ registry. Arguments are argv tokens after `ssot`.
392
+ """
393
+
394
+ original_args = list(args or [])
395
+ normalized_args, normalization_warnings = _normalize_mcp_cli_args(original_args)
396
+ repo_root = _resolve_repo_for_cli(repo, normalized_args)
397
+ argv = list(normalized_args)
398
+ metadata_request = _is_cli_metadata_request(argv)
399
+ if "--output-format" not in argv and not metadata_request:
400
+ argv = ["--output-format", "json", *argv]
401
+ stdout = io.StringIO()
402
+ stderr = io.StringIO()
403
+ with _CLI_LOCK:
404
+ with _cwd(repo_root), contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
405
+ try:
406
+ exit_code = ssot_cli_main(argv)
407
+ except SystemExit as exc:
408
+ code = exc.code
409
+ exit_code = int(code) if isinstance(code, int) else 1
410
+ stdout_text = stdout.getvalue()
411
+ stderr_text = stderr.getvalue()
412
+ try:
413
+ payload: Any = json.loads(stdout_text) if stdout_text.strip() else None
414
+ except json.JSONDecodeError:
415
+ payload = stdout_text
416
+ mutating_roots = {
417
+ "init",
418
+ "upgrade",
419
+ "config",
420
+ "adr",
421
+ "spec",
422
+ "feature",
423
+ "profile",
424
+ "test",
425
+ "issue",
426
+ "claim",
427
+ "evidence",
428
+ "risk",
429
+ "boundary",
430
+ "release",
431
+ "registry",
432
+ }
433
+ command = _cli_root_command(original_args)
434
+ if exit_code == 0 and command in mutating_roots:
435
+ _notify_registry_updated(repo_root, {"source": "ssot-mcp", "tool": "run_ssot_cli", "args": original_args, "normalized_args": normalized_args})
436
+ return {
437
+ "passed": exit_code == 0,
438
+ "exit_code": exit_code,
439
+ "args": original_args,
440
+ "normalized_args": normalized_args,
441
+ "warnings": normalization_warnings,
442
+ "output": payload,
443
+ "stdout": stdout_text,
444
+ "stderr": stderr_text,
445
+ }
@@ -0,0 +1,82 @@
1
+ Metadata-Version: 2.4
2
+ Name: ssot-mcp
3
+ Version: 0.1.1.dev2
4
+ Summary: MCP server for optional SSOT pull-worker control-plane coordination.
5
+ Author-email: Jacob Stewart <jacob@swarmauri.com>
6
+ License-Expression: Apache-2.0
7
+ Keywords: ssot,mcp,workers,leases,control-plane
8
+ Requires-Python: <3.15,>=3.10
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: mcp>=1.0
11
+ Requires-Dist: ssot-core<0.3.0,>=0.2.19.dev2
12
+
13
+ # ssot-mcp
14
+
15
+ Optional MCP server for SSOT pull-worker coordination. Workers still pull work
16
+ through `claim_next_maturation_slice`; pushed update notifications only wake,
17
+ pause, refresh, or stop workers.
18
+
19
+ The core `ssot` CLI and `.ssot/registry.json` workflows do not require this
20
+ package. Deploy `ssot-mcp` only when a Codex/MCP client should coordinate
21
+ campaigns, leases, worker events, and registry writes through MCP tools.
22
+
23
+ Run one pinned server per repository in normal use:
24
+
25
+ ```powershell
26
+ ssot-mcp --transport stdio --repo E:\swarmauri_github\ssot-registry
27
+ ```
28
+
29
+ Run global/dev mode only when callers must pass an explicit `repo` argument on
30
+ every tool/resource call:
31
+
32
+ ```powershell
33
+ ssot-mcp --transport stdio --repo-mode explicit
34
+ ```
35
+
36
+ See [Codex MCP configuration](../../docs/coordination/codex-mcp.md) for Codex
37
+ `config.toml` examples.
38
+
39
+ ## Registry write authority
40
+
41
+ Workers must not hand-edit `.ssot/registry.json`. When a worker needs SSOT
42
+ entity changes, it asks `ssot-mcp` to perform the mutation through one of the
43
+ registry tools:
44
+
45
+ - `get_blocked_transitions`
46
+ - `scaffold_target_claim_wiring`
47
+ - `repair_blocked_transition`
48
+ - `registry_entity_get`
49
+ - `registry_entity_list`
50
+ - `registry_entity_search`
51
+ - `registry_entity_upsert`
52
+ - `registry_entity_delete`
53
+ - `registry_entity_link`
54
+ - `registry_entity_unlink`
55
+ - `get_ssot_cli_surface`
56
+ - `run_ssot_cli`
57
+
58
+ The structured entity tools use the same core registry mutation APIs as the
59
+ CLI, validate the registry before saving, and emit `registry_updated` events.
60
+ `run_ssot_cli` delegates to the repo-local CLI parser in-process for command
61
+ coverage that is not yet exposed as a dedicated MCP tool. It supports global
62
+ flags, help/version requests, commands, subcommands, command flags, and
63
+ subcommand flags as argv tokens. `get_ssot_cli_surface` returns the live CLI
64
+ surface (`global_flags`, `top_level_commands`, `subcommand_paths`, and
65
+ `flags_by_path`) so workers can discover the exact supported command shape
66
+ before calling `run_ssot_cli`. Help and invalid-argument parser exits are
67
+ captured as normal tool results; they must not close the MCP transport.
68
+
69
+ Campaign claims can also be scoped instead of running over every active
70
+ in-bounds feature. `claim_next_maturation_slice` accepts `feature_ids`,
71
+ `profile_ids`, and `boundary_ids`; unscoped campaigns consider 25 in-bounds
72
+ active features by default, and operators can raise `feature_limit` explicitly
73
+ for broader campaigns. Out-of-bounds features are filtered from assignment and
74
+ campaign status output. It caps blocker discovery with `max_blockers_per_claim`;
75
+ and auto-scaffolding is enabled by default so
76
+ `ssot-mcp` attempts target-tier claim/test/evidence scaffolding before returning
77
+ a blocked result. Pass `auto_scaffold=false` only when intentionally testing or
78
+ observing raw blocked-transition behavior. When a claim response returns
79
+ `kind="blocked"`, it includes a top-level `reason` and a structured
80
+ `problem_detail` with blocker rows and recommended MCP tool calls such as
81
+ `repair_blocked_transition` or `scaffold_target_claim_wiring`; workers should
82
+ perform those repairs and then pull again.
@@ -0,0 +1,13 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/ssot_mcp/__init__.py
4
+ src/ssot_mcp/__main__.py
5
+ src/ssot_mcp/resources.py
6
+ src/ssot_mcp/server.py
7
+ src/ssot_mcp/tools.py
8
+ src/ssot_mcp.egg-info/PKG-INFO
9
+ src/ssot_mcp.egg-info/SOURCES.txt
10
+ src/ssot_mcp.egg-info/dependency_links.txt
11
+ src/ssot_mcp.egg-info/entry_points.txt
12
+ src/ssot_mcp.egg-info/requires.txt
13
+ src/ssot_mcp.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ssot-mcp = ssot_mcp.server:main
@@ -0,0 +1,2 @@
1
+ mcp>=1.0
2
+ ssot-core<0.3.0,>=0.2.19.dev2
@@ -0,0 +1 @@
1
+ ssot_mcp