diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ba0430d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__/ \ No newline at end of file diff --git a/file_tools/README.md b/file_tools/README.md new file mode 100644 index 0000000..dc6d8ee --- /dev/null +++ b/file_tools/README.md @@ -0,0 +1,117 @@ +# File Tools MCP 技能包 + +该技能包提供文件与 URL 的 Base64 处理能力,主要用于将文件内容转换为 JSON 字符串或 data URI,供 MCP Agent 直接调用。 + +## 功能 + +- 文件对象转 JSON 字符串 +- 文件对象转 data URI(Base64) +- 文件路径转 data URI(Base64) +- 文件 URL 转 data URI(Base64) + +## 工具列表 + +### 1. file_to_json + +将文件对象转储为 JSON 字符串。 + +**入参** + +```json +{ + "file": { + "name": "example.txt", + "type": "text/plain", + "size": 123, + "last_modified": 1700000000, + "content_base64": "SGVsbG8=" + } +} +``` + +也可以使用 `file_path`: + +```json +{ + "file_path": "e:\\lzwcai-mcp\\README.md" +} +``` + +### 2. file_to_data_uri + +将文件对象转换为 data URI。 + +**入参** + +```json +{ + "file": { + "name": "hello.txt", + "content_base64": "SGVsbG8=" + }, + "type": "text/plain" +} +``` + +### 3. file_path_to_data_uri + +将本地文件路径转换为 data URI。 + +**入参** + +```json +{ + "file_path": "e:\\lzwcai-mcp\\README.md", + "type": "text/plain" +} +``` + +### 4. url_to_data_uri + +将文件 URL 转换为 data URI。 + +**入参** + +```json +{ + "url": "https://example.com/image.png" +} +``` + +## 启动方式 + +### uvx + +```json +{ + "mcpServers": { + "file-tools-mcp": { + "command": "uvx", + "type": "stdio", + "args": [ + "lzwcai-mcpskills-file-tools-mcp" + ], + "env": {} + } + } +} +``` + +### python + +```json +{ + "mcpServers": { + "file-tools-mcp": { + "disabled": false, + "type": "stdio", + "timeout": 30, + "command": "python", + "args": [ + "-m", + "file_tools.main" + ] + } + } +} +``` diff --git a/file_tools/file_tools/__init__.py b/file_tools/file_tools/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/file_tools/file_tools/__init__.py @@ -0,0 +1 @@ + diff --git a/file_tools/file_tools/main.py b/file_tools/file_tools/main.py new file mode 100644 index 0000000..2e2949c --- /dev/null +++ b/file_tools/file_tools/main.py @@ -0,0 +1,47 @@ +import asyncio +import logging + +from mcp.server import Server +from mcp.server.stdio import stdio_server + +from .tools import handle_call_tool, tools + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +) +logger = logging.getLogger(__name__) + + +class FileToolsServer: + def __init__(self) -> None: + self.server: Server = Server("lzwcai-mcpskills-file-tools-mcp") + 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): + logger.info("Received tool call request: %s", name) + return await handle_call_tool(name, arguments) + + +async def _main() -> None: + server = FileToolsServer() + 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() diff --git a/file_tools/file_tools/tools.py b/file_tools/file_tools/tools.py new file mode 100644 index 0000000..aa63b2d --- /dev/null +++ b/file_tools/file_tools/tools.py @@ -0,0 +1,256 @@ +import base64 +import json +import mimetypes +import tempfile +from pathlib import Path +from urllib.parse import urlparse +from typing import Any, Dict, List, Optional, Tuple + +import httpx +from mcp.types import TextContent, Tool + +tools: List[Tool] = [ + Tool( + name="file_to_json", + description="将文件对象转储为 JSON 字符串", + inputSchema={ + "type": "object", + "properties": { + "file": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "type": {"type": "string"}, + "size": {"type": "integer"}, + "last_modified": {"type": "integer"}, + "content_base64": {"type": "string"} + }, + "required": ["name"] + }, + "file_path": {"type": "string"} + }, + "required": [] + } + ), + Tool( + name="file_to_data_uri", + description="将文件转换为 data URI(Base64 编码)", + inputSchema={ + "type": "object", + "properties": { + "file": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "type": {"type": "string"}, + "size": {"type": "integer"}, + "last_modified": {"type": "integer"}, + "content_base64": {"type": "string"} + }, + "required": ["name", "content_base64"] + }, + "type": {"type": "string"}, + "file_path": {"type": "string"} + }, + "required": [] + } + ), + Tool( + name="file_path_to_data_uri", + description="将文件路径转换为 data URI(Base64 编码)", + inputSchema={ + "type": "object", + "properties": { + "file_path": {"type": "string"}, + "type": {"type": "string"} + }, + "required": ["file_path"] + } + ), + Tool( + name="url_to_data_uri", + description="将文件 URL 转换为 data URI(Base64 编码)", + inputSchema={ + "type": "object", + "properties": { + "url": {"type": "string"} + }, + "required": ["url"] + } + ), + Tool( + name="url_to_temp_file", + description="将文件 URL 下载为临时文件并返回路径", + inputSchema={ + "type": "object", + "properties": { + "url": {"type": "string"}, + "file_path": {"type": "string"} + }, + "required": ["url"] + } + ) +] + + +def _guess_mime_type(name: Optional[str]) -> str: + if not name: + return "application/octet-stream" + mime_type, _ = mimetypes.guess_type(name) + return mime_type or "application/octet-stream" + + +def _is_url(value: Optional[str]) -> bool: + if not value: + return False + parsed = urlparse(value) + return parsed.scheme in {"http", "https"} and bool(parsed.netloc) + + +def _decode_base64(content_base64: str) -> bytes: + try: + return base64.b64decode(content_base64, validate=True) + except Exception: + return base64.b64decode(content_base64) + + +def _build_data_uri(data: bytes, mime_type: str) -> str: + encoded = base64.b64encode(data).decode("ascii") + return f"data:{mime_type};base64,{encoded}" + + +def _extract_file_payload(arguments: Dict[str, Any]) -> Tuple[Dict[str, Any], Optional[bytes], Optional[str]]: + file_payload = arguments.get("file") + file_path = arguments.get("file_path") + if file_payload is None and file_path is None: + raise ValueError("missing file or file_path") + + name = None + mime_type = None + size = None + last_modified = None + content_base64 = None + data = None + + if file_payload is not None: + name = file_payload.get("name") + mime_type = file_payload.get("type") + size = file_payload.get("size") + last_modified = file_payload.get("last_modified") + content_base64 = file_payload.get("content_base64") + + if file_path: + path = Path(file_path) + if name is None: + name = path.name + if content_base64 is None: + data = path.read_bytes() + + if data is None and content_base64 is not None: + data = _decode_base64(content_base64) + + return ( + { + "name": name, + "type": mime_type, + "size": size, + "last_modified": last_modified, + "content_base64": content_base64 + }, + data, + name + ) + + +def _normalize_mime_type(mime_type: Optional[str], name: Optional[str]) -> str: + if mime_type: + return mime_type + return _guess_mime_type(name) + + +def _build_file_json(arguments: Dict[str, Any]) -> str: + file_info, data, name = _extract_file_payload(arguments) + if file_info["type"] is None: + file_info["type"] = _guess_mime_type(name) + if file_info["size"] is None and data is not None: + file_info["size"] = len(data) + return json.dumps(file_info, ensure_ascii=False) + + +def _build_file_data_uri(arguments: Dict[str, Any]) -> str: + file_info, data, name = _extract_file_payload(arguments) + if data is None: + raise ValueError("missing file content for data uri") + mime_type = arguments.get("type") or file_info.get("type") + mime_type = _normalize_mime_type(mime_type, name) + return _build_data_uri(data, mime_type) + + +def _build_path_data_uri(arguments: Dict[str, Any]) -> str: + file_path = arguments.get("file_path") + if not file_path: + raise ValueError("missing file_path") + path = Path(file_path) + data = path.read_bytes() + mime_type = arguments.get("type") or _guess_mime_type(path.name) + return _build_data_uri(data, mime_type) + + +async def _build_url_data_uri(arguments: Dict[str, Any]) -> str: + url = arguments.get("url") + if not url: + raise ValueError("missing url") + async with httpx.AsyncClient(follow_redirects=True, timeout=20) as client: + response = await client.get(url) + response.raise_for_status() + content_type = response.headers.get("content-type", "") + mime_type = content_type.split(";")[0].strip() if content_type else "" + if not mime_type: + mime_type = _guess_mime_type(url) + return _build_data_uri(response.content, mime_type) + + +async def _build_url_file_path(arguments: Dict[str, Any]) -> str: + url = arguments.get("url") + if not url: + raise ValueError("missing url") + file_path = arguments.get("file_path") + if file_path: + path = Path(file_path) + path.parent.mkdir(parents=True, exist_ok=True) + else: + suffix = Path(urlparse(url).path).suffix + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_file: + path = Path(temp_file.name) + async with httpx.AsyncClient(follow_redirects=True, timeout=20) as client: + response = await client.get(url) + response.raise_for_status() + path.write_bytes(response.content) + return str(path) + + +async def handle_call_tool(name: str, arguments: Dict[str, Any]) -> List[TextContent]: + try: + if name == "file_to_json": + result = _build_file_json(arguments) + elif name == "file_to_data_uri": + file_path = arguments.get("file_path") + if _is_url(file_path): + result = await _build_url_data_uri({"url": file_path}) + else: + result = _build_file_data_uri(arguments) + elif name == "file_path_to_data_uri": + file_path = arguments.get("file_path") + if _is_url(file_path): + result = await _build_url_data_uri({"url": file_path}) + else: + result = _build_path_data_uri(arguments) + elif name == "url_to_data_uri": + result = await _build_url_data_uri(arguments) + elif name == "url_to_temp_file": + result = await _build_url_file_path(arguments) + 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}")] diff --git a/file_tools/mcp-server-file-tools.json b/file_tools/mcp-server-file-tools.json new file mode 100644 index 0000000..0a49d92 --- /dev/null +++ b/file_tools/mcp-server-file-tools.json @@ -0,0 +1,14 @@ +{ + "mcpServers": { + "lzwcai-mcpskills-file-tools-mcp": { + "disabled": false, + "type": "stdio", + "timeout": 30, + "command": "uvx", + "args": [ + "lzwcai-mcpskills-file-tools-mcp" + ], + "env": {} + } + } +} diff --git a/file_tools/pyproject.toml b/file_tools/pyproject.toml new file mode 100644 index 0000000..f2a715a --- /dev/null +++ b/file_tools/pyproject.toml @@ -0,0 +1,20 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "lzwcai-mcpskills-file-tools-mcp" +version = "0.1.9" +description = "File tools MCP server" +requires-python = ">=3.10" +dependencies = [ + "mcp[cli]>=1.6.0", + "httpx>=0.24.0" +] + +[project.scripts] +lzwcai-mcpskills-file-tools-mcp = "file_tools.main:main" + +[tool.hatch.build.targets.wheel] +packages = ["file_tools"] + diff --git a/file_tools/test_tools.py b/file_tools/test_tools.py new file mode 100644 index 0000000..01d09c7 --- /dev/null +++ b/file_tools/test_tools.py @@ -0,0 +1,67 @@ +import argparse +import asyncio +import base64 +from typing import Optional + +from file_tools.tools import handle_call_tool + + +async def _run( + url: Optional[str], + file_path: Optional[str], + text: Optional[str], + name: str, + download_url: Optional[str], + download_path: Optional[str] +) -> None: + if url: + result = await handle_call_tool("url_to_data_uri", {"url": url}) + print("url_to_data_uri:", result[0].text[:80], "len=", len(result[0].text)) + + if file_path: + result = await handle_call_tool("file_path_to_data_uri", {"file_path": file_path}) + print("file_path_to_data_uri:", result[0].text[:80], "len=", len(result[0].text)) + + if download_url: + payload = {"url": download_url} + if download_path: + payload["file_path"] = download_path + result = await handle_call_tool("url_to_temp_file", payload) + print("url_to_temp_file:", result[0].text) + + if text is not None: + payload = { + "file": { + "name": name, + "content_base64": base64.b64encode(text.encode("utf-8")).decode("ascii") + } + } + result = await handle_call_tool("file_to_json", payload) + print("file_to_json:", result[0].text) + result = await handle_call_tool("file_to_data_uri", payload) + print("file_to_data_uri:", result[0].text[:80], "len=", len(result[0].text)) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--url") + parser.add_argument("--file-path") + parser.add_argument("--download-url") + parser.add_argument("--download-path") + parser.add_argument("--text") + parser.add_argument("--name", default="sample.txt") + args = parser.parse_args() + asyncio.run( + _run( + args.url, + args.file_path, + args.text, + args.name, + args.download_url, + args.download_path + ) + ) + + +if __name__ == "__main__": + main() diff --git a/lzwcai_lark_mcp/lzwcai_lark_mcp/__init__.py b/lzwcai_lark_mcp/lzwcai_lark_mcp/__init__.py new file mode 100644 index 0000000..470d693 --- /dev/null +++ b/lzwcai_lark_mcp/lzwcai_lark_mcp/__init__.py @@ -0,0 +1 @@ +__all__ = ["main"] diff --git a/lzwcai_lark_mcp/lzwcai_lark_mcp/config.py b/lzwcai_lark_mcp/lzwcai_lark_mcp/config.py new file mode 100644 index 0000000..cea8f0d --- /dev/null +++ b/lzwcai_lark_mcp/lzwcai_lark_mcp/config.py @@ -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", "") diff --git a/lzwcai_lark_mcp/lzwcai_lark_mcp/main.py b/lzwcai_lark_mcp/lzwcai_lark_mcp/main.py new file mode 100644 index 0000000..38ae3a5 --- /dev/null +++ b/lzwcai_lark_mcp/lzwcai_lark_mcp/main.py @@ -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() diff --git a/lzwcai_lark_mcp/lzwcai_lark_mcp/tools.py b/lzwcai_lark_mcp/lzwcai_lark_mcp/tools.py new file mode 100644 index 0000000..60533bf --- /dev/null +++ b/lzwcai_lark_mcp/lzwcai_lark_mcp/tools.py @@ -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"时间 {current_time}", + "i18n_content": { + "en_us": f"Incident time\n{current_time}" + }, + "text_align": "center", + "text_size": "normal_v2", + "margin": "0px 0px 0px 0px" + }, + { + "tag": "markdown", + "content": f"\n进入仓库", + "i18n_content": { + "en_us": "Alert details\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}")] diff --git a/lzwcai_lark_mcp/mcp-server.json b/lzwcai_lark_mcp/mcp-server.json new file mode 100644 index 0000000..63c2aaf --- /dev/null +++ b/lzwcai_lark_mcp/mcp-server.json @@ -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" + } + } + } +} \ No newline at end of file diff --git a/lzwcai_lark_mcp/pyproject.toml b/lzwcai_lark_mcp/pyproject.toml new file mode 100644 index 0000000..cae69c4 --- /dev/null +++ b/lzwcai_lark_mcp/pyproject.toml @@ -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"] diff --git a/lzwcai_lark_mcp/test.py b/lzwcai_lark_mcp/test.py new file mode 100644 index 0000000..09bedbe --- /dev/null +++ b/lzwcai_lark_mcp/test.py @@ -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() diff --git a/terminal_temi_mcp/dist/terminal_temi_mcp-0.1.21-py3-none-any.whl b/terminal_temi_mcp/dist/terminal_temi_mcp-0.1.21-py3-none-any.whl deleted file mode 100644 index 9b7b395..0000000 Binary files a/terminal_temi_mcp/dist/terminal_temi_mcp-0.1.21-py3-none-any.whl and /dev/null differ diff --git a/terminal_temi_mcp/dist/terminal_temi_mcp-0.1.21.tar.gz b/terminal_temi_mcp/dist/terminal_temi_mcp-0.1.21.tar.gz deleted file mode 100644 index 809f317..0000000 Binary files a/terminal_temi_mcp/dist/terminal_temi_mcp-0.1.21.tar.gz and /dev/null differ diff --git a/terminal_temi_mcp/mcp-server-temi.json b/terminal_temi_mcp/mcp-server-temi.json index 325a683..1b7e99b 100644 --- a/terminal_temi_mcp/mcp-server-temi.json +++ b/terminal_temi_mcp/mcp-server-temi.json @@ -1,17 +1,13 @@ { "mcpServers": { - "terminal-temi-mcp": { + "terminal_temi_mcp": { "disabled": false, "type": "stdio", "timeout": 30, "command": "uvx", "args": [ "terminal_temi_mcp" - ], - "env": { - "employeeId": "$employeeId$", - "userId": "$employeeId$" - } + ] } } } \ No newline at end of file diff --git a/terminal_temi_mcp/pyproject.toml b/terminal_temi_mcp/pyproject.toml index ac2080e..19508fd 100644 --- a/terminal_temi_mcp/pyproject.toml +++ b/terminal_temi_mcp/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "terminal_temi_mcp" -version = "0.1.21" +version = "0.1.28" description = "MQTT-based navigation server for robot" requires-python = ">=3.10" dependencies = [ diff --git a/terminal_temi_mcp/terminal_temi_mcp/main.py b/terminal_temi_mcp/terminal_temi_mcp/main.py index 1292f37..d6b41c8 100644 --- a/terminal_temi_mcp/terminal_temi_mcp/main.py +++ b/terminal_temi_mcp/terminal_temi_mcp/main.py @@ -50,7 +50,7 @@ class NavServer: params = { "data": {"location": location, "flag": flag} } - return await self.publish_Cmd("123456", user_id, "nav", params) + return await self.publish_Cmd("111111", user_id, "nav", params) except Exception as e: logger.error(f"Failed to call navigation mcp-tool: {str(e)} ", exc_info=True) return f"Failed to call navigation mcp-tool: {str(e)}" @@ -64,12 +64,12 @@ class NavServer: params = { "data": {"speech": speech} } - return await self.publish_Cmd("123456", user_id, "speak", params) + return await self.publish_Cmd("111111", user_id, "speak", params) except Exception as e: logger.error(f"Failed to call speak mcp-tool: {str(e)} ", exc_info=True) return f"Failed to call speak mcp-tool: {str(e)}" - async def reception(self, user_id: str, location: str): + async def reception(self, user_id: str, location: str, name: str = "贵宾", waitingTime: int = 20*60*1000): """轮足机器人移动到指定位置迎宾""" try: if not location: @@ -77,10 +77,12 @@ class NavServer: else: params = { "data": { - "location": location + "location": location, + "name": name, + "waitingTime": waitingTime } } - return await self.publish_Cmd("123456", user_id, "reception", params) + return await self.publish_Cmd("111111", user_id, "reception", params) except Exception as e: logger.error(f"Failed to call reception mcp-tool: {str(e)} ", exc_info=True) return f"Failed to call reception mcp-tool: {str(e)}" @@ -97,7 +99,7 @@ class NavServer: "text": text } } - return await self.publish_Cmd("123456", user_id, "notification", params) + return await self.publish_Cmd("111111", user_id, "notification", params) except Exception as e: logger.error(f"Failed to call notification mcp-tool: {str(e)} ", exc_info=True) return f"Failed to call notification mcp-tool: {str(e)}" @@ -110,7 +112,7 @@ class NavServer: "action": "repose" } } - return await self.publish_Cmd("123456", user_id, "map", params) + return await self.publish_Cmd("111111", user_id, "map", params) except Exception as e: logger.error(f"Failed to call repose mcp-tool: {str(e)} ", exc_info=True) return f"Failed to call repose mcp-tool: {str(e)}" @@ -127,7 +129,7 @@ class NavServer: "next_location": next_location } } - return await self.publish_Cmd("123456", user_id, "delivery", params) + return await self.publish_Cmd("111111", user_id, "delivery", params) except Exception as e: logger.error(f"Failed to call delivery mcp-tool: {str(e)} ", exc_info=True) return f"Failed to call delivery mcp-tool: {str(e)}" @@ -141,7 +143,7 @@ class NavServer: params = { "data": {"locations": locations} } - return await self.publish_Cmd("123456", user_id, "patrol", params) + return await self.publish_Cmd("111111", user_id, "patrol", params) except Exception as e: logger.error(f"Failed to call patrol mcp-tool: {str(e)} ", exc_info=True) return f"Failed to call patrol mcp-tool: {str(e)}" @@ -155,7 +157,7 @@ class NavServer: params = { "data": {} } - return await self.publish_Cmd("123456", user_id, "face", params) + return await self.publish_Cmd("111111", user_id, "face", params) except Exception as e: logger.error(f"Failed to call face recognize mcp-tool: {str(e)} ", exc_info=True) return f"Failed to call face recognize mcp-tool: {str(e)}" @@ -169,7 +171,7 @@ class NavServer: params = { "data": {} } - return await self.publish_Cmd("123456", user_id, "qrcode", params) + return await self.publish_Cmd("111111", user_id, "qrcode", params) except Exception as e: logger.error(f"Failed to call QR code scan mcp-tool: {str(e)} ", exc_info=True) return f"Failed to call QR code scan mcp-tool: {str(e)}" @@ -183,7 +185,7 @@ class NavServer: params = { "data": {"location_name": location_name} } - return await self.publish_Cmd("123456", user_id, "saveLocation", params) + return await self.publish_Cmd("111111", user_id, "saveLocation", params) except Exception as e: logger.error(f"Failed to call save_position mcp-tool: {str(e)} ", exc_info=True) return f"Failed to call save_position mcp-tool: {str(e)}" @@ -196,7 +198,7 @@ class NavServer: params = { "data": [data.model_dump() for data in datas] } - return await self.publish_Cmd("123456", user_id, "guide", params) + return await self.publish_Cmd("111111", user_id, "guide", params) except Exception as e: logger.error(f"Failed to call guide mcp-tool: {str(e)} ", exc_info=True) return f"Failed to call guide mcp-tool: {str(e)}" @@ -263,12 +265,22 @@ async def serve() -> None: "description": "引导接待客人到这个位置", "minLength": 1 }, + "name": { + "type": "string", + "description": "客人姓名", + "minLength": 1 + }, + "waitingTime": { + "type": "integer", + "description": "等待时间(单位:毫秒)", + "default": 20*60*1000 + }, "user_id": { "type": "string", "description": "用户ID" } }, - "required": ["location", "user_id"] + "required": ["location", "name", "user_id"] } ), # Tool( @@ -437,7 +449,9 @@ async def serve() -> None: raise ValueError("缺少必要参数: location or user_id") result = await nav_server.reception( user_id=arguments["user_id"], - location=arguments["location"] + location=arguments["location"], + name=arguments.get("name", ""), + waitingTime=arguments.get("waitingTime", 20*60*1000) ) # elif name == "notification": # if "location" not in arguments or "text" not in arguments or "user_id" not in arguments: