okstra 0.220.1 → 0.221.0
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.
- package/dist/cli-registry.mjs +6 -0
- package/dist/cli-registry.mjs.map +1 -1
- package/docs/architecture.md +2 -0
- package/docs/cli.md +7 -3
- package/docs/project-structure-overview.md +1 -1
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/prompts/launch.template.md +1 -1
- package/runtime/prompts/lead/okstra-lead-contract.md +9 -5
- package/runtime/prompts/lead/team-contract.md +1 -1
- package/runtime/python/okstra_ctl/adapters/hosts/capability_adapter.py +3 -2
- package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +3 -3
- package/runtime/python/okstra_ctl/adapters/hosts/claude-code/worker-session.md +4 -2
- package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +3 -3
- package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +27 -0
- package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +24 -0
- package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +24 -0
- package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +25 -0
- package/runtime/python/okstra_ctl/adapters/providers/zai/adapter.py +20 -1
- package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +3 -0
- package/runtime/python/okstra_ctl/analysis_packet.py +14 -0
- package/runtime/python/okstra_ctl/correction_cli.py +44 -5
- package/runtime/python/okstra_ctl/correction_round.py +40 -11
- package/runtime/python/okstra_ctl/domain/mcp.py +43 -0
- package/runtime/python/okstra_ctl/domain/provider.py +2 -0
- package/runtime/python/okstra_ctl/domain/wizard/interaction.py +89 -8
- package/runtime/python/okstra_ctl/file_size_cap.py +142 -0
- package/runtime/python/okstra_ctl/initial_prompt_materialization.py +3 -6
- package/runtime/python/okstra_ctl/manager_cli.py +11 -1
- package/runtime/python/okstra_ctl/manager_sync.py +38 -2
- package/runtime/python/okstra_ctl/mcp_cli.py +179 -0
- package/runtime/python/okstra_ctl/mcp_servers.py +421 -0
- package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-deliverable.md +4 -3
- package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-executor.md +1 -0
- package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-self-check.md +1 -0
- package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-verifier.md +3 -2
- package/runtime/python/okstra_ctl/phases/implementation_option_selection/profile.md +1 -1
- package/runtime/python/okstra_ctl/phases/implementation_planning/authoring.py +19 -0
- package/runtime/python/okstra_ctl/phases/implementation_planning/instructions/plan-body-verification.md +14 -0
- package/runtime/python/okstra_ctl/phases/implementation_planning/profile.md +2 -1
- package/runtime/python/okstra_ctl/phases/technical_verification/entry.py +37 -0
- package/runtime/python/okstra_ctl/phases/technical_verification/validation.py +3 -3
- package/runtime/python/okstra_ctl/render.py +7 -48
- package/runtime/python/okstra_ctl/report_language.py +4 -5
- package/runtime/python/okstra_ctl/run.py +29 -0
- package/runtime/python/okstra_ctl/stage_done.py +37 -3
- package/runtime/skills/_fragments/wizard-lead-takeover.md +1 -1
- package/runtime/skills/_fragments/wizard-relay-protocol.md +2 -2
- package/runtime/skills/okstra-discussion/SKILL.md +3 -3
- package/runtime/skills/okstra-manager/SKILL.md +1 -0
- package/runtime/skills/okstra-run/SKILL.md +3 -3
- package/runtime/skills/okstra-setup/SKILL.md +2 -0
- package/runtime/skills/okstra-setup/references/project-config.md +34 -0
- package/runtime/templates/report-writer-prompt-preamble.md +1 -1
- package/runtime/templates/reports/task-brief.template.md +3 -3
- package/runtime/validators/validate-run.py +13 -0
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
"""Read project-local okstra state into manager-owned snapshots."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
+
from datetime import datetime
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
|
|
6
7
|
from okstra_project import TASKS_RELATIVE, StateError, parse_task_key, read_task_catalog, read_task_manifest, slugify
|
|
8
|
+
from okstra_project.dirs import TASK_MANIFEST_FILENAME
|
|
7
9
|
|
|
8
10
|
from . import next_phase
|
|
9
11
|
from .next_phase import STATUS_BLOCKED, STATUS_TERMINAL
|
|
@@ -168,12 +170,35 @@ def summary_bucket(row: dict) -> str:
|
|
|
168
170
|
return _POINTER_STATUS_BUCKETS.get(pointer_status, "running")
|
|
169
171
|
|
|
170
172
|
|
|
171
|
-
def
|
|
173
|
+
def _epoch(value: str) -> int | None:
|
|
174
|
+
if not value:
|
|
175
|
+
return None
|
|
176
|
+
return int(datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp())
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _manifest_changed_since(project: dict | None, task_key: str, synced: int | None) -> bool:
|
|
180
|
+
"""프로젝트의 task-manifest 가 마지막 sync 뒤에 바뀌었는지 — 파일 시각만 본다."""
|
|
181
|
+
if project is None or not task_key:
|
|
182
|
+
return False
|
|
183
|
+
root = Path(str(project.get("projectRoot") or ""))
|
|
184
|
+
if not root.is_dir():
|
|
185
|
+
return False
|
|
186
|
+
task_dir = _resolve_task_root_read_only(root, task_key)
|
|
187
|
+
manifest_path = task_dir / TASK_MANIFEST_FILENAME if task_dir is not None else None
|
|
188
|
+
if manifest_path is None or not manifest_path.is_file():
|
|
189
|
+
return False
|
|
190
|
+
# syncedAt 은 초 단위라 mtime 도 초로 내려 같은 초에 쓴 파일을 변경으로 세지 않는다.
|
|
191
|
+
return synced is None or int(manifest_path.stat().st_mtime) > synced
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def status_task(home: Path, manager_id: str, task_group: str, task_id: str, *, now: str | None = None) -> dict:
|
|
172
195
|
manifest = _read_json(task_manifest_path(home, manager_id, task_group, task_id))
|
|
173
196
|
children = _read_json_default(children_json_path(home, manager_id, task_group, task_id), {"children": []})
|
|
174
197
|
snapshot = _read_json_default(snapshots_json_path(home, manager_id, task_group, task_id), {"children": []})
|
|
175
198
|
by_key = {row.get("taskKey"): row for row in snapshot.get("children", []) if isinstance(row, dict)}
|
|
176
199
|
linked = _project_map(home, manager_id)
|
|
200
|
+
synced_at = str(snapshot.get("syncedAt") or "")
|
|
201
|
+
synced = _epoch(synced_at)
|
|
177
202
|
rows = []
|
|
178
203
|
summary = {"planned": 0, "missing": 0, "running": 0, "done": 0, "blocked": 0, "discussion": 0}
|
|
179
204
|
for child in children.get("children", []):
|
|
@@ -184,6 +209,17 @@ def status_task(home: Path, manager_id: str, task_group: str, task_id: str) -> d
|
|
|
184
209
|
row["summaryBucket"] = "planned" if snapshot_row is None else summary_bucket(row)
|
|
185
210
|
# `remove project` 는 하위 태스크를 남긴다. 등록 목록에 없으면 해제된 프로젝트의 것이다.
|
|
186
211
|
row["projectLinked"] = str(child.get("projectId") or "") in linked
|
|
212
|
+
try:
|
|
213
|
+
row["changedSinceSync"] = _manifest_changed_since(
|
|
214
|
+
linked.get(str(child.get("projectId") or "")), str(child.get("taskKey") or ""), synced)
|
|
215
|
+
except StateError as exc:
|
|
216
|
+
row["changedSinceSync"] = True
|
|
217
|
+
row["error"] = str(exc)
|
|
187
218
|
summary[row["summaryBucket"]] += 1
|
|
188
219
|
rows.append(row)
|
|
189
|
-
|
|
220
|
+
payload = {"manifest": manifest, "summary": summary, "children": rows, "lastSnapshot": snapshot,
|
|
221
|
+
"syncedAt": synced_at,
|
|
222
|
+
"changedSinceSyncCount": sum(1 for row in rows if row["changedSinceSync"])}
|
|
223
|
+
if synced is not None:
|
|
224
|
+
payload["syncAgeSeconds"] = max(0, (_epoch(now or _now_iso()) or 0) - synced)
|
|
225
|
+
return payload
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""`okstra mcp` — declare project MCP servers and check provider reachability."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from okstra_project import resolve_project_root
|
|
10
|
+
from okstra_project.resolver import ResolverError
|
|
11
|
+
|
|
12
|
+
from .json_boundary import JsonBoundaryError
|
|
13
|
+
from .mcp_servers import (
|
|
14
|
+
McpDeclarationError,
|
|
15
|
+
assess,
|
|
16
|
+
brief_source_warnings,
|
|
17
|
+
load_declarations,
|
|
18
|
+
parse_declaration,
|
|
19
|
+
probe_inventories,
|
|
20
|
+
provider_probes,
|
|
21
|
+
reachability_sentence,
|
|
22
|
+
remove_declaration,
|
|
23
|
+
upsert_declaration,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
_CLI_EPILOG = r"""Usage:
|
|
27
|
+
okstra mcp list [--json] [--cwd <dir>]
|
|
28
|
+
okstra mcp add --name <name> --description <text> --tool <tool>... [--alias <name>...] [--notes <text>] [--cwd <dir>]
|
|
29
|
+
okstra mcp remove --name <name> [--cwd <dir>]
|
|
30
|
+
okstra mcp check [--provider <id>...] [--brief <path>] [--json] [--cwd <dir>]
|
|
31
|
+
|
|
32
|
+
Declarations live in .okstra/project.json `mcpServers`. `--alias` names the
|
|
33
|
+
server as a provider CLI lists it (for example `figma-desktop`). `check` runs
|
|
34
|
+
each provider adapter's MCP listing command; it never sends an inference call.
|
|
35
|
+
Add a missing server through that provider CLI's own MCP configuration.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _project_root(cwd: str) -> Path:
|
|
40
|
+
return resolve_project_root(cwd=str(Path(cwd).resolve()))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _list(root: Path, as_json: bool) -> int:
|
|
44
|
+
declarations, problems = load_declarations(root)
|
|
45
|
+
if as_json:
|
|
46
|
+
print(json.dumps({
|
|
47
|
+
"servers": [d.to_json() for d in declarations],
|
|
48
|
+
"problems": list(problems),
|
|
49
|
+
}, ensure_ascii=False, indent=2))
|
|
50
|
+
return 1 if problems else 0
|
|
51
|
+
if not declarations:
|
|
52
|
+
print("No MCP servers declared.")
|
|
53
|
+
for declaration in declarations:
|
|
54
|
+
aliases = f" (aliases: {', '.join(declaration.aliases)})" if declaration.aliases else ""
|
|
55
|
+
print(f"{declaration.name}{aliases}: {declaration.description}")
|
|
56
|
+
print(f" tools: {', '.join(declaration.tools)}")
|
|
57
|
+
if declaration.notes:
|
|
58
|
+
print(f" notes: {declaration.notes}")
|
|
59
|
+
for problem in problems:
|
|
60
|
+
print(f"invalid: {problem}")
|
|
61
|
+
return 1 if problems else 0
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _add(root: Path, args: argparse.Namespace) -> int:
|
|
65
|
+
raw = {
|
|
66
|
+
"name": args.name,
|
|
67
|
+
"description": args.description,
|
|
68
|
+
"tools": args.tool or [],
|
|
69
|
+
}
|
|
70
|
+
if args.alias:
|
|
71
|
+
raw["aliases"] = args.alias
|
|
72
|
+
if args.notes:
|
|
73
|
+
raw["notes"] = args.notes
|
|
74
|
+
declaration, errors = parse_declaration(raw)
|
|
75
|
+
if declaration is None:
|
|
76
|
+
print("error: " + "; ".join(errors))
|
|
77
|
+
return 1
|
|
78
|
+
replaced = upsert_declaration(root, declaration)
|
|
79
|
+
print(f"{'Replaced' if replaced else 'Added'} MCP server `{declaration.name}`.")
|
|
80
|
+
print("Run `okstra mcp check` to see which providers can reach it.")
|
|
81
|
+
return 0
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _remove(root: Path, name: str) -> int:
|
|
85
|
+
if not remove_declaration(root, name):
|
|
86
|
+
print(f"error: no MCP server named `{name}` is declared")
|
|
87
|
+
return 1
|
|
88
|
+
print(f"Removed MCP server `{name}`.")
|
|
89
|
+
return 0
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _check(root: Path, providers: list[str], brief: str, as_json: bool) -> int:
|
|
93
|
+
from .registry.provider_registry import default_provider_registry
|
|
94
|
+
|
|
95
|
+
declarations, problems = load_declarations(root)
|
|
96
|
+
provider_ids = providers or list(default_provider_registry().ids())
|
|
97
|
+
rows = assess(declarations, probe_inventories(provider_probes(provider_ids), root))
|
|
98
|
+
brief_warnings = (
|
|
99
|
+
brief_source_warnings(Path(brief).read_text(encoding="utf-8"), declarations, rows)
|
|
100
|
+
if brief else ()
|
|
101
|
+
)
|
|
102
|
+
if as_json:
|
|
103
|
+
print(json.dumps({
|
|
104
|
+
"servers": [
|
|
105
|
+
{
|
|
106
|
+
"name": row.server.name,
|
|
107
|
+
"providers": [
|
|
108
|
+
{
|
|
109
|
+
"provider": cell.provider_id,
|
|
110
|
+
"status": cell.status,
|
|
111
|
+
"visibleName": cell.visible_name,
|
|
112
|
+
"note": cell.note,
|
|
113
|
+
}
|
|
114
|
+
for cell in row.cells
|
|
115
|
+
],
|
|
116
|
+
}
|
|
117
|
+
for row in rows
|
|
118
|
+
],
|
|
119
|
+
"problems": list(problems),
|
|
120
|
+
"briefWarnings": list(brief_warnings),
|
|
121
|
+
}, ensure_ascii=False, indent=2))
|
|
122
|
+
return 0
|
|
123
|
+
if not declarations:
|
|
124
|
+
print("No MCP servers declared. Register one with `okstra mcp add`.")
|
|
125
|
+
for row in rows:
|
|
126
|
+
print(f"{row.server.name}: {reachability_sentence(row)}")
|
|
127
|
+
for cell in row.cells:
|
|
128
|
+
if cell.note and cell.status != "missing":
|
|
129
|
+
print(f" {cell.provider_id}: {cell.note}")
|
|
130
|
+
for problem in problems:
|
|
131
|
+
print(f"invalid: {problem}")
|
|
132
|
+
for warning in brief_warnings:
|
|
133
|
+
print(warning)
|
|
134
|
+
return 0
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def main(argv: list[str] | None = None) -> int:
|
|
138
|
+
parser = argparse.ArgumentParser(
|
|
139
|
+
description="Declare project MCP servers and check provider reachability.",
|
|
140
|
+
epilog=_CLI_EPILOG,
|
|
141
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
142
|
+
prog="okstra mcp",
|
|
143
|
+
)
|
|
144
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
145
|
+
listed = sub.add_parser("list")
|
|
146
|
+
listed.add_argument("--json", action="store_true")
|
|
147
|
+
listed.add_argument("--cwd", default=".")
|
|
148
|
+
add = sub.add_parser("add")
|
|
149
|
+
add.add_argument("--name", required=True)
|
|
150
|
+
add.add_argument("--description", required=True)
|
|
151
|
+
add.add_argument("--tool", action="append")
|
|
152
|
+
add.add_argument("--alias", action="append")
|
|
153
|
+
add.add_argument("--notes", default="")
|
|
154
|
+
add.add_argument("--cwd", default=".")
|
|
155
|
+
remove = sub.add_parser("remove")
|
|
156
|
+
remove.add_argument("--name", required=True)
|
|
157
|
+
remove.add_argument("--cwd", default=".")
|
|
158
|
+
check = sub.add_parser("check")
|
|
159
|
+
check.add_argument("--provider", action="append", default=[])
|
|
160
|
+
check.add_argument("--brief", default="")
|
|
161
|
+
check.add_argument("--json", action="store_true")
|
|
162
|
+
check.add_argument("--cwd", default=".")
|
|
163
|
+
args = parser.parse_args(argv)
|
|
164
|
+
try:
|
|
165
|
+
root = _project_root(args.cwd)
|
|
166
|
+
if args.command == "list":
|
|
167
|
+
return _list(root, args.json)
|
|
168
|
+
if args.command == "add":
|
|
169
|
+
return _add(root, args)
|
|
170
|
+
if args.command == "remove":
|
|
171
|
+
return _remove(root, args.name)
|
|
172
|
+
return _check(root, args.provider, args.brief, args.json)
|
|
173
|
+
except (McpDeclarationError, JsonBoundaryError, ResolverError, OSError, ValueError) as exc:
|
|
174
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
175
|
+
return 1
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
if __name__ == "__main__":
|
|
179
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
"""프로젝트 MCP 서버 선언과 provider 별 도달성 판정.
|
|
2
|
+
|
|
3
|
+
선언은 논리 서버 이름과, provider CLI 가 그 서버를 나열하는 이름(`aliases`)만
|
|
4
|
+
갖는다. 어느 provider 가 볼 수 있는지는 각 어댑터의 `mcp_inventory` 가 답하며,
|
|
5
|
+
이 모듈은 provider 이름을 하나도 모른다.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
import subprocess
|
|
11
|
+
from collections.abc import Callable, Iterable, Mapping, Sequence
|
|
12
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Literal
|
|
16
|
+
|
|
17
|
+
from okstra_project.dirs import OKSTRA_DIR_NAME, project_json_path
|
|
18
|
+
|
|
19
|
+
from .domain.mcp import McpInventory, McpInventoryProbe
|
|
20
|
+
from .json_boundary import JsonBoundaryError, load_owned_object, write_owned_object_atomic
|
|
21
|
+
|
|
22
|
+
PROBE_TIMEOUT_SECONDS = 20.0
|
|
23
|
+
|
|
24
|
+
Reachability = Literal["reachable", "disabled", "missing", "unknown"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class McpDeclarationError(ValueError):
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class McpServerDeclaration:
|
|
33
|
+
name: str
|
|
34
|
+
description: str
|
|
35
|
+
tools: tuple[str, ...]
|
|
36
|
+
aliases: tuple[str, ...] = ()
|
|
37
|
+
notes: str = ""
|
|
38
|
+
|
|
39
|
+
def to_json(self) -> dict[str, Any]:
|
|
40
|
+
payload: dict[str, Any] = {
|
|
41
|
+
"name": self.name,
|
|
42
|
+
"description": self.description,
|
|
43
|
+
"tools": list(self.tools),
|
|
44
|
+
}
|
|
45
|
+
if self.aliases:
|
|
46
|
+
payload["aliases"] = list(self.aliases)
|
|
47
|
+
if self.notes:
|
|
48
|
+
payload["notes"] = self.notes
|
|
49
|
+
return payload
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class ProviderCell:
|
|
54
|
+
provider_id: str
|
|
55
|
+
status: Reachability
|
|
56
|
+
visible_name: str = ""
|
|
57
|
+
note: str = ""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class ServerReachability:
|
|
62
|
+
server: McpServerDeclaration
|
|
63
|
+
cells: tuple[ProviderCell, ...]
|
|
64
|
+
|
|
65
|
+
def reachable_from(self) -> tuple[str, ...]:
|
|
66
|
+
return tuple(c.provider_id for c in self.cells if c.status == "reachable")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _string_list(value: object, field: str, errors: list[str]) -> tuple[str, ...]:
|
|
70
|
+
if not isinstance(value, list) or not all(
|
|
71
|
+
isinstance(item, str) and item.strip() for item in value
|
|
72
|
+
):
|
|
73
|
+
errors.append(f"`{field}` must be a list of non-empty strings")
|
|
74
|
+
return ()
|
|
75
|
+
return tuple(item.strip() for item in value)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def parse_declaration(raw: object) -> tuple[McpServerDeclaration | None, list[str]]:
|
|
79
|
+
if not isinstance(raw, dict):
|
|
80
|
+
return None, ["entry must be an object"]
|
|
81
|
+
errors: list[str] = []
|
|
82
|
+
name = raw.get("name")
|
|
83
|
+
if not isinstance(name, str) or not name.strip():
|
|
84
|
+
errors.append("`name` must be a non-empty string")
|
|
85
|
+
description = raw.get("description")
|
|
86
|
+
if not isinstance(description, str) or not description.strip():
|
|
87
|
+
errors.append("`description` must be a non-empty string")
|
|
88
|
+
tools = _string_list(raw.get("tools"), "tools", errors)
|
|
89
|
+
if raw.get("tools") == []:
|
|
90
|
+
errors.append("`tools` must not be empty")
|
|
91
|
+
aliases = _string_list(raw["aliases"], "aliases", errors) if "aliases" in raw else ()
|
|
92
|
+
notes = raw.get("notes", "")
|
|
93
|
+
if not isinstance(notes, str):
|
|
94
|
+
errors.append("`notes` must be a string")
|
|
95
|
+
if errors:
|
|
96
|
+
return None, errors
|
|
97
|
+
return (
|
|
98
|
+
McpServerDeclaration(
|
|
99
|
+
name=name.strip(),
|
|
100
|
+
description=description.strip(),
|
|
101
|
+
tools=tools,
|
|
102
|
+
aliases=aliases,
|
|
103
|
+
notes=notes.strip(),
|
|
104
|
+
),
|
|
105
|
+
[],
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def load_declarations(
|
|
110
|
+
project_root: Path,
|
|
111
|
+
) -> tuple[tuple[McpServerDeclaration, ...], tuple[str, ...]]:
|
|
112
|
+
path = project_json_path(project_root)
|
|
113
|
+
if not path.is_file():
|
|
114
|
+
return (), ()
|
|
115
|
+
try:
|
|
116
|
+
raw = load_owned_object(path, artifact="project config")
|
|
117
|
+
except JsonBoundaryError as exc:
|
|
118
|
+
return (), (str(exc),)
|
|
119
|
+
servers = raw.get("mcpServers")
|
|
120
|
+
if servers is None:
|
|
121
|
+
return (), ()
|
|
122
|
+
if not isinstance(servers, list):
|
|
123
|
+
return (), (f"`mcpServers` in {path} must be a list",)
|
|
124
|
+
declarations: list[McpServerDeclaration] = []
|
|
125
|
+
problems: list[str] = []
|
|
126
|
+
seen: set[str] = set()
|
|
127
|
+
for index, entry in enumerate(servers):
|
|
128
|
+
declaration, errors = parse_declaration(entry)
|
|
129
|
+
if declaration is None:
|
|
130
|
+
problems.append(f"mcpServers[{index}]: " + "; ".join(errors))
|
|
131
|
+
continue
|
|
132
|
+
if declaration.name in seen:
|
|
133
|
+
problems.append(f"mcpServers[{index}]: duplicate name `{declaration.name}`")
|
|
134
|
+
continue
|
|
135
|
+
seen.add(declaration.name)
|
|
136
|
+
declarations.append(declaration)
|
|
137
|
+
return tuple(declarations), tuple(problems)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _read_project_config(project_root: Path) -> tuple[Path, dict[str, Any]]:
|
|
141
|
+
path = project_json_path(project_root)
|
|
142
|
+
if not path.is_file():
|
|
143
|
+
raise McpDeclarationError(
|
|
144
|
+
f"{path} not found; register the project with `okstra setup` first"
|
|
145
|
+
)
|
|
146
|
+
return path, load_owned_object(path, artifact="project config")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def upsert_declaration(project_root: Path, declaration: McpServerDeclaration) -> bool:
|
|
150
|
+
path, data = _read_project_config(project_root)
|
|
151
|
+
servers = data.get("mcpServers") or []
|
|
152
|
+
if not isinstance(servers, list):
|
|
153
|
+
raise McpDeclarationError(f"`mcpServers` in {path} must be a list")
|
|
154
|
+
kept = [
|
|
155
|
+
entry for entry in servers
|
|
156
|
+
if not (isinstance(entry, dict) and entry.get("name") == declaration.name)
|
|
157
|
+
]
|
|
158
|
+
replaced = len(kept) != len(servers)
|
|
159
|
+
data["mcpServers"] = [*kept, declaration.to_json()]
|
|
160
|
+
write_owned_object_atomic(path, data, artifact="project config")
|
|
161
|
+
return replaced
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def remove_declaration(project_root: Path, name: str) -> bool:
|
|
165
|
+
path, data = _read_project_config(project_root)
|
|
166
|
+
servers = data.get("mcpServers") or []
|
|
167
|
+
if not isinstance(servers, list):
|
|
168
|
+
raise McpDeclarationError(f"`mcpServers` in {path} must be a list")
|
|
169
|
+
kept = [
|
|
170
|
+
entry for entry in servers
|
|
171
|
+
if not (isinstance(entry, dict) and entry.get("name") == name)
|
|
172
|
+
]
|
|
173
|
+
if len(kept) == len(servers):
|
|
174
|
+
return False
|
|
175
|
+
data["mcpServers"] = kept
|
|
176
|
+
write_owned_object_atomic(path, data, artifact="project config")
|
|
177
|
+
return True
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def normalize_server_name(name: str) -> str:
|
|
181
|
+
return re.sub(r"[\s._-]+", "", name).lower()
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _match(
|
|
185
|
+
declaration: McpServerDeclaration, inventory: McpInventory,
|
|
186
|
+
) -> tuple[Reachability, str]:
|
|
187
|
+
if not inventory.listed:
|
|
188
|
+
return "unknown", ""
|
|
189
|
+
wanted = {normalize_server_name(n) for n in (declaration.name, *declaration.aliases)}
|
|
190
|
+
found = [s for s in inventory.servers if normalize_server_name(s.name) in wanted]
|
|
191
|
+
for server in found:
|
|
192
|
+
if server.state == "enabled":
|
|
193
|
+
return "reachable", server.name
|
|
194
|
+
if found:
|
|
195
|
+
return "disabled", found[0].name
|
|
196
|
+
return "missing", ""
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@dataclass(frozen=True)
|
|
200
|
+
class CommandOutcome:
|
|
201
|
+
stdout: str | None
|
|
202
|
+
failure: str = ""
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
CommandRunner = Callable[[tuple[str, ...], Path], CommandOutcome]
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def run_command(argv: tuple[str, ...], project_root: Path) -> CommandOutcome:
|
|
209
|
+
command = " ".join(argv)
|
|
210
|
+
try:
|
|
211
|
+
completed = subprocess.run(
|
|
212
|
+
list(argv),
|
|
213
|
+
cwd=project_root,
|
|
214
|
+
capture_output=True,
|
|
215
|
+
text=True,
|
|
216
|
+
timeout=PROBE_TIMEOUT_SECONDS,
|
|
217
|
+
check=False,
|
|
218
|
+
)
|
|
219
|
+
except FileNotFoundError:
|
|
220
|
+
return CommandOutcome(None, f"`{argv[0]}` is not installed")
|
|
221
|
+
except subprocess.TimeoutExpired:
|
|
222
|
+
return CommandOutcome(None, f"`{command}` timed out after {PROBE_TIMEOUT_SECONDS:.0f}s")
|
|
223
|
+
except OSError as exc:
|
|
224
|
+
return CommandOutcome(None, f"`{command}` failed to start ({exc})")
|
|
225
|
+
if completed.returncode != 0:
|
|
226
|
+
return CommandOutcome(None, f"`{command}` exited {completed.returncode}")
|
|
227
|
+
return CommandOutcome(completed.stdout)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def probe_inventories(
|
|
231
|
+
probes: Mapping[str, McpInventoryProbe | None],
|
|
232
|
+
project_root: Path,
|
|
233
|
+
runner: CommandRunner | None = None,
|
|
234
|
+
) -> dict[str, McpInventory]:
|
|
235
|
+
"""같은 명령은 한 번만 실행한다. claude 와 zai 처럼 CLI 를 공유하는 어댑터가
|
|
236
|
+
목록 비용(실측 7.5초)을 두 번 치르지 않게 하려는 것이다."""
|
|
237
|
+
commands = {probe.argv for probe in probes.values() if probe is not None}
|
|
238
|
+
outcomes: dict[tuple[str, ...], CommandOutcome] = {}
|
|
239
|
+
if commands:
|
|
240
|
+
with ThreadPoolExecutor(max_workers=len(commands)) as pool:
|
|
241
|
+
futures = {
|
|
242
|
+
argv: pool.submit(runner or run_command, argv, project_root)
|
|
243
|
+
for argv in commands
|
|
244
|
+
}
|
|
245
|
+
outcomes = {argv: future.result() for argv, future in futures.items()}
|
|
246
|
+
inventories: dict[str, McpInventory] = {}
|
|
247
|
+
for provider_id, probe in probes.items():
|
|
248
|
+
if probe is None:
|
|
249
|
+
inventories[provider_id] = McpInventory.unavailable(
|
|
250
|
+
"adapter declares no MCP inventory probe"
|
|
251
|
+
)
|
|
252
|
+
continue
|
|
253
|
+
outcome = outcomes[probe.argv]
|
|
254
|
+
inventories[provider_id] = (
|
|
255
|
+
probe.parse(outcome.stdout)
|
|
256
|
+
if outcome.stdout is not None
|
|
257
|
+
else McpInventory.unavailable(outcome.failure)
|
|
258
|
+
)
|
|
259
|
+
return inventories
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def assess(
|
|
263
|
+
declarations: Sequence[McpServerDeclaration],
|
|
264
|
+
inventories: Mapping[str, McpInventory],
|
|
265
|
+
) -> tuple[ServerReachability, ...]:
|
|
266
|
+
rows: list[ServerReachability] = []
|
|
267
|
+
for declaration in declarations:
|
|
268
|
+
cells = []
|
|
269
|
+
for provider_id, inventory in inventories.items():
|
|
270
|
+
status, visible = _match(declaration, inventory)
|
|
271
|
+
cells.append(ProviderCell(provider_id, status, visible, inventory.note))
|
|
272
|
+
rows.append(ServerReachability(declaration, tuple(cells)))
|
|
273
|
+
return tuple(rows)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def provider_probes(provider_ids: Iterable[str]) -> dict[str, McpInventoryProbe | None]:
|
|
277
|
+
from .registry.provider_registry import default_provider_registry
|
|
278
|
+
|
|
279
|
+
registry = default_provider_registry()
|
|
280
|
+
return {
|
|
281
|
+
provider_id: registry.resolve(provider_id).mcp_inventory
|
|
282
|
+
for provider_id in dict.fromkeys(provider_ids)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _cell_text(cell: ProviderCell) -> str:
|
|
287
|
+
if cell.status == "reachable":
|
|
288
|
+
return f"{cell.provider_id} (as `{cell.visible_name}`)"
|
|
289
|
+
if cell.status == "disabled":
|
|
290
|
+
return f"{cell.provider_id} (disabled as `{cell.visible_name}`)"
|
|
291
|
+
return f"{cell.provider_id} ({cell.status})"
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def reachability_sentence(row: ServerReachability) -> str:
|
|
295
|
+
reachable = [c for c in row.cells if c.status == "reachable"]
|
|
296
|
+
other = [c for c in row.cells if c.status != "reachable"]
|
|
297
|
+
parts = []
|
|
298
|
+
if reachable:
|
|
299
|
+
parts.append("Reachable from: " + ", ".join(_cell_text(c) for c in reachable))
|
|
300
|
+
if other:
|
|
301
|
+
parts.append("Not reachable: " + ", ".join(_cell_text(c) for c in other))
|
|
302
|
+
return ". ".join(parts)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def reachability_warnings(rows: Sequence[ServerReachability]) -> tuple[str, ...]:
|
|
306
|
+
warnings = []
|
|
307
|
+
for row in rows:
|
|
308
|
+
for cell in row.cells:
|
|
309
|
+
if cell.status == "reachable":
|
|
310
|
+
continue
|
|
311
|
+
detail = _cell_text(cell)
|
|
312
|
+
if cell.note:
|
|
313
|
+
detail += f": {cell.note}"
|
|
314
|
+
warnings.append(
|
|
315
|
+
f"mcp: server `{row.server.name}` is not reachable from provider {detail}"
|
|
316
|
+
)
|
|
317
|
+
return tuple(warnings)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
NO_MCP_SERVERS_LINE = (
|
|
321
|
+
f"- No MCP servers are declared in `{OKSTRA_DIR_NAME}/project.json`'s "
|
|
322
|
+
"`mcpServers` array. Treat MCP tools as unavailable for this run. To enable "
|
|
323
|
+
"them, register each server with `okstra mcp add` and re-render the bundle."
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def render_block(
|
|
328
|
+
declarations: Sequence[McpServerDeclaration],
|
|
329
|
+
rows: Sequence[ServerReachability] = (),
|
|
330
|
+
) -> str:
|
|
331
|
+
"""툴 이름은 논리 이름으로만 싣는다. 실제 호출 이름은 CLI 마다 규칙이 달라
|
|
332
|
+
워커가 자기 CLI 의 툴 목록에서 찾는다."""
|
|
333
|
+
if not declarations:
|
|
334
|
+
return NO_MCP_SERVERS_LINE
|
|
335
|
+
by_name = {row.server.name: row for row in rows}
|
|
336
|
+
lines = []
|
|
337
|
+
for declaration in declarations:
|
|
338
|
+
parts = [f"`{declaration.name}`"]
|
|
339
|
+
if declaration.aliases:
|
|
340
|
+
parts[0] += " (also listed as " + ", ".join(
|
|
341
|
+
f"`{alias}`" for alias in declaration.aliases
|
|
342
|
+
) + ")"
|
|
343
|
+
parts.append(declaration.description)
|
|
344
|
+
parts.append("Tools: " + ", ".join(f"`{tool}`" for tool in declaration.tools))
|
|
345
|
+
if declaration.notes:
|
|
346
|
+
parts.append(declaration.notes)
|
|
347
|
+
row = by_name.get(declaration.name)
|
|
348
|
+
if row is not None and row.cells:
|
|
349
|
+
parts.append(reachability_sentence(row))
|
|
350
|
+
lines.append("- " + ". ".join(p.rstrip(".") for p in parts) + ".")
|
|
351
|
+
return "\n".join(lines)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
@dataclass(frozen=True)
|
|
355
|
+
class BriefSource:
|
|
356
|
+
label: str
|
|
357
|
+
pattern: re.Pattern[str]
|
|
358
|
+
keyword: str
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
BRIEF_SOURCES = (
|
|
362
|
+
BriefSource(
|
|
363
|
+
"Figma",
|
|
364
|
+
re.compile(r"figma\.com/(?:file|design|proto|board)/", re.IGNORECASE),
|
|
365
|
+
"figma",
|
|
366
|
+
),
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def brief_source_warnings(
|
|
371
|
+
brief_text: str,
|
|
372
|
+
declarations: Sequence[McpServerDeclaration],
|
|
373
|
+
rows: Sequence[ServerReachability] = (),
|
|
374
|
+
) -> tuple[str, ...]:
|
|
375
|
+
warnings = []
|
|
376
|
+
for source in BRIEF_SOURCES:
|
|
377
|
+
if not source.pattern.search(brief_text):
|
|
378
|
+
continue
|
|
379
|
+
matching = [
|
|
380
|
+
d for d in declarations
|
|
381
|
+
if any(source.keyword in normalize_server_name(n) for n in (d.name, *d.aliases))
|
|
382
|
+
]
|
|
383
|
+
if not matching:
|
|
384
|
+
warnings.append(
|
|
385
|
+
f"mcp: brief cites a {source.label} URL but no mcpServers entry matches "
|
|
386
|
+
f"'{source.keyword}'; register it with `okstra mcp add`"
|
|
387
|
+
)
|
|
388
|
+
continue
|
|
389
|
+
names = {d.name for d in matching}
|
|
390
|
+
checked = [row for row in rows if row.server.name in names]
|
|
391
|
+
if checked and not any(row.reachable_from() for row in checked):
|
|
392
|
+
warnings.append(
|
|
393
|
+
f"mcp: brief cites a {source.label} URL but no selected provider can "
|
|
394
|
+
f"reach {', '.join(sorted(names))}; add it to a provider CLI's MCP "
|
|
395
|
+
"configuration or select a provider that lists it"
|
|
396
|
+
)
|
|
397
|
+
return tuple(warnings)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
@dataclass(frozen=True)
|
|
401
|
+
class PreparedMcp:
|
|
402
|
+
block: str
|
|
403
|
+
warnings: tuple[str, ...]
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def prepare_mcp(
|
|
407
|
+
project_root: Path,
|
|
408
|
+
provider_ids: Iterable[str],
|
|
409
|
+
brief_text: str,
|
|
410
|
+
runner: CommandRunner | None = None,
|
|
411
|
+
) -> PreparedMcp:
|
|
412
|
+
"""판정 결과는 경고와 렌더에만 쓰고 run 을 거절하지 않는다."""
|
|
413
|
+
declarations, problems = load_declarations(project_root)
|
|
414
|
+
warnings = [f"mcp: {problem}" for problem in problems]
|
|
415
|
+
rows: tuple[ServerReachability, ...] = ()
|
|
416
|
+
if declarations:
|
|
417
|
+
inventories = probe_inventories(provider_probes(provider_ids), project_root, runner)
|
|
418
|
+
rows = assess(declarations, inventories)
|
|
419
|
+
warnings.extend(reachability_warnings(rows))
|
|
420
|
+
warnings.extend(brief_source_warnings(brief_text, declarations, rows))
|
|
421
|
+
return PreparedMcp(render_block(declarations, rows), tuple(warnings))
|