From d5d6963c767a2d3fc194eff4cf94f948cac6fb8c Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 21 Jun 2026 05:01:30 +0000
Subject: [PATCH 1/5] Initial plan


From 007491b9c115344d7850a5584ae03524176331f8 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 21 Jun 2026 05:08:51 +0000
Subject: [PATCH 2/5] Initial plan for code simplification

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
---
 .github/workflows/smoke-copilot.lock.yml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/.github/workflows/smoke-copilot.lock.yml b/.github/workflows/smoke-copilot.lock.yml
index cd5100ff72f..0708abac297 100644
--- a/.github/workflows/smoke-copilot.lock.yml
+++ b/.github/workflows/smoke-copilot.lock.yml
@@ -1,4 +1,4 @@
-# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"6669d3fc0bdfdd7a9e54e10d8c1e41c04dc17e1d162f2e639dabdb1255ee8a78","body_hash":"3ed9a0835f964798b12a6c4c8600addbbf66518ae974a9e6ed7efefc76b0dc43","agent_id":"copilot","agent_model":"gpt-5.4","engine_versions":{"copilot":"1.0.63"}}
+# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"6669d3fc0bdfdd7a9e54e10d8c1e41c04dc17e1d162f2e639dabdb1255ee8a78","body_hash":"112ba8db78a4994cdc51d736f773b2b1ac912f174b6b8a98fdfb331329235d12","agent_id":"copilot","agent_model":"gpt-5.4","engine_versions":{"copilot":"1.0.63"}}
 # gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GH_AW_OTEL_GRAFANA_AUTHORIZATION","GH_AW_OTEL_GRAFANA_ENDPOINT","GH_AW_OTEL_SENTRY_AUTHORIZATION","GH_AW_OTEL_SENTRY_ENDPOINT","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache/restore","sha":"27d5ce7f107fe9357f9df03efb73ab90386fccae","version":"v5.0.5"},{"repo":"actions/cache/save","sha":"27d5ce7f107fe9357f9df03efb73ab90386fccae","version":"v5.0.5"},{"repo":"actions/checkout","sha":"9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0","version":"v7.0.0"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-go","sha":"4a3601121dd01d1626a1e23e37211e3254c1c06c","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"docker/build-push-action","sha":"f9f3042f7e2789586610d6e8b85c8f03e5195baf","version":"v7.2.0"},{"repo":"docker/setup-buildx-action","sha":"d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5","version":"v4.1.0"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7","digest":"sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.7@sha256:aae231e4635c8999d039c132f1602d3df850fe9b84a00aa2b5ac981179b5661c"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7","digest":"sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.7@sha256:009caf2e3d88fa77b64e9a03a95a228fc58db0f1701c6d324b29ba5a3c7c79b6"},{"image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.27.7","digest":"sha256:4757f198a3fa20f88bdbe70be7ae1a05f127d9c0a9e96a5d6460ef40c08fc83d","pinned_image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.27.7@sha256:4757f198a3fa20f88bdbe70be7ae1a05f127d9c0a9e96a5d6460ef40c08fc83d"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7","digest":"sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.7@sha256:deb1d4e19de62d51cee0508057a596a19315c3423ada4d675cad136dc8037c96"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.27","digest":"sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.27@sha256:fe984bddde4ec05d756d9043edb0a32912e6b7b72f6a121b1082f29221421cc7"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.3.0","digest":"sha256:5c83359327a0bacc3d34db730bea6557d39d341cee0bf6c58c9a896e33150e80","pinned_image":"ghcr.io/github/github-mcp-server:v1.3.0@sha256:5c83359327a0bacc3d34db730bea6557d39d341cee0bf6c58c9a896e33150e80"},{"image":"ghcr.io/github/serena-mcp-server:latest","digest":"sha256:bf343399e3725c45528f531a230f3a04521d4cdef29f9a5af6282ff0d3c393c5","pinned_image":"ghcr.io/github/serena-mcp-server:latest@sha256:bf343399e3725c45528f531a230f3a04521d4cdef29f9a5af6282ff0d3c393c5"}]}
 # This file was automatically generated by gh-aw. DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md
 #

From 3fa9ac7c75f9945135e2448820aa619cda40d24a Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 21 Jun 2026 05:33:31 +0000
Subject: [PATCH 3/5] code simplifier: drain3_server.py and
 allowed_issue_fields.cjs

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
---
 .github/workflows/shared/mcp/drain3_server.py | 98 ++++++++-----------
 actions/setup/js/allowed_issue_fields.cjs     |  9 +-
 2 files changed, 50 insertions(+), 57 deletions(-)

