@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.
- package/README.md +80 -5
- package/VERSION +1 -1
- package/logics_manager/assist.py +185 -21
- package/logics_manager/audit.py +72 -18
- package/logics_manager/cli.py +162 -61
- package/logics_manager/cli_output.py +18 -0
- package/logics_manager/flow.py +1257 -83
- package/logics_manager/index.py +3 -7
- package/logics_manager/insights.py +418 -0
- package/logics_manager/lint.py +21 -7
- package/logics_manager/mcp.py +385 -27
- package/logics_manager/path_utils.py +31 -0
- package/logics_manager/sync.py +24 -12
- package/package.json +2 -1
- package/pyproject.toml +1 -1
- package/scripts/npm/logics-manager.mjs +13 -1
package/logics_manager/mcp.py
CHANGED
|
@@ -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
|
|
985
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
-
"
|
|
1080
|
-
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
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()
|