@grifhinz/logics-manager 2.0.5 → 2.1.1
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/README.md +163 -156
- package/VERSION +1 -1
- package/logics_manager/cli.py +13 -3
- package/logics_manager/flow.py +10 -7
- package/logics_manager/mcp.py +1188 -0
- package/logics_manager/sync.py +476 -1
- package/package.json +10 -3
- package/pyproject.toml +1 -1
|
@@ -0,0 +1,1188 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import contextlib
|
|
5
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
6
|
+
import io
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import secrets
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
from urllib.error import URLError
|
|
16
|
+
from urllib.parse import urlparse
|
|
17
|
+
from urllib.request import Request, urlopen
|
|
18
|
+
|
|
19
|
+
from .audit import audit_payload
|
|
20
|
+
from .config import ConfigError, find_repo_root
|
|
21
|
+
from .flow import flow_list_payload
|
|
22
|
+
from .lint import expected_workflow_mermaid_signature, lint_payload
|
|
23
|
+
from .sync import append_workflow_note_payload, build_context_pack_payload, list_logics_docs_payload, read_logics_doc_payload, search_logics_docs_payload, update_workflow_indicators_payload
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
ALLOWED_WRITE_DIRS = (
|
|
27
|
+
"logics/request",
|
|
28
|
+
"logics/backlog",
|
|
29
|
+
"logics/tasks",
|
|
30
|
+
"logics/product",
|
|
31
|
+
"logics/architecture",
|
|
32
|
+
)
|
|
33
|
+
MAX_RAW_DIFF_CHARS = 12000
|
|
34
|
+
JSONRPC_VERSION = "2.0"
|
|
35
|
+
AUTH_ENV_VAR = "LOGICS_MCP_BEARER_TOKEN"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class McpToolError(Exception):
|
|
39
|
+
def __init__(self, code: str, message: str, *, details: dict[str, Any] | None = None) -> None:
|
|
40
|
+
super().__init__(message)
|
|
41
|
+
self.code = code
|
|
42
|
+
self.message = message
|
|
43
|
+
self.details = details or {}
|
|
44
|
+
|
|
45
|
+
def to_payload(self) -> dict[str, Any]:
|
|
46
|
+
payload: dict[str, Any] = {"ok": False, "error": self.code, "message": self.message}
|
|
47
|
+
if self.details:
|
|
48
|
+
payload["details"] = self.details
|
|
49
|
+
return payload
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _tool_schema(properties: dict[str, Any], required: list[str] | None = None) -> dict[str, Any]:
|
|
53
|
+
return {
|
|
54
|
+
"type": "object",
|
|
55
|
+
"properties": properties,
|
|
56
|
+
"required": required or [],
|
|
57
|
+
"additionalProperties": False,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
TOOL_DEFINITIONS: list[dict[str, Any]] = [
|
|
62
|
+
{
|
|
63
|
+
"name": "create_request",
|
|
64
|
+
"description": "Create a Logics request from framed product conversation.",
|
|
65
|
+
"inputSchema": _tool_schema(
|
|
66
|
+
{
|
|
67
|
+
"title": {"type": "string"},
|
|
68
|
+
"needs": {"type": "array", "items": {"type": "string"}},
|
|
69
|
+
"context": {"type": "array", "items": {"type": "string"}},
|
|
70
|
+
"acceptance_criteria": {"type": "array", "items": {"type": "string"}},
|
|
71
|
+
"theme": {"type": "string"},
|
|
72
|
+
"complexity": {"type": "string", "enum": ["Low", "Medium", "High"]},
|
|
73
|
+
},
|
|
74
|
+
["title", "needs", "context", "acceptance_criteria"],
|
|
75
|
+
),
|
|
76
|
+
"annotations": {"readOnlyHint": False, "idempotentHint": False, "destructiveHint": False},
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
"name": "promote_request_to_backlog",
|
|
80
|
+
"description": "Promote an existing Logics request to a backlog item.",
|
|
81
|
+
"inputSchema": _tool_schema({"request_path": {"type": "string"}}, ["request_path"]),
|
|
82
|
+
"annotations": {"readOnlyHint": False, "idempotentHint": False, "destructiveHint": False},
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
"name": "promote_backlog_to_task",
|
|
86
|
+
"description": "Promote an existing Logics backlog item to an executable task.",
|
|
87
|
+
"inputSchema": _tool_schema({"backlog_path": {"type": "string"}}, ["backlog_path"]),
|
|
88
|
+
"annotations": {"readOnlyHint": False, "idempotentHint": False, "destructiveHint": False},
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
"name": "create_product_brief",
|
|
92
|
+
"description": "Create a Logics product companion document.",
|
|
93
|
+
"inputSchema": _tool_schema(
|
|
94
|
+
{
|
|
95
|
+
"title": {"type": "string"},
|
|
96
|
+
"request_path": {"type": "string"},
|
|
97
|
+
"backlog_path": {"type": "string"},
|
|
98
|
+
"task_path": {"type": "string"},
|
|
99
|
+
},
|
|
100
|
+
["title"],
|
|
101
|
+
),
|
|
102
|
+
"annotations": {"readOnlyHint": False, "idempotentHint": False, "destructiveHint": False},
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
"name": "create_architecture_decision",
|
|
106
|
+
"description": "Create a Logics architecture companion document.",
|
|
107
|
+
"inputSchema": _tool_schema(
|
|
108
|
+
{
|
|
109
|
+
"title": {"type": "string"},
|
|
110
|
+
"request_path": {"type": "string"},
|
|
111
|
+
"backlog_path": {"type": "string"},
|
|
112
|
+
"task_path": {"type": "string"},
|
|
113
|
+
},
|
|
114
|
+
["title"],
|
|
115
|
+
),
|
|
116
|
+
"annotations": {"readOnlyHint": False, "idempotentHint": False, "destructiveHint": False},
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
"name": "list_active_work",
|
|
120
|
+
"description": "List active Logics request, backlog, and task documents.",
|
|
121
|
+
"inputSchema": _tool_schema({"kind": {"type": "string", "enum": ["all", "request", "backlog", "task"]}}),
|
|
122
|
+
"annotations": {"readOnlyHint": True, "idempotentHint": True, "destructiveHint": False},
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
"name": "read_logics_doc",
|
|
126
|
+
"description": "Read one approved Logics workflow document by ref or repo-relative path.",
|
|
127
|
+
"inputSchema": _tool_schema(
|
|
128
|
+
{
|
|
129
|
+
"source": {"type": "string"},
|
|
130
|
+
"max_chars": {"type": "integer"},
|
|
131
|
+
"sections": {"type": "array", "items": {"type": "string"}},
|
|
132
|
+
},
|
|
133
|
+
["source"],
|
|
134
|
+
),
|
|
135
|
+
"annotations": {"readOnlyHint": True, "idempotentHint": True, "destructiveHint": False},
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
"name": "build_context_pack",
|
|
139
|
+
"description": "Build a compact Logics context pack for a workflow ref.",
|
|
140
|
+
"inputSchema": _tool_schema(
|
|
141
|
+
{
|
|
142
|
+
"ref": {"type": "string"},
|
|
143
|
+
"mode": {"type": "string", "enum": ["summary-only", "diff-first", "full"]},
|
|
144
|
+
"profile": {"type": "string", "enum": ["tiny", "normal", "deep"]},
|
|
145
|
+
},
|
|
146
|
+
["ref"],
|
|
147
|
+
),
|
|
148
|
+
"annotations": {"readOnlyHint": True, "idempotentHint": True, "destructiveHint": False},
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
"name": "list_logics_docs",
|
|
152
|
+
"description": "List Logics workflow documents by bounded criteria.",
|
|
153
|
+
"inputSchema": _tool_schema(
|
|
154
|
+
{
|
|
155
|
+
"kind": {"type": "string", "enum": ["all", "request", "backlog", "task"]},
|
|
156
|
+
"status": {"type": "string"},
|
|
157
|
+
"ref_prefix": {"type": "string"},
|
|
158
|
+
"limit": {"type": "integer"},
|
|
159
|
+
}
|
|
160
|
+
),
|
|
161
|
+
"annotations": {"readOnlyHint": True, "idempotentHint": True, "destructiveHint": False},
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
"name": "search_logics_docs",
|
|
165
|
+
"description": "Search approved Logics workflow docs with bounded snippets.",
|
|
166
|
+
"inputSchema": _tool_schema(
|
|
167
|
+
{
|
|
168
|
+
"query": {"type": "string"},
|
|
169
|
+
"kind": {"type": "string", "enum": ["all", "request", "backlog", "task"]},
|
|
170
|
+
"status": {"type": "string"},
|
|
171
|
+
"limit": {"type": "integer"},
|
|
172
|
+
"max_snippet_chars": {"type": "integer"},
|
|
173
|
+
},
|
|
174
|
+
["query"],
|
|
175
|
+
),
|
|
176
|
+
"annotations": {"readOnlyHint": True, "idempotentHint": True, "destructiveHint": False},
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
"name": "finish_task",
|
|
180
|
+
"description": "Finish a Logics task through the canonical flow finish task command.",
|
|
181
|
+
"inputSchema": _tool_schema({"task_path": {"type": "string"}, "dry_run": {"type": "boolean"}}, ["task_path"]),
|
|
182
|
+
"annotations": {"readOnlyHint": False, "idempotentHint": False, "destructiveHint": False},
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
"name": "close_workflow_doc",
|
|
186
|
+
"description": "Close a Logics request, backlog item, or task through the canonical flow close command.",
|
|
187
|
+
"inputSchema": _tool_schema({"kind": {"type": "string", "enum": ["request", "backlog", "task"]}, "source_path": {"type": "string"}, "dry_run": {"type": "boolean"}}, ["kind", "source_path"]),
|
|
188
|
+
"annotations": {"readOnlyHint": False, "idempotentHint": False, "destructiveHint": False},
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
"name": "close_eligible_requests",
|
|
192
|
+
"description": "Close requests whose linked backlog items are already done.",
|
|
193
|
+
"inputSchema": _tool_schema({"dry_run": {"type": "boolean"}}),
|
|
194
|
+
"annotations": {"readOnlyHint": False, "idempotentHint": False, "destructiveHint": False},
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
"name": "refresh_mermaid_signatures",
|
|
198
|
+
"description": "Refresh deterministic workflow Mermaid signatures.",
|
|
199
|
+
"inputSchema": _tool_schema({"dry_run": {"type": "boolean"}}),
|
|
200
|
+
"annotations": {"readOnlyHint": False, "idempotentHint": False, "destructiveHint": False},
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
"name": "update_workflow_indicators",
|
|
204
|
+
"description": "Update approved workflow indicators without free-form Markdown editing.",
|
|
205
|
+
"inputSchema": _tool_schema(
|
|
206
|
+
{
|
|
207
|
+
"source": {"type": "string"},
|
|
208
|
+
"status": {"type": "string"},
|
|
209
|
+
"progress": {"type": "string"},
|
|
210
|
+
"understanding": {"type": "string"},
|
|
211
|
+
"confidence": {"type": "string"},
|
|
212
|
+
"theme": {"type": "string"},
|
|
213
|
+
"complexity": {"type": "string"},
|
|
214
|
+
"dry_run": {"type": "boolean"},
|
|
215
|
+
},
|
|
216
|
+
["source"],
|
|
217
|
+
),
|
|
218
|
+
"annotations": {"readOnlyHint": False, "idempotentHint": False, "destructiveHint": False},
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
"name": "append_report_entry",
|
|
222
|
+
"description": "Append bounded content to a task Report section.",
|
|
223
|
+
"inputSchema": _tool_schema({"source": {"type": "string"}, "text": {"type": "string"}, "dry_run": {"type": "boolean"}}, ["source", "text"]),
|
|
224
|
+
"annotations": {"readOnlyHint": False, "idempotentHint": False, "destructiveHint": False},
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
"name": "append_validation_note",
|
|
228
|
+
"description": "Append bounded content to a workflow Validation section.",
|
|
229
|
+
"inputSchema": _tool_schema({"source": {"type": "string"}, "text": {"type": "string"}, "dry_run": {"type": "boolean"}}, ["source", "text"]),
|
|
230
|
+
"annotations": {"readOnlyHint": False, "idempotentHint": False, "destructiveHint": False},
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
"name": "append_decision_note",
|
|
234
|
+
"description": "Append bounded rationale to an approved workflow decision or notes section.",
|
|
235
|
+
"inputSchema": _tool_schema({"source": {"type": "string"}, "text": {"type": "string"}, "dry_run": {"type": "boolean"}}, ["source", "text"]),
|
|
236
|
+
"annotations": {"readOnlyHint": False, "idempotentHint": False, "destructiveHint": False},
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
"name": "split_request",
|
|
240
|
+
"description": "Split one Logics request into multiple backlog items through the canonical flow split command.",
|
|
241
|
+
"inputSchema": _tool_schema({"request_path": {"type": "string"}, "titles": {"type": "array", "items": {"type": "string"}}, "dry_run": {"type": "boolean"}}, ["request_path", "titles"]),
|
|
242
|
+
"annotations": {"readOnlyHint": False, "idempotentHint": False, "destructiveHint": False},
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
"name": "split_backlog",
|
|
246
|
+
"description": "Split one Logics backlog item into multiple tasks through the canonical flow split command.",
|
|
247
|
+
"inputSchema": _tool_schema({"backlog_path": {"type": "string"}, "titles": {"type": "array", "items": {"type": "string"}}, "dry_run": {"type": "boolean"}}, ["backlog_path", "titles"]),
|
|
248
|
+
"annotations": {"readOnlyHint": False, "idempotentHint": False, "destructiveHint": False},
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
"name": "autofix_ac_traceability",
|
|
252
|
+
"description": "Run deterministic audit autofix for missing AC traceability skeleton entries.",
|
|
253
|
+
"inputSchema": _tool_schema({"paths": {"type": "array", "items": {"type": "string"}}, "refs": {"type": "array", "items": {"type": "string"}}}),
|
|
254
|
+
"annotations": {"readOnlyHint": False, "idempotentHint": False, "destructiveHint": False},
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
"name": "autofix_structure",
|
|
258
|
+
"description": "Run deterministic audit autofix for supported workflow document structure repairs.",
|
|
259
|
+
"inputSchema": _tool_schema({"paths": {"type": "array", "items": {"type": "string"}}, "refs": {"type": "array", "items": {"type": "string"}}}),
|
|
260
|
+
"annotations": {"readOnlyHint": False, "idempotentHint": False, "destructiveHint": False},
|
|
261
|
+
},
|
|
262
|
+
{
|
|
263
|
+
"name": "run_logics_lint",
|
|
264
|
+
"description": "Run Logics lint with required status indicators.",
|
|
265
|
+
"inputSchema": _tool_schema({}),
|
|
266
|
+
"annotations": {"readOnlyHint": True, "idempotentHint": True, "destructiveHint": False},
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
"name": "run_logics_audit",
|
|
270
|
+
"description": "Run the standard Logics workflow audit.",
|
|
271
|
+
"inputSchema": _tool_schema({}),
|
|
272
|
+
"annotations": {"readOnlyHint": True, "idempotentHint": True, "destructiveHint": False},
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
"name": "show_git_diff",
|
|
276
|
+
"description": "Show a size-limited Git diff summary for Logics paths.",
|
|
277
|
+
"inputSchema": _tool_schema({"paths": {"type": "array", "items": {"type": "string"}}}),
|
|
278
|
+
"annotations": {"readOnlyHint": True, "idempotentHint": True, "destructiveHint": False},
|
|
279
|
+
},
|
|
280
|
+
]
|
|
281
|
+
TOOLS_BY_NAME = {str(tool["name"]): tool for tool in TOOL_DEFINITIONS}
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _server_version() -> str:
|
|
285
|
+
version_file = Path(__file__).resolve().parents[1] / "VERSION"
|
|
286
|
+
try:
|
|
287
|
+
version = version_file.read_text(encoding="utf-8").strip()
|
|
288
|
+
except OSError:
|
|
289
|
+
return "0.0.0"
|
|
290
|
+
return version or "0.0.0"
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _repo_root(repo_root: Path | None = None) -> Path:
|
|
294
|
+
if repo_root is not None:
|
|
295
|
+
root = repo_root.resolve()
|
|
296
|
+
else:
|
|
297
|
+
try:
|
|
298
|
+
root = find_repo_root(Path.cwd()).resolve()
|
|
299
|
+
except ConfigError as exc:
|
|
300
|
+
raise McpToolError("command_failed", str(exc)) from exc
|
|
301
|
+
if not (root / "logics").is_dir():
|
|
302
|
+
raise McpToolError("command_failed", f"Repository root has no logics directory: {root}")
|
|
303
|
+
return root
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _validate_arguments(name: str, arguments: dict[str, Any]) -> None:
|
|
307
|
+
schema = TOOLS_BY_NAME[name]["inputSchema"]
|
|
308
|
+
properties = schema.get("properties", {})
|
|
309
|
+
required = schema.get("required", [])
|
|
310
|
+
for key in required:
|
|
311
|
+
if key not in arguments:
|
|
312
|
+
raise McpToolError("missing_required_argument", f"Missing required argument: {key}", details={"argument": key})
|
|
313
|
+
unknown = sorted(set(arguments) - set(properties))
|
|
314
|
+
if unknown:
|
|
315
|
+
raise McpToolError("unsupported_argument", "Unsupported argument(s).", details={"arguments": unknown})
|
|
316
|
+
for key, value in arguments.items():
|
|
317
|
+
expected = properties.get(key, {})
|
|
318
|
+
expected_type = expected.get("type")
|
|
319
|
+
if expected_type == "string" and not isinstance(value, str):
|
|
320
|
+
raise McpToolError("invalid_argument_type", f"Argument `{key}` must be a string.", details={"argument": key, "expected": "string"})
|
|
321
|
+
if expected_type == "array":
|
|
322
|
+
if not isinstance(value, list) or any(not isinstance(item, str) for item in value):
|
|
323
|
+
raise McpToolError("invalid_argument_type", f"Argument `{key}` must be an array of strings.", details={"argument": key, "expected": "array[string]"})
|
|
324
|
+
if expected_type == "integer" and (not isinstance(value, int) or isinstance(value, bool)):
|
|
325
|
+
raise McpToolError("invalid_argument_type", f"Argument `{key}` must be an integer.", details={"argument": key, "expected": "integer"})
|
|
326
|
+
if expected_type == "boolean" and not isinstance(value, bool):
|
|
327
|
+
raise McpToolError("invalid_argument_type", f"Argument `{key}` must be a boolean.", details={"argument": key, "expected": "boolean"})
|
|
328
|
+
enum = expected.get("enum")
|
|
329
|
+
if enum and value not in enum:
|
|
330
|
+
raise McpToolError("invalid_argument_value", f"Argument `{key}` has an unsupported value.", details={"argument": key, "allowed": enum, "value": value})
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _relative_path(repo_root: Path, raw_path: str, allowed_dirs: tuple[str, ...]) -> Path:
|
|
334
|
+
if not raw_path or not raw_path.strip():
|
|
335
|
+
raise McpToolError("invalid_path", "Path is required.")
|
|
336
|
+
candidate = Path(raw_path)
|
|
337
|
+
if candidate.is_absolute():
|
|
338
|
+
raise McpToolError("invalid_path", "Absolute paths are not accepted.", details={"path": raw_path})
|
|
339
|
+
if any(part == ".." for part in candidate.parts):
|
|
340
|
+
raise McpToolError("invalid_path", "Path traversal is not accepted.", details={"path": raw_path})
|
|
341
|
+
normalized = Path(*candidate.parts)
|
|
342
|
+
normalized_posix = normalized.as_posix()
|
|
343
|
+
if normalized_posix == ".":
|
|
344
|
+
raise McpToolError("invalid_path", "Path is required.")
|
|
345
|
+
if not any(normalized_posix == directory or normalized_posix.startswith(f"{directory}/") for directory in allowed_dirs):
|
|
346
|
+
raise McpToolError("invalid_path", "Path is outside the allowed Logics area.", details={"path": raw_path, "allowed_dirs": list(allowed_dirs)})
|
|
347
|
+
resolved = (repo_root / normalized).resolve()
|
|
348
|
+
try:
|
|
349
|
+
resolved.relative_to(repo_root)
|
|
350
|
+
except ValueError as exc:
|
|
351
|
+
raise McpToolError("invalid_path", "Resolved path escapes the repository root.", details={"path": raw_path}) from exc
|
|
352
|
+
if resolved.is_symlink():
|
|
353
|
+
raise McpToolError("invalid_path", "Symlink paths are not accepted.", details={"path": raw_path})
|
|
354
|
+
return normalized
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _run_command(repo_root: Path, args: list[str]) -> subprocess.CompletedProcess[str]:
|
|
358
|
+
command = [sys.executable, "-m", "logics_manager", *args]
|
|
359
|
+
result = subprocess.run(command, cwd=repo_root, check=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
360
|
+
if result.returncode != 0:
|
|
361
|
+
raise McpToolError(
|
|
362
|
+
"command_failed",
|
|
363
|
+
"Underlying logics-manager command failed.",
|
|
364
|
+
details={"command": ["python3", "-m", "logics_manager", *args], "stdout": result.stdout, "stderr": result.stderr, "returncode": result.returncode},
|
|
365
|
+
)
|
|
366
|
+
return result
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _run_json_command(repo_root: Path, args: list[str]) -> dict[str, Any]:
|
|
370
|
+
command = [sys.executable, "-m", "logics_manager", *args]
|
|
371
|
+
result = subprocess.run(command, cwd=repo_root, check=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
372
|
+
payload = _json_from_stdout_or_none(result.stdout)
|
|
373
|
+
if payload is None:
|
|
374
|
+
raise McpToolError(
|
|
375
|
+
"command_failed",
|
|
376
|
+
"Underlying logics-manager command failed.",
|
|
377
|
+
details={"command": ["python3", "-m", "logics_manager", *args], "stdout": result.stdout, "stderr": result.stderr, "returncode": result.returncode},
|
|
378
|
+
)
|
|
379
|
+
return payload
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _json_from_stdout(stdout: str) -> dict[str, Any]:
|
|
383
|
+
start = stdout.find("{")
|
|
384
|
+
end = stdout.rfind("}")
|
|
385
|
+
if start == -1 or end == -1 or end < start:
|
|
386
|
+
raise McpToolError("command_failed", "Expected JSON output from logics-manager.", details={"stdout": stdout})
|
|
387
|
+
try:
|
|
388
|
+
payload = json.loads(stdout[start : end + 1])
|
|
389
|
+
except json.JSONDecodeError as exc:
|
|
390
|
+
raise McpToolError("command_failed", "Could not parse JSON output from logics-manager.", details={"stdout": stdout}) from exc
|
|
391
|
+
if not isinstance(payload, dict):
|
|
392
|
+
raise McpToolError("command_failed", "Expected a JSON object from logics-manager.", details={"stdout": stdout})
|
|
393
|
+
return payload
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _created_doc_from_stdout(stdout: str, *, command: str, kind: str) -> dict[str, Any]:
|
|
397
|
+
payload = _json_from_stdout_or_none(stdout)
|
|
398
|
+
if payload is not None:
|
|
399
|
+
return payload
|
|
400
|
+
match = re.search(rf"Created\s+{re.escape(kind)}:\s+(\S+)", stdout)
|
|
401
|
+
if match is None:
|
|
402
|
+
raise McpToolError("command_failed", "Could not find created document path in logics-manager output.", details={"stdout": stdout})
|
|
403
|
+
path = match.group(1)
|
|
404
|
+
return {"command": command, "kind": kind, "path": path, "ref": Path(path).stem, "dry_run": False}
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _json_from_stdout_or_none(stdout: str) -> dict[str, Any] | None:
|
|
408
|
+
start = stdout.find("{")
|
|
409
|
+
end = stdout.rfind("}")
|
|
410
|
+
if start == -1 or end == -1 or end < start:
|
|
411
|
+
return None
|
|
412
|
+
try:
|
|
413
|
+
payload = json.loads(stdout[start : end + 1])
|
|
414
|
+
except json.JSONDecodeError:
|
|
415
|
+
return None
|
|
416
|
+
return payload if isinstance(payload, dict) else None
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _run_git(repo_root: Path, args: list[str]) -> subprocess.CompletedProcess[str]:
|
|
420
|
+
result = subprocess.run(["git", *args], cwd=repo_root, check=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
421
|
+
if result.returncode != 0:
|
|
422
|
+
raise McpToolError("command_failed", "Git command failed.", details={"command": ["git", *args], "stderr": result.stderr, "returncode": result.returncode})
|
|
423
|
+
return result
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _git_status_entries(repo_root: Path, paths: list[str]) -> dict[str, str]:
|
|
427
|
+
result = _run_git(repo_root, ["status", "--short", "--", *paths])
|
|
428
|
+
entries: dict[str, str] = {}
|
|
429
|
+
for line in result.stdout.splitlines():
|
|
430
|
+
if len(line) < 4:
|
|
431
|
+
continue
|
|
432
|
+
status = line[:2]
|
|
433
|
+
path = line[3:].strip()
|
|
434
|
+
if " -> " in path:
|
|
435
|
+
path = path.split(" -> ", 1)[1].strip()
|
|
436
|
+
if path:
|
|
437
|
+
entries[path] = status
|
|
438
|
+
return entries
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _ensure_no_dirty_conflict(repo_root: Path, paths: list[str]) -> None:
|
|
442
|
+
statuses = _git_status_entries(repo_root, paths)
|
|
443
|
+
conflicts = {
|
|
444
|
+
path: status
|
|
445
|
+
for path, status in statuses.items()
|
|
446
|
+
if status != "??"
|
|
447
|
+
}
|
|
448
|
+
if conflicts:
|
|
449
|
+
raise McpToolError(
|
|
450
|
+
"dirty_conflict",
|
|
451
|
+
"Refusing to modify existing uncommitted Logics changes.",
|
|
452
|
+
details={"paths": conflicts},
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _diff_summary(raw_diff: str, *, untracked_count: int = 0) -> str:
|
|
457
|
+
files = 0
|
|
458
|
+
added = 0
|
|
459
|
+
removed = 0
|
|
460
|
+
for line in raw_diff.splitlines():
|
|
461
|
+
if line.startswith("diff --git "):
|
|
462
|
+
files += 1
|
|
463
|
+
elif line.startswith("+") and not line.startswith("+++"):
|
|
464
|
+
added += 1
|
|
465
|
+
elif line.startswith("-") and not line.startswith("---"):
|
|
466
|
+
removed += 1
|
|
467
|
+
suffix = f", {untracked_count} untracked file(s)" if untracked_count else ""
|
|
468
|
+
return f"{files} tracked diff file(s), {added} insertion(s), {removed} deletion(s){suffix}"
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _show_git_diff(repo_root: Path, paths: list[str] | None = None) -> dict[str, Any]:
|
|
472
|
+
path_args: list[str] = []
|
|
473
|
+
if paths:
|
|
474
|
+
for raw_path in paths:
|
|
475
|
+
path_args.append(_relative_path(repo_root, raw_path, ("logics",)).as_posix())
|
|
476
|
+
else:
|
|
477
|
+
path_args = ["logics"]
|
|
478
|
+
diff_result = _run_git(repo_root, ["diff", "--", *path_args])
|
|
479
|
+
status_result = _run_git(repo_root, ["status", "--short", "-uall", "--", *path_args])
|
|
480
|
+
raw_diff = diff_result.stdout
|
|
481
|
+
truncated = len(raw_diff) > MAX_RAW_DIFF_CHARS
|
|
482
|
+
changed_paths = [line[3:].strip() for line in status_result.stdout.splitlines() if line[3:].strip()]
|
|
483
|
+
untracked_count = sum(1 for line in status_result.stdout.splitlines() if line.startswith("?? "))
|
|
484
|
+
if truncated and paths:
|
|
485
|
+
raise McpToolError(
|
|
486
|
+
"output_too_large",
|
|
487
|
+
"Diff output exceeded the MCP response limit.",
|
|
488
|
+
details={"limit": MAX_RAW_DIFF_CHARS, "diff_summary": _diff_summary(raw_diff, untracked_count=untracked_count)},
|
|
489
|
+
)
|
|
490
|
+
return {
|
|
491
|
+
"ok": True,
|
|
492
|
+
"changed_paths": changed_paths,
|
|
493
|
+
"diff_summary": _diff_summary(raw_diff, untracked_count=untracked_count),
|
|
494
|
+
"raw_diff": raw_diff[:MAX_RAW_DIFF_CHARS],
|
|
495
|
+
"truncated": truncated,
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _lint_status(repo_root: Path) -> dict[str, Any]:
|
|
500
|
+
payload = lint_payload(repo_root, require_status=True)
|
|
501
|
+
return {
|
|
502
|
+
"ok": bool(payload.get("ok")),
|
|
503
|
+
"issue_count": payload.get("issue_count", 0),
|
|
504
|
+
"warning_count": payload.get("warning_count", 0),
|
|
505
|
+
"issues": payload.get("issues", []),
|
|
506
|
+
"warnings": payload.get("warnings", []),
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _audit_status(repo_root: Path) -> dict[str, Any]:
|
|
511
|
+
payload = audit_payload(repo_root, legacy_cutoff_version="1.1.0", group_by_doc=True)
|
|
512
|
+
return {
|
|
513
|
+
"ok": bool(payload.get("ok")),
|
|
514
|
+
"issue_count": payload.get("issue_count", 0),
|
|
515
|
+
"issues": payload.get("issues", []),
|
|
516
|
+
"issues_by_doc": payload.get("issues_by_doc", {}),
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _bullets(values: Any) -> list[str]:
|
|
521
|
+
if not isinstance(values, list):
|
|
522
|
+
raise McpToolError("invalid_argument_type", "Expected a list of strings.")
|
|
523
|
+
out = [str(value).strip() for value in values if str(value).strip()]
|
|
524
|
+
if not out:
|
|
525
|
+
raise McpToolError("invalid_argument_value", "Expected at least one non-empty string.")
|
|
526
|
+
return out
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _replace_section(lines: list[str], heading: str, replacement: list[str]) -> list[str]:
|
|
530
|
+
start = None
|
|
531
|
+
for idx, line in enumerate(lines):
|
|
532
|
+
if line.startswith("# ") and line[2:].strip().lower() == heading.lower():
|
|
533
|
+
start = idx + 1
|
|
534
|
+
break
|
|
535
|
+
if start is None:
|
|
536
|
+
return lines
|
|
537
|
+
end = len(lines)
|
|
538
|
+
for idx in range(start, len(lines)):
|
|
539
|
+
if lines[idx].startswith("# "):
|
|
540
|
+
end = idx
|
|
541
|
+
break
|
|
542
|
+
return [*lines[:start], *replacement, "", *lines[end:]]
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _refresh_mermaid_signature(path: Path, kind: str) -> None:
|
|
546
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
547
|
+
expected = expected_workflow_mermaid_signature(kind, lines)
|
|
548
|
+
if not expected:
|
|
549
|
+
return
|
|
550
|
+
updated = re.sub(
|
|
551
|
+
r"^(\s*%%\s*logics-signature:\s*).+$",
|
|
552
|
+
rf"\g<1>{expected}",
|
|
553
|
+
"\n".join(lines),
|
|
554
|
+
count=1,
|
|
555
|
+
flags=re.MULTILINE,
|
|
556
|
+
)
|
|
557
|
+
path.write_text(updated.rstrip() + "\n", encoding="utf-8")
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _update_created_request(repo_root: Path, rel_path: str, arguments: dict[str, Any]) -> None:
|
|
561
|
+
path = repo_root / rel_path
|
|
562
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
563
|
+
needs = [f"- {item}" for item in _bullets(arguments.get("needs"))]
|
|
564
|
+
context = [f"- {item}" for item in _bullets(arguments.get("context"))]
|
|
565
|
+
acceptance = []
|
|
566
|
+
for index, item in enumerate(_bullets(arguments.get("acceptance_criteria")), start=1):
|
|
567
|
+
text = re.sub(r"^AC\d+\s*:\s*", "", item).strip()
|
|
568
|
+
acceptance.append(f"- AC{index}: {text}")
|
|
569
|
+
lines = _replace_section(lines, "Needs", needs)
|
|
570
|
+
lines = _replace_section(lines, "Context", context)
|
|
571
|
+
lines = _replace_section(lines, "Acceptance criteria", acceptance)
|
|
572
|
+
path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8")
|
|
573
|
+
_refresh_mermaid_signature(path, "request")
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _flow_path_ref(path_value: str | None) -> str | None:
|
|
577
|
+
if not path_value:
|
|
578
|
+
return None
|
|
579
|
+
return Path(path_value).stem
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _validation_result(repo_root: Path, *, include_audit: bool = False) -> dict[str, Any]:
|
|
583
|
+
lint = _lint_status(repo_root)
|
|
584
|
+
payload: dict[str, Any] = {"lint_status": lint}
|
|
585
|
+
if include_audit:
|
|
586
|
+
payload["audit_status"] = _audit_status(repo_root)
|
|
587
|
+
return payload
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def _document_preview(repo_root: Path, rel_path: str, *, max_chars: int = 1600) -> dict[str, Any]:
|
|
591
|
+
path = repo_root / rel_path
|
|
592
|
+
text = path.read_text(encoding="utf-8")
|
|
593
|
+
return {
|
|
594
|
+
"path": rel_path,
|
|
595
|
+
"content": text[:max_chars],
|
|
596
|
+
"truncated": len(text) > max_chars,
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def _bounded_int(value: Any, *, default: int, maximum: int) -> int:
|
|
601
|
+
if not isinstance(value, int) or isinstance(value, bool):
|
|
602
|
+
return default
|
|
603
|
+
if value <= 0:
|
|
604
|
+
return default
|
|
605
|
+
return min(value, maximum)
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def _mcp_read_error(exc: BaseException) -> McpToolError:
|
|
609
|
+
if isinstance(exc, McpToolError):
|
|
610
|
+
return exc
|
|
611
|
+
return McpToolError("invalid_reference", str(exc))
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _mcp_mutation_error(exc: BaseException) -> McpToolError:
|
|
615
|
+
if isinstance(exc, McpToolError):
|
|
616
|
+
return exc
|
|
617
|
+
return McpToolError("invalid_argument_value", str(exc))
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def _workflow_doc_path_for_source(repo_root: Path, source: str) -> str:
|
|
621
|
+
try:
|
|
622
|
+
payload = read_logics_doc_payload(repo_root, source, max_chars=1, sections=[])
|
|
623
|
+
except SystemExit as exc:
|
|
624
|
+
raise _mcp_read_error(exc) from exc
|
|
625
|
+
return str(payload["path"])
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def _nonempty_titles(values: Any) -> list[str]:
|
|
629
|
+
titles = [str(value).strip() for value in values if str(value).strip()] if isinstance(values, list) else []
|
|
630
|
+
if not titles:
|
|
631
|
+
raise McpToolError("invalid_argument_value", "At least one non-empty title is required.", details={"argument": "titles"})
|
|
632
|
+
return titles
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def _workflow_write_result(repo_root: Path, payload: dict[str, Any], *, paths: list[str] | None = None) -> dict[str, Any]:
|
|
636
|
+
return {
|
|
637
|
+
"ok": True,
|
|
638
|
+
**payload,
|
|
639
|
+
**_validation_result(repo_root, include_audit=True),
|
|
640
|
+
**_show_git_diff(repo_root, paths),
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def call_tool(name: str, arguments: dict[str, Any] | None = None, *, repo_root: Path | None = None) -> dict[str, Any]:
|
|
645
|
+
root = _repo_root(repo_root)
|
|
646
|
+
args = arguments or {}
|
|
647
|
+
if name not in TOOLS_BY_NAME:
|
|
648
|
+
raise McpToolError("unsupported_action", f"Unsupported MCP tool: {name}")
|
|
649
|
+
_validate_arguments(name, args)
|
|
650
|
+
|
|
651
|
+
if name == "run_logics_lint":
|
|
652
|
+
status = _lint_status(root)
|
|
653
|
+
return {"ok": bool(status["ok"]), "status": status}
|
|
654
|
+
if name == "run_logics_audit":
|
|
655
|
+
status = _audit_status(root)
|
|
656
|
+
return {"ok": bool(status["ok"]), "status": status}
|
|
657
|
+
if name == "list_active_work":
|
|
658
|
+
kind = str(args.get("kind") or "all")
|
|
659
|
+
if kind not in {"all", "request", "backlog", "task"}:
|
|
660
|
+
raise McpToolError("invalid_argument_value", "Unsupported list kind.", details={"kind": kind, "allowed": ["all", "request", "backlog", "task"]})
|
|
661
|
+
return {"ok": True, "items": flow_list_payload(root, kind=kind)["entries"]}
|
|
662
|
+
if name == "read_logics_doc":
|
|
663
|
+
try:
|
|
664
|
+
payload = read_logics_doc_payload(root, str(args.get("source") or ""), max_chars=_bounded_int(args.get("max_chars"), default=4000, maximum=12000), sections=args.get("sections") if isinstance(args.get("sections"), list) else None)
|
|
665
|
+
except SystemExit as exc:
|
|
666
|
+
raise _mcp_read_error(exc) from exc
|
|
667
|
+
return {"ok": True, **payload}
|
|
668
|
+
if name == "build_context_pack":
|
|
669
|
+
try:
|
|
670
|
+
payload = build_context_pack_payload(root, str(args.get("ref") or ""), mode=str(args.get("mode") or "summary-only"), profile=str(args.get("profile") or "normal"), config=None)
|
|
671
|
+
except SystemExit as exc:
|
|
672
|
+
raise _mcp_read_error(exc) from exc
|
|
673
|
+
return {"ok": True, **payload}
|
|
674
|
+
if name == "list_logics_docs":
|
|
675
|
+
payload = list_logics_docs_payload(
|
|
676
|
+
root,
|
|
677
|
+
kind=str(args.get("kind") or "all"),
|
|
678
|
+
status=str(args["status"]) if args.get("status") else None,
|
|
679
|
+
ref_prefix=str(args["ref_prefix"]) if args.get("ref_prefix") else None,
|
|
680
|
+
limit=_bounded_int(args.get("limit"), default=50, maximum=200),
|
|
681
|
+
)
|
|
682
|
+
return {"ok": True, **payload}
|
|
683
|
+
if name == "search_logics_docs":
|
|
684
|
+
try:
|
|
685
|
+
payload = search_logics_docs_payload(
|
|
686
|
+
root,
|
|
687
|
+
str(args.get("query") or ""),
|
|
688
|
+
kind=str(args.get("kind") or "all"),
|
|
689
|
+
status=str(args["status"]) if args.get("status") else None,
|
|
690
|
+
limit=_bounded_int(args.get("limit"), default=20, maximum=100),
|
|
691
|
+
max_snippet_chars=_bounded_int(args.get("max_snippet_chars"), default=240, maximum=1000),
|
|
692
|
+
)
|
|
693
|
+
except SystemExit as exc:
|
|
694
|
+
raise _mcp_read_error(exc) from exc
|
|
695
|
+
return {"ok": True, **payload}
|
|
696
|
+
if name == "finish_task":
|
|
697
|
+
rel_path = _relative_path(root, str(args.get("task_path") or ""), ("logics/tasks",))
|
|
698
|
+
dry_run = bool(args.get("dry_run", False))
|
|
699
|
+
if not dry_run:
|
|
700
|
+
_ensure_no_dirty_conflict(root, ["logics"])
|
|
701
|
+
command = ["flow", "finish", "task", rel_path.as_posix(), "--format", "json"]
|
|
702
|
+
if dry_run:
|
|
703
|
+
command.append("--dry-run")
|
|
704
|
+
payload = _json_from_stdout(_run_command(root, command).stdout)
|
|
705
|
+
return _workflow_write_result(root, {"source_path": payload["source"], "dry_run": payload["dry_run"], "summary": f"Finished task {Path(payload['source']).stem}"}, paths=None if not dry_run else [rel_path.as_posix()])
|
|
706
|
+
if name == "close_workflow_doc":
|
|
707
|
+
kind = str(args.get("kind") or "")
|
|
708
|
+
allowed_dir = {"request": "logics/request", "backlog": "logics/backlog", "task": "logics/tasks"}[kind]
|
|
709
|
+
rel_path = _relative_path(root, str(args.get("source_path") or ""), (allowed_dir,))
|
|
710
|
+
dry_run = bool(args.get("dry_run", False))
|
|
711
|
+
if not dry_run:
|
|
712
|
+
_ensure_no_dirty_conflict(root, ["logics"])
|
|
713
|
+
command = ["flow", "close", kind, rel_path.as_posix(), "--format", "json"]
|
|
714
|
+
if dry_run:
|
|
715
|
+
command.append("--dry-run")
|
|
716
|
+
payload = _json_from_stdout(_run_command(root, command).stdout)
|
|
717
|
+
return _workflow_write_result(root, {"kind": payload["kind"], "source_path": payload["source"], "dry_run": payload["dry_run"], "summary": f"Closed {kind} {Path(payload['source']).stem}"}, paths=None if not dry_run else [rel_path.as_posix()])
|
|
718
|
+
if name == "close_eligible_requests":
|
|
719
|
+
dry_run = bool(args.get("dry_run", False))
|
|
720
|
+
if not dry_run:
|
|
721
|
+
_ensure_no_dirty_conflict(root, ["logics"])
|
|
722
|
+
command = ["sync", "close-eligible-requests", "--format", "json"]
|
|
723
|
+
if dry_run:
|
|
724
|
+
command.append("--dry-run")
|
|
725
|
+
payload = _json_from_stdout(_run_command(root, command).stdout)
|
|
726
|
+
return _workflow_write_result(root, payload)
|
|
727
|
+
if name == "refresh_mermaid_signatures":
|
|
728
|
+
dry_run = bool(args.get("dry_run", False))
|
|
729
|
+
if not dry_run:
|
|
730
|
+
_ensure_no_dirty_conflict(root, ["logics"])
|
|
731
|
+
command = ["sync", "refresh-mermaid-signatures", "--format", "json"]
|
|
732
|
+
if dry_run:
|
|
733
|
+
command.append("--dry-run")
|
|
734
|
+
payload = _json_from_stdout(_run_command(root, command).stdout)
|
|
735
|
+
paths = [str(path) for path in payload.get("modified_files", [])] or None
|
|
736
|
+
return _workflow_write_result(root, payload, paths=paths)
|
|
737
|
+
if name == "update_workflow_indicators":
|
|
738
|
+
source = str(args.get("source") or "")
|
|
739
|
+
dry_run = bool(args.get("dry_run", False))
|
|
740
|
+
rel_path = _workflow_doc_path_for_source(root, source)
|
|
741
|
+
if not dry_run:
|
|
742
|
+
_ensure_no_dirty_conflict(root, [rel_path])
|
|
743
|
+
indicators = {
|
|
744
|
+
"Status": args.get("status"),
|
|
745
|
+
"Progress": args.get("progress"),
|
|
746
|
+
"Understanding": args.get("understanding"),
|
|
747
|
+
"Confidence": args.get("confidence"),
|
|
748
|
+
"Theme": args.get("theme"),
|
|
749
|
+
"Complexity": args.get("complexity"),
|
|
750
|
+
}
|
|
751
|
+
try:
|
|
752
|
+
payload = update_workflow_indicators_payload(root, source, {key: str(value) for key, value in indicators.items() if value is not None}, dry_run=dry_run)
|
|
753
|
+
except SystemExit as exc:
|
|
754
|
+
raise _mcp_mutation_error(exc) from exc
|
|
755
|
+
return _workflow_write_result(root, payload, paths=[rel_path])
|
|
756
|
+
if name in {"append_report_entry", "append_validation_note", "append_decision_note"}:
|
|
757
|
+
source = str(args.get("source") or "")
|
|
758
|
+
dry_run = bool(args.get("dry_run", False))
|
|
759
|
+
rel_path = _workflow_doc_path_for_source(root, source)
|
|
760
|
+
if not dry_run:
|
|
761
|
+
_ensure_no_dirty_conflict(root, [rel_path])
|
|
762
|
+
note_kind = {"append_report_entry": "report", "append_validation_note": "validation", "append_decision_note": "decision"}[name]
|
|
763
|
+
try:
|
|
764
|
+
payload = append_workflow_note_payload(root, source, note_kind=note_kind, text=str(args.get("text") or ""), dry_run=dry_run)
|
|
765
|
+
except SystemExit as exc:
|
|
766
|
+
raise _mcp_mutation_error(exc) from exc
|
|
767
|
+
return _workflow_write_result(root, payload, paths=[rel_path])
|
|
768
|
+
if name == "split_request":
|
|
769
|
+
rel_path = _relative_path(root, str(args.get("request_path") or ""), ("logics/request",))
|
|
770
|
+
titles = _nonempty_titles(args.get("titles"))
|
|
771
|
+
dry_run = bool(args.get("dry_run", False))
|
|
772
|
+
if not dry_run:
|
|
773
|
+
_ensure_no_dirty_conflict(root, [rel_path.as_posix()])
|
|
774
|
+
command = ["flow", "split", "request", rel_path.as_posix(), "--format", "json"]
|
|
775
|
+
for title in titles:
|
|
776
|
+
command.extend(["--title", title])
|
|
777
|
+
if dry_run:
|
|
778
|
+
command.append("--dry-run")
|
|
779
|
+
payload = _json_from_stdout(_run_command(root, command).stdout)
|
|
780
|
+
created_paths = [f"logics/backlog/{ref}.md" for ref in payload.get("created_refs", [])]
|
|
781
|
+
return _workflow_write_result(root, {"created_paths": created_paths, **payload}, paths=[rel_path.as_posix(), *created_paths])
|
|
782
|
+
if name == "split_backlog":
|
|
783
|
+
rel_path = _relative_path(root, str(args.get("backlog_path") or ""), ("logics/backlog",))
|
|
784
|
+
titles = _nonempty_titles(args.get("titles"))
|
|
785
|
+
dry_run = bool(args.get("dry_run", False))
|
|
786
|
+
if not dry_run:
|
|
787
|
+
_ensure_no_dirty_conflict(root, [rel_path.as_posix()])
|
|
788
|
+
command = ["flow", "split", "backlog", rel_path.as_posix(), "--format", "json"]
|
|
789
|
+
for title in titles:
|
|
790
|
+
command.extend(["--title", title])
|
|
791
|
+
if dry_run:
|
|
792
|
+
command.append("--dry-run")
|
|
793
|
+
payload = _json_from_stdout(_run_command(root, command).stdout)
|
|
794
|
+
created_paths = [f"logics/tasks/{ref}.md" for ref in payload.get("created_refs", [])]
|
|
795
|
+
return _workflow_write_result(root, {"created_paths": created_paths, **payload}, paths=[rel_path.as_posix(), *created_paths])
|
|
796
|
+
if name in {"autofix_ac_traceability", "autofix_structure"}:
|
|
797
|
+
raw_paths = args.get("paths") if isinstance(args.get("paths"), list) else []
|
|
798
|
+
paths = [_relative_path(root, str(path), ("logics",)).as_posix() for path in raw_paths]
|
|
799
|
+
refs = [str(ref).strip() for ref in args.get("refs", []) if str(ref).strip()] if isinstance(args.get("refs"), list) else []
|
|
800
|
+
_ensure_no_dirty_conflict(root, paths or ["logics"])
|
|
801
|
+
flag = "--autofix-ac-traceability" if name == "autofix_ac_traceability" else "--autofix-structure"
|
|
802
|
+
command = ["audit", flag, "--format", "json"]
|
|
803
|
+
if paths:
|
|
804
|
+
command.append("--paths")
|
|
805
|
+
command.extend(paths)
|
|
806
|
+
if refs:
|
|
807
|
+
command.append("--refs")
|
|
808
|
+
command.extend(refs)
|
|
809
|
+
payload = _run_json_command(root, command)
|
|
810
|
+
modified = [str(path) for path in payload.get("autofix", {}).get("modified_files", [])]
|
|
811
|
+
return _workflow_write_result(root, {"audit_payload": payload, "modified_paths": modified}, paths=modified or paths or None)
|
|
812
|
+
if name == "show_git_diff":
|
|
813
|
+
raw_paths = args.get("paths")
|
|
814
|
+
paths = [str(path) for path in raw_paths] if isinstance(raw_paths, list) else None
|
|
815
|
+
return _show_git_diff(root, paths)
|
|
816
|
+
|
|
817
|
+
if name == "create_request":
|
|
818
|
+
title = str(args.get("title") or "").strip()
|
|
819
|
+
if not title:
|
|
820
|
+
raise McpToolError("missing_required_argument", "title is required.", details={"argument": "title"})
|
|
821
|
+
command = ["flow", "new", "request", "--title", title, "--format", "json"]
|
|
822
|
+
if args.get("theme"):
|
|
823
|
+
command.extend(["--theme", str(args["theme"])])
|
|
824
|
+
if args.get("complexity"):
|
|
825
|
+
command.extend(["--complexity", str(args["complexity"])])
|
|
826
|
+
payload = _created_doc_from_stdout(_run_command(root, command).stdout, command="new", kind="request")
|
|
827
|
+
_update_created_request(root, str(payload["path"]), args)
|
|
828
|
+
return {
|
|
829
|
+
"ok": True,
|
|
830
|
+
"path": payload["path"],
|
|
831
|
+
"ref": payload["ref"],
|
|
832
|
+
"summary": f"Created request {payload['ref']}",
|
|
833
|
+
"document_preview": _document_preview(root, str(payload["path"])),
|
|
834
|
+
"next_suggested_tool": "promote_request_to_backlog",
|
|
835
|
+
**_validation_result(root),
|
|
836
|
+
**_show_git_diff(root, [str(payload["path"])]),
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
if name == "promote_request_to_backlog":
|
|
840
|
+
rel_path = _relative_path(root, str(args.get("request_path") or ""), ("logics/request",))
|
|
841
|
+
_ensure_no_dirty_conflict(root, [rel_path.as_posix()])
|
|
842
|
+
payload = _json_from_stdout(_run_command(root, ["flow", "promote", "request-to-backlog", rel_path.as_posix(), "--format", "json"]).stdout)
|
|
843
|
+
return {
|
|
844
|
+
"ok": True,
|
|
845
|
+
"source_path": payload["source"],
|
|
846
|
+
"created_path": payload["created_path"],
|
|
847
|
+
"created_ref": payload["created_ref"],
|
|
848
|
+
"document_preview": _document_preview(root, str(payload["created_path"])),
|
|
849
|
+
"next_suggested_tool": "promote_backlog_to_task",
|
|
850
|
+
**_validation_result(root),
|
|
851
|
+
**_show_git_diff(root, [str(payload["source"]), str(payload["created_path"])]),
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
if name == "promote_backlog_to_task":
|
|
855
|
+
rel_path = _relative_path(root, str(args.get("backlog_path") or ""), ("logics/backlog",))
|
|
856
|
+
_ensure_no_dirty_conflict(root, [rel_path.as_posix()])
|
|
857
|
+
payload = _json_from_stdout(_run_command(root, ["flow", "promote", "backlog-to-task", rel_path.as_posix(), "--format", "json"]).stdout)
|
|
858
|
+
return {
|
|
859
|
+
"ok": True,
|
|
860
|
+
"source_path": payload["source"],
|
|
861
|
+
"created_path": payload["created_path"],
|
|
862
|
+
"created_ref": payload["created_ref"],
|
|
863
|
+
"document_preview": _document_preview(root, str(payload["created_path"])),
|
|
864
|
+
"next_suggested_tool": "run_logics_lint",
|
|
865
|
+
**_validation_result(root),
|
|
866
|
+
**_show_git_diff(root, [str(payload["source"]), str(payload["created_path"])]),
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
if name in {"create_product_brief", "create_architecture_decision"}:
|
|
870
|
+
title = str(args.get("title") or "").strip()
|
|
871
|
+
if not title:
|
|
872
|
+
raise McpToolError("missing_required_argument", "title is required.", details={"argument": "title"})
|
|
873
|
+
companion_kind = "product" if name == "create_product_brief" else "architecture"
|
|
874
|
+
command = ["flow", "companion", companion_kind, "--title", title, "--format", "json"]
|
|
875
|
+
ref_args = (
|
|
876
|
+
("request_path", "--request-ref", "logics/request"),
|
|
877
|
+
("backlog_path", "--backlog-ref", "logics/backlog"),
|
|
878
|
+
("task_path", "--task-ref", "logics/tasks"),
|
|
879
|
+
)
|
|
880
|
+
linked_refs: dict[str, str] = {}
|
|
881
|
+
for key, flag, directory in ref_args:
|
|
882
|
+
if args.get(key):
|
|
883
|
+
rel_path = _relative_path(root, str(args[key]), (directory,))
|
|
884
|
+
ref = _flow_path_ref(rel_path.as_posix())
|
|
885
|
+
if ref:
|
|
886
|
+
command.extend([flag, ref])
|
|
887
|
+
linked_refs[key] = rel_path.as_posix()
|
|
888
|
+
payload = _json_from_stdout(_run_command(root, command).stdout)
|
|
889
|
+
return {
|
|
890
|
+
"ok": True,
|
|
891
|
+
"path": payload["path"],
|
|
892
|
+
"ref": payload["ref"],
|
|
893
|
+
"linked_refs": linked_refs,
|
|
894
|
+
"document_preview": _document_preview(root, str(payload["path"])),
|
|
895
|
+
"next_suggested_tool": "run_logics_lint",
|
|
896
|
+
**_validation_result(root, include_audit=True),
|
|
897
|
+
**_show_git_diff(root, [str(payload["path"])]),
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
raise McpToolError("unsupported_action", f"Unsupported MCP tool: {name}")
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
def mcp_result(payload: dict[str, Any]) -> dict[str, Any]:
|
|
904
|
+
return {
|
|
905
|
+
"content": [{"type": "text", "text": json.dumps(payload, indent=2, sort_keys=True)}],
|
|
906
|
+
"structuredContent": payload,
|
|
907
|
+
"isError": not bool(payload.get("ok", True)),
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
def handle_jsonrpc(message: dict[str, Any], *, repo_root: Path | None = None) -> dict[str, Any] | None:
|
|
912
|
+
method = message.get("method")
|
|
913
|
+
request_id = message.get("id")
|
|
914
|
+
if method == "notifications/initialized":
|
|
915
|
+
return None
|
|
916
|
+
try:
|
|
917
|
+
if method == "initialize":
|
|
918
|
+
result = {
|
|
919
|
+
"protocolVersion": "2025-06-18",
|
|
920
|
+
"capabilities": {"tools": {"listChanged": False}},
|
|
921
|
+
"serverInfo": {"name": "logics-manager", "version": _server_version()},
|
|
922
|
+
}
|
|
923
|
+
elif method == "tools/list":
|
|
924
|
+
result = {"tools": TOOL_DEFINITIONS}
|
|
925
|
+
elif method == "tools/call":
|
|
926
|
+
params = message.get("params") if isinstance(message.get("params"), dict) else {}
|
|
927
|
+
name = str(params.get("name") or "")
|
|
928
|
+
arguments = params.get("arguments") if isinstance(params.get("arguments"), dict) else {}
|
|
929
|
+
result = mcp_result(call_tool(name, arguments, repo_root=repo_root))
|
|
930
|
+
else:
|
|
931
|
+
raise McpToolError("unsupported_action", f"Unsupported JSON-RPC method: {method}")
|
|
932
|
+
if request_id is None:
|
|
933
|
+
return None
|
|
934
|
+
return {"jsonrpc": JSONRPC_VERSION, "id": request_id, "result": result}
|
|
935
|
+
except McpToolError as exc:
|
|
936
|
+
error_payload = exc.to_payload()
|
|
937
|
+
if method == "tools/call" and request_id is not None:
|
|
938
|
+
return {"jsonrpc": JSONRPC_VERSION, "id": request_id, "result": mcp_result(error_payload)}
|
|
939
|
+
return {"jsonrpc": JSONRPC_VERSION, "id": request_id, "error": {"code": -32000, "message": exc.message, "data": error_payload}}
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
def serve_stdio(*, repo_root: Path | None = None) -> int:
|
|
943
|
+
root = _repo_root(repo_root)
|
|
944
|
+
for line in sys.stdin:
|
|
945
|
+
stripped = line.strip()
|
|
946
|
+
if not stripped:
|
|
947
|
+
continue
|
|
948
|
+
try:
|
|
949
|
+
message = json.loads(stripped)
|
|
950
|
+
if not isinstance(message, dict):
|
|
951
|
+
raise ValueError("JSON-RPC message must be an object.")
|
|
952
|
+
response = handle_jsonrpc(message, repo_root=root)
|
|
953
|
+
except Exception as exc:
|
|
954
|
+
response = {"jsonrpc": JSONRPC_VERSION, "id": None, "error": {"code": -32700, "message": str(exc)}}
|
|
955
|
+
if response is not None:
|
|
956
|
+
print(json.dumps(response, separators=(",", ":")), flush=True)
|
|
957
|
+
return 0
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
def make_http_handler(repo_root: Path, *, bearer_token: str | None = None) -> type[BaseHTTPRequestHandler]:
|
|
961
|
+
class LogicsMcpHttpHandler(BaseHTTPRequestHandler):
|
|
962
|
+
server_version = "LogicsMCP/1.0"
|
|
963
|
+
|
|
964
|
+
def _send_json(self, status: int, payload: dict[str, Any]) -> None:
|
|
965
|
+
encoded = json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
|
966
|
+
self.send_response(status)
|
|
967
|
+
self.send_header("Content-Type", "application/json")
|
|
968
|
+
self.send_header("Content-Length", str(len(encoded)))
|
|
969
|
+
self.end_headers()
|
|
970
|
+
self.wfile.write(encoded)
|
|
971
|
+
|
|
972
|
+
def do_GET(self) -> None:
|
|
973
|
+
parsed = urlparse(self.path)
|
|
974
|
+
if parsed.path == "/health":
|
|
975
|
+
self._send_json(200, {"ok": True, "server": "logics-manager-mcp", "version": _server_version()})
|
|
976
|
+
return
|
|
977
|
+
self._send_json(404, {"ok": False, "error": "not_found", "message": "Use POST /mcp for JSON-RPC."})
|
|
978
|
+
|
|
979
|
+
def do_POST(self) -> None:
|
|
980
|
+
parsed = urlparse(self.path)
|
|
981
|
+
if parsed.path != "/mcp":
|
|
982
|
+
self._send_json(404, {"ok": False, "error": "not_found", "message": "Use POST /mcp for JSON-RPC."})
|
|
983
|
+
return
|
|
984
|
+
if bearer_token:
|
|
985
|
+
expected = f"Bearer {bearer_token}"
|
|
986
|
+
actual = self.headers.get("Authorization", "")
|
|
987
|
+
if not secrets.compare_digest(actual, expected):
|
|
988
|
+
self.send_response(401)
|
|
989
|
+
self.send_header("Content-Type", "application/json")
|
|
990
|
+
self.send_header("WWW-Authenticate", 'Bearer realm="logics-mcp"')
|
|
991
|
+
encoded = json.dumps({"ok": False, "error": "unauthorized", "message": "Missing or invalid bearer token."}, separators=(",", ":")).encode("utf-8")
|
|
992
|
+
self.send_header("Content-Length", str(len(encoded)))
|
|
993
|
+
self.end_headers()
|
|
994
|
+
self.wfile.write(encoded)
|
|
995
|
+
return
|
|
996
|
+
try:
|
|
997
|
+
length = int(self.headers.get("Content-Length", "0"))
|
|
998
|
+
except ValueError:
|
|
999
|
+
self._send_json(400, {"ok": False, "error": "bad_request", "message": "Invalid Content-Length."})
|
|
1000
|
+
return
|
|
1001
|
+
raw_body = self.rfile.read(length).decode("utf-8")
|
|
1002
|
+
try:
|
|
1003
|
+
message = json.loads(raw_body)
|
|
1004
|
+
if not isinstance(message, dict):
|
|
1005
|
+
raise ValueError("JSON-RPC message must be an object.")
|
|
1006
|
+
response = handle_jsonrpc(message, repo_root=repo_root)
|
|
1007
|
+
except Exception as exc:
|
|
1008
|
+
self._send_json(400, {"jsonrpc": JSONRPC_VERSION, "id": None, "error": {"code": -32700, "message": str(exc)}})
|
|
1009
|
+
return
|
|
1010
|
+
if response is None:
|
|
1011
|
+
self.send_response(202)
|
|
1012
|
+
self.send_header("Content-Length", "0")
|
|
1013
|
+
self.end_headers()
|
|
1014
|
+
return
|
|
1015
|
+
self._send_json(200, response)
|
|
1016
|
+
|
|
1017
|
+
def log_message(self, format: str, *args: Any) -> None:
|
|
1018
|
+
print(f"logics-mcp-http: {format % args}", file=sys.stderr)
|
|
1019
|
+
|
|
1020
|
+
return LogicsMcpHttpHandler
|
|
1021
|
+
|
|
1022
|
+
|
|
1023
|
+
def serve_http(*, repo_root: Path | None = None, host: str = "127.0.0.1", port: int = 8765, bearer_token: str | None = None) -> int:
|
|
1024
|
+
root = _repo_root(repo_root)
|
|
1025
|
+
token = bearer_token or os.environ.get(AUTH_ENV_VAR)
|
|
1026
|
+
server = ThreadingHTTPServer((host, port), make_http_handler(root, bearer_token=token))
|
|
1027
|
+
print(f"Logics MCP HTTP listening on http://{host}:{server.server_port}/mcp", file=sys.stderr)
|
|
1028
|
+
if token:
|
|
1029
|
+
print("Logics MCP HTTP requires Authorization: Bearer <token> for POST /mcp", file=sys.stderr)
|
|
1030
|
+
else:
|
|
1031
|
+
print(f"WARNING: Logics MCP HTTP is running without bearer-token auth. Set {AUTH_ENV_VAR} or pass --bearer-token before tunneling.", file=sys.stderr)
|
|
1032
|
+
try:
|
|
1033
|
+
server.serve_forever()
|
|
1034
|
+
except KeyboardInterrupt:
|
|
1035
|
+
return 130
|
|
1036
|
+
finally:
|
|
1037
|
+
server.server_close()
|
|
1038
|
+
return 0
|
|
1039
|
+
|
|
1040
|
+
|
|
1041
|
+
def _connector_urls(public_url: str | None) -> dict[str, str]:
|
|
1042
|
+
if not public_url:
|
|
1043
|
+
return {}
|
|
1044
|
+
base = public_url.rstrip("/")
|
|
1045
|
+
if base.endswith("/mcp"):
|
|
1046
|
+
mcp_url = base
|
|
1047
|
+
health_url = base[:-4].rstrip("/") + "/health"
|
|
1048
|
+
else:
|
|
1049
|
+
mcp_url = f"{base}/mcp"
|
|
1050
|
+
health_url = f"{base}/health"
|
|
1051
|
+
return {"public_url": base, "mcp_url": mcp_url, "health_url": health_url}
|
|
1052
|
+
|
|
1053
|
+
|
|
1054
|
+
def connector_plan(*, repo_root: Path, host: str, port: int, bearer_token: str | None = None, public_url: str | None = None) -> dict[str, Any]:
|
|
1055
|
+
token = bearer_token or secrets.token_urlsafe(32)
|
|
1056
|
+
urls = _connector_urls(public_url)
|
|
1057
|
+
local_mcp_url = f"http://{host}:{port}/mcp"
|
|
1058
|
+
local_health_url = f"http://{host}:{port}/health"
|
|
1059
|
+
server_command = f'{AUTH_ENV_VAR}="{token}" python3 -m logics_manager mcp serve-http --repo-root {repo_root.as_posix()} --host {host} --port {port}'
|
|
1060
|
+
return {
|
|
1061
|
+
"ok": True,
|
|
1062
|
+
"repo_root": repo_root.as_posix(),
|
|
1063
|
+
"bearer_token": token,
|
|
1064
|
+
"auth_header": f"Authorization: Bearer {token}",
|
|
1065
|
+
"local_mcp_url": local_mcp_url,
|
|
1066
|
+
"local_health_url": local_health_url,
|
|
1067
|
+
"server_command": server_command,
|
|
1068
|
+
"tunnel_target": f"{host}:{port}",
|
|
1069
|
+
"chatgpt": {
|
|
1070
|
+
"developer_mode": True,
|
|
1071
|
+
"mcp_url": urls.get("mcp_url", "<your HTTPS tunnel URL>/mcp"),
|
|
1072
|
+
"auth_type": "Bearer token",
|
|
1073
|
+
"auth_value": token,
|
|
1074
|
+
},
|
|
1075
|
+
"smoke_checks": {
|
|
1076
|
+
"health": urls.get("health_url", f"<your HTTPS tunnel URL>/health"),
|
|
1077
|
+
"mcp_tools_list": urls.get("mcp_url", "<your HTTPS tunnel URL>/mcp"),
|
|
1078
|
+
},
|
|
1079
|
+
"cleanup": [
|
|
1080
|
+
"Stop the HTTPS tunnel process.",
|
|
1081
|
+
"Stop the local mcp serve-http process with Ctrl-C.",
|
|
1082
|
+
"Treat the bearer token as expired once the local session is stopped.",
|
|
1083
|
+
],
|
|
1084
|
+
**urls,
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
|
|
1088
|
+
def connector_smoke_check(public_url: str, bearer_token: str, *, timeout: float = 5.0) -> dict[str, Any]:
|
|
1089
|
+
urls = _connector_urls(public_url)
|
|
1090
|
+
health_ok = False
|
|
1091
|
+
mcp_ok = False
|
|
1092
|
+
errors: list[str] = []
|
|
1093
|
+
try:
|
|
1094
|
+
with urlopen(urls["health_url"], timeout=timeout) as response:
|
|
1095
|
+
health_ok = response.status == 200
|
|
1096
|
+
except (OSError, URLError) as exc:
|
|
1097
|
+
errors.append(f"health: {exc}")
|
|
1098
|
+
try:
|
|
1099
|
+
body = json.dumps({"jsonrpc": JSONRPC_VERSION, "id": 1, "method": "tools/list", "params": {}}).encode("utf-8")
|
|
1100
|
+
request = Request(urls["mcp_url"], data=body, headers={"Content-Type": "application/json", "Authorization": f"Bearer {bearer_token}"}, method="POST")
|
|
1101
|
+
with urlopen(request, timeout=timeout) as response:
|
|
1102
|
+
payload = json.loads(response.read().decode("utf-8"))
|
|
1103
|
+
mcp_ok = response.status == 200 and "result" in payload
|
|
1104
|
+
except (OSError, URLError, json.JSONDecodeError) as exc:
|
|
1105
|
+
errors.append(f"mcp: {exc}")
|
|
1106
|
+
return {"ok": health_ok and mcp_ok, "health_ok": health_ok, "mcp_ok": mcp_ok, "errors": errors, **urls}
|
|
1107
|
+
|
|
1108
|
+
|
|
1109
|
+
def _print_connector_plan(plan: dict[str, Any]) -> None:
|
|
1110
|
+
print("Logics MCP Connector")
|
|
1111
|
+
print(f"Server command:\n {plan['server_command']}")
|
|
1112
|
+
print(f"Tunnel target: {plan['tunnel_target']}")
|
|
1113
|
+
print(f"ChatGPT developer-mode MCP URL: {plan['chatgpt']['mcp_url']}")
|
|
1114
|
+
print(f"Authorization header: {plan['auth_header']}")
|
|
1115
|
+
print("Smoke checks:")
|
|
1116
|
+
print(f" health: {plan['smoke_checks']['health']}")
|
|
1117
|
+
print(f" mcp tools/list: {plan['smoke_checks']['mcp_tools_list']}")
|
|
1118
|
+
print("Cleanup:")
|
|
1119
|
+
for item in plan["cleanup"]:
|
|
1120
|
+
print(f" - {item}")
|
|
1121
|
+
|
|
1122
|
+
|
|
1123
|
+
def main(argv: list[str] | None = None) -> int:
|
|
1124
|
+
parser = argparse.ArgumentParser(prog="logics-manager mcp", description="Run or inspect the Logics MCP server.")
|
|
1125
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
1126
|
+
serve = sub.add_parser("serve", help="Serve MCP JSON-RPC over stdio.")
|
|
1127
|
+
serve.add_argument("--repo-root", default=None)
|
|
1128
|
+
serve_http_parser = sub.add_parser("serve-http", help="Serve MCP JSON-RPC over local HTTP for tunnel testing.")
|
|
1129
|
+
serve_http_parser.add_argument("--repo-root", default=None)
|
|
1130
|
+
serve_http_parser.add_argument("--host", default="127.0.0.1")
|
|
1131
|
+
serve_http_parser.add_argument("--port", type=int, default=8765)
|
|
1132
|
+
serve_http_parser.add_argument("--bearer-token", default=None, help=f"Require this OAuth-style bearer token for POST /mcp. Defaults to ${AUTH_ENV_VAR} when set.")
|
|
1133
|
+
tools = sub.add_parser("tools", help="Print the exposed MCP tool definitions.")
|
|
1134
|
+
tools.add_argument("--format", choices=("json",), default="json")
|
|
1135
|
+
call = sub.add_parser("call", help="Call one MCP tool directly for local testing.")
|
|
1136
|
+
call.add_argument("name")
|
|
1137
|
+
call.add_argument("--arguments", default="{}")
|
|
1138
|
+
call.add_argument("--repo-root", default=None)
|
|
1139
|
+
connect = sub.add_parser("connect", help="Print local HTTP connector setup for ChatGPT developer mode.")
|
|
1140
|
+
connect.add_argument("--repo-root", default=None)
|
|
1141
|
+
connect.add_argument("--host", default="127.0.0.1")
|
|
1142
|
+
connect.add_argument("--port", type=int, default=8765)
|
|
1143
|
+
connect.add_argument("--bearer-token", default=None)
|
|
1144
|
+
connect.add_argument("--public-url", default=None, help="Optional HTTPS tunnel URL used for copyable ChatGPT setup and smoke checks.")
|
|
1145
|
+
connect.add_argument("--check", action="store_true", help="Run /health and authenticated /mcp smoke checks against --public-url.")
|
|
1146
|
+
connect.add_argument("--format", choices=("text", "json"), default="text")
|
|
1147
|
+
parsed = parser.parse_args(argv)
|
|
1148
|
+
|
|
1149
|
+
if parsed.command == "tools":
|
|
1150
|
+
print(json.dumps({"tools": TOOL_DEFINITIONS}, indent=2, sort_keys=True))
|
|
1151
|
+
return 0
|
|
1152
|
+
if parsed.command == "serve":
|
|
1153
|
+
return serve_stdio(repo_root=Path(parsed.repo_root) if parsed.repo_root else None)
|
|
1154
|
+
if parsed.command == "serve-http":
|
|
1155
|
+
return serve_http(repo_root=Path(parsed.repo_root) if parsed.repo_root else None, host=parsed.host, port=parsed.port, bearer_token=parsed.bearer_token)
|
|
1156
|
+
if parsed.command == "call":
|
|
1157
|
+
try:
|
|
1158
|
+
arguments = json.loads(parsed.arguments)
|
|
1159
|
+
except json.JSONDecodeError as exc:
|
|
1160
|
+
raise SystemExit(f"Invalid JSON arguments: {exc}") from exc
|
|
1161
|
+
if not isinstance(arguments, dict):
|
|
1162
|
+
raise SystemExit("Arguments must be a JSON object.")
|
|
1163
|
+
try:
|
|
1164
|
+
with contextlib.redirect_stdout(io.StringIO()):
|
|
1165
|
+
payload = call_tool(parsed.name, arguments, repo_root=Path(parsed.repo_root) if parsed.repo_root else None)
|
|
1166
|
+
except McpToolError as exc:
|
|
1167
|
+
print(json.dumps(exc.to_payload(), indent=2, sort_keys=True))
|
|
1168
|
+
return 1
|
|
1169
|
+
print(json.dumps(payload, indent=2, sort_keys=True))
|
|
1170
|
+
return 0
|
|
1171
|
+
if parsed.command == "connect":
|
|
1172
|
+
root = _repo_root(Path(parsed.repo_root) if parsed.repo_root else None)
|
|
1173
|
+
plan = connector_plan(repo_root=root, host=parsed.host, port=parsed.port, bearer_token=parsed.bearer_token, public_url=parsed.public_url)
|
|
1174
|
+
if parsed.check:
|
|
1175
|
+
if not parsed.public_url:
|
|
1176
|
+
raise SystemExit("--check requires --public-url.")
|
|
1177
|
+
plan["check"] = connector_smoke_check(parsed.public_url, str(plan["bearer_token"]))
|
|
1178
|
+
plan["ok"] = bool(plan["check"]["ok"])
|
|
1179
|
+
if parsed.format == "json":
|
|
1180
|
+
print(json.dumps(plan, indent=2, sort_keys=True))
|
|
1181
|
+
else:
|
|
1182
|
+
_print_connector_plan(plan)
|
|
1183
|
+
if "check" in plan:
|
|
1184
|
+
print(f"Check: {'OK' if plan['check']['ok'] else 'FAILED'}")
|
|
1185
|
+
for error in plan["check"]["errors"]:
|
|
1186
|
+
print(f" - {error}")
|
|
1187
|
+
return 0 if plan["ok"] else 1
|
|
1188
|
+
return 1
|