diff --git a/.github/workflows/shared/mcp/drain3_server.py b/.github/workflows/shared/mcp/drain3_server.py
index 262138ec812..a7fef37b6bf 100755
--- a/.github/workflows/shared/mcp/drain3_server.py
+++ b/.github/workflows/shared/mcp/drain3_server.py
@@ -4,7 +4,7 @@
 # Deps: pip install fastmcp drain3
 from __future__ import annotations
 from typing import Any, Dict, Iterable, List, Optional
-import os, json, time, sys, logging
+import os, json, re, time, sys, logging
 from pathlib import Path
 
 from fastmcp import FastMCP
@@ -94,6 +94,28 @@ def _clusters_as_dicts(miner: TemplateMiner, limit: Optional[int] = None) -> Lis
 def _jsonl(obj: Any) -> str:
     return json.dumps(obj, ensure_ascii=False) + "\n"
 
+def _snapshot_error(p: Path, snapshot: Path) -> Optional[str]:
+    if not snapshot.exists():
+        logger.error(f"No snapshot found for {str(p)}")
+        return _jsonl({"event": "error", "error": f"No snapshot for {str(p)}. Run index_file first.", "file": str(p)})
+    return None
+
+def _stream_clusters(path: str, limit: Optional[int], item_event: str) -> Iterable[str]:
+    p = Path(path).expanduser().resolve()
+    snapshot = _snapshot_path_for(p)
+    if err := _snapshot_error(p, snapshot):
+        yield err
+        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": item_event, "file": str(p), "snapshot": str(snapshot), **c})
+
+    yield _jsonl({"event": "summary", "file": str(p), "snapshot": str(snapshot), "count": len(clusters)})
+
 # -----------------------
 # MCP tools (streaming)
 # -----------------------
@@ -192,11 +214,10 @@ def query_file(path: str, text: str):
     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)})
+    if err := _snapshot_error(p, snapshot):
+        yield err
         return
-    
+
     logger.info(f"Snapshot exists: {str(snapshot)}")
 
     miner = _new_miner(snapshot)
@@ -224,21 +245,7 @@ def list_templates(path: str, limit: Optional[int] = None):
       - 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)})
+    yield from _stream_clusters(path, limit, "template")
 
 @mcp.tool()
 def list_clusters(path: str, limit: Optional[int] = None):
@@ -249,21 +256,7 @@ def list_clusters(path: str, limit: Optional[int] = None):
       - 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)})
+    yield from _stream_clusters(path, limit, "cluster")
 
 @mcp.tool()
 def cluster_stats(path: str, cluster_id: int):
@@ -275,11 +268,10 @@ def cluster_stats(path: str, cluster_id: int):
     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)})
+    if err := _snapshot_error(p, snapshot):
+        yield err
         return
-    
+
     logger.info(f"Snapshot exists: {str(snapshot)}")
 
     miner = _new_miner(snapshot)
@@ -323,11 +315,10 @@ def find_anomalies(path: str, threshold: float = 0.01):
     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)})
+    if err := _snapshot_error(p, snapshot):
+        yield err
         return
-    
+
     logger.info(f"Snapshot exists: {str(snapshot)}")
 
     miner = _new_miner(snapshot)
@@ -386,17 +377,15 @@ def compare_runs(path1: str, path2: str):
     # 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)})
+    if err := _snapshot_error(p1, snapshot1):
+        yield err
         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)})
+    if err := _snapshot_error(p2, snapshot2):
+        yield err
         return
     
     logger.info(f"Both snapshots exist: {str(snapshot1)}, {str(snapshot2)}")
@@ -480,19 +469,16 @@ def search_pattern(path: str, pattern: str, use_regex: bool = False):
     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)})
+    if err := _snapshot_error(p, snapshot):
+        yield err
         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)
