diff --git a/.github/workflows/shared/mcp-debug.md b/.github/workflows/shared/mcp-debug.md index c168c8a3ff5..92081dba633 100644 --- a/.github/workflows/shared/mcp-debug.md +++ b/.github/workflows/shared/mcp-debug.md @@ -256,19 +256,19 @@ The diagnostic report will be automatically posted as a comment on the pull requ ``` # Server failed to start - investigate -Read /tmp/gh-aw/mcp-logs/drain3/server.log +Read /tmp/gh-aw/mcp-logs//server.log -# Found error: ModuleNotFoundError: No module named 'fastmcp' +# Found error: connection refused on port XXXX # Verify connectivity status -Use mcp-inspect with workflow_file="dev" and server="drain3" +Use mcp-inspect with workflow_file="dev" and server="" # Create diagnostic report using safe-output Output report_diagnostics_to_pull_request with: -- Issue: Drain3 MCP server failed to start -- Root Cause: Missing fastmcp dependency -- Evidence: ModuleNotFoundError from server.log -- Fix: Add pip install fastmcp to workflow steps +- Issue: MCP server failed to start +- Root Cause: Port already in use or missing dependency +- Evidence: Error message from server.log +- Fix: Verify port availability and dependency installation ``` Remember: Always conclude your debugging session by posting a diagnostic report using the `report_diagnostics_to_pull_request` safe-output. This ensures your findings are documented and actionable. diff --git a/.github/workflows/shared/mcp/drain3.md b/.github/workflows/shared/mcp/drain3.md deleted file mode 100644 index 99ca682da46..00000000000 --- a/.github/workflows/shared/mcp/drain3.md +++ /dev/null @@ -1,196 +0,0 @@ ---- -tools: - cache-memory: -mcp-servers: - drain3: - type: http - url: http://localhost:8766/mcp - allowed: - - index_file - - query_file - - list_templates - - list_clusters - - cluster_stats - - find_anomalies - - compare_runs - - search_pattern -steps: - - name: Setup Python - uses: actions/setup-python@v6.2.0 - with: - python-version: '3.11' - - name: Install Drain3 dependencies - run: | - pip install fastmcp drain3 - - name: Copy Drain3 MCP Server script - run: | - mkdir -p /tmp/gh-aw/agent/mcp-servers/drain3/ - cp .github/workflows/shared/mcp/drain3_server.py /tmp/gh-aw/agent/mcp-servers/drain3/ - chmod +x /tmp/gh-aw/agent/mcp-servers/drain3/drain3_server.py - - name: Start Drain3 MCP Server - run: | - set -e - mkdir -p /tmp/gh-aw/mcp-logs/drain3/ - python /tmp/gh-aw/agent/mcp-servers/drain3/drain3_server.py > /tmp/gh-aw/mcp-logs/drain3/server.log 2>&1 & - MCP_PID=$! - - # Wait for server to start - sleep 3 - - # Check if server is still running - if ! kill -0 $MCP_PID 2>/dev/null; then - echo "Drain3 MCP server failed to start" - echo "Server logs:" - cat /tmp/gh-aw/mcp-logs/drain3/server.log || true - exit 1 - fi - - # Check if server is listening on port 8766 - if ! netstat -tln | grep -q ":8766 "; then - echo "Drain3 MCP server not listening on port 8766" - echo "Server logs:" - cat /tmp/gh-aw/mcp-logs/drain3/server.log || true - exit 1 - fi - - # Test HTTP endpoint with curl - echo "Testing HTTP endpoint with curl..." - if curl -v -X GET http://localhost:8766/mcp 2>&1 | tee /tmp/gh-aw/mcp-logs/drain3/curl-test.log; then - echo "✓ HTTP endpoint responded" - else - echo "✗ HTTP endpoint did not respond" - echo "Server logs:" - cat /tmp/gh-aw/mcp-logs/drain3/server.log || true - echo "Curl test logs:" - cat /tmp/gh-aw/mcp-logs/drain3/curl-test.log || true - exit 1 - fi - - echo "Drain3 MCP server started successfully with PID $MCP_PID" - echo "Server logs (first 50 lines):" - head -n 50 /tmp/gh-aw/mcp-logs/drain3/server.log || true - env: - PORT: "8766" - HOST: "0.0.0.0" - STATE_DIR: "/tmp/gh-aw/cache-memory" ---- - - diff --git a/.github/workflows/shared/mcp/drain3_server.py b/.github/workflows/shared/mcp/drain3_server.py deleted file mode 100755 index 262138ec812..00000000000 --- a/.github/workflows/shared/mcp/drain3_server.py +++ /dev/null @@ -1,554 +0,0 @@ -#!/usr/bin/env python3 -# Drain3 MCP HTTP server — live streaming JSONL -# Tools: index_file, query_file, list_templates -# Deps: pip install fastmcp drain3 -from __future__ import annotations -from typing import Any, Dict, Iterable, List, Optional -import os, json, time, sys, logging -from pathlib import Path - -from fastmcp import FastMCP -from drain3 import TemplateMiner -from drain3.file_persistence import FilePersistence -from drain3.template_miner_config import TemplateMinerConfig - -# ----------------------- -# Logging Configuration -# ----------------------- -logging.basicConfig( - level=logging.DEBUG, - format='%(asctime)s [%(levelname)s] %(name)s: %(message)s', - stream=sys.stderr -) -logger = logging.getLogger(__name__) - -# ----------------------- -# Configuration -# ----------------------- -HOST = os.getenv("HOST", "0.0.0.0") -PORT = int(os.getenv("PORT", "8766")) - -logger.info(f"Initializing Drain3 MCP server") -logger.info(f"Configuration: HOST={HOST}, PORT={PORT}") - -STATE_DIR = Path(os.getenv("STATE_DIR", ".drain3")).resolve() -STATE_DIR.mkdir(parents=True, exist_ok=True) -logger.info(f"State directory: {STATE_DIR}") - -SIM_TH = float(os.getenv("DRAIN3_SIM_TH", "0.4")) -DEPTH = int(os.getenv("DRAIN3_DEPTH", "4")) -MAX_CHILDREN = int(os.getenv("DRAIN3_MAX_CHILDREN", "100")) -MAX_CLUSTERS = int(os.getenv("DRAIN3_MAX_CLUSTERS", "0")) - -logger.info(f"Drain3 config: SIM_TH={SIM_TH}, DEPTH={DEPTH}, MAX_CHILDREN={MAX_CHILDREN}, MAX_CLUSTERS={MAX_CLUSTERS}") - -# Stream tuning -STREAM_FLUSH_EVERY = int(os.getenv("STREAM_FLUSH_EVERY", "500")) # emit a progress event every N lines -STREAM_SLEEP = float(os.getenv("STREAM_SLEEP", "0")) # throttle (seconds) between flushes; 0 = no sleep - -logger.info(f"Stream config: FLUSH_EVERY={STREAM_FLUSH_EVERY}, SLEEP={STREAM_SLEEP}") - -logger.info("Creating FastMCP instance") -mcp = FastMCP("drain3-http") -logger.info("FastMCP instance created successfully") - -# ----------------------- -# Helpers -# ----------------------- -def _snapshot_path_for(file_path: Path) -> Path: - safe_stem = file_path.name.replace("/", "_") - return STATE_DIR / f"{safe_stem}.snapshot.json" - -def _build_config() -> TemplateMinerConfig: - cfg = TemplateMinerConfig() - cfg.drain_sim_th = SIM_TH - cfg.drain_depth = DEPTH - cfg.drain_max_children = MAX_CHILDREN - if MAX_CLUSTERS > 0: - cfg.drain_max_clusters = MAX_CLUSTERS - # Use default masking configuration from drain3 - # Custom masking caused serialization errors with dict objects - return cfg - -def _new_miner(snapshot_path: Path) -> TemplateMiner: - return TemplateMiner(FilePersistence(str(snapshot_path)), _build_config()) - -def _read_lines(p: Path, encoding="utf-8") -> Iterable[str]: - with p.open("r", encoding=encoding, errors="ignore") as f: - for ln in f: - yield ln.rstrip("\n") - -def _clusters_as_dicts(miner: TemplateMiner, limit: Optional[int] = None) -> List[Dict[str, Any]]: - clusters = getattr(miner.drain, "clusters", []) or [] - if limit: - clusters = clusters[:limit] - return [ - { - "cluster_id": getattr(c, "cluster_id", None), - "size": getattr(c, "size", None), - "template": " ".join(getattr(c, "log_template_tokens", []) or []) - } - for c in clusters - ] - -def _jsonl(obj: Any) -> str: - return json.dumps(obj, ensure_ascii=False) + "\n" - -# ----------------------- -# MCP tools (streaming) -# ----------------------- -@mcp.tool() -def index_file(paths: List[str], encoding: str = "utf-8", max_lines: Optional[int] = None): - """ - Stream-mines templates from one or more log files and persists Drain3 snapshots. - Accepts an array of file paths and processes them sequentially as a single operation. - Yields JSONL lines progressively: - - {"event":"start", file, snapshot, ...} - - {"event":"progress", file, processed:} - - {"event":"template", file, cluster_id, size, template} - - {"event":"file_summary", file, cluster_count, processed_lines, ...} - - {"event":"total_summary", total_files, total_lines, total_clusters, ...} - """ - # Handle both single string (backward compat) and array - if isinstance(paths, str): - paths = [paths] - - logger.info(f"index_file called: paths={paths}, encoding={encoding}, max_lines={max_lines}") - - total_files = 0 - total_lines = 0 - total_clusters_count = 0 - failed_files = [] - - for path in paths: - p = Path(path).expanduser().resolve() - if not p.exists() or not p.is_file(): - logger.error(f"File not found: {str(p)}") - yield _jsonl({"event": "error", "error": f"File not found: {str(p)}", "file": str(p)}) - failed_files.append(str(p)) - continue - - logger.info(f"File found: {str(p)}") - - snapshot = _snapshot_path_for(p) - logger.info(f"Snapshot path: {str(snapshot)}") - miner = _new_miner(snapshot) - logger.info("Template miner created") - - yield _jsonl({"event": "start", "file": str(p), "snapshot": str(snapshot)}) - logger.info("Started processing file") - - processed = 0 - for processed, ln in enumerate(_read_lines(p, encoding), start=1): - if max_lines and processed > max_lines: - processed -= 1 # last increment doesn't count - break - if ln.strip(): - miner.add_log_message(ln) - - if processed % STREAM_FLUSH_EVERY == 0: - yield _jsonl({"event": "progress", "file": str(p), "processed": processed}) - if STREAM_SLEEP > 0: - time.sleep(STREAM_SLEEP) - - # Save at end (older Drain3 may auto-save, but we try explicitly) - try: - miner.save_state("manual_save") - except Exception: - pass - - clusters = _clusters_as_dicts(miner) - # Emit clusters as independent events so consumers can start handling immediately - for c in clusters: - yield _jsonl({"event": "template", "file": str(p), **c}) - - yield _jsonl({ - "event": "file_summary", - "file": str(p), - "snapshot": str(snapshot), - "processed_lines": processed, - "cluster_count": len(clusters), - }) - - total_files += 1 - total_lines += processed - total_clusters_count += len(clusters) - - # Final summary across all files - yield _jsonl({ - "event": "total_summary", - "total_files": total_files, - "total_lines": total_lines, - "total_clusters": total_clusters_count, - "failed_files": failed_files, - }) - -@mcp.tool() -def query_file(path: str, text: str): - """ - Streams a single JSONL event with the match result: - - {"event":"query", cluster_id, cluster_size, template, ...} - """ - logger.info(f"query_file called: path={path}, text_len={len(text)}") - p = Path(path).expanduser().resolve() - snapshot = _snapshot_path_for(p) - if not snapshot.exists(): - logger.error(f"No snapshot found for {str(p)}") - yield _jsonl({"event": "error", "error": f"No snapshot for {str(p)}. Run index_file first.", "file": str(p)}) - return - - logger.info(f"Snapshot exists: {str(snapshot)}") - - miner = _new_miner(snapshot) - result = miner.match(text) - if result is None: - yield _jsonl({"event": "query", "file": str(p), "snapshot": str(snapshot), - "cluster_id": None, "cluster_size": None, "template": None}) - return - - cluster = result[0] - yield _jsonl({ - "event": "query", - "file": str(p), - "snapshot": str(snapshot), - "cluster_id": getattr(cluster, "cluster_id", None), - "cluster_size": getattr(cluster, "size", None), - "template": " ".join(getattr(cluster, "log_template_tokens", []) or []), - }) - -@mcp.tool() -def list_templates(path: str, limit: Optional[int] = None): - """ - Streams templates from an existing snapshot: - - one {"event":"template", ...} per cluster - - final {"event":"summary", count, ...} - """ - logger.info(f"list_templates called: path={path}, limit={limit}") - p = Path(path).expanduser().resolve() - snapshot = _snapshot_path_for(p) - if not snapshot.exists(): - logger.error(f"No snapshot found for {str(p)}") - yield _jsonl({"event": "error", "error": f"No snapshot for {str(p)}. Run index_file first.", "file": str(p)}) - return - - logger.info(f"Snapshot exists: {str(snapshot)}") - - miner = _new_miner(snapshot) - clusters = _clusters_as_dicts(miner, limit) - for c in clusters: - yield _jsonl({"event": "template", "file": str(p), "snapshot": str(snapshot), **c}) - - yield _jsonl({"event": "summary", "file": str(p), "snapshot": str(snapshot), "count": len(clusters)}) - -@mcp.tool() -def list_clusters(path: str, limit: Optional[int] = None): - """ - Enumerate all discovered log pattern clusters from an indexed file. - Streams cluster information including cluster_id, size, and template. - - one {"event":"cluster", ...} per cluster - - final {"event":"summary", count, ...} - """ - logger.info(f"list_clusters called: path={path}, limit={limit}") - p = Path(path).expanduser().resolve() - snapshot = _snapshot_path_for(p) - if not snapshot.exists(): - logger.error(f"No snapshot found for {str(p)}") - yield _jsonl({"event": "error", "error": f"No snapshot for {str(p)}. Run index_file first.", "file": str(p)}) - return - - logger.info(f"Snapshot exists: {str(snapshot)}") - - miner = _new_miner(snapshot) - clusters = _clusters_as_dicts(miner, limit) - for c in clusters: - yield _jsonl({"event": "cluster", "file": str(p), "snapshot": str(snapshot), **c}) - - yield _jsonl({"event": "summary", "file": str(p), "snapshot": str(snapshot), "count": len(clusters)}) - -@mcp.tool() -def cluster_stats(path: str, cluster_id: int): - """ - Retrieve detailed metrics for a specific cluster including count, frequency, and examples. - Returns: - - {"event":"stats", cluster_id, size, template, frequency, ...} - """ - logger.info(f"cluster_stats called: path={path}, cluster_id={cluster_id}") - p = Path(path).expanduser().resolve() - snapshot = _snapshot_path_for(p) - if not snapshot.exists(): - logger.error(f"No snapshot found for {str(p)}") - yield _jsonl({"event": "error", "error": f"No snapshot for {str(p)}. Run index_file first.", "file": str(p)}) - return - - logger.info(f"Snapshot exists: {str(snapshot)}") - - miner = _new_miner(snapshot) - clusters = getattr(miner.drain, "clusters", []) or [] - - # Find the cluster by ID - target_cluster = None - for c in clusters: - if getattr(c, "cluster_id", None) == cluster_id: - target_cluster = c - break - - if target_cluster is None: - yield _jsonl({"event": "error", "error": f"Cluster {cluster_id} not found", "file": str(p)}) - return - - # Calculate statistics - size = getattr(target_cluster, "size", 0) - template_tokens = getattr(target_cluster, "log_template_tokens", []) or [] - template = " ".join(template_tokens) - - yield _jsonl({ - "event": "stats", - "file": str(p), - "snapshot": str(snapshot), - "cluster_id": cluster_id, - "size": size, - "template": template, - "frequency": size, # size represents the count of occurrences - }) - -@mcp.tool() -def find_anomalies(path: str, threshold: float = 0.01): - """ - Detect outlier clusters or rare patterns that may indicate new issues. - Clusters with frequency below the threshold (as a percentage of total lines) are considered anomalies. - Streams anomalous clusters: - - {"event":"anomaly", cluster_id, size, template, frequency_pct, ...} - - {"event":"summary", anomaly_count, total_clusters, ...} - """ - logger.info(f"find_anomalies called: path={path}, threshold={threshold}") - p = Path(path).expanduser().resolve() - snapshot = _snapshot_path_for(p) - if not snapshot.exists(): - logger.error(f"No snapshot found for {str(p)}") - yield _jsonl({"event": "error", "error": f"No snapshot for {str(p)}. Run index_file first.", "file": str(p)}) - return - - logger.info(f"Snapshot exists: {str(snapshot)}") - - miner = _new_miner(snapshot) - clusters = getattr(miner.drain, "clusters", []) or [] - - # Calculate total log lines processed - total_lines = sum(getattr(c, "size", 0) for c in clusters) - - if total_lines == 0: - yield _jsonl({"event": "summary", "file": str(p), "anomaly_count": 0, "total_clusters": 0}) - return - - anomalies = [] - for c in clusters: - size = getattr(c, "size", 0) - frequency_pct = (size / total_lines) * 100 - - if frequency_pct <= threshold: - cluster_id = getattr(c, "cluster_id", None) - template_tokens = getattr(c, "log_template_tokens", []) or [] - template = " ".join(template_tokens) - - anomalies.append({ - "cluster_id": cluster_id, - "size": size, - "template": template, - "frequency_pct": frequency_pct - }) - - # Stream anomalies - for anomaly in anomalies: - yield _jsonl({"event": "anomaly", "file": str(p), "snapshot": str(snapshot), **anomaly}) - - yield _jsonl({ - "event": "summary", - "file": str(p), - "snapshot": str(snapshot), - "anomaly_count": len(anomalies), - "total_clusters": len(clusters), - "threshold_pct": threshold - }) - -@mcp.tool() -def compare_runs(path1: str, path2: str): - """ - Compare two log files or runs to identify added, removed, or changed clusters. - Both files must have been indexed previously. - Streams comparison results: - - {"event":"added", cluster_id, template, ...} - - {"event":"removed", cluster_id, template, ...} - - {"event":"changed", cluster_id, template, old_size, new_size, ...} - - {"event":"summary", added_count, removed_count, changed_count, ...} - """ - logger.info(f"compare_runs called: path1={path1}, path2={path2}") - - # Load first file - p1 = Path(path1).expanduser().resolve() - snapshot1 = _snapshot_path_for(p1) - if not snapshot1.exists(): - logger.error(f"No snapshot found for {str(p1)}") - yield _jsonl({"event": "error", "error": f"No snapshot for {str(p1)}. Run index_file first.", "file": str(p1)}) - return - - # Load second file - p2 = Path(path2).expanduser().resolve() - snapshot2 = _snapshot_path_for(p2) - if not snapshot2.exists(): - logger.error(f"No snapshot found for {str(p2)}") - yield _jsonl({"event": "error", "error": f"No snapshot for {str(p2)}. Run index_file first.", "file": str(p2)}) - return - - logger.info(f"Both snapshots exist: {str(snapshot1)}, {str(snapshot2)}") - - # Load miners - miner1 = _new_miner(snapshot1) - miner2 = _new_miner(snapshot2) - - clusters1 = getattr(miner1.drain, "clusters", []) or [] - clusters2 = getattr(miner2.drain, "clusters", []) or [] - - # Build template -> cluster mapping for comparison - templates1 = {} - for c in clusters1: - template = " ".join(getattr(c, "log_template_tokens", []) or []) - templates1[template] = { - "cluster_id": getattr(c, "cluster_id", None), - "size": getattr(c, "size", 0), - "template": template - } - - templates2 = {} - for c in clusters2: - template = " ".join(getattr(c, "log_template_tokens", []) or []) - templates2[template] = { - "cluster_id": getattr(c, "cluster_id", None), - "size": getattr(c, "size", 0), - "template": template - } - - # Find added, removed, and changed clusters - added = [] - removed = [] - changed = [] - - # Check for added and changed - for template, info2 in templates2.items(): - if template not in templates1: - added.append(info2) - elif templates1[template]["size"] != info2["size"]: - changed.append({ - **info2, - "old_size": templates1[template]["size"], - "new_size": info2["size"] - }) - - # Check for removed - for template, info1 in templates1.items(): - if template not in templates2: - removed.append(info1) - - # Stream results - for item in added: - yield _jsonl({"event": "added", "file1": str(p1), "file2": str(p2), **item}) - - for item in removed: - yield _jsonl({"event": "removed", "file1": str(p1), "file2": str(p2), **item}) - - for item in changed: - yield _jsonl({"event": "changed", "file1": str(p1), "file2": str(p2), **item}) - - yield _jsonl({ - "event": "summary", - "file1": str(p1), - "file2": str(p2), - "added_count": len(added), - "removed_count": len(removed), - "changed_count": len(changed), - "total_clusters_file1": len(clusters1), - "total_clusters_file2": len(clusters2) - }) - -@mcp.tool() -def search_pattern(path: str, pattern: str, use_regex: bool = False): - """ - Search logs for specific text or regex patterns, returning matching clusters. - Streams matching clusters: - - {"event":"match", cluster_id, size, template, ...} - - {"event":"summary", match_count, total_clusters, ...} - """ - logger.info(f"search_pattern called: path={path}, pattern={pattern}, use_regex={use_regex}") - p = Path(path).expanduser().resolve() - snapshot = _snapshot_path_for(p) - if not snapshot.exists(): - logger.error(f"No snapshot found for {str(p)}") - yield _jsonl({"event": "error", "error": f"No snapshot for {str(p)}. Run index_file first.", "file": str(p)}) - return - - logger.info(f"Snapshot exists: {str(snapshot)}") - - miner = _new_miner(snapshot) - clusters = getattr(miner.drain, "clusters", []) or [] - - matches = [] - import re - - for c in clusters: - template_tokens = getattr(c, "log_template_tokens", []) or [] - template = " ".join(template_tokens) - - # Check if pattern matches - if use_regex: - try: - if re.search(pattern, template, re.IGNORECASE): - matches.append({ - "cluster_id": getattr(c, "cluster_id", None), - "size": getattr(c, "size", 0), - "template": template - }) - except re.error as e: - yield _jsonl({"event": "error", "error": f"Invalid regex pattern: {str(e)}", "file": str(p)}) - return - else: - if pattern.lower() in template.lower(): - matches.append({ - "cluster_id": getattr(c, "cluster_id", None), - "size": getattr(c, "size", 0), - "template": template - }) - - # Stream matches - for match in matches: - yield _jsonl({"event": "match", "file": str(p), "snapshot": str(snapshot), **match}) - - yield _jsonl({ - "event": "summary", - "file": str(p), - "snapshot": str(snapshot), - "match_count": len(matches), - "total_clusters": len(clusters), - "pattern": pattern, - "use_regex": use_regex - }) - -# ----------------------- -# Entry point -# ----------------------- -if __name__ == "__main__": - # HTTP transport for self-hosted MCP server - # Note: Do NOT use 'fastmcp run' which defaults to stdio transport - # For HTTP, run this script directly with Python - logger.info("="*60) - logger.info("Starting Drain3 MCP HTTP Server") - logger.info(f"Host: {HOST}") - logger.info(f"Port: {PORT}") - logger.info(f"Transport: http") - logger.info("="*60) - - try: - logger.info("Calling mcp.run()...") - mcp.run(transport="http", host=HOST, port=PORT) - logger.info("mcp.run() completed") - except Exception as e: - logger.error(f"Server failed with exception: {e}", exc_info=True) - raise diff --git a/.github/workflows/shared/mcp/test_drain3_server.py b/.github/workflows/shared/mcp/test_drain3_server.py deleted file mode 100644 index e0d06fcdaf5..00000000000 --- a/.github/workflows/shared/mcp/test_drain3_server.py +++ /dev/null @@ -1,144 +0,0 @@ -#!/usr/bin/env python3 -""" -Test script for drain3_server.py -Validates the server code without requiring dependencies to be installed. -""" -import ast -import sys -from pathlib import Path - - -def test_syntax(): - """Test that the Python file has valid syntax.""" - script_path = Path(__file__).parent / "drain3_server.py" - with open(script_path, "r") as f: - code = f.read() - - try: - ast.parse(code) - print("✓ Python syntax is valid") - return True - except SyntaxError as e: - print(f"✗ Syntax error: {e}") - return False - - -def test_structure(): - """Test that the file has expected structure.""" - script_path = Path(__file__).parent / "drain3_server.py" - with open(script_path, "r") as f: - code = f.read() - - tree = ast.parse(code) - - # Check for required imports - imports = [] - for node in ast.walk(tree): - if isinstance(node, ast.ImportFrom): - imports.append(node.module) - - required_imports = ["fastmcp"] - missing = [imp for imp in required_imports if not any(imp in i for i in imports if i)] - - if missing: - print(f"✗ Missing imports: {missing}") - return False - print("✓ Required imports present") - - # Check for tool decorators - tool_functions = [] - for node in ast.walk(tree): - if isinstance(node, ast.FunctionDef): - for decorator in node.decorator_list: - # Check for @mcp.tool() (Call node) - if isinstance(decorator, ast.Call): - if isinstance(decorator.func, ast.Attribute) and decorator.func.attr == "tool": - tool_functions.append(node.name) - - expected_tools = ["index_file", "query_file", "list_templates", "list_clusters", "cluster_stats", "find_anomalies", "compare_runs", "search_pattern"] - missing_tools = [tool for tool in expected_tools if tool not in tool_functions] - - if missing_tools: - print(f"✗ Missing tool functions: {missing_tools}") - return False - print(f"✓ All expected tool functions found: {', '.join(expected_tools)}") - - # Check for main block - has_main = False - for node in ast.walk(tree): - if isinstance(node, ast.If): - if isinstance(node.test, ast.Compare): - if any(isinstance(comp, ast.Eq) for comp in node.test.ops): - has_main = True - break - - if not has_main: - print("✗ No main block found") - return False - print("✓ Main block present") - - return True - - -def test_no_invalid_params(): - """Test that mcp.run() doesn't have invalid parameters and decorators are called correctly.""" - script_path = Path(__file__).parent / "drain3_server.py" - with open(script_path, "r") as f: - code = f.read() - - tree = ast.parse(code) - - # Check decorators are called (with parentheses) - for node in ast.walk(tree): - if isinstance(node, ast.FunctionDef): - for decorator in node.decorator_list: - if isinstance(decorator, ast.Attribute) and decorator.attr == "tool": - # This should be a Call node (mcp.tool()), not just an Attribute (mcp.tool) - print(f"✗ @mcp.tool decorator on '{node.name}' should be called with parentheses: @mcp.tool()") - return False - - # Find mcp.run() calls - for node in ast.walk(tree): - if isinstance(node, ast.Call): - if (isinstance(node.func, ast.Attribute) and - node.func.attr == "run"): - # Check keywords - keywords = [kw.arg for kw in node.keywords] - # path parameter should not be in mcp.run() for streamable-http - if "path" in keywords: - print("✗ mcp.run() should not have 'path' parameter for streamable-http transport") - return False - print(f"✓ mcp.run() parameters look correct: {', '.join(keywords)}") - - print("✓ All decorators are called correctly with parentheses") - return True - - -def main(): - """Run all tests.""" - print("Testing drain3_server.py...") - print() - - tests = [ - ("Syntax validation", test_syntax), - ("Structure validation", test_structure), - ("Parameter validation", test_no_invalid_params), - ] - - results = [] - for name, test_func in tests: - print(f"Running: {name}") - result = test_func() - results.append(result) - print() - - if all(results): - print("✓ All tests passed!") - return 0 - else: - print("✗ Some tests failed") - return 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/actions/setup/js/allowed_issue_fields.cjs b/actions/setup/js/allowed_issue_fields.cjs index 1b1770f3df0..7436cc36c00 100644 --- a/actions/setup/js/allowed_issue_fields.cjs +++ b/actions/setup/js/allowed_issue_fields.cjs @@ -52,8 +52,17 @@ function validateAllowedIssueFields(issueFields, allowedFields) { if (!Array.isArray(issueFields) || issueFields.length === 0) { return; } + if (!Array.isArray(allowedFields) || allowedFields.length === 0) { + return; + } + const allowedFieldSet = new Set(allowedFields.map(f => f.toLowerCase())); + if (allowedFieldSet.has("*")) { + return; + } for (const field of issueFields) { - validateAllowedIssueFieldName(field.name, allowedFields); + if (!allowedFieldSet.has(field.name.toLowerCase())) { + throw new Error(`${ERR_VALIDATION}: issue field "${field.name}" is not in the allowed-fields list: ${allowedFields.join(", ")}`); + } } } diff --git a/docs/src/content/docs/guides/mcps.md b/docs/src/content/docs/guides/mcps.md index ecada0132e3..4eca40f5196 100644 --- a/docs/src/content/docs/guides/mcps.md +++ b/docs/src/content/docs/guides/mcps.md @@ -210,7 +210,6 @@ Pre-configured MCP server specifications are available in [`.github/workflows/sh | MCP Server | Import Path | Key Capabilities | |------------|-------------|------------------| | **Jupyter** | `shared/mcp/jupyter.md` | Execute code, manage notebooks, visualize data | -| **Drain3** | `shared/mcp/drain3.md` | Log pattern mining with 8 tools including `index_file`, `list_clusters`, `find_anomalies` | | **AgentDB** | `shared/mcp/agentdb.md` | Semantic and hybrid retrieval over agent-collected corpora (e.g. discussions, issues), backed by a runtime store at `AGENTDB_PATH` | | **Others** | `shared/mcp/*.md` | AST-Grep, Azure, Brave Search, Context7, DataDog, DeepWiki, Fabric RTI, MarkItDown, Microsoft Docs, Notion, Sentry, Serena, Server Memory, Slack, Tavily |