feat: 添加资产变动确认卡片和Excel图片解析功能

- 新增 `send_asset_confirmation_card` 工具,用于发送资产变动确认卡片
- 新增 `upload_image_by_excel` 工具,支持从Excel中根据image_key定位并上传图片
- 在file-tools中添加 `excel_image_key_to_temp_file` 和 `upload_file_to_minio` 工具
- 新增配置文件管理和MinIO集成支持
- 更新项目依赖版本,添加openpyxl和minio库
This commit is contained in:
2026-02-13 19:57:02 +08:00
parent 0a308726a6
commit 135c8e379e
8 changed files with 1035 additions and 10 deletions

View File

@@ -0,0 +1,29 @@
import json
import os
from pathlib import Path
def _load_env_from_config() -> None:
config_path = Path(__file__).resolve().parent.parent / "mcp-server-file-tools.json"
if not config_path.exists():
return
config_data = json.loads(config_path.read_text(encoding="utf-8"))
env_data = (
config_data.get("mcpServers", {})
.get("lzwcai-mcpskills-file-tools-mcp", {})
.get("env", {})
)
for key, value in env_data.items():
if value is None:
continue
if os.getenv(key) is None:
os.environ[key] = str(value)
_load_env_from_config()
class Config:
MINIO_ENDPOINT = os.getenv("minio_endpoint", "")
MINIO_ACCESS_KEY = os.getenv("minio_access_key", "")
MINIO_SECRET_KEY = os.getenv("minio_secret_key", "")

View File

