memstack-skill-loader 4.0.4__tar.gz → 4.0.5__tar.gz

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.
Files changed (28) hide show
  1. {memstack_skill_loader-4.0.4/src/memstack_skill_loader.egg-info → memstack_skill_loader-4.0.5}/PKG-INFO +1 -1
  2. {memstack_skill_loader-4.0.4 → memstack_skill_loader-4.0.5}/pyproject.toml +1 -1
  3. {memstack_skill_loader-4.0.4 → memstack_skill_loader-4.0.5}/src/memstack_skill_loader/__init__.py +1 -1
  4. {memstack_skill_loader-4.0.4 → memstack_skill_loader-4.0.5}/src/memstack_skill_loader/agent_runner.py +123 -11
  5. {memstack_skill_loader-4.0.4 → memstack_skill_loader-4.0.5}/src/memstack_skill_loader/dashboard.html +217 -4
  6. {memstack_skill_loader-4.0.4 → memstack_skill_loader-4.0.5}/src/memstack_skill_loader/dashboard.py +67 -1
  7. {memstack_skill_loader-4.0.4 → memstack_skill_loader-4.0.5/src/memstack_skill_loader.egg-info}/PKG-INFO +1 -1
  8. {memstack_skill_loader-4.0.4 → memstack_skill_loader-4.0.5}/MANIFEST.in +0 -0
  9. {memstack_skill_loader-4.0.4 → memstack_skill_loader-4.0.5}/README.md +0 -0
  10. {memstack_skill_loader-4.0.4 → memstack_skill_loader-4.0.5}/setup.cfg +0 -0
  11. {memstack_skill_loader-4.0.4 → memstack_skill_loader-4.0.5}/src/memstack_skill_loader/__main__.py +0 -0
  12. {memstack_skill_loader-4.0.4 → memstack_skill_loader-4.0.5}/src/memstack_skill_loader/categories.py +0 -0
  13. {memstack_skill_loader-4.0.4 → memstack_skill_loader-4.0.5}/src/memstack_skill_loader/compression.py +0 -0
  14. {memstack_skill_loader-4.0.4 → memstack_skill_loader-4.0.5}/src/memstack_skill_loader/config.py +0 -0
  15. {memstack_skill_loader-4.0.4 → memstack_skill_loader-4.0.5}/src/memstack_skill_loader/indexer.py +0 -0
  16. {memstack_skill_loader-4.0.4 → memstack_skill_loader-4.0.5}/src/memstack_skill_loader/license.py +0 -0
  17. {memstack_skill_loader-4.0.4 → memstack_skill_loader-4.0.5}/src/memstack_skill_loader/memory_db.py +0 -0
  18. {memstack_skill_loader-4.0.4 → memstack_skill_loader-4.0.5}/src/memstack_skill_loader/search.py +0 -0
  19. {memstack_skill_loader-4.0.4 → memstack_skill_loader-4.0.5}/src/memstack_skill_loader/server.py +0 -0
  20. {memstack_skill_loader-4.0.4 → memstack_skill_loader-4.0.5}/src/memstack_skill_loader/skill_config.py +0 -0
  21. {memstack_skill_loader-4.0.4 → memstack_skill_loader-4.0.5}/src/memstack_skill_loader/stats.py +0 -0
  22. {memstack_skill_loader-4.0.4 → memstack_skill_loader-4.0.5}/src/memstack_skill_loader/tfidf_search.py +0 -0
  23. {memstack_skill_loader-4.0.4 → memstack_skill_loader-4.0.5}/src/memstack_skill_loader/version_check.py +0 -0
  24. {memstack_skill_loader-4.0.4 → memstack_skill_loader-4.0.5}/src/memstack_skill_loader.egg-info/SOURCES.txt +0 -0
  25. {memstack_skill_loader-4.0.4 → memstack_skill_loader-4.0.5}/src/memstack_skill_loader.egg-info/dependency_links.txt +0 -0
  26. {memstack_skill_loader-4.0.4 → memstack_skill_loader-4.0.5}/src/memstack_skill_loader.egg-info/entry_points.txt +0 -0
  27. {memstack_skill_loader-4.0.4 → memstack_skill_loader-4.0.5}/src/memstack_skill_loader.egg-info/requires.txt +0 -0
  28. {memstack_skill_loader-4.0.4 → memstack_skill_loader-4.0.5}/src/memstack_skill_loader.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memstack-skill-loader
3
- Version: 4.0.4
3
+ Version: 4.0.5
4
4
  Summary: MCP server that vector-indexes MemStack Pro skills for on-demand loading
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: mcp>=1.0.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "memstack-skill-loader"
7
- version = "4.0.4"
7
+ version = "4.0.5"
8
8
  description = "MCP server that vector-indexes MemStack Pro skills for on-demand loading"
9
9
  requires-python = ">=3.10"
10
10
  dependencies = [
@@ -1,3 +1,3 @@
1
1
  """MemStack Skill Loader — MCP server for semantic skill search."""
2
2
 
3
- __version__ = "4.0.4"
3
+ __version__ = "4.0.5"
@@ -88,6 +88,49 @@ SYSTEM_PROMPTS = {
88
88
  ),
89
89
  }
90
90
 
