@grifhinz/logics-manager 2.1.1 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,8 +8,10 @@ import json
8
8
  import os
9
9
  import re
10
10
  import secrets
11
+ import signal
11
12
  import subprocess
12
13
  import sys
14
+ import time
13
15
  from pathlib import Path
14
16
  from typing import Any
15
17
  from urllib.error import URLError
@@ -19,6 +21,7 @@ from urllib.request import Request, urlopen
19
21
  from .audit import audit_payload
20
22
  from .config import ConfigError, find_repo_root
21
23
  from .flow import flow_list_payload
24
+ from .insights import followups_payload, health_payload, product_consistency_payload, status_payload
22
25
  from .lint import expected_workflow_mermaid_signature, lint_payload
23
26
  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
27
 
@@ -115,6 +118,17 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [
115
118
  ),
116
119
  "annotations": {"readOnlyHint": False, "idempotentHint": False, "destructiveHint": False},
117
120
  },
121
+ {
122
+ "name": "list_companion_docs",
123
+ "description": "List Logics companion documents such as product briefs and architecture decisions.",
124
+ "inputSchema": _tool_schema(
125
+ {
126
+ "kind": {"type": "string", "enum": ["all", "product", "architecture"]},
127
+ "limit": {"type": "integer"},
128
+ }
129
+ ),
130
+ "annotations": {"readOnlyHint": True, "idempotentHint": True, "destructiveHint": False},
131
+ },
118
132
  {
119
133
  "name": "list_active_work",
120
134
  "description": "List active Logics request, backlog, and task documents.",
@@ -175,6 +189,37 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [
175
189
  ),
176
190
  "annotations": {"readOnlyHint": True, "idempotentHint": True, "destructiveHint": False},
177
191
  },
192
+ {
193
+ "name": "get_logics_status",
194
+ "description": "Summarize open Logics workflow docs and next actions.",
195
+ "inputSchema": _tool_schema({"limit": {"type": "integer"}}),
196
+ "annotations": {"readOnlyHint": True, "idempotentHint": True, "destructiveHint": False},
197
+ },
198
+ {
199
+ "name": "get_logics_health",
200
+ "description": "Show Logics workflow health counts and issue signals.",
201
+ "inputSchema": _tool_schema({"limit": {"type": "integer"}}),
202
+ "annotations": {"readOnlyHint": True, "idempotentHint": True, "destructiveHint": False},
203
+ },
204
+ {
205
+ "name": "list_logics_followups",
206
+ "description": "List actionable Logics follow-up areas with request creation commands.",
207
+ "inputSchema": _tool_schema(
208
+ {
209
+ "source_kind": {"type": "string", "enum": ["all", "request", "backlog", "task", "product", "architecture"]},
210
+ "include_closed": {"type": "boolean"},
211
+ "closed_only": {"type": "boolean"},
212
+ "limit": {"type": "integer"},
213
+ }
214
+ ),
215
+ "annotations": {"readOnlyHint": True, "idempotentHint": True, "destructiveHint": False},
216
+ },
217
+ {
218
+ "name": "check_product_consistency",
219
+ "description": "Check product brief lineage links for active and validated product docs.",
220
+ "inputSchema": _tool_schema({"limit": {"type": "integer"}}),
221
+ "annotations": {"readOnlyHint": True, "idempotentHint": True, "destructiveHint": False},
222
+ },
178
223
  {
179
224
  "name": "finish_task",
180
225
  "description": "Finish a Logics task through the canonical flow finish task command.",
@@ -277,6 +322,25 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [
277
322
  "inputSchema": _tool_schema({"paths": {"type": "array", "items": {"type": "string"}}}),
278
323
  "annotations": {"readOnlyHint": True, "idempotentHint": True, "destructiveHint": False},
279
324
  },
325
+ {
326
+ "name": "delete_logics_file",
327
+ "description": "Delete one bounded Logics Markdown file from an approved Logics directory.",
328
+ "inputSchema": _tool_schema({"path": {"type": "string"}, "dry_run": {"type": "boolean"}}, ["path"]),
329
+ "annotations": {"readOnlyHint": False, "idempotentHint": False, "destructiveHint": True},
330
+ },
331
+ {
332
+ "name": "rename_logics_file",
333
+ "description": "Rename one bounded Logics Markdown file within approved Logics directories.",
334
+ "inputSchema": _tool_schema(
335
+ {
336
+ "source_path": {"type": "string"},
337
+ "destination_path": {"type": "string"},
338
+ "dry_run": {"type": "boolean"},
339
+ },
340
+ ["source_path", "destination_path"],
341
+ ),
342
+ "annotations": {"readOnlyHint": False, "idempotentHint": False, "destructiveHint": False},
343
+ },
280
344
  ]
