ssot-mcp 0.1.1.dev2__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.
- ssot_mcp/__init__.py +5 -0
- ssot_mcp/__main__.py +6 -0
- ssot_mcp/resources.py +24 -0
- ssot_mcp/server.py +69 -0
- ssot_mcp/tools.py +445 -0
- ssot_mcp-0.1.1.dev2.dist-info/METADATA +82 -0
- ssot_mcp-0.1.1.dev2.dist-info/RECORD +10 -0
- ssot_mcp-0.1.1.dev2.dist-info/WHEEL +5 -0
- ssot_mcp-0.1.1.dev2.dist-info/entry_points.txt +2 -0
- ssot_mcp-0.1.1.dev2.dist-info/top_level.txt +1 -0
ssot_mcp/__init__.py
ADDED
ssot_mcp/__main__.py
ADDED
ssot_mcp/resources.py
ADDED
|
@@ -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)}
|
ssot_mcp/server.py
ADDED
|
@@ -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())
|
ssot_mcp/tools.py
ADDED
|
@@ -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,10 @@
|
|
|
1
|
+
ssot_mcp/__init__.py,sha256=Osv-JZSy8d2Pkf7ZQ2B4ulIhVc7XhWZDeJgRmpiDezg,85
|
|
2
|
+
ssot_mcp/__main__.py,sha256=SwJShB5Z9EEcdl5-quGnBMVOmM1b39hshNfrOdgKQho,118
|
|
3
|
+
ssot_mcp/resources.py,sha256=If63E--NwAWPgo8Am9prR4A9ns0Jx7O3l0FaHft_PS4,898
|
|
4
|
+
ssot_mcp/server.py,sha256=oVv3nLX7a7_V6Dsep5otgBNxutzh4gB_buFp1NX_JQc,2738
|
|
5
|
+
ssot_mcp/tools.py,sha256=OHTzR7Qi2oUnKmrrjNBqvLJtgkFjTpxKQe62wsyZJ6A,16423
|
|
6
|
+
ssot_mcp-0.1.1.dev2.dist-info/METADATA,sha256=nWGdNbuQAAkvtuQsVoLEPkPBGSt3VBR1qTPJoW8lxl0,3514
|
|
7
|
+
ssot_mcp-0.1.1.dev2.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
8
|
+
ssot_mcp-0.1.1.dev2.dist-info/entry_points.txt,sha256=RUo1A6E59JxUzwzEC0C2v61fYZxMLv3S4ZlJuYScgPA,50
|
|
9
|
+
ssot_mcp-0.1.1.dev2.dist-info/top_level.txt,sha256=_Dtb_TISs86Mo1w_fn_yOrTdiZJGm6zHuG_vF-4uGz4,9
|
|
10
|
+
ssot_mcp-0.1.1.dev2.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ssot_mcp
|