91
+ BUILDER_TOOLS_CONFIG_FILE = Path.home() / ".memstack" / "builder-tools-config.json"
92
+
93
+
94
+ # ---------------------------------------------------------------------------
95
+ # MCP server discovery & Builder tools config
96
+ # ---------------------------------------------------------------------------
97
+
98
+ def discover_mcp_servers(workdir: str) -> list[str]:
99
+ """Read .mcp.json from workdir and return the list of MCP server name keys."""
100
+ try:
101
+ mcp_file = Path(workdir) / ".mcp.json"
102
+ if not mcp_file.is_file():
103
+ return []
104
+ data = json.loads(mcp_file.read_text(encoding="utf-8"))
105
+ servers = data.get("mcpServers", {})
106
+ return sorted(servers.keys()) if isinstance(servers, dict) else []
107
+ except Exception:
108
+ return []
109
+
110
+
111
+ def load_builder_tools_config() -> dict:
112
+ """Load per-project blocked MCP server lists from ~/.memstack/builder-tools-config.json."""
113
+ try:
114
+ if BUILDER_TOOLS_CONFIG_FILE.is_file():
115
+ return json.loads(BUILDER_TOOLS_CONFIG_FILE.read_text(encoding="utf-8"))
116
+ except Exception:
117
+ pass
118
+ return {}
119
+
120
+
121
+ def save_builder_tools_config(config: dict) -> None:
122
+ """Save per-project blocked MCP server config."""
123
+ BUILDER_TOOLS_CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
124
+ BUILDER_TOOLS_CONFIG_FILE.write_text(json.dumps(config, indent=2), encoding="utf-8")
125
+
126
+
127
+ def get_blocked_servers_for_project(workdir: str) -> list[str]:
128
+ """Return blocked MCP servers for a project, falling back to global defaults."""
129
+ cfg = load_builder_tools_config()
130
+ if workdir in cfg:
131
+ return cfg[workdir]
132
+ return cfg.get("_global_defaults", [])
133
+
91
134
 
92
135
  # ---------------------------------------------------------------------------
93
136
  # Project context gathering
@@ -259,6 +302,30 @@ def _parse_stream_json(raw: str) -> tuple[str, int, int, float, int]:
259
302
  return text, input_tokens, output_tokens, cost_usd, context_tokens
260
303
 
261
304
 
305
+ def _extract_text_from_stream_line(line: str) -> Optional[str]:
306
+ """Extract user-visible text from a single stream-json line.
307
+
308
+ Returns the text if the line contains assistant text or a result, else None.
309
+ """
310
+ try:
311
+ obj = json.loads(line.strip())
312
+ except (json.JSONDecodeError, ValueError):
313
+ return None
314
+ msg_type = obj.get("type")
315
+ if msg_type == "assistant":
316
+ parts = []
317
+ for block in obj.get("message", {}).get("content", []):
318
+ if block.get("type") == "text":
319
+ t = block.get("text", "")
320
+ if t:
321
+ parts.append(t)
322
+ return "\n".join(parts) if parts else None
323
+ if msg_type == "result":
324
+ t = obj.get("result", "")
325
+ return t if t else None
326
+ return None
327
+
328
+
262
329
  # ---------------------------------------------------------------------------
263
330
  # Direct API invocation (Manager / Reviewer — no file tools needed)
264
331
  # ---------------------------------------------------------------------------
@@ -359,7 +426,8 @@ def _invoke_api_agent(name: str, prompt: str, system_prompt: str,
359
426
  def _invoke_agent(name: str, prompt: str, working_dir: str, log_path: Optional[Path] = None,
360
427
  skip_permissions: bool = False,
361
428
  session_id: Optional[str] = None, timeout: int = AGENT_TIMEOUT,
362
- model: str = "") -> tuple[str, int, int, int]:
429
+ model: str = "", disallowed_tools: Optional[list[str]] = None,
430
+ session: Optional["Session"] = None) -> tuple[str, int, int, int]:
363
431
  """Run a single claude --print invocation and return the output."""
364
432
  claude_bin = shutil.which("claude")
365
433
  if not claude_bin:
