mcp-server warning clear

This commit is contained in:
windpacer
2026-05-09 04:28:10 +09:00
parent 9b87ad13a0
commit 05e2156843
55 changed files with 1555 additions and 13280 deletions

View File

@@ -10,6 +10,7 @@ ExperionCrawler Unified MCP Server
from __future__ import annotations
import sys
import os
import json
import logging
import httpx
@@ -19,19 +20,19 @@ from mcp.server.fastmcp import FastMCP
logging.basicConfig(level=logging.WARNING, stream=sys.stderr)
# ── 설정 ──────────────────────────────────────────────────────────────────────
QDRANT_URL = "http://localhost:6333"
OLLAMA_URL = "http://localhost:11434"
EMBED_MODEL = "nomic-embed-text" # 768-dim, Roo Code 인덱스와 동일
VLLM_BASE_URL = "http://localhost:8000/v1"
VLLM_MODEL = "Qwen3.6-27B-FP8"
QDRANT_URL = os.environ.get("QDRANT_URL", "http://localhost:6333")
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434")
EMBED_MODEL = os.environ.get("EMBED_MODEL", "nomic-embed-text")
VLLM_BASE_URL = os.environ.get("VLLM_BASE_URL", "http://localhost:8000/v1")
VLLM_MODEL = os.environ.get("VLLM_MODEL", "Qwen3.6-27B-FP8")
# Qdrant 컬렉션
COL_CODEBASE = "ws-65f457145aee80b2" # ExperionCrawler 소스코드
COL_OPC_DOCS = "experion-opc-docs" # Experion HS R530 OPC UA 공식 문서 (266 chunks)
# PostgreSQL 연결
DB_CONNECTION_STRING = "postgresql://postgres:postgres@localhost:5432/iiot_platform"
DB_TIMEOUT = 10 # 초
DB_CONNECTION_STRING = os.environ.get("DB_CONNECTION_STRING", "postgresql://postgres:postgres@localhost:5432/iiot_platform")
DB_TIMEOUT = int(os.environ.get("DB_TIMEOUT", "10"))
# C# McpClient(localhost:5001)와 통신: json_response+stateless로 단순 POST→JSON 방식
mcp = FastMCP(
@@ -47,158 +48,7 @@ from pipeline.topology import PidTopologyBuilder
from pipeline.mapper import IntelligentMapper
from pipeline.analyzer import PidAnalysisEngine
import networkx as nx
import os
import asyncio
import subprocess
import atexit
import signal
from dataclasses import dataclass
from typing import Dict, Optional
from functools import cache
# ── ProcessManager ─────────────────────────────────────────────────────────────
@dataclass
class WorkerProcess:
process: subprocess.Popen
port: int
status: str # "running", "stopped", "error"
one_shot: bool = False # 요청 후 프로세스 종료 여부 (P&ID 워커용)
class ProcessManager:
"""워커 프로세스 관리자."""
def __init__(self):
self.workers: Dict[str, WorkerProcess] = {}
self._locks: Dict[str, asyncio.Lock] = {}
self._pid_locks: Dict[str, asyncio.Lock] = {} # 파일/ID별 세부 Lock
self._worker_ports = {"rag": 5002, "nl2sql": 5003, "pid": 5004}
# 정리 훅 등록
atexit.register(self._cleanup)
signal.signal(signal.SIGTERM, lambda *_: self._cleanup())
signal.signal(signal.SIGINT, lambda *_: self._cleanup())
def _get_available_port(self, worker_type: str) -> int:
"""워커 타입에 대한 포트 반환."""
return self._worker_ports.get(worker_type, 5002)
def _classify_tool(self, tool_name: str) -> str:
"""도구 이름을 워커 타입으로 분류."""
rag_tools = {"search_codebase", "search_r530_docs", "ask_iiot_llm", "rag_query"}
nl2sql_tools = {"run_sql", "query_pv_history", "get_tag_metadata", "list_drawings", "query_with_nl"}
pid_tools = {
"extract_pid_tags", "match_pid_tags", "parse_pid_dxf", "parse_pid_pdf",
"parse_pid_drawing", "build_pid_graph_parallel", "analyze_pid_impact"
}
if tool_name in rag_tools:
return "rag"
elif tool_name in nl2sql_tools:
return "nl2sql"
elif tool_name in pid_tools:
return "pid"
else:
return "default"
async def start_worker(self, worker_type: str, one_shot: bool = False) -> WorkerProcess:
"""서브 프로세스 시작.
Args:
worker_type: 워커 타입 (rag, nl2sql, pid)
one_shot: True일 경우 요청 후 프로세스 종료 (P&ID 워커용)
"""
port = self._get_available_port(worker_type)
cmd = [
sys.executable,
f"worker/{worker_type}_worker.py",
str(port)
]
# 로그 파일 열기
log_dir = os.path.join(os.path.dirname(__file__), "logs")
os.makedirs(log_dir, exist_ok=True)
log_file = open(os.path.join(log_dir, f"{worker_type}_worker.log"), "a")
proc = subprocess.Popen(
cmd,
stdout=log_file,
stderr=log_file,
)
# 헬스체크 루프 (최대 15초 대기)
for _ in range(30): # 0.5초 * 30 = 15초
await asyncio.sleep(0.5)
if proc.poll() is not None:
log_file.close()
raise RuntimeError(f"{worker_type} 워커가 시작 직후 종료됨")
try:
async with httpx.AsyncClient(timeout=1) as client:
await client.get(f"http://localhost:{port}/health")
break # 헬스체크 성공
except Exception:
continue
else:
proc.kill()
log_file.close()
raise RuntimeError(f"{worker_type} 워커 시작 타임아웃")
worker = WorkerProcess(
process=proc,
port=port,
status="running",
one_shot=one_shot
)
self.workers[worker_type] = worker
log_file.close()
return worker
async def stop_worker(self, worker_type: str):
"""서브 프로세스 종료."""
if worker_type in self.workers:
proc = self.workers[worker_type].process
proc.terminate()
await asyncio.sleep(0.5)
if proc.poll() is None:
proc.kill()
del self.workers[worker_type]
async def get_worker(self, tool_name: str, one_shot: bool = False) -> WorkerProcess:
"""도구 이름에 해당하는 워커 프로세스 반환 (자동 시작).
Args:
tool_name: 도구 이름
one_shot: True일 경우 요청 후 프로세스 종료 (P&ID 워커용)
"""
worker_type = self._classify_tool(tool_name)
if worker_type not in self._locks:
self._locks[worker_type] = asyncio.Lock()
async with self._locks[worker_type]:
if worker_type not in self.workers:
return await self.start_worker(worker_type, one_shot)
proc = self.workers[worker_type].process
if proc.poll() is not None:
del self.workers[worker_type]
return await self.start_worker(worker_type, one_shot)
return self.workers[worker_type]
def _cleanup(self):
"""모든 워커 프로세스 정리."""
for wtype, worker in list(self.workers.items()):
try:
worker.process.terminate()
except Exception:
pass
self.workers.clear()
# 전역 ProcessManager 인스턴스
process_manager = ProcessManager()
# ── 임베딩 (Ollama) ───────────────────────────────────────────────────────────
@@ -444,7 +294,7 @@ PostgreSQL 시계열 데이터베이스 스키마
테이블: tag_metadata (태그 메타데이터 - 변경 드묾)
base_tag TEXT - 기본 태그명 (예: 'ficq-6101', 'xv-6124')
attribute TEXT - 속성명 ('desc', 'area', 'state0descriptor', ...)
attribute TEXT - 속성명 ('desc', 'area')
value TEXT - 메타데이터 값
node_id TEXT - OPC UA 노드 ID
loaded_at TIMESTAMPTZ - 마지막 로드 시각
@@ -459,20 +309,16 @@ PostgreSQL 시계열 데이터베이스 스키마
instate2 TEXT - 상태 비트 2 (true/false)
description TEXT - 장비 설명 (tag_metadata.desc)
area TEXT - 소속 플랜트 (tag_metadata.area)
state0_descriptor TEXT - 비트 0 의미 (예: "Run/Stop")
state1_descriptor TEXT - 비트 1 의미 (예: "Remote/Local")
state2_descriptor TEXT - 비트 2 의미 (예: "Trip/Normal")
새로운 태그 타입:
- 아날로그: ficq-6101.pv/sp/op (Double)
- 디지털 XV: xv-6124.pv/op (Int32), xv-6124.instate0~7 (Boolean)
- Pump: p-6102.pv/op (Int32), p-6102.instate0~7 (Boolean)
- 메타데이터: desc (String), area (Enum), state0descriptor~7 (String)
- 메타데이터: desc (String), area (Enum)
BCD 상태 조회 팁:
- instate0~7은 Boolean (true/false)
- state0descriptor~7은 해당 비트의 의미 설명
- instate0=true이고 state0descriptor="Run/Stop"이면 → "Run" 상태
- pv 값이 EnumValueType 형식인 경우 `{코드 | DisplayName | }`에서 DisplayName으로 상태 확인 가능
- v_tag_summary 뷰를 사용하면 실시간값+메타데이터 한 번에 조회 가능
N분 간격 집계 공식 (time_bucket 금지, date_trunc 사용):
@@ -500,7 +346,7 @@ N분 간격 집계 공식 (time_bucket 금지, date_trunc 사용):
# ── RAG 도구 ─────────────────────────────────────────────────────────────────
@mcp.tool()
def search_codebase(query: str, top_k: int = 6) -> str:
async def search_codebase(query: str, top_k: int = 6) -> str:
"""ExperionCrawler 프로젝트 소스코드 검색 (우리가 개발한 .NET 8 C# 코드).
Experion HS R530 공식 문서가 아닌, ExperionCrawler 구현 코드를 검색함.
@@ -511,11 +357,11 @@ def search_codebase(query: str, top_k: int = 6) -> str:
query: 검색어 (예: "OPC UA 구독 시작", "히스토리 스냅샷", "TextToSql 서비스")
top_k: 반환 결과 수 (기본 6)
"""
return _search(COL_CODEBASE, query, top_k)
return await _search(COL_CODEBASE, query, top_k)
@mcp.tool()
def search_r530_docs(query: str, top_k: int = 5) -> str:
async def search_r530_docs(query: str, top_k: int = 5) -> str:
"""Honeywell Experion HS R530 공식 제품 문서 검색.
ExperionCrawler 코드가 아닌, Honeywell 공식 HTM 문서를 검색함.
@@ -526,7 +372,7 @@ def search_r530_docs(query: str, top_k: int = 5) -> str:
query: 검색어 (예: "certificate configuration", "endpoint security policy")
top_k: 반환 결과 수 (기본 5)
"""
return _search(COL_OPC_DOCS, query, top_k)
return await _search(COL_OPC_DOCS, query, top_k)
@mcp.tool()
@@ -559,7 +405,7 @@ def ask_iiot_llm(question: str, context: str = "") -> str:
@mcp.tool()
def rag_query(question: str, search_code: bool = False, search_docs: bool = True) -> str:
async def rag_query(question: str, search_code: bool = False, search_docs: bool = True) -> str:
"""검색 → Qwen3.6-27B-FP8 답변 생성 (통합 RAG).
기본값: Experion HS R530 공식 문서만 검색 (search_docs=True, search_code=False).
@@ -572,9 +418,9 @@ def rag_query(question: str, search_code: bool = False, search_docs: bool = True
"""
context_parts: list[str] = []
if search_docs:
context_parts.append(f"=== Experion HS R530 공식 문서 ===\n{_search(COL_OPC_DOCS, question, 4)}")
context_parts.append(f"=== Experion HS R530 공식 문서 ===\n{await _search(COL_OPC_DOCS, question, 4)}")
if search_code:
context_parts.append(f"=== ExperionCrawler 구현 코드 ===\n{_search(COL_CODEBASE, question, 3)}")
context_parts.append(f"=== ExperionCrawler 구현 코드 ===\n{await _search(COL_CODEBASE, question, 3)}")
return ask_iiot_llm(question, "\n\n".join(context_parts))
@@ -620,7 +466,7 @@ async def run_sql(sql: str) -> str:
@mcp.tool()
def query_pv_history(tag_names: list[str], time_from: str, time_to: str, limit: int = 100) -> str:
async def query_pv_history(tag_names: list[str], time_from: str, time_to: str, limit: int = 100) -> str:
"""과거 값(PV) 히스토리 조회.
Args:
@@ -635,7 +481,7 @@ def query_pv_history(tag_names: list[str], time_from: str, time_to: str, limit:
conn = None
try:
limit = min(limit, 5000)
conn = _get_db_connection()
conn = await _get_db_connection()
with conn.cursor() as cur:
cur.execute(
"""SELECT tagname, recorded_at, value
@@ -663,7 +509,7 @@ def query_pv_history(tag_names: list[str], time_from: str, time_to: str, limit:
@mcp.tool()
def get_tag_metadata(query: str, limit: int = 10) -> str:
async def get_tag_metadata(query: str, limit: int = 10) -> str:
"""태그 메타데이터 검색 (realtime_table 기반).
Args:
@@ -675,7 +521,7 @@ def get_tag_metadata(query: str, limit: int = 10) -> str:
"""
conn = None
try:
conn = _get_db_connection()
conn = await _get_db_connection()
with conn.cursor() as cur:
cur.execute(
"""SELECT tagname, livevalue, timestamp, node_id
@@ -698,7 +544,7 @@ def get_tag_metadata(query: str, limit: int = 10) -> str:
@mcp.tool()
def list_drawings(unit_no: str | None = None) -> str:
async def list_drawings(unit_no: str | None = None) -> str:
"""단위별 도면 목록 조회 (node_map_master.name 기반).
Args:
@@ -709,7 +555,7 @@ def list_drawings(unit_no: str | None = None) -> str:
"""
conn = None
try:
conn = _get_db_connection()
conn = await _get_db_connection()
with conn.cursor() as cur:
if unit_no:
cur.execute(
@@ -1248,15 +1094,13 @@ async def build_pid_graph_parallel(filepath: str) -> str:
# 실제로는 get_tag_metadata 등을 통해 전체 태그 리스트를 확보해야 함
system_tags = []
try:
def _fetch_system_tags():
conn = _get_db_connection()
try:
with conn.cursor() as cur:
cur.execute("SELECT tagname FROM realtime_table")
return [r[0] for r in cur.fetchall()]
finally:
conn.close()
system_tags = await asyncio.to_thread(_fetch_system_tags)
conn = await _get_db_connection()
try:
with conn.cursor() as cur:
cur.execute("SELECT tagname FROM realtime_table")
system_tags = [r[0] for r in cur.fetchall()]
finally:
conn.close()
except Exception as e:
logging.warning(f"Failed to fetch system tags: {e}")
@@ -1378,223 +1222,6 @@ async def parse_pid_drawing(filepath: str) -> str:
}, ensure_ascii=False)
# ── 워커 요청 전달 ────────────────────────────────────────────────────────────
async def _forward_request(port: int, tool_name: str, params: dict, one_shot: bool = False) -> str:
"""HTTP를 통해 워커 프로세스로 요청 전달.
Args:
port: 워커 포트
tool_name: 도구 이름
params: 요청 파라미터
one_shot: True일 경우 요청 완료 후 워커 종료
"""
async with httpx.AsyncClient(timeout=600) as client: # 5분 타임아웃 (대용량 DXF 처리용)
endpoint = "/execute/one_shot" if one_shot else "/execute"
response = await client.post(
f"http://localhost:{port}{endpoint}",
json={"tool": tool_name, "params": params}
)
response.raise_for_status()
return response.text
# ── 요청 라우팅 (워커 프로세스 사용) ───────────────────────────────────────────
@mcp.tool()
async def search_codebase(query: str, top_k: int = 6) -> str:
"""RAG 워커로 요청 전달."""
worker = await process_manager.get_worker("search_codebase")
return await _forward_request(worker.port, "search_codebase", {
"query": query,
"top_k": top_k
})
@mcp.tool()
async def search_r530_docs(query: str, top_k: int = 5) -> str:
"""RAG 워커로 요청 전달."""
worker = await process_manager.get_worker("search_r530_docs")
return await _forward_request(worker.port, "search_r530_docs", {
"query": query,
"top_k": top_k
})
@mcp.tool()
async def ask_iiot_llm(question: str, context: str = "") -> str:
"""RAG 워커로 요청 전달."""
worker = await process_manager.get_worker("ask_iiot_llm")
return await _forward_request(worker.port, "ask_iiot_llm", {
"question": question,
"context": context
})
@mcp.tool()
async def rag_query(question: str, search_code: bool = False, search_docs: bool = True) -> str:
"""RAG 워커로 요청 전달."""
worker = await process_manager.get_worker("rag_query")
return await _forward_request(worker.port, "rag_query", {
"question": question,
"search_code": search_code,
"search_docs": search_docs
})
@mcp.tool()
async def run_sql(sql: str) -> str:
"""NL2SQL 워커로 요청 전달."""
worker = await process_manager.get_worker("run_sql")
return await _forward_request(worker.port, "run_sql", {"sql": sql})
@mcp.tool()
async def query_pv_history(tag_names: list[str], time_from: str, time_to: str, limit: int = 100) -> str:
"""NL2SQL 워커로 요청 전달."""
worker = await process_manager.get_worker("query_pv_history")
return await _forward_request(worker.port, "query_pv_history", {
"tag_names": tag_names,
"time_from": time_from,
"time_to": time_to,
"limit": limit
})
@mcp.tool()
async def get_tag_metadata(query: str, limit: int = 10) -> str:
"""NL2SQL 워커로 요청 전달."""
worker = await process_manager.get_worker("get_tag_metadata")
return await _forward_request(worker.port, "get_tag_metadata", {
"query": query,
"limit": limit
})
@mcp.tool()
async def list_drawings(unit_no: str = None) -> str:
"""NL2SQL 워커로 요청 전달."""
worker = await process_manager.get_worker("list_drawings")
return await _forward_request(worker.port, "list_drawings", {
"unit_no": unit_no
})
@mcp.tool()
async def parse_pid_dxf(filepath: str) -> str:
"""P&ID 워커로 요청 전달 (one_shot: 요청 후 종료)."""
# 파일 경로 기반으로 Lock 획득하여 동일 파일 중복 처리 방지 및 다른 파일 병렬 처리 허용
lock_key = os.path.basename(filepath)
if lock_key not in process_manager._pid_locks:
process_manager._pid_locks[lock_key] = asyncio.Lock()
async with process_manager._pid_locks[lock_key]:
worker = await process_manager.get_worker("parse_pid_dxf", one_shot=True)
return await _forward_request(worker.port, "parse_pid_dxf", {"filepath": filepath}, one_shot=True)
@mcp.tool()
async def parse_pid_pdf(filepath: str, use_ocr: bool = True) -> str:
"""P&ID 워커로 요청 전달 (one_shot: 요청 후 종료)."""
lock_key = os.path.basename(filepath)
if lock_key not in process_manager._pid_locks:
process_manager._pid_locks[lock_key] = asyncio.Lock()
async with process_manager._pid_locks[lock_key]:
worker = await process_manager.get_worker("parse_pid_pdf", one_shot=True)
return await _forward_request(worker.port, "parse_pid_pdf", {
"filepath": filepath,
"use_ocr": use_ocr
}, one_shot=True)
@mcp.tool()
async def parse_pid_drawing(filepath: str) -> str:
"""P&ID 워커로 요청 전달 (one_shot: 요청 후 종료)."""
lock_key = os.path.basename(filepath)
if lock_key not in process_manager._pid_locks:
process_manager._pid_locks[lock_key] = asyncio.Lock()
async with process_manager._pid_locks[lock_key]:
worker = await process_manager.get_worker("parse_pid_drawing", one_shot=True)
return await _forward_request(worker.port, "parse_pid_drawing", {"filepath": filepath}, one_shot=True)
@mcp.tool()
async def extract_pid_tags(text: str, source_type: str) -> str:
"""P&ID 워커로 요청 전달 (one_shot: 요청 후 종료)."""
# 텍스트 추출/매핑은 특정 파일에 종속되지 않으므로 전역 Lock 사용 (또는 세마포어 유지)
# 여기서는 단순화를 위해 전역 Lock 하나를 사용하거나,
# 텍스트 기반 작업은 병렬 처리가 가능하므로 Lock을 제거할 수도 있으나,
# 워커 리소스 보호를 위해 'global_text' 키로 Lock 관리
lock_key = "global_text_processing"
if lock_key not in process_manager._pid_locks:
process_manager._pid_locks[lock_key] = asyncio.Lock()
async with process_manager._pid_locks[lock_key]:
worker = await process_manager.get_worker("extract_pid_tags", one_shot=True)
return await _forward_request(worker.port, "extract_pid_tags", {
"text": text,
"source_type": source_type
}, one_shot=True)
@mcp.tool()
async def match_pid_tags(pid_tags: list[str], experion_tags: list[str]) -> str:
"""P&ID 워커로 요청 전달 (one_shot: 요청 후 종료)."""
lock_key = "global_matching"
if lock_key not in process_manager._pid_locks:
process_manager._pid_locks[lock_key] = asyncio.Lock()
async with process_manager._pid_locks[lock_key]:
worker = await process_manager.get_worker("match_pid_tags", one_shot=True)
return await _forward_request(worker.port, "match_pid_tags", {
"pid_tags": pid_tags,
"experion_tags": experion_tags
}, one_shot=True)
@mcp.tool()
async def build_pid_graph_parallel(filepath: str) -> str:
"""P&ID 워커로 요청 전달 (one_shot: 요청 후 종료)."""
lock_key = os.path.basename(filepath)
if lock_key not in process_manager._pid_locks:
process_manager._pid_locks[lock_key] = asyncio.Lock()
async with process_manager._pid_locks[lock_key]:
worker = await process_manager.get_worker("build_pid_graph_parallel", one_shot=True)
return await _forward_request(worker.port, "build_pid_graph_parallel", {"filepath": filepath}, one_shot=True)
@mcp.tool()
async def analyze_pid_impact(graph_id: str, start_node_id: str) -> str:
"""P&ID 워커로 요청 전달 (one_shot: 요청 후 종료)."""
# graph_id 기반으로 Lock 관리
lock_key = graph_id
if lock_key not in process_manager._pid_locks:
process_manager._pid_locks[lock_key] = asyncio.Lock()
async with process_manager._pid_locks[lock_key]:
worker = await process_manager.get_worker("analyze_pid_impact", one_shot=True)
return await _forward_request(worker.port, "analyze_pid_impact", {
"graph_id": graph_id,
"start_node_id": start_node_id
}, one_shot=True)
@mcp.tool()
def get_worker_status() -> str:
"""모든 워커 프로세스 상태 조회."""
status = {}
for name, worker in process_manager.workers.items():
status[name] = {
"pid": worker.process.pid,
"status": worker.status,
"port": worker.port,
"one_shot": worker.one_shot
}
return json.dumps(status, ensure_ascii=False, indent=2)
# ── 엔트리포인트 ──────────────────────────────────────────────────────────────