feat: 新增飞书和文件工具MCP服务并优化机器人配置

- 新增飞书MCP服务,包含图片上传和消息卡片发送功能
- 新增文件工具MCP服务,提供文件转JSON和data URI功能
- 更新机器人MCP服务配置,移除环境变量硬编码
- 升级机器人版本至0.1.28并优化迎宾功能参数
- 添加.gitignore和项目配置文件
This commit is contained in:
2026-02-11 18:57:31 +08:00
parent ff39bdbd8a
commit 0a308726a6
20 changed files with 1005 additions and 22 deletions

View File

@@ -0,0 +1 @@
__all__ = ["main"]

View File

@@ -0,0 +1,9 @@
import os
from dotenv import load_dotenv
load_dotenv()
class Config:
APP_ID = os.getenv("app_id", "")
APP_SECRET = os.getenv("app_secret", "")

View File

@@ -0,0 +1,85 @@
import asyncio
import logging
import os
import time
from typing import Tuple
import requests
from mcp.server import Server
from mcp.server.stdio import stdio_server
from .config import Config
from .tools import handle_call_tool, tools
logging.basicConfig(
level=getattr(logging, os.getenv("LOG_LEVEL", "INFO"), logging.INFO),
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
class LarkMcpServer:
def __init__(self) -> None:
self.server = Server("lzwcai-mcpskills-lark-mcp")
self.tenant_access_token: str | None = None
self.token_expire_at: float = 0
self._register_handlers()
def _register_handlers(self) -> None:
@self.server.list_tools()
async def list_tools():
return tools
@self.server.call_tool()
async def call_tool(name: str, arguments: dict):
await self.ensure_token()
return await handle_call_tool(name, arguments, self.tenant_access_token or "")
async def ensure_token(self) -> None:
if self.tenant_access_token and time.time() < self.token_expire_at - 60:
return
token, expires_in = await asyncio.to_thread(self._request_token)
self.tenant_access_token = token
self.token_expire_at = time.time() + expires_in
def _request_token(self) -> Tuple[str, int]:
if not Config.APP_ID or not Config.APP_SECRET:
raise ValueError("app_id or app_secret is missing")
auth_url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
headers = {"Content-Type": "application/json"}
payload = {"app_id": Config.APP_ID, "app_secret": Config.APP_SECRET}
response = requests.post(
auth_url,
json=payload,
headers=headers,
timeout=10
)
response.raise_for_status()
data = response.json()
if data.get("code") not in (0, None):
raise RuntimeError(f"lark auth failed: {data}")
token = data.get("tenant_access_token")
if not token:
raise RuntimeError(f"lark auth response missing token: {data}")
expires_in = int(data.get("expire", 3600))
return token, expires_in
async def _main() -> None:
server = LarkMcpServer()
await server.ensure_token()
async with stdio_server() as (read_stream, write_stream):
await server.server.run(
read_stream,
write_stream,
server.server.create_initialization_options()
)
def main() -> None:
asyncio.run(_main())
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,280 @@
import json
import mimetypes
import os
import re
from datetime import datetime
from typing import Dict, List
from urllib.parse import urlparse
import requests
from mcp.types import Tool, TextContent
tools = [
Tool(
name="upload_image_by_url",
description="上传图片并返回image_key入参为可下载的文件URL",
inputSchema={
"type": "object",
"properties": {
"image_type": {
"type": "string",
"description": "图片类型,例如 message"
},
"url": {
"type": "string",
"description": "可直接下载的图片文件URL"
}
},
"required": ["url"]
}
),
Tool(
name="send_card_message",
description="发送消息卡片入参为receiver_ids、person_id和image_key",
inputSchema={
"type": "object",
"properties": {
"receiver_ids": {
"type": "array",
"description": "消息接收者ID列表",
"items": {
"type": "string"
}
},
"person_id": {
"type": "string",
"description": "卡片内person组件的ID"
},
"image_key": {
"type": "string",
"description": "图片image_key"
}
},
"required": ["image_key", "person_id"]
}
)
]
def upload_image_by_url(token: str, url: str, image_type: str) -> str:
download_response = requests.get(url, stream=True, timeout=30)
download_response.raise_for_status()
content = download_response.content
content_disposition = download_response.headers.get("Content-Disposition") or download_response.headers.get("content-disposition")
filename = ""
if content_disposition:
match = re.search(r'filename\*?=(?:UTF-8\'\')?"?([^";]+)"?', content_disposition)
if match:
filename = match.group(1).split("/")[-1]
if not filename:
path = urlparse(url).path
filename = path.split("/")[-1] if path else ""
if not filename:
filename = "image"
content_type = download_response.headers.get("Content-Type")
if not content_type:
content_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
headers = {"Authorization": f"Bearer {token}"}
data = {"image_type": image_type}
files = {"image": (filename, content, content_type)}
response = requests.post(
"https://open.feishu.cn/open-apis/im/v1/images",
headers=headers,
data=data,
files=files,
timeout=30
)
response.raise_for_status()
payload = response.json()
if payload.get("code") not in (0, None):
raise RuntimeError(f"lark image upload failed: {payload}")
image_key = payload.get("data", {}).get("image_key")
if not image_key:
raise RuntimeError(f"lark image upload missing image_key: {payload}")
return image_key
def send_card_message(token: str, receiver_id: str, person_id: str, image_key: str) -> str:
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
content = {
"schema": "2.0",
"config": {
"update_multi": True,
"locales": ["en_us"],
"style": {
"text_size": {
"normal_v2": {
"default": "normal",
"pc": "normal",
"mobile": "heading"
}
}
}
},
"body": {
"direction": "vertical",
"padding": "12px 12px 12px 12px",
"elements": [
{
"tag": "markdown",
"content": f"<font color=\"grey\">时间</font> {current_time}",
"i18n_content": {
"en_us": f"<font color=\"grey\">Incident time</font>\n{current_time}"
},
"text_align": "center",
"text_size": "normal_v2",
"margin": "0px 0px 0px 0px"
},
{
"tag": "markdown",
"content": f"\n<person id='{person_id}' show_name=true show_avatar=true style='normal'></person><font color=\"grey\">进入仓库</font>",
"i18n_content": {
"en_us": "<font color=\"grey\">Alert details</font>\nMobile client crash rate at 5%"
},
"text_align": "center",
"text_size": "normal",
"margin": "0px 0px 0px 0px"
},
{
"tag": "column_set",
"horizontal_spacing": "8px",
"horizontal_align": "left",
"columns": [
{
"tag": "column",
"width": "weighted",
"elements": [],
"vertical_spacing": "8px",
"horizontal_align": "left",
"vertical_align": "top",
"weight": 1
},
{
"tag": "column",
"width": "auto",
"elements": [
{
"tag": "img",
"img_key": image_key,
"preview": True,
"transparent": False,
"scale_type": "crop_center",
"size": "large",
"corner_radius": "16px",
"margin": "0px 0px 0px 0px"
}
],
"vertical_spacing": "8px",
"horizontal_align": "left",
"vertical_align": "top"
},
{
"tag": "column",
"width": "weighted",
"elements": [],
"vertical_spacing": "8px",
"horizontal_align": "left",
"vertical_align": "top",
"weight": 1
}
],
"margin": "0px 0px 0px 0px"
},
{
"tag": "hr",
"margin": "0px 0px 0px 0px"
}
]
},
"header": {
"title": {
"tag": "plain_text",
"content": "人员进入仓库通知",
"i18n_content": {
"en_us": "[Action Needed] Alert: Process Error - Please Address Promptly"
}
},
"subtitle": {
"tag": "plain_text",
"content": ""
},
"template": "wathet",
"icon": {
"tag": "standard_icon",
"token": "bell_filled"
},
"padding": "12px 12px 12px 12px"
}
}
payload = {
"receive_id": receiver_id,
"msg_type": "interactive",
"content": json.dumps(content, ensure_ascii=False)
}
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
}
response = requests.post(
"https://open.feishu.cn/open-apis/im/v1/messages",
params={"receive_id_type": "user_id"},
headers=headers,
json=payload,
timeout=30
)
try:
data = response.json()
except ValueError:
data = {"raw": response.text}
if not response.ok:
raise RuntimeError(f"lark send message http error: {data}")
if data.get("code") not in (0, None):
raise RuntimeError(f"lark send message failed: {data}")
message_id = data.get("data", {}).get("message_id")
if not message_id:
raise RuntimeError(f"lark send message missing message_id: {data}")
return message_id
def send_card_messages(token: str, receiver_ids: List[str], person_id: str, image_key: str) -> str:
if not receiver_ids:
raise ValueError("missing receiver_ids")
message_ids = []
for receiver_id in receiver_ids:
normalized_receiver_id = str(receiver_id).strip()
if not normalized_receiver_id:
continue
message_ids.append(send_card_message(token, normalized_receiver_id, person_id, image_key))
if not message_ids:
raise ValueError("missing receiver_ids")
return json.dumps(message_ids, ensure_ascii=False)
async def handle_call_tool(name: str, arguments: Dict[str, object], token: str) -> List[TextContent]:
try:
if name == "upload_image_by_url":
image_type = str(arguments.get("image_type") or "message").strip()
url = str(arguments.get("url", "")).strip()
if not image_type:
raise ValueError("missing image_type")
if not url:
raise ValueError("missing url")
result = upload_image_by_url(token, url, image_type)
elif name == "send_card_message":
image_key = str(arguments.get("image_key", "")).strip()
receiver_ids = arguments.get("receiver_ids")
person_id = str(arguments.get("person_id", "")).strip()
if not image_key:
raise ValueError("missing image_key")
if not person_id:
raise ValueError("missing person_id")
if receiver_ids is not None:
if not isinstance(receiver_ids, list):
raise ValueError("receiver_ids must be a list")
result = send_card_messages(token, receiver_ids, person_id, image_key)
else:
raise ValueError(f"unknown tool name: {name}")
return [TextContent(type="text", text=result)]
except Exception as exc:
return [TextContent(type="text", text=f"Failed to call tool {name}: {exc}")]