@@ -370,6 +438,9 @@ def _invoke_agent(name: str, prompt: str, working_dir: str, log_path: Optional[P
370
438
  cmd.append("--dangerously-skip-permissions")
371
439
  if model:
372
440
  cmd.extend(["--model", model])
441
+ if disallowed_tools:
442
+ patterns = ",".join(f"mcp__{srv}__*" for srv in disallowed_tools)
443
+ cmd.extend(["--disallowedTools", patterns])
373
444
 
374
445
  if log_path:
375
446
  log_path.parent.mkdir(parents=True, exist_ok=True)
@@ -393,9 +464,6 @@ def _invoke_agent(name: str, prompt: str, working_dir: str, log_path: Optional[P
393
464
  stdout=subprocess.PIPE,
394
465
  stderr=subprocess.PIPE,
395
466
  cwd=working_dir,
396
- text=True,
397
- encoding="utf-8",
398
- errors="replace",
399
467
  env=_build_env(),
400
468
  creationflags=0,
401
469
  )
@@ -413,12 +481,38 @@ def _invoke_agent(name: str, prompt: str, working_dir: str, log_path: Optional[P
413
481
  killed_by_watchdog.set()
414
482
  proc.kill()
415
483
 
484
+ stdout_chunks: list[str] = []
485
+ stderr_chunks: list[str] = []
486
+ partial_text: list[str] = []
487
+
488
+ def _read_stdout() -> None:
489
+ for raw_line in proc.stdout:
490
+ line = raw_line.decode("utf-8", errors="replace")
491
+ stdout_chunks.append(line)
492
+ extracted = _extract_text_from_stream_line(line)
493
+ if extracted and session is not None:
494
+ partial_text.append(extracted)
495
+ try:
496
+ session.agents[name]["last_output"] = "".join(partial_text)[-500:]
497
+ session._save_state()
498
+ except Exception:
499
+ pass
500
+
501
+ def _read_stderr() -> None:
502
+ for raw_line in proc.stderr:
503
+ stderr_chunks.append(raw_line.decode("utf-8", errors="replace"))
504
+
505
+ t_out = threading.Thread(target=_read_stdout, daemon=True)
506
+ t_err = threading.Thread(target=_read_stderr, daemon=True)
507
+ t_out.start()
508
+ t_err.start()
509
+
416
510
  watchdog = threading.Timer(timeout, _watchdog_kill)
417
511
  watchdog.daemon = True
418
512
  watchdog.start()
419
513
 
420
514
  try:
421
- stdout_data, stderr_data = proc.communicate()
515
+ proc.wait()
422
516
  except Exception as e:
423
517
  watchdog.cancel()
424
518
  with _lock:
@@ -426,20 +520,26 @@ def _invoke_agent(name: str, prompt: str, working_dir: str, log_path: Optional[P
426
520
  _current_process = None
427
521
  if log_path:
428
522
  with open(log_path, "a", encoding="utf-8") as f:
429
- f.write(f"[{ts}] communicate() error: {e}\n")
430
- raise RuntimeError(f"{name} communicate() failed: {e}") from e
523
+ f.write(f"[{ts}] wait() error: {e}\n")
524
+ raise RuntimeError(f"{name} wait() failed: {e}") from e
431
525
  finally:
432
526
  watchdog.cancel()
433
527
  with _lock:
434
528
  if _current_process is proc:
435
529
  _current_process = None
436
530
 
531
+ t_out.join(timeout=10)
532
+ t_err.join(timeout=10)
533
+
437
534
  if killed_by_watchdog.is_set():
438
535
  raise subprocess.TimeoutExpired(cmd, timeout)
439
536
 
537
+ stdout_data = "".join(stdout_chunks)
538
+ stderr_data = "".join(stderr_chunks)
539
+
440
540
  if log_path:
441
541
  with open(log_path, "a", encoding="utf-8") as f:
442
- f.write(f"[{ts}] communicate() returned: stdout={len(stdout_data)} stderr={len(stderr_data)}\n")
542
+ f.write(f"[{ts}] Process exited: stdout={len(stdout_data)} stderr={len(stderr_data)}\n")
443
543
 
444
544
  raw_stdout = stdout_data or ""
445
545
  stderr = stderr_data or ""
@@ -485,7 +585,8 @@ class Session:
485
585
  """Tracks the full state of an agent run session."""
486
586
 
487
587
  def __init__(self, task: str, working_dir: str, context: Optional[str] = None, auto_commit: bool = False, timeout_minutes: int = 60,
488
- manager_model: str = "", builder_model: str = "", reviewer_model: str = "", user_name: str = ""):
588
+ manager_model: str = "", builder_model: str = "", reviewer_model: str = "", user_name: str = "",
589
+ blocked_mcp_servers: Optional[list[str]] = None):
489
590
  self.session_id = uuid.uuid4().hex[:12]
490
591
  self.task = task
491
592
  self.working_dir = working_dir
@@ -493,6 +594,7 @@ class Session:
493
594
  self.user_name = user_name
494
595
  self.context = context
495
596
  self.auto_commit = auto_commit
597
+ self.blocked_mcp_servers = blocked_mcp_servers or []
496
598
  self.auto_committed = False
497
599
  self.timeout = max(5, min(120, timeout_minutes)) * 60
498
600
  self.status = "running"
@@ -607,6 +709,7 @@ def _orchestrate(session: Session) -> None:
607
709
  # Step 1: Manager analyzes the task
608
710
  session.agents["manager"]["status"] = "busy"
609
711
  session.agents["manager"]["started_at"] = time.strftime("%Y-%m-%dT%H:%M:%S")
712
+ session.agents["manager"]["last_output"] = ""
610
713
  session._save_state()
611
714
 
612
715
  context = _gather_project_context(session.working_dir)
@@ -666,6 +769,7 @@ def _orchestrate(session: Session) -> None:
666
769
  # Builder
667
770
  session.agents["builder"]["status"] = "busy"
668
771
  session.agents["builder"]["started_at"] = time.strftime("%Y-%m-%dT%H:%M:%S")
772
+ session.agents["builder"]["last_output"] = ""
669
773
  session._save_state()
670
774
 
671
775
  builder_prompt = (
@@ -684,6 +788,8 @@ def _orchestrate(session: Session) -> None:
684
788
  skip_permissions=True, session_id=session.session_id,
685
789
  timeout=session.timeout,
686
790
  model=session.models.get("builder", ""),
791
+ disallowed_tools=session.blocked_mcp_servers,
792
+ session=session,
687
793
  )
688
794
  except subprocess.TimeoutExpired:
689
795
  session.agents["builder"]["status"] = "timeout"
@@ -733,6 +839,7 @@ def _orchestrate(session: Session) -> None:
733
839
  # Reviewer
734
840
  session.agents["reviewer"]["status"] = "busy"
735
841
  session.agents["reviewer"]["started_at"] = time.strftime("%Y-%m-%dT%H:%M:%S")
842
+ session.agents["reviewer"]["last_output"] = ""
736
843
  session._save_state()
737
844
 
738
845
  reviewer_prompt = (
@@ -876,7 +983,8 @@ _BLOCKED_NIX = {"/etc", "/var", "/proc", "/sys", "/boot"}
876
983
 
877
984
 
878
985
  def start_run(task: str, working_dir: Optional[str] = None, context: Optional[str] = None, auto_commit: bool = True, timeout_minutes: int = 60,
879
- manager_model: str = "", builder_model: str = "", reviewer_model: str = "", user_name: str = "") -> dict:
986
+ manager_model: str = "", builder_model: str = "", reviewer_model: str = "", user_name: str = "",
987
+ blocked_mcp_servers: Optional[list[str]] = None) -> dict:
880
988
  """Start a new agent run. Returns session info."""
881
989
  global _current_session, _orchestration_thread
882
990
 
@@ -895,12 +1003,16 @@ def start_run(task: str, working_dir: Optional[str] = None, context: Optional[st
895
1003
  if any(str_wd == bp or str_wd.startswith(bp + "/") for bp in _BLOCKED_NIX):
896
1004
  return {"error": f"Working directory not allowed: {wd}"}
897
1005
 
1006
+ if blocked_mcp_servers is None:
1007
+ blocked_mcp_servers = get_blocked_servers_for_project(str(wd))
1008
+
898
1009
  with _lock:
899
1010
  if _current_session and _current_session.status == "running":
900
1011
  return {"error": "A session is already running", "session_id": _current_session.session_id}
901
1012
 
902
1013
  session = Session(task=task, working_dir=working_dir, context=context, auto_commit=auto_commit, timeout_minutes=timeout_minutes,
903
- manager_model=manager_model, builder_model=builder_model, reviewer_model=reviewer_model, user_name=user_name)
1014
+ manager_model=manager_model, builder_model=builder_model, reviewer_model=reviewer_model, user_name=user_name,
1015
+ blocked_mcp_servers=blocked_mcp_servers)
904
1016
  _current_session = session
905
1017
 
906
1018
  session._save_state()
@@ -835,6 +835,76 @@
835
835
  font-size: 0.82rem;
836
836
  }
837
837
 
838
+ /* ── Builder MCP Tools section ── */
839
+ .mcp-tools-details {
840
+ margin-bottom: 0.8rem;
841
+ }
842
+ .mcp-tools-summary {
843
+ cursor: pointer;
844
+ font-size: 0.82rem;
845
+ color: #8b949e;
846
+ font-weight: 600;
847
+ list-style: none;
848
+ display: flex;
849
+ align-items: center;
850
+ gap: 0.4rem;
851
+ }
852
+ .mcp-tools-summary::-webkit-details-marker { display: none; }
853
+ .mcp-tools-summary::before {
854
+ content: '▶';
855
+ font-size: 0.6rem;
856
+ transition: transform 0.2s;
857
+ }
858
+ .mcp-tools-details[open] > .mcp-tools-summary::before {
859
+ transform: rotate(90deg);
860
+ }
861
+ .mcp-tools-summary:hover { color: #c9d1d9; }
862
+ .mcp-tools-list {
863
+ margin-top: 0.5rem;
864
+ display: flex;
865
+ flex-direction: column;
866
+ gap: 0.3rem;
867
+ }
868
+ .mcp-tools-row {
869
+ display: flex;
870
+ align-items: center;
871
+ gap: 0.5rem;
872
+ font-size: 0.82rem;
873
+ color: #c9d1d9;
874
+ }
875
+ .mcp-tools-row input[type="checkbox"] {
876
+ accent-color: #238636;
877
+ width: 14px;
878
+ height: 14px;
879
+ cursor: pointer;
880
+ flex-shrink: 0;
881
+ }
882
+ .mcp-tools-row label { cursor: pointer; font-family: monospace; }
883
+ .mcp-tools-hint {
884
+ font-size: 0.72rem;
885
+ color: #484f58;
886
+ margin-top: 0.4rem;
887
+ }
888
+ .mcp-tools-save-btn {
889
+ margin-top: 0.5rem;
890
+ background: #21262d;
891
+ color: #8b949e;
892
+ border: 1px solid #30363d;
893
+ padding: 0.3rem 0.8rem;
894
+ border-radius: 6px;
895
+ font-size: 0.75rem;
896
+ cursor: pointer;
897
+ transition: background 0.2s, color 0.2s;
898
+ }
899
+ .mcp-tools-save-btn:hover { background: #30363d; color: #c9d1d9; }
900
+ .mcp-tools-saved {
901
+ font-size: 0.72rem;
902
+ color: #39d353;
903
+ margin-left: 0.5rem;
904
+ opacity: 0;
905
+ transition: opacity 0.3s;
906
+ }
907
+
838
908
  /* ── Dancing status phrases ── */
839
909
  .agent-status-phrase {
840
910
  transition: opacity 0.3s ease;
@@ -1431,7 +1501,7 @@
1431
1501
  <label style="display:block;font-size:0.82rem;color:#8b949e;margin-bottom:0.4rem;font-weight:600;">Task Description</label>
1432
1502
  <div style="position:relative;margin-bottom:1rem;">
1433
1503
  <textarea id="agent-task-input" rows="4" placeholder="Describe what you want the agents to build..." style="width:100%;background:#0d1117;border:1px solid #30363d;border-radius:6px;color:#e6edf3;padding:0.7rem;font-size:0.88rem;resize:vertical;font-family:inherit;margin:0;"></textarea>
1434
- <button onclick="document.getElementById('agent-task-input').value='';document.getElementById('agent-workdir-input').value='';document.getElementById('agent-context-input').value='';document.getElementById('agent-task-input').focus();" style="position:absolute;top:4px;right:4px;background:#21262d;border:1px solid #30363d;color:#8b949e;padding:8px;border-radius:4px;cursor:pointer;font-size:14px;line-height:1;z-index:1;" onmouseenter="this.style.color='#e6edf3'" onmouseleave="this.style.color='#8b949e'" title="Clear task and working directory">&times;</button>
1504
+ <button onclick="document.getElementById('agent-task-input').value='';document.getElementById('agent-workdir-input').value='';document.getElementById('agent-context-input').value='';fetchMcpServers('');document.getElementById('agent-task-input').focus();" style="position:absolute;top:4px;right:4px;background:#21262d;border:1px solid #30363d;color:#8b949e;padding:8px;border-radius:4px;cursor:pointer;font-size:14px;line-height:1;z-index:1;" onmouseenter="this.style.color='#e6edf3'" onmouseleave="this.style.color='#8b949e'" title="Clear task and working directory">&times;</button>
1435
1505
  </div>
1436
1506
  <label style="display:block;font-size:0.82rem;color:#8b949e;margin-bottom:0.4rem;font-weight:600;">Working Directory</label>
1437
1507
  <div style="display:flex;gap:0.5rem;margin-bottom:0.3rem;">
@@ -1474,6 +1544,20 @@
1474
1544
  </div>
1475
1545
  </div>
1476
1546
  </details>
1547
+ <details class="mcp-tools-details" id="mcp-tools-details">
1548
+ <summary class="mcp-tools-summary" id="mcp-tools-summary">Builder MCP Tools</summary>
1549
+ <div id="mcp-tools-container">
1550
+ <div id="mcp-tools-loading" style="font-size:0.78rem;color:#484f58;padding:0.3rem 0;">Enter a working directory to discover MCP servers</div>
1551
+ <div id="mcp-tools-list" class="mcp-tools-list" style="display:none;"></div>
1552
+ <div id="mcp-tools-empty" style="display:none;font-size:0.78rem;color:#484f58;padding:0.3rem 0;">No MCP servers found in this project</div>
1553
+ <p class="mcp-tools-hint" id="mcp-tools-hint" style="display:none;">Unchecked servers will be excluded from the Builder's context to reduce token usage</p>
1554
+ <p id="mcp-tools-global-note" style="display:none;font-size:0.72rem;color:#d29922;margin:0.3rem 0 0;">Using global defaults</p>
1555
+ <div id="mcp-tools-actions" style="display:none;margin-top:0.4rem;">
1556
+ <button class="mcp-tools-save-btn" onclick="saveMcpToolsConfig()">Save as Default</button>
1557
+ <span class="mcp-tools-saved" id="mcp-tools-saved">Saved</span>
1558
+ </div>
1559
+ </div>
1560
+ </details>
1477
1561
  <label for="agent-autocommit-checkbox" style="display:flex;align-items:center;gap:0.5rem;font-size:0.82rem;color:#8b949e;margin-bottom:1rem;cursor:pointer;position:relative;z-index:1;">
1478
1562
  <input id="agent-autocommit-checkbox" type="checkbox" style="accent-color:#238636;width:15px;height:15px;cursor:pointer;flex-shrink:0;">
1479
1563
  Auto-commit on approval
@@ -1658,6 +1742,22 @@
1658
1742
  </div>
1659
1743
  </div>
1660
1744
 
1745
+ <div class="panel">
1746
+ <details class="mcp-tools-details">
1747
+ <summary class="mcp-tools-summary">Default MCP Tools</summary>
1748
+ <div style="padding:0.3rem 0;">
1749
+ <p style="color:#8b949e;font-size:0.78rem;margin:0 0 0.6rem;">Set which MCP servers are blocked by default across all projects. Per-project settings override these defaults.</p>
1750
+ <label style="font-size:0.78rem;color:#8b949e;display:block;margin-bottom:0.3rem;">Blocked servers (comma-separated)</label>
1751
+ <input type="text" id="settings-global-mcp-blocked" class="settings-text-input" placeholder="e.g. agent-bridge, connectstack-uptimerobot">
1752
+ <div style="display:flex;gap:0.5rem;margin-top:0.5rem;align-items:center;">
1753
+ <button class="mcp-tools-save-btn" onclick="saveGlobalMcpDefaults(false)">Save Defaults</button>
1754
+ <button class="mcp-tools-save-btn" style="background:#30363d;" onclick="applyGlobalMcpToAll()">Apply to All Projects</button>
1755
+ <span id="settings-global-mcp-saved" class="mcp-tools-saved">Saved</span>
1756
+ </div>
1757
+ </div>
1758
+ </details>
1759
+ </div>
1760
+
1661
1761
  <div class="panel">
1662
1762
  <h3>Dashboard Info</h3>
1663
1763
  <div style="display:grid;grid-template-columns:auto 1fr;gap:0.4rem 1.2rem;font-size:0.82rem;">
@@ -2819,6 +2919,8 @@ async function startAgentTask() {
2819
2919
  body.builder_model = document.getElementById('agent-model-builder').value;
2820
2920
  body.reviewer_model = document.getElementById('agent-model-reviewer').value;
2821
2921
  body.user_name = userProfile.user_name || '';
2922
+ const blockedMcp = getBlockedMcpServers();
2923
+ if (blockedMcp.length) body.blocked_mcp_servers = blockedMcp;
2822
2924
  const res = await fetch('/api/agent-run', {method:'POST', headers: AUTH_HEADERS, body: JSON.stringify(body)});
2823
2925
  const data = await res.json();
2824
2926
  if (data.error) { alert(data.error); return; }
@@ -2857,6 +2959,12 @@ async function loadAgentMonitor() {
2857
2959
  lbl.textContent = userProfile.agent_names[role];
2858
2960
  }
2859
2961
  }
2962
+ updateMcpToolsSummaryLabel();
2963
+ const wdInput = document.getElementById('agent-workdir-input');
2964
+ if (wdInput && !wdInput._mcpBound) {
2965
+ wdInput._mcpBound = true;
2966
+ wdInput.addEventListener('blur', () => fetchMcpServers(wdInput.value.trim()));
2967
+ }
2860
2968
  fetchAgentStatus();
2861
2969
  loadRecentProjects();
2862
2970
  loadLastWorkdir();
@@ -2872,10 +2980,110 @@ async function loadLastWorkdir() {
2872
2980
  if (input.value.trim()) return;
2873
2981
  const res = await fetch('/api/last-workdir', {headers: {'X-Auth-Token': AUTH_TOKEN}});
2874
2982
  const data = await res.json();
2875
- if (data.path) input.value = data.path;
2983
+ if (data.path) { input.value = data.path; fetchMcpServers(data.path); }
2876
2984
  } catch(e) { /* ignore */ }
2877
2985
  }
2878
2986
 
2987
+ /* ─── Builder MCP Tools ─── */
2988
+ let _mcpServers = [];
2989
+ let _mcpBlocked = new Set();
2990
+
2991
+ function updateMcpToolsSummaryLabel() {
2992
+ const el = document.getElementById('mcp-tools-summary');
2993
+ if (!el) return;
2994
+ const name = (userProfile.agent_names && userProfile.agent_names.builder) || 'Builder';
2995
+ el.textContent = name + ' MCP Tools';
2996
+ }
2997
+
2998
+ async function fetchMcpServers(workdir) {
2999
+ const listEl = document.getElementById('mcp-tools-list');
3000
+ const emptyEl = document.getElementById('mcp-tools-empty');
3001
+ const loadingEl = document.getElementById('mcp-tools-loading');
3002
+ const hintEl = document.getElementById('mcp-tools-hint');
3003
+ const actionsEl = document.getElementById('mcp-tools-actions');
3004
+ if (!workdir) {
3005
+ listEl.style.display = 'none'; emptyEl.style.display = 'none'; hintEl.style.display = 'none'; actionsEl.style.display = 'none';
3006
+ const gn = document.getElementById('mcp-tools-global-note'); if (gn) gn.style.display = 'none';
3007
+ loadingEl.style.display = ''; loadingEl.textContent = 'Enter a working directory to discover MCP servers';
3008
+ _mcpServers = []; _mcpBlocked = new Set();
3009
+ return;
3010
+ }
3011
+ const globalNoteEl = document.getElementById('mcp-tools-global-note');
3012
+ loadingEl.style.display = ''; loadingEl.textContent = 'Discovering MCP servers...';
3013
+ listEl.style.display = 'none'; emptyEl.style.display = 'none'; hintEl.style.display = 'none'; actionsEl.style.display = 'none';
3014
+ if (globalNoteEl) globalNoteEl.style.display = 'none';
3015
+ try {
3016
+ const res = await fetch('/api/mcp-servers?workdir=' + encodeURIComponent(workdir), {headers: AUTH_GET});
3017
+ const data = await res.json();
3018
+ _mcpServers = data.servers || [];
3019
+ _mcpBlocked = new Set(data.blocked || []);
3020
+ loadingEl.style.display = 'none';
3021
+ if (_mcpServers.length === 0) {
3022
+ emptyEl.style.display = ''; return;
3023
+ }
3024
+ listEl.innerHTML = _mcpServers.map(srv => {
3025
+ const checked = !_mcpBlocked.has(srv) ? 'checked' : '';
3026
+ const id = 'mcp-srv-' + srv.replace(/[^a-zA-Z0-9_-]/g, '_');
3027
+ return `<div class="mcp-tools-row"><input type="checkbox" id="${id}" value="${escapeHtml(srv)}" ${checked}><label for="${id}">${escapeHtml(srv)}</label></div>`;
3028
+ }).join('');
3029
+ listEl.style.display = ''; hintEl.style.display = ''; actionsEl.style.display = '';
3030
+ if (globalNoteEl && !data.has_project_config && (data.global_blocked || []).length > 0) {
3031
+ globalNoteEl.style.display = '';
3032
+ }
3033
+ } catch(e) {
3034
+ loadingEl.style.display = 'none'; emptyEl.style.display = ''; emptyEl.textContent = 'Failed to discover MCP servers';
3035
+ }
3036
+ }
3037
+
3038
+ function getBlockedMcpServers() {
3039
+ const blocked = [];
3040
+ for (const srv of _mcpServers) {
3041
+ const id = 'mcp-srv-' + srv.replace(/[^a-zA-Z0-9_-]/g, '_');
3042
+ const cb = document.getElementById(id);
3043
+ if (cb && !cb.checked) blocked.push(srv);
3044
+ }
3045
+ return blocked;
3046
+ }
3047
+
3048
+ async function saveMcpToolsConfig() {
3049
+ const workdir = document.getElementById('agent-workdir-input').value.trim();
3050
+ if (!workdir) return;
3051
+ const blocked = getBlockedMcpServers();
3052
+ try {
3053
+ await fetch('/api/builder-tools-config', {method: 'POST', headers: AUTH_HEADERS, body: JSON.stringify({workdir, blocked})});
3054
+ const savedEl = document.getElementById('mcp-tools-saved');
3055
+ savedEl.style.opacity = '1';
3056
+ setTimeout(() => { savedEl.style.opacity = '0'; }, 2000);
3057
+ } catch(e) { /* ignore */ }
3058
+ }
3059
+
3060
+ async function loadGlobalMcpDefaults() {
3061
+ try {
3062
+ const res = await fetch('/api/global-mcp-defaults', {headers: AUTH_GET});
3063
+ const d = await res.json();
3064
+ const el = document.getElementById('settings-global-mcp-blocked');
3065
+ if (el) el.value = (d.blocked || []).join(', ');
3066
+ } catch(e) { /* ignore */ }
3067
+ }
3068
+
3069
+ async function saveGlobalMcpDefaults(applyAll) {
3070
+ const el = document.getElementById('settings-global-mcp-blocked');
3071
+ if (!el) return;
3072
+ const blocked = el.value.split(',').map(s => s.trim()).filter(Boolean);
3073
+ try {
3074
+ await fetch('/api/global-mcp-defaults', {method: 'POST', headers: AUTH_HEADERS, body: JSON.stringify({blocked, apply_all: !!applyAll})});
3075
+ const savedEl = document.getElementById('settings-global-mcp-saved');
3076
+ savedEl.textContent = applyAll ? 'Applied to all' : 'Saved';
3077
+ savedEl.style.opacity = '1';
3078
+ setTimeout(() => { savedEl.style.opacity = '0'; }, 2000);
3079
+ } catch(e) { /* ignore */ }
3080
+ }
3081
+
3082
+ function applyGlobalMcpToAll() {
3083
+ if (!confirm('This will overwrite all per-project MCP settings. Continue?')) return;
3084
+ saveGlobalMcpDefaults(true);
3085
+ }
3086
+
2879
3087
  function autoResize(textarea) {
2880
3088
  textarea.style.height = 'auto';
2881
3089
  textarea.style.height = textarea.scrollHeight + 'px';
@@ -2984,7 +3192,7 @@ async function loadRecentProjects() {
2984
3192
  if (!dirs.length) { dropdown.style.display = 'none'; return; }
2985
3193
  dropdown.style.display = 'block';
2986
3194
  dropdown.innerHTML = dirs.map(d =>
2987
- `<div style="padding:0.4rem 0.7rem;cursor:pointer;font-size:0.8rem;font-family:monospace;color:#8b949e;border-bottom:1px solid #21262d;transition:background 0.15s;" onmouseenter="this.style.background='#161b22';this.style.color='#e6edf3'" onmouseleave="this.style.background='';this.style.color='#8b949e'" onclick="document.getElementById('agent-workdir-input').value=this.textContent">${escapeHtml(d)}</div>`
3195
+ `<div style="padding:0.4rem 0.7rem;cursor:pointer;font-size:0.8rem;font-family:monospace;color:#8b949e;border-bottom:1px solid #21262d;transition:background 0.15s;" onmouseenter="this.style.background='#161b22';this.style.color='#e6edf3'" onmouseleave="this.style.background='';this.style.color='#8b949e'" onclick="document.getElementById('agent-workdir-input').value=this.textContent;fetchMcpServers(this.textContent)">${escapeHtml(d)}</div>`
2988
3196
  ).join('');
2989
3197
  } catch(e) { /* ignore */ }
2990
3198
  }
@@ -3006,6 +3214,7 @@ function closeDirBrowser() {
3006
3214
  function selectDirBrowser() {
3007
3215
  document.getElementById('agent-workdir-input').value = dirBrowserCurrentPath;
3008
3216
  closeDirBrowser();
3217
+ fetchMcpServers(dirBrowserCurrentPath);
3009
3218
  }
3010
3219
 
3011
3220
  function dirBrowserBack() {
@@ -3245,6 +3454,7 @@ function renderAgentUI(data) {
3245
3454
  const pulseStyle = a.status === 'busy' ? 'animation:pulse-busy 2s infinite;' : '';
3246
3455
  const roleIcon = role === 'manager' ? '&#128188;' : role === 'builder' ? '&#128736;' : '&#128269;';
3247
3456
  let statusDisplay;
3457
+ const hasStreamOutput = a.status === 'busy' && a.last_output && a.last_output.trim().length > 0;
3248
3458
  if (a.status === 'busy' && BUSY_PHRASES[role]) {
3249
3459
  const phrase = BUSY_PHRASES[role][busyPhraseIndices[role] % BUSY_PHRASES[role].length];
3250
3460
  const customName = (userProfile.agent_names && userProfile.agent_names[role]) || role.charAt(0).toUpperCase() + role.slice(1);
@@ -3290,6 +3500,8 @@ function renderAgentUI(data) {
3290
3500
  }
3291
3501
  }
3292
3502
  const errorBorder = (a.status === 'error' || a.status === 'timeout' || a.status === 'crashed') ? ' agent-card-error-border' : '';
3503
+ const outputMaxHeight = hasStreamOutput ? '6em' : '3em';
3504
+ const outputSnippet = escapeHtml((a.last_output || '').substring(0, hasStreamOutput ? 500 : 150));
3293
3505
  return `<div class="agent-card${errorBorder}" style="${pulseStyle}">
3294
3506
  <span class="agent-tooltip">${escapeHtml(ROLE_DESCRIPTIONS[role] || '')}</span>
3295
3507
  <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.6rem;">
@@ -3297,7 +3509,7 @@ function renderAgentUI(data) {
3297
3509
  <span style="background:${color}20;color:${color};padding:2px 8px;border-radius:12px;font-size:0.7rem;font-weight:600;text-transform:uppercase;">${statusDisplay}</span>
3298
3510
  </div>
3299
3511
  <div style="font-size:0.75rem;color:#8b949e;line-height:1.6;">
3300
- <div style="margin-top:0.3rem;color:#c9d1d9;font-size:0.72rem;max-height:3em;overflow:hidden;text-overflow:ellipsis;">${escapeHtml((a.last_output || '').substring(0, 150))}</div>
3512
+ <div style="margin-top:0.3rem;color:#c9d1d9;font-size:0.72rem;max-height:${outputMaxHeight};overflow:hidden;text-overflow:ellipsis;white-space:pre-wrap;word-break:break-word;">${outputSnippet}</div>
3301
3513
  </div>
3302
3514
  ${elapsedHtml}
3303
3515
  ${contextHtml}
@@ -3512,6 +3724,7 @@ async function loadSettings() {
3512
3724
 
3513
3725
  loadModelPrefs();
3514
3726
  await loadUserProfile();
3727
+ loadGlobalMcpDefaults();
3515
3728
  settingsLoaded = true;
3516
3729
  } catch (e) {
3517
3730
  console.error('Settings load failed:', e);
@@ -458,6 +458,27 @@ class _Handler(BaseHTTPRequestHandler):
458
458
  pass
459
459
  body = json.dumps({"path": path}).encode()
460
460
  self._respond(200, "application/json", body)
461
+ elif self.path.startswith("/api/mcp-servers"):
462
+ parsed = urlparse(self.path)
463
+ qs = parse_qs(parsed.query)
464
+ workdir = qs.get("workdir", [""])[0]
465
+ servers = agent_runner.discover_mcp_servers(workdir) if workdir else []
466
+ cfg = agent_runner.load_builder_tools_config()
467
+ blocked: list[str] = []
468
+ has_project_config = False
469
+ if workdir:
470
+ if workdir in cfg:
471
+ blocked = cfg[workdir]
472
+ has_project_config = True
473
+ else:
474
+ blocked = cfg.get("_global_defaults", [])
475
+ global_blocked = cfg.get("_global_defaults", [])
476
+ body = json.dumps({"servers": servers, "blocked": blocked, "global_blocked": global_blocked, "has_project_config": has_project_config}).encode()
477
+ self._respond(200, "application/json", body)
478
+ elif self.path == "/api/global-mcp-defaults":
479
+ cfg = agent_runner.load_builder_tools_config()
480
+ body = json.dumps({"blocked": cfg.get("_global_defaults", [])}).encode()
481
+ self._respond(200, "application/json", body)
461
482
  elif self.path == "/api/headroom-stats":
462
483
  try:
463
484
  req = urllib.request.Request("http://127.0.0.1:8787/stats")
@@ -725,8 +746,10 @@ class _Handler(BaseHTTPRequestHandler):
725
746
  builder_model = data.get("builder_model", "")
726
747
  reviewer_model = data.get("reviewer_model", "")
727
748
  user_name = str(data.get("user_name", "")).strip()
749
+ blocked_mcp = data.get("blocked_mcp_servers", [])
728
750
  result = agent_runner.start_run(task=task, working_dir=working_dir, context=context, auto_commit=auto_commit, timeout_minutes=timeout_minutes,
729
- manager_model=manager_model, builder_model=builder_model, reviewer_model=reviewer_model, user_name=user_name)
751
+ manager_model=manager_model, builder_model=builder_model, reviewer_model=reviewer_model, user_name=user_name,
752
+ blocked_mcp_servers=blocked_mcp)
730
753
  body = json.dumps(result).encode()
731
754
  status_code = 200 if "session_id" in result else 400
732
755
  self._respond(status_code, "application/json", body)
@@ -768,6 +791,49 @@ class _Handler(BaseHTTPRequestHandler):
768
791
  except Exception as exc:
769
792
  body = json.dumps({"success": False, "error": str(exc)}).encode()
770
793
  self._respond(500, "application/json", body)
794
+ elif self.path == "/api/builder-tools-config":
795
+ try:
796
+ content_len = int(self.headers.get("Content-Length", 0))
797
+ raw = self.rfile.read(content_len).decode("utf-8") if content_len else ""
798
+ data = json.loads(raw)
799
+ workdir = data.get("workdir", "").strip()
800
+ blocked = data.get("blocked", [])
801
+ if not workdir:
802
+ body = json.dumps({"success": False, "error": "Missing 'workdir' field."}).encode()
803
+ self._respond(400, "application/json", body)
804
+ return
805
+ cfg = agent_runner.load_builder_tools_config()
806
+ cfg[workdir] = blocked
807
+ agent_runner.save_builder_tools_config(cfg)
808
+ body = json.dumps({"success": True}).encode()
809
+ self._respond(200, "application/json", body)
810
+ except (json.JSONDecodeError, ValueError):
811
+ body = json.dumps({"success": False, "error": "Invalid JSON body."}).encode()
812
+ self._respond(400, "application/json", body)
813
+ except Exception as exc:
814
+ body = json.dumps({"success": False, "error": str(exc)}).encode()
815
+ self._respond(500, "application/json", body)
816
+ elif self.path == "/api/global-mcp-defaults":
817
+ try:
818
+ content_len = int(self.headers.get("Content-Length", 0))
819
+ raw = self.rfile.read(content_len).decode("utf-8") if content_len else ""
820
+ data = json.loads(raw)
821
+ blocked = data.get("blocked", [])
822
+ cfg = agent_runner.load_builder_tools_config()
823
+ cfg["_global_defaults"] = blocked
824
+ if data.get("apply_all"):
825
+ for key in list(cfg.keys()):
826
+ if key != "_global_defaults":
827
+ cfg[key] = list(blocked)
828
+ agent_runner.save_builder_tools_config(cfg)
829
+ body = json.dumps({"success": True}).encode()
830
+ self._respond(200, "application/json", body)
831
+ except (json.JSONDecodeError, ValueError):
832
+ body = json.dumps({"success": False, "error": "Invalid JSON body."}).encode()
833
+ self._respond(400, "application/json", body)
834
+ except Exception as exc:
835
+ body = json.dumps({"success": False, "error": str(exc)}).encode()
836
+ self._respond(500, "application/json", body)
771
837
  elif self.path == "/api/burn-report/reset":
772
838
  try:
773
839
  from .stats import reset_burn_stats
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memstack-skill-loader
3
- Version: 4.0.4
3
+ Version: 4.0.5
4
4
  Summary: MCP server that vector-indexes MemStack Pro skills for on-demand loading
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: mcp>=1.0.0