diff --git a/actions/setup/js/allowed_issue_fields.cjs b/actions/setup/js/allowed_issue_fields.cjs
index 1b1770f3df0..72070b274b6 100644
--- a/actions/setup/js/allowed_issue_fields.cjs
+++ b/actions/setup/js/allowed_issue_fields.cjs
@@ -52,8 +52,15 @@ 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(", ")}`);
+    }
   }
 }
 

From 1b4f0e1b66f31e6c355284a3470abc4d2585a29a Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 21 Jun 2026 05:35:05 +0000
Subject: [PATCH 4/5] style: use block-form return in
 validateAllowedIssueFields

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
---
 actions/setup/js/allowed_issue_fields.cjs | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/actions/setup/js/allowed_issue_fields.cjs b/actions/setup/js/allowed_issue_fields.cjs
index 72070b274b6..7436cc36c00 100644
--- a/actions/setup/js/allowed_issue_fields.cjs
+++ b/actions/setup/js/allowed_issue_fields.cjs
@@ -56,7 +56,9 @@ function validateAllowedIssueFields(issueFields, allowedFields) {
     return;
   }
   const allowedFieldSet = new Set(allowedFields.map(f => f.toLowerCase()));
-  if (allowedFieldSet.has("*")) return;
+  if (allowedFieldSet.has("*")) {
+    return;
+  }
   for (const field of issueFields) {
     if (!allowedFieldSet.has(field.name.toLowerCase())) {
       throw new Error(`${ERR_VALIDATION}: issue field "${field.name}" is not in the allowed-fields list: ${allowedFields.join(", ")}`);

From 59c2811d9b5d1887d35eb0817817f505b603df4c Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 21 Jun 2026 05:43:23 +0000
Subject: [PATCH 5/5] remove drain3_server.py and all usages

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
---
 .github/workflows/shared/mcp-debug.md         |  14 +-
 .github/workflows/shared/mcp/drain3.md        | 196 -------
 .github/workflows/shared/mcp/drain3_server.py | 540 ------------------
 .../shared/mcp/test_drain3_server.py          | 144 -----
 docs/src/content/docs/guides/mcps.md          |   1 -
 5 files changed, 7 insertions(+), 888 deletions(-)
 delete mode 100644 .github/workflows/shared/mcp/drain3.md
 delete mode 100755 .github/workflows/shared/mcp/drain3_server.py
 delete mode 100644 .github/workflows/shared/mcp/test_drain3_server.py

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-name>/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="<server-name>"
 
 # 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"
----
-
-<!--
-
-Drain3 MCP Server
-Log template mining and pattern extraction tool
-
-Drain3 is an online log template miner that extracts structured patterns from 
-unstructured log files. This MCP server provides tools for indexing log files, 
-querying patterns, and listing extracted templates.
-
-Documentation: https://github.com/logpai/Drain3
-
-This shared configuration provides a local HTTP MCP server that runs Drain3
-for log analysis and template extraction. The server uses streaming JSONL
-responses for progressive results.
-
-Available tools:
-  - index_file: Stream-mine templates from a log file and persist snapshot
-    Parameters:
-      - path: Path to the log file to analyze
-      - encoding: File encoding (default: utf-8)
-      - max_lines: Maximum lines to process (optional)
-    Returns: Streaming JSONL events (start, progress, template, summary)
-
-  - query_file: Match a log line against previously indexed templates
-    Parameters:
-      - path: Path to the indexed log file
-      - text: Log line text to match
-    Returns: Matching cluster information (cluster_id, size, template)
-
-  - list_templates: List all extracted templates from an indexed file
-    Parameters:
-      - path: Path to the indexed log file
-      - limit: Maximum number of templates to return (optional)
-    Returns: Streaming JSONL events for each template
-
-  - list_clusters: Enumerate all discovered log pattern clusters
-    Parameters:
-      - path: Path to the indexed log file
-      - limit: Maximum number of clusters to return (optional)
-    Returns: Streaming JSONL events for each cluster
-
-  - cluster_stats: Retrieve detailed metrics for a specific cluster
-    Parameters:
-      - path: Path to the indexed log file
-      - cluster_id: ID of the cluster to retrieve stats for
-    Returns: Cluster metrics including count, frequency, template
-
-  - find_anomalies: Detect outlier clusters or rare patterns
-    Parameters:
-      - path: Path to the indexed log file
-      - threshold: Frequency threshold as percentage (default: 0.01)
-    Returns: Streaming JSONL events for anomalous clusters
-
-  - compare_runs: Compare two log files to identify changes
-    Parameters:
-      - path1: Path to the first indexed log file
-      - path2: Path to the second indexed log file
-    Returns: Streaming JSONL events for added, removed, and changed clusters
-
-  - search_pattern: Search logs for specific text or regex patterns
-    Parameters:
-      - path: Path to the indexed log file
-      - pattern: Text or regex pattern to search for
-      - use_regex: Whether to use regex matching (default: false)
-    Returns: Streaming JSONL events for matching clusters
-
-Configuration:
-  The server can be configured via environment variables in the workflow:
-  - DRAIN3_SIM_TH: Similarity threshold (default: 0.4)
-  - DRAIN3_DEPTH: Tree depth (default: 4)
-  - DRAIN3_MAX_CHILDREN: Maximum children per node (default: 100)
-  - DRAIN3_MAX_CLUSTERS: Maximum clusters (default: 0 = unlimited)
-  - STREAM_FLUSH_EVERY: Progress event frequency (default: 500 lines)
-  - STREAM_SLEEP: Throttle between flushes in seconds (default: 0)
-
-Setup:
-  1. Include in Your Workflow:
-     imports:
-       - shared/mcp/drain3.md
-
-  2. The server will be automatically installed and started on localhost:8766
-
-Example Usage:
-  Analyze GitHub Actions workflow logs to identify common error patterns
-  and failure templates. Index a log file, then query specific error messages
-  to find which cluster they belong to.
-
-  ```
-  Use the drain3 tool to index the workflow log file at /tmp/gh-aw/agent/workflow.log
-  and extract error patterns. Then query a specific error message to find
-  its template cluster.
-  ```
-
-Connection Type:
-  This configuration uses a local HTTP MCP server running Python with FastMCP 2.0.
-  The server runs with `transport="http"` which must be started by executing the
-  Python script directly (not via `fastmcp run` which defaults to stdio transport).
-  Responses stream as JSONL (JSON Lines) for progressive results.
-
-State Persistence:
-  Drain3 snapshots are stored in ${{ github.workspace }}/.drain3/ directory.
-  Each indexed file gets its own snapshot file for quick reloading.
-
-Troubleshooting:
-  Server Failed to Start:
-  - Verify Python 3.11+ is available
-  - Check that port 8766 is not in use
-  - Review server logs for dependency installation issues
-  
-  Index/Query Errors:
-  - Ensure file paths are absolute or relative to workspace
-  - Check that the file was indexed before querying
-  - Verify file permissions are readable
-
-Usage:
-  imports:
-    - shared/mcp/drain3.md
-
--->
diff --git a/.github/workflows/shared/mcp/drain3_server.py b/.github/workflows/shared/mcp/drain3_server.py
deleted file mode 100755
index a7fef37b6bf..00000000000
--- a/.github/workflows/shared/mcp/drain3_server.py
+++ /dev/null
@@ -1,540 +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, re, 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"
-
-def _snapshot_error(p: Path, snapshot: Path) -> Optional[str]:
-    if not snapshot.exists():
-        logger.error(f"No snapshot found for {str(p)}")
-        return _jsonl({"event": "error", "error": f"No snapshot for {str(p)}. Run index_file first.", "file": str(p)})
-    return None
-
-def _stream_clusters(path: str, limit: Optional[int], item_event: str) -> Iterable[str]:
-    p = Path(path).expanduser().resolve()
-    snapshot = _snapshot_path_for(p)
-    if err := _snapshot_error(p, snapshot):
-        yield err
-        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": item_event, "file": str(p), "snapshot": str(snapshot), **c})
-
-    yield _jsonl({"event": "summary", "file": str(p), "snapshot": str(snapshot), "count": len(clusters)})
-
-# -----------------------
-# 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:<n>}
-      - {"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 err := _snapshot_error(p, snapshot):
-        yield err
-        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}")
-    yield from _stream_clusters(path, limit, "template")
-
-@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}")
-    yield from _stream_clusters(path, limit, "cluster")
-
-@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 err := _snapshot_error(p, snapshot):
-        yield err
-        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 err := _snapshot_error(p, snapshot):
-        yield err
-        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 err := _snapshot_error(p1, snapshot1):
-        yield err
-        return
-
-    # Load second file
-    p2 = Path(path2).expanduser().resolve()
-    snapshot2 = _snapshot_path_for(p2)
-    if err := _snapshot_error(p2, snapshot2):
-        yield err
-        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 err := _snapshot_error(p, snapshot):
-        yield err
-        return
-
-    logger.info(f"Snapshot exists: {str(snapshot)}")
-
-    miner = _new_miner(snapshot)
-    clusters = getattr(miner.drain, "clusters", []) or []
-    
-    matches = []
-    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/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 |
 