View File

@@ -0,0 +1,17 @@
{
"mcpServers": {
"lzwcai-mcpskills-lark-mcp": {
"disabled": false,
"type": "stdio",
"timeout": 30,
"command": "uvx",
"args": [
"lzwcai-mcpskills-lark-mcp"
],
"env": {
"app_id": "cli_a8d0e0c140169013",
"app_secret": "yEc0E8Aoo8Mo9NPPzphidez51xB71HXW"
}
}
}
}

View File

@@ -0,0 +1,20 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "lzwcai-mcpskills-lark-mcp"
version = "0.1.4"
description = "Lark MCP server"
requires-python = ">=3.10"
dependencies = [
"mcp[cli]>=1.6.0",
"python-dotenv>=0.21.0",
"requests>=2.25.0"
]
[project.scripts]
lzwcai-mcpskills-lark-mcp = "lzwcai_lark_mcp.main:main"
[tool.hatch.build.targets.wheel]
packages = ["lzwcai_lark_mcp"]

38
lzwcai_lark_mcp/test.py Normal file
View File

@@ -0,0 +1,38 @@
import asyncio
import json
import os
from pathlib import Path
def main() -> None:
if not os.getenv("app_id") or not os.getenv("app_secret"):
config_path = Path(__file__).with_name("mcp-server.json")
if config_path.exists():
config_data = json.loads(config_path.read_text(encoding="utf-8"))
env_data = (
config_data.get("mcpServers", {})
.get("lzwcai-mcpskills-lark-mcp", {})
.get("env", {})
)
if env_data.get("app_id") and env_data.get("app_secret"):
os.environ["app_id"] = env_data["app_id"]
os.environ["app_secret"] = env_data["app_secret"]
if not os.getenv("app_id") or not os.getenv("app_secret"):
raise RuntimeError("missing app_id or app_secret")
from lzwcai_lark_mcp.main import LarkMcpServer
from lzwcai_lark_mcp.tools import send_card_message
async def _run() -> None:
server = LarkMcpServer()
await server.ensure_token()
image_key = "img_v3_02uq_d48b3ee1-0f89-44a3-80cd-a5afa4f8c39g"
receiver_id = "843ga2gb"
person_id = receiver_id
result = send_card_message(server.tenant_access_token or "", receiver_id, person_id, image_key)
print(result)
asyncio.run(_run())
if __name__ == "__main__":
main()