@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.
@@ -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