281
345
  TOOLS_BY_NAME = {str(tool["name"]): tool for tool in TOOL_DEFINITIONS}
282
346
 
@@ -354,6 +418,13 @@ def _relative_path(repo_root: Path, raw_path: str, allowed_dirs: tuple[str, ...]
354
418
  return normalized
355
419
 
356
420
 
421
+ def _markdown_file_path(repo_root: Path, raw_path: str, allowed_dirs: tuple[str, ...] = ALLOWED_WRITE_DIRS) -> Path:
422
+ rel_path = _relative_path(repo_root, raw_path, allowed_dirs)
423
+ if rel_path.suffix != ".md":
424
+ raise McpToolError("invalid_path", "Only Markdown files are accepted.", details={"path": raw_path, "extension": rel_path.suffix})
425
+ return rel_path
426
+
427
+
357
428
  def _run_command(repo_root: Path, args: list[str]) -> subprocess.CompletedProcess[str]:
358
429
  command = [sys.executable, "-m", "logics_manager", *args]
359
430
  result = subprocess.run(command, cwd=repo_root, check=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
@@ -511,8 +582,16 @@ def _audit_status(repo_root: Path) -> dict[str, Any]:
511
582
  payload = audit_payload(repo_root, legacy_cutoff_version="1.1.0", group_by_doc=True)
512
583
  return {
513
584
  "ok": bool(payload.get("ok")),
585
+ "can_continue": bool(payload.get("can_continue", payload.get("ok"))),
586
+ "release_ready": bool(payload.get("release_ready", payload.get("ok"))),
514
587
  "issue_count": payload.get("issue_count", 0),
588
+ "warning_count": payload.get("warning_count", 0),
589
+ "strict_count": payload.get("strict_count", 0),
590
+ "finding_count": payload.get("finding_count", payload.get("issue_count", 0)),
515
591
  "issues": payload.get("issues", []),
592
+ "warnings": payload.get("warnings", []),
593
+ "strict": payload.get("strict", []),
594
+ "findings": payload.get("findings", payload.get("issues", [])),
516
595
  "issues_by_doc": payload.get("issues_by_doc", {}),
517
596
  }
518
597
 
@@ -597,6 +676,74 @@ def _document_preview(repo_root: Path, rel_path: str, *, max_chars: int = 1600)
597
676
  }
598
677
 
599
678
 
679
+ def _indicator_from_lines(lines: list[str], key: str) -> str | None:
680
+ prefix = f"> {key}:"
681
+ for line in lines:
682
+ if line.startswith(prefix):
683
+ return line.split(":", 1)[1].strip()
684
+ return None
685
+
686
+
687
+ def _title_from_heading(lines: list[str], fallback: str) -> str:
688
+ for line in lines:
689
+ if not line.startswith("## "):
690
+ continue
691
+ heading = line[3:].strip()
692
+ if " - " in heading:
693
+ return heading.split(" - ", 1)[1].strip()
694
+ return heading
695
+ return fallback
696
+
697
+
698
+ def _parse_companion_refs(value: str | None) -> list[str]:
699
+ if not value or value == "(none yet)":
700
+ return []
701
+ refs = re.findall(r"`([^`]+)`", value)
702
+ if refs:
703
+ return [ref for ref in refs if ref not in {"(none)", "(none yet)"}]
704
+ return [part.strip() for part in value.split(",") if part.strip() and part.strip() not in {"(none)", "(none yet)"}]
705
+
706
+
707
+ def _companion_doc_entry(repo_root: Path, rel_path: Path, kind: str) -> dict[str, Any]:
708
+ path = repo_root / rel_path
709
+ lines = path.read_text(encoding="utf-8").splitlines()
710
+ related = {
711
+ "request": _parse_companion_refs(_indicator_from_lines(lines, "Related request")),
712
+ "backlog": _parse_companion_refs(_indicator_from_lines(lines, "Related backlog")),
713
+ "task": _parse_companion_refs(_indicator_from_lines(lines, "Related task")),
714
+ "architecture": _parse_companion_refs(_indicator_from_lines(lines, "Related architecture")),
715
+ }
716
+ return {
717
+ "kind": kind,
718
+ "ref": rel_path.stem,
719
+ "path": rel_path.as_posix(),
720
+ "title": _title_from_heading(lines, rel_path.stem),
721
+ "status": _indicator_from_lines(lines, "Status") or "Unknown",
722
+ "related": {key: refs for key, refs in related.items() if refs},
723
+ }
724
+
725
+
726
+ def _list_companion_docs(repo_root: Path, *, kind: str = "all", limit: int = 50) -> dict[str, Any]:
727
+ if kind not in {"all", "product", "architecture"}:
728
+ raise McpToolError("invalid_argument_value", "Unsupported companion document kind.", details={"kind": kind, "allowed": ["all", "product", "architecture"]})
729
+ targets = []
730
+ if kind in {"all", "product"}:
731
+ targets.append(("product", Path("logics/product"), "prod_*.md"))
732
+ if kind in {"all", "architecture"}:
733
+ targets.append(("architecture", Path("logics/architecture"), "adr_*.md"))
734
+ items: list[dict[str, Any]] = []
735
+ for doc_kind, directory, pattern in targets:
736
+ root = repo_root / directory
737
+ if not root.is_dir():
738
+ continue
739
+ for path in sorted(root.glob(pattern)):
740
+ if path.is_file() and not path.is_symlink():
741
+ items.append(_companion_doc_entry(repo_root, path.relative_to(repo_root), doc_kind))
742
+ items.sort(key=lambda item: str(item["path"]))
743
+ bounded_items = items[:limit]
744
+ return {"kind": kind, "limit": limit, "count": len(bounded_items), "total_count": len(items), "items": bounded_items}
745
+
746
+
600
747
  def _bounded_int(value: Any, *, default: int, maximum: int) -> int:
601
748
  if not isinstance(value, int) or isinstance(value, bool):
602
749
  return default
@@ -659,6 +806,9 @@ def call_tool(name: str, arguments: dict[str, Any] | None = None, *, repo_root:
659
806
  if kind not in {"all", "request", "backlog", "task"}:
660
807
  raise McpToolError("invalid_argument_value", "Unsupported list kind.", details={"kind": kind, "allowed": ["all", "request", "backlog", "task"]})
661
808
  return {"ok": True, "items": flow_list_payload(root, kind=kind)["entries"]}
809
+ if name == "list_companion_docs":
810
+ payload = _list_companion_docs(root, kind=str(args.get("kind") or "all"), limit=_bounded_int(args.get("limit"), default=50, maximum=200))
811
+ return {"ok": True, **payload}
662
812
  if name == "read_logics_doc":
663
813
  try:
664
814
  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)
@@ -693,6 +843,24 @@ def call_tool(name: str, arguments: dict[str, Any] | None = None, *, repo_root:
693
843
  except SystemExit as exc:
694
844
  raise _mcp_read_error(exc) from exc
695
845
  return {"ok": True, **payload}
846
+ if name == "get_logics_status":
847
+ return status_payload(root, limit=_bounded_int(args.get("limit"), default=10, maximum=100))
848
+ if name == "get_logics_health":
849
+ return health_payload(root, limit=_bounded_int(args.get("limit"), default=10, maximum=100))
850
+ if name == "list_logics_followups":
851
+ include_closed = bool(args.get("include_closed", False))
852
+ closed_only = bool(args.get("closed_only", False))
853
+ if include_closed and closed_only:
854
+ raise McpToolError("invalid_argument_value", "include_closed and closed_only are mutually exclusive.", details={"arguments": ["include_closed", "closed_only"]})
855
+ return followups_payload(
856
+ root,
857
+ limit=_bounded_int(args.get("limit"), default=50, maximum=200),
858
+ source_kind=str(args.get("source_kind") or "all"),
859
+ include_closed=include_closed,
860
+ closed_only=closed_only,
861
+ )
862
+ if name == "check_product_consistency":
863
+ return product_consistency_payload(root, limit=_bounded_int(args.get("limit"), default=50, maximum=200))
696
864
  if name == "finish_task":
697
865
  rel_path = _relative_path(root, str(args.get("task_path") or ""), ("logics/tasks",))
698
866
  dry_run = bool(args.get("dry_run", False))
@@ -813,6 +981,58 @@ def call_tool(name: str, arguments: dict[str, Any] | None = None, *, repo_root:
813
981
  raw_paths = args.get("paths")
814
982
  paths = [str(path) for path in raw_paths] if isinstance(raw_paths, list) else None
815
983
  return _show_git_diff(root, paths)
984
+ if name == "delete_logics_file":
985
+ rel_path = _markdown_file_path(root, str(args.get("path") or ""))
986
+ target = root / rel_path
987
+ dry_run = bool(args.get("dry_run", False))
988
+ if not target.exists():
989
+ raise McpToolError("not_found", "Logics file not found.", details={"path": rel_path.as_posix()})
990
+ if not target.is_file() or target.is_symlink():
991
+ raise McpToolError("invalid_path", "Only regular Markdown files can be deleted.", details={"path": rel_path.as_posix()})
992
+ if not dry_run:
993
+ _ensure_no_dirty_conflict(root, [rel_path.as_posix()])
994
+ target.unlink()
995
+ return _workflow_write_result(
996
+ root,
997
+ {
998
+ "path": rel_path.as_posix(),
999
+ "dry_run": dry_run,
1000
+ "deleted": not dry_run,
1001
+ "would_delete": dry_run,
1002
+ "summary": f"{'Would delete' if dry_run else 'Deleted'} {rel_path.as_posix()}",
1003
+ },
1004
+ paths=[rel_path.as_posix()],
1005
+ )
1006
+ if name == "rename_logics_file":
1007
+ source_rel = _markdown_file_path(root, str(args.get("source_path") or ""))
1008
+ destination_rel = _markdown_file_path(root, str(args.get("destination_path") or ""))
1009
+ source = root / source_rel
1010
+ destination = root / destination_rel
1011
+ dry_run = bool(args.get("dry_run", False))
1012
+ if source_rel == destination_rel:
1013
+ raise McpToolError("invalid_path", "Source and destination paths must differ.", details={"source_path": source_rel.as_posix(), "destination_path": destination_rel.as_posix()})
1014
+ if not source.exists():
1015
+ raise McpToolError("not_found", "Source Logics file not found.", details={"source_path": source_rel.as_posix()})
1016
+ if not source.is_file() or source.is_symlink():
1017
+ raise McpToolError("invalid_path", "Only regular Markdown files can be renamed.", details={"source_path": source_rel.as_posix()})
1018
+ if destination.exists():
1019
+ raise McpToolError("already_exists", "Destination already exists.", details={"destination_path": destination_rel.as_posix()})
1020
+ if not dry_run:
1021
+ _ensure_no_dirty_conflict(root, [source_rel.as_posix(), destination_rel.as_posix()])
1022
+ destination.parent.mkdir(parents=True, exist_ok=True)
1023
+ source.rename(destination)
1024
+ return _workflow_write_result(
1025
+ root,
1026
+ {
1027
+ "source_path": source_rel.as_posix(),
1028
+ "destination_path": destination_rel.as_posix(),
1029
+ "dry_run": dry_run,
1030
+ "renamed": not dry_run,
1031
+ "would_rename": dry_run,
1032
+ "summary": f"{'Would rename' if dry_run else 'Renamed'} {source_rel.as_posix()} to {destination_rel.as_posix()}",
1033
+ },
1034
+ paths=[source_rel.as_posix(), destination_rel.as_posix()],
1035
+ )
816
1036
 
817
1037
  if name == "create_request":
818
1038
  title = str(args.get("title") or "").strip()
@@ -969,11 +1189,48 @@ def make_http_handler(repo_root: Path, *, bearer_token: str | None = None) -> ty
969
1189
  self.end_headers()
970
1190
  self.wfile.write(encoded)
971
1191
 
1192
+ def _authorized(self) -> bool:
1193
+ if not bearer_token:
1194
+ return True
1195
+ expected = f"Bearer {bearer_token}"
1196
+ actual = self.headers.get("Authorization", "")
1197
+ if secrets.compare_digest(actual, expected):
1198
+ return True
1199
+ self.send_response(401)
1200
+ self.send_header("Content-Type", "application/json")
1201
+ self.send_header("WWW-Authenticate", 'Bearer realm="logics-mcp"')
1202
+ encoded = json.dumps({"ok": False, "error": "unauthorized", "message": "Missing or invalid bearer token."}, separators=(",", ":")).encode("utf-8")
1203
+ self.send_header("Content-Length", str(len(encoded)))
1204
+ self.end_headers()
1205
+ self.wfile.write(encoded)
1206
+ return False
1207
+
1208
+ def _send_sse_stream(self) -> None:
1209
+ self.send_response(200)
1210
+ self.send_header("Content-Type", "text/event-stream")
1211
+ self.send_header("Cache-Control", "no-cache")
1212
+ self.send_header("Connection", "keep-alive")
1213
+ self.end_headers()
1214
+ self.wfile.write(b": logics-manager-mcp ready\n\n")
1215
+ self.wfile.flush()
1216
+ while True:
1217
+ time.sleep(15)
1218
+ self.wfile.write(b": keepalive\n\n")
1219
+ self.wfile.flush()
1220
+
972
1221
  def do_GET(self) -> None:
973
1222
  parsed = urlparse(self.path)
974
1223
  if parsed.path == "/health":
975
1224
  self._send_json(200, {"ok": True, "server": "logics-manager-mcp", "version": _server_version()})
976
1225
  return
1226
+ if parsed.path == "/mcp":
1227
+ if not self._authorized():
1228
+ return
1229
+ try:
1230
+ self._send_sse_stream()
1231
+ except (BrokenPipeError, ConnectionResetError):
1232
+ return
1233
+ return
977
1234
  self._send_json(404, {"ok": False, "error": "not_found", "message": "Use POST /mcp for JSON-RPC."})
978
1235
 
979
1236
  def do_POST(self) -> None:
@@ -981,18 +1238,8 @@ def make_http_handler(repo_root: Path, *, bearer_token: str | None = None) -> ty
981
1238
  if parsed.path != "/mcp":
982
1239
  self._send_json(404, {"ok": False, "error": "not_found", "message": "Use POST /mcp for JSON-RPC."})
983
1240
  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
1241
+ if not self._authorized():
1242
+ return
996
1243
  try:
997
1244
  length = int(self.headers.get("Content-Length", "0"))
998
1245
  except ValueError:
@@ -1051,17 +1298,29 @@ def _connector_urls(public_url: str | None) -> dict[str, str]:
1051
1298
  return {"public_url": base, "mcp_url": mcp_url, "health_url": health_url}
1052
1299
 
1053
1300
 
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)
1301
+ def connector_plan(*, repo_root: Path, host: str, port: int, bearer_token: str | None = None, public_url: str | None = None, no_bearer: bool = False, project_binary: str | None = None) -> dict[str, Any]:
1302
+ token = None if no_bearer else bearer_token or secrets.token_urlsafe(32)
1056
1303
  urls = _connector_urls(public_url)
1057
1304
  local_mcp_url = f"http://{host}:{port}/mcp"
1058
1305
  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}'
1306
+ launcher = project_binary or "python3 -m logics_manager"
1307
+ auth_args = "" if no_bearer else f'{AUTH_ENV_VAR}="{token}" '
1308
+ server_command = f"{auth_args}{launcher} mcp serve-http --repo-root {repo_root.as_posix()} --host {host} --port {port}"
1309
+ auth_header = None if no_bearer else f"Authorization: Bearer {token}"
1310
+ cleanup = [
1311
+ "Stop the HTTPS tunnel process.",
1312
+ "Stop the local mcp serve-http process with Ctrl-C.",
1313
+ ]
1314
+ if token:
1315
+ cleanup.append("Treat the bearer token as expired once the local session is stopped.")
1316
+ else:
1317
+ cleanup.append("Treat the public tunnel URL as exposed until both processes are stopped.")
1060
1318
  return {
1061
1319
  "ok": True,
1062
1320
  "repo_root": repo_root.as_posix(),
1063
1321
  "bearer_token": token,
1064
- "auth_header": f"Authorization: Bearer {token}",
1322
+ "auth_mode": "none" if no_bearer else "bearer",
1323
+ "auth_header": auth_header,
1065
1324
  "local_mcp_url": local_mcp_url,
1066
1325
  "local_health_url": local_health_url,
1067
1326
  "server_command": server_command,
@@ -1069,23 +1328,20 @@ def connector_plan(*, repo_root: Path, host: str, port: int, bearer_token: str |
1069
1328
  "chatgpt": {
1070
1329
  "developer_mode": True,
1071
1330
  "mcp_url": urls.get("mcp_url", "<your HTTPS tunnel URL>/mcp"),
1072
- "auth_type": "Bearer token",
1331
+ "auth_type": "None" if no_bearer else "Bearer token",
1073
1332
  "auth_value": token,
1074
1333
  },
1075
1334
  "smoke_checks": {
1076
1335
  "health": urls.get("health_url", f"<your HTTPS tunnel URL>/health"),
1077
1336
  "mcp_tools_list": urls.get("mcp_url", "<your HTTPS tunnel URL>/mcp"),
1078
1337
  },
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
- ],
1338
+ "warnings": ["No-bearer mode is unauthenticated. Use only for short-lived local debugging."] if no_bearer else [],
1339
+ "cleanup": cleanup,
1084
1340
  **urls,
1085
1341
  }
1086
1342
 
1087
1343
 
1088
- def connector_smoke_check(public_url: str, bearer_token: str, *, timeout: float = 5.0) -> dict[str, Any]:
1344
+ def connector_smoke_check(public_url: str, bearer_token: str | None = None, *, timeout: float = 5.0) -> dict[str, Any]:
1089
1345
  urls = _connector_urls(public_url)
1090
1346
  health_ok = False
1091
1347
  mcp_ok = False
@@ -1097,7 +1353,10 @@ def connector_smoke_check(public_url: str, bearer_token: str, *, timeout: float
1097
1353
  errors.append(f"health: {exc}")
1098
1354
  try:
1099
1355
  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")
1356
+ headers = {"Content-Type": "application/json"}
1357
+ if bearer_token:
1358
+ headers["Authorization"] = f"Bearer {bearer_token}"
1359
+ request = Request(urls["mcp_url"], data=body, headers=headers, method="POST")
1101
1360
  with urlopen(request, timeout=timeout) as response:
1102
1361
  payload = json.loads(response.read().decode("utf-8"))
1103
1362
  mcp_ok = response.status == 200 and "result" in payload
@@ -1108,10 +1367,13 @@ def connector_smoke_check(public_url: str, bearer_token: str, *, timeout: float
1108
1367
 
1109
1368
  def _print_connector_plan(plan: dict[str, Any]) -> None:
1110
1369
  print("Logics MCP Connector")
1370
+ for warning in plan.get("warnings", []):
1371
+ print(f"WARNING: {warning}")
1111
1372
  print(f"Server command:\n {plan['server_command']}")
1112
1373
  print(f"Tunnel target: {plan['tunnel_target']}")
1113
1374
  print(f"ChatGPT developer-mode MCP URL: {plan['chatgpt']['mcp_url']}")
1114
- print(f"Authorization header: {plan['auth_header']}")
1375
+ print(f"Auth mode: {plan['auth_mode']}")
1376
+ print(f"Authorization header: {plan['auth_header'] or '(none)'}")
1115
1377
  print("Smoke checks:")
1116
1378
  print(f" health: {plan['smoke_checks']['health']}")
1117
1379
  print(f" mcp tools/list: {plan['smoke_checks']['mcp_tools_list']}")
@@ -1120,6 +1382,88 @@ def _print_connector_plan(plan: dict[str, Any]) -> None:
1120
1382
  print(f" - {item}")
1121
1383
 
1122
1384
 
1385
+ def _project_binary_path(repo_root: Path) -> str:
1386
+ candidate = repo_root / "scripts" / "npm" / "logics-manager.mjs"
1387
+ if candidate.is_file():
1388
+ return f"node {candidate.as_posix()}"
1389
+ return "python3 -m logics_manager"
1390
+
1391
+
1392
+ def _terminate_process(process: subprocess.Popen[str]) -> None:
1393
+ if process.poll() is not None:
1394
+ return
1395
+ process.terminate()
1396
+ try:
1397
+ process.wait(timeout=5)
1398
+ except subprocess.TimeoutExpired:
1399
+ process.kill()
1400
+
1401
+
1402
+ def launch_tunnel(
1403
+ *,
1404
+ repo_root: Path,
1405
+ host: str,
1406
+ port: int,
1407
+ bearer_token: str | None = None,
1408
+ no_bearer: bool = False,
1409
+ tunnel_command: list[str] | None = None,
1410
+ ) -> int:
1411
+ token = None if no_bearer else bearer_token or secrets.token_urlsafe(32)
1412
+ server_command = [sys.executable, "-m", "logics_manager", "mcp", "serve-http", "--repo-root", repo_root.as_posix(), "--host", host, "--port", str(port)]
1413
+ env = os.environ.copy()
1414
+ if token:
1415
+ env[AUTH_ENV_VAR] = token
1416
+ tunnel_command = tunnel_command or ["npx", "localtunnel", "--port", str(port)]
1417
+ server = subprocess.Popen(server_command, cwd=repo_root, env=env, text=True)
1418
+ tunnel: subprocess.Popen[str] | None = None
1419
+ previous_sigint = signal.getsignal(signal.SIGINT)
1420
+ previous_sigterm = signal.getsignal(signal.SIGTERM)
1421
+
1422
+ def stop(_signum: int | None = None, _frame: Any | None = None) -> None:
1423
+ if tunnel is not None:
1424
+ _terminate_process(tunnel)
1425
+ _terminate_process(server)
1426
+
1427
+ signal.signal(signal.SIGINT, stop)
1428
+ signal.signal(signal.SIGTERM, stop)
1429
+ try:
1430
+ time.sleep(0.8)
1431
+ if server.poll() is not None:
1432
+ return server.returncode or 1
1433
+ tunnel = subprocess.Popen(tunnel_command, cwd=repo_root, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
1434
+ public_url = None
1435
+ start = time.monotonic()
1436
+ while time.monotonic() - start < 30:
1437
+ if tunnel.poll() is not None:
1438
+ return tunnel.returncode or 1
1439
+ line = tunnel.stdout.readline() if tunnel.stdout else ""
1440
+ if not line:
1441
+ time.sleep(0.1)
1442
+ continue
1443
+ print(line.rstrip())
1444
+ match = re.search(r"https://\S+", line)
1445
+ if match:
1446
+ public_url = match.group(0).rstrip("/")
1447
+ break
1448
+ if not public_url:
1449
+ raise McpToolError("command_failed", "Tunnel command did not print a public HTTPS URL within 30 seconds.", details={"command": tunnel_command})
1450
+ plan = connector_plan(repo_root=repo_root, host=host, port=port, bearer_token=token, public_url=public_url, no_bearer=no_bearer, project_binary=_project_binary_path(repo_root))
1451
+ _print_connector_plan(plan)
1452
+ print("Processes are running. Press Ctrl-C to stop server and tunnel.")
1453
+ while True:
1454
+ if server.poll() is not None:
1455
+ return server.returncode or 1
1456
+ if tunnel.poll() is not None:
1457
+ return tunnel.returncode or 1
1458
+ time.sleep(1)
1459
+ except KeyboardInterrupt:
1460
+ return 130
1461
+ finally:
1462
+ stop()
1463
+ signal.signal(signal.SIGINT, previous_sigint)
1464
+ signal.signal(signal.SIGTERM, previous_sigterm)
1465
+
1466
+
1123
1467
  def main(argv: list[str] | None = None) -> int:
1124
1468
  parser = argparse.ArgumentParser(prog="logics-manager mcp", description="Run or inspect the Logics MCP server.")
1125
1469
  sub = parser.add_subparsers(dest="command", required=True)
@@ -1141,9 +1485,16 @@ def main(argv: list[str] | None = None) -> int:
1141
1485
  connect.add_argument("--host", default="127.0.0.1")
1142
1486
  connect.add_argument("--port", type=int, default=8765)
1143
1487
  connect.add_argument("--bearer-token", default=None)
1488
+ connect.add_argument("--no-bearer", action="store_true", help="Print a no-auth connector plan for short-lived local debugging.")
1144
1489
  connect.add_argument("--public-url", default=None, help="Optional HTTPS tunnel URL used for copyable ChatGPT setup and smoke checks.")
1145
1490
  connect.add_argument("--check", action="store_true", help="Run /health and authenticated /mcp smoke checks against --public-url.")
1146
1491
  connect.add_argument("--format", choices=("text", "json"), default="text")
1492
+ tunnel = sub.add_parser("tunnel", help="Start the local MCP HTTP server plus an HTTPS localtunnel session.")
1493
+ tunnel.add_argument("--repo-root", default=None)
1494
+ tunnel.add_argument("--host", default="127.0.0.1")
1495
+ tunnel.add_argument("--port", type=int, default=8765)
1496
+ tunnel.add_argument("--bearer-token", default=None)
1497
+ tunnel.add_argument("--no-bearer", action="store_true", help="Run without bearer auth for short-lived local debugging.")
1147
1498
  parsed = parser.parse_args(argv)
1148
1499
 
1149
1500
  if parsed.command == "tools":
@@ -1170,11 +1521,13 @@ def main(argv: list[str] | None = None) -> int:
1170
1521
  return 0
1171
1522
  if parsed.command == "connect":
1172
1523
  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)
1524
+ if parsed.no_bearer and parsed.bearer_token:
1525
+ raise SystemExit("--no-bearer cannot be combined with --bearer-token.")
1526
+ plan = connector_plan(repo_root=root, host=parsed.host, port=parsed.port, bearer_token=parsed.bearer_token, public_url=parsed.public_url, no_bearer=parsed.no_bearer, project_binary=_project_binary_path(root))
1174
1527
  if parsed.check:
1175
1528
  if not parsed.public_url:
1176
1529
  raise SystemExit("--check requires --public-url.")
1177
- plan["check"] = connector_smoke_check(parsed.public_url, str(plan["bearer_token"]))
1530
+ plan["check"] = connector_smoke_check(parsed.public_url, str(plan["bearer_token"]) if plan["bearer_token"] else None)
1178
1531
  plan["ok"] = bool(plan["check"]["ok"])
1179
1532
  if parsed.format == "json":
1180
1533
  print(json.dumps(plan, indent=2, sort_keys=True))
@@ -1185,4 +1538,9 @@ def main(argv: list[str] | None = None) -> int:
1185
1538
  for error in plan["check"]["errors"]:
1186
1539
  print(f" - {error}")
1187
1540
  return 0 if plan["ok"] else 1
1541
+ if parsed.command == "tunnel":
1542
+ if parsed.no_bearer and parsed.bearer_token:
1543
+ raise SystemExit("--no-bearer cannot be combined with --bearer-token.")
1544
+ root = _repo_root(Path(parsed.repo_root) if parsed.repo_root else None)
1545
+ return launch_tunnel(repo_root=root, host=parsed.host, port=parsed.port, bearer_token=parsed.bearer_token, no_bearer=parsed.no_bearer)
1188
1546
  return 1
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+
6
+ def ensure_relative_to(path: Path, root: Path, *, label: str = "path") -> Path:
7
+ try:
8
+ return path.resolve().relative_to(root.resolve())
9
+ except ValueError as exc:
10
+ raise SystemExit(f"Unsupported {label}: `{path}` is outside the repository.") from exc
11
+
12
+
13
+ def resolve_repo_output_path(repo_root: Path, raw_path: str, *, label: str = "--out") -> tuple[Path, str]:
14
+ candidate = Path(raw_path)
15
+ if candidate.is_absolute() or any(part == ".." for part in candidate.parts):
16
+ raise SystemExit(f"Unsupported {label} path `{raw_path}`. Use a repo-relative path inside the repository.")
17
+ resolved = (repo_root / candidate).resolve()
18
+ relative = ensure_relative_to(resolved, repo_root, label=label)
19
+ return resolved, relative.as_posix()
20
+
21
+
22
+ def resolve_repo_config_path(repo_root: Path, raw_path: str, *, label: str = "configured path") -> tuple[Path, str]:
23
+ candidate = Path(raw_path)
24
+ if any(part == ".." for part in candidate.parts):
25
+ raise SystemExit(f"Unsupported {label} path `{raw_path}`. Use a repo-relative path or absolute path inside the repository.")
26
+ resolved = candidate.resolve() if candidate.is_absolute() else (repo_root / candidate).resolve()
27
+ try:
28
+ relative = ensure_relative_to(resolved, repo_root, label=label)
29
+ except SystemExit as exc:
30
+ raise SystemExit(f"Unsupported {label} path `{raw_path}`. Use a repo-relative path or absolute path inside the repository.") from exc
31
+ return resolved, relative.as_posix()