@@ -1,14 +1,22 @@
import asyncio
import base64
import hashlib
import json
import mimetypes
import re
import tempfile
import zipfile
from datetime import datetime
from pathlib import Path
from urllib.parse import urlparse
from typing import Any, Dict, List, Optional, Tuple
from xml.etree import ElementTree as ET
import httpx
from minio import Minio
from mcp.types import TextContent, Tool
from .config import Config
tools: List[Tool] = [
Tool(
name="file_to_json",
@@ -89,6 +97,29 @@ tools: List[Tool] = [
},
"required": ["url"]
}
),
Tool(
name="excel_image_key_to_temp_file",
description="根据Excel内image_key定位图片并转为临时文件路径",
inputSchema={
"type": "object",
"properties": {
"excel_path": {"type": "string"},
"image_key": {"type": "string"}
},
"required": ["excel_path", "image_key"]
}
),
Tool(
name="upload_file_to_minio",
description="上传本地文件到MinIO并返回URL",
inputSchema={
"type": "object",
"properties": {
"file_path": {"type": "string"}
},
"required": ["file_path"]
}
)
]
@@ -168,6 +199,209 @@ def _normalize_mime_type(mime_type: Optional[str], name: Optional[str]) -> str:
return _guess_mime_type(name)
def _normalize_header(value: Any) -> str:
if value is None:
return ""
return str(value).strip().lower()
def _extract_dispimg_id(value: Any) -> str:
if value is None:
return ""
text = str(value).strip()
if not text:
return ""
match = re.search(r"dispimg\(\s*\"([^\"]+)\"", text, re.IGNORECASE)
if match:
return match.group(1).strip()
match = re.search(r"dispimg\(\s*'([^']+)'", text, re.IGNORECASE)
if match:
return match.group(1).strip()
return text
def _resolve_dispimg_temp_file(excel_path: str, image_key: str) -> str:
image_id = _extract_dispimg_id(image_key)
if not image_id:
raise ValueError("missing dispimg id")
with zipfile.ZipFile(excel_path) as zip_ref:
names = set(zip_ref.namelist())
if "xl/cellimages.xml" not in names:
raise ValueError("cellimages.xml not found in excel")
if "xl/_rels/cellimages.xml.rels" not in names:
raise ValueError("cellimages.xml.rels not found in excel")
rels_root = ET.fromstring(zip_ref.read("xl/_rels/cellimages.xml.rels"))
rel_ns = "http://schemas.openxmlformats.org/package/2006/relationships"
rels_map: Dict[str, str] = {}
for rel in rels_root.findall(f"{{{rel_ns}}}Relationship"):
rel_id = rel.attrib.get("Id")
target = rel.attrib.get("Target")
if rel_id and target:
rels_map[rel_id] = target
cell_root = ET.fromstring(zip_ref.read("xl/cellimages.xml"))
namespaces = {
"etc": "http://www.wps.cn/officeDocument/2017/etCustomData",
"xdr": "http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing",
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
}
name_to_embed: Dict[str, str] = {}
for cell_image in cell_root.findall("etc:cellImage", namespaces):
c_nv_pr = cell_image.find(".//xdr:cNvPr", namespaces)
blip = cell_image.find(".//a:blip", namespaces)
name = c_nv_pr.attrib.get("name") if c_nv_pr is not None else ""
embed_id = blip.attrib.get(f"{{{namespaces['r']}}}embed") if blip is not None else ""
if name and embed_id:
name_to_embed[name] = embed_id
if not name_to_embed:
raise ValueError("no cell images found in excel")
candidates = [image_id]
if image_id.startswith("ID_"):
candidates.append(image_id[3:])
else:
candidates.append(f"ID_{image_id}")
name_to_embed_lower = {key.lower(): value for key, value in name_to_embed.items()}
embed_id = ""
for candidate in candidates:
if candidate in name_to_embed:
embed_id = name_to_embed[candidate]
break
lower_candidate = candidate.lower()
if lower_candidate in name_to_embed_lower:
embed_id = name_to_embed_lower[lower_candidate]
break
if not embed_id:
raise ValueError("dispimg id not found in excel")
target = rels_map.get(embed_id)
if not target:
raise ValueError("dispimg image target not found in excel")
target_path = f"xl/{target.lstrip('/')}"
if target_path not in names:
raise ValueError("dispimg image file missing in excel")
suffix = Path(target).suffix
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_file:
temp_path = Path(temp_file.name)
temp_path.write_bytes(zip_ref.read(target_path))
return str(temp_path)
def _resolve_image_source_from_excel(excel_path: str, image_key: str) -> str:
from openpyxl import load_workbook
workbook = load_workbook(excel_path, read_only=True, data_only=True)
try:
worksheet = workbook.worksheets[0]
header_row = next(worksheet.iter_rows(min_row=1, max_row=1, values_only=True), None)
if not header_row:
raise ValueError("excel header row is empty")
header_map: Dict[str, int] = {}
for index, header in enumerate(header_row):
name = _normalize_header(header)
if name:
header_map[name] = index
key_col_index = None
for key_name in ("image_key", "imagekey", "key"):
if key_name in header_map:
key_col_index = header_map[key_name]
break
if key_col_index is None:
raise ValueError("missing image_key column in excel")
candidate_columns = ["image_path", "image_url", "image", "url", "file_path", "path", "image_file"]
candidate_indices = [header_map[name] for name in candidate_columns if name in header_map]
matched_source = ""
for row in worksheet.iter_rows(min_row=2, values_only=True):
if key_col_index >= len(row):
continue
cell_value = row[key_col_index]
if cell_value is None:
continue
if str(cell_value).strip() != image_key:
continue
for index in candidate_indices:
if index < len(row):
candidate_value = row[index]
if candidate_value is not None and str(candidate_value).strip():
matched_source = str(candidate_value).strip()
break
if not matched_source:
matched_source = str(cell_value).strip()
break
if not matched_source:
raise ValueError(f"missing image source for image_key: {image_key}")
if not _is_url(matched_source):
if not Path(matched_source).is_absolute():
matched_source = str(Path(excel_path).parent / matched_source)
return matched_source
finally:
workbook.close()
def _build_local_temp_file(file_path: str) -> str:
source_path = Path(file_path)
if not source_path.is_file():
raise FileNotFoundError(f"image file not found: {file_path}")
suffix = source_path.suffix
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_file:
temp_path = Path(temp_file.name)
temp_path.write_bytes(source_path.read_bytes())
return str(temp_path)
def _normalize_minio_endpoint(endpoint: str) -> Tuple[str, Optional[bool], str]:
raw = str(endpoint).strip()
if not raw:
return "", None, ""
if raw.startswith("http://") or raw.startswith("https://"):
parsed = urlparse(raw)
secure = parsed.scheme == "https"
return parsed.netloc, secure, f"{parsed.scheme}://{parsed.netloc}"
return raw, None, f"http://{raw}"
def _hash_file_md5(file_path: str) -> str:
hasher = hashlib.md5()
with open(file_path, "rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
hasher.update(chunk)
return hasher.hexdigest()
def _upload_file_to_minio_sync(file_path: str) -> str:
if not file_path:
raise ValueError("missing file_path")
source_path = Path(file_path)
if not source_path.is_file():
raise FileNotFoundError(f"file_path not found: {file_path}")
endpoint_raw = Config.MINIO_ENDPOINT
access_key = Config.MINIO_ACCESS_KEY
secret_key = Config.MINIO_SECRET_KEY
if not endpoint_raw:
raise ValueError("missing minio_endpoint")
if not access_key:
raise ValueError("missing minio_access_key")
if not secret_key:
raise ValueError("missing minio_secret_key")
endpoint, endpoint_secure, endpoint_base = _normalize_minio_endpoint(endpoint_raw)
if not endpoint:
raise ValueError("invalid minio_endpoint")
secure = endpoint_secure or False
client = Minio(endpoint, access_key=access_key, secret_key=secret_key, secure=secure)
bucket = "lzwcai"
prefix = "tmp"
if not client.bucket_exists(bucket):
client.make_bucket(bucket)
date_str = datetime.now().strftime("%Y-%m-%d")
file_hash = _hash_file_md5(file_path)
suffix = source_path.suffix
object_name = f"{prefix}/{date_str}/{file_hash}/{file_hash}{suffix}"
content_type = _guess_mime_type(source_path.name)
client.fput_object(bucket, object_name, file_path, content_type=content_type)
scheme = "https" if secure else "http"
public_base = endpoint_base if endpoint_base else f"{scheme}://{endpoint}"
public_base = f"{public_base.rstrip('/')}/{bucket}"
return f"{public_base}/{object_name}"
def _build_file_json(arguments: Dict[str, Any]) -> str:
file_info, data, name = _extract_file_payload(arguments)
if file_info["type"] is None:
@@ -249,6 +483,42 @@ async def handle_call_tool(name: str, arguments: Dict[str, Any]) -> List[TextCon
result = await _build_url_data_uri(arguments)
elif name == "url_to_temp_file":
result = await _build_url_file_path(arguments)
elif name == "excel_image_key_to_temp_file":
excel_path = arguments.get("excel_path")
image_key = arguments.get("image_key")
if not excel_path:
raise ValueError("missing excel_path")
if not image_key:
raise ValueError("missing image_key")
excel_path = str(excel_path).strip()
image_key = str(image_key).strip()
path = Path(excel_path)
if not path.exists():
raise FileNotFoundError(f"excel_path not found: {excel_path}")
if path.suffix.lower() in (".xls",):
raise ValueError("xls is not supported, please convert to xlsx")
if path.suffix.lower() not in (".xlsx", ".xlsm", ".xltx", ".xltm"):
raise ValueError("excel_path must be xlsx format")
dispimg_error: Optional[Exception] = None
table_error: Optional[Exception] = None
try:
result = _resolve_dispimg_temp_file(excel_path, image_key)
except Exception as exc:
dispimg_error = exc
try:
image_source = _resolve_image_source_from_excel(excel_path, image_key)
if _is_url(image_source):
result = await _build_url_file_path({"url": image_source})
else:
result = _build_local_temp_file(image_source)
except Exception as exc2:
table_error = exc2
raise ValueError(f"excel image not found: dispimg={dispimg_error}; table={table_error}")
elif name == "upload_file_to_minio":
file_path = str(arguments.get("file_path", "")).strip()
if not file_path:
raise ValueError("missing file_path")
result = await asyncio.to_thread(_upload_file_to_minio_sync, file_path)
else:
raise ValueError(f"unknown tool name: {name}")
return [TextContent(type="text", text=result)]