Compare commits
12 Commits
fa226733b8
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 4bce8dbad0 | |||
| 7e33e398ce | |||
| ede63f6e92 | |||
| 49bdf45bfa | |||
|
|
e4909f159d | ||
| 357541a776 | |||
| 7d4400bb23 | |||
| 3b881bf8c8 | |||
| 894ee1dfbf | |||
| 3d04166314 | |||
| 7daa8e46c2 | |||
| 10fbb58b70 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1 +1,5 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
|
*.txt
|
||||||
|
.DS*
|
||||||
|
|
||||||
|
test*
|
||||||
@@ -120,6 +120,24 @@ tools: List[Tool] = [
|
|||||||
},
|
},
|
||||||
"required": ["file_path"]
|
"required": ["file_path"]
|
||||||
}
|
}
|
||||||
|
),
|
||||||
|
Tool(
|
||||||
|
name="parse_json",
|
||||||
|
description="读取txt或json文件内容,优先按JSON解析并返回结构化数据",
|
||||||
|
inputSchema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"file": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string"},
|
||||||
|
"content_base64": {"type": "string"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"file_path": {"type": "string"}
|
||||||
|
},
|
||||||
|
"required": []
|
||||||
|
}
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -387,7 +405,7 @@ def _upload_file_to_minio_sync(file_path: str) -> str:
|
|||||||
secure = endpoint_secure or False
|
secure = endpoint_secure or False
|
||||||
client = Minio(endpoint, access_key=access_key, secret_key=secret_key, secure=secure)
|
client = Minio(endpoint, access_key=access_key, secret_key=secret_key, secure=secure)
|
||||||
bucket = "lzwcai"
|
bucket = "lzwcai"
|
||||||
prefix = "tmp"
|
prefix = "upload"
|
||||||
if not client.bucket_exists(bucket):
|
if not client.bucket_exists(bucket):
|
||||||
client.make_bucket(bucket)
|
client.make_bucket(bucket)
|
||||||
date_str = datetime.now().strftime("%Y-%m-%d")
|
date_str = datetime.now().strftime("%Y-%m-%d")
|
||||||
@@ -430,6 +448,49 @@ def _build_path_data_uri(arguments: Dict[str, Any]) -> str:
|
|||||||
return _build_data_uri(data, mime_type)
|
return _build_data_uri(data, mime_type)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_txt_or_json_result(data: bytes, name: Optional[str]) -> str:
|
||||||
|
text = data.decode("utf-8-sig")
|
||||||
|
try:
|
||||||
|
parsed = json.loads(text)
|
||||||
|
return json.dumps(
|
||||||
|
{
|
||||||
|
"type": "json",
|
||||||
|
"name": name,
|
||||||
|
"data": parsed
|
||||||
|
},
|
||||||
|
ensure_ascii=False
|
||||||
|
)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return json.dumps(
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"name": name,
|
||||||
|
"data": text
|
||||||
|
},
|
||||||
|
ensure_ascii=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _parse_json(arguments: Dict[str, Any]) -> str:
|
||||||
|
raw_file_path = str(arguments.get("file_path", ""))
|
||||||
|
file_path = raw_file_path.strip().strip("`").strip()
|
||||||
|
if _is_url(file_path):
|
||||||
|
async with httpx.AsyncClient(follow_redirects=True, timeout=20) as client:
|
||||||
|
response = await client.get(file_path)
|
||||||
|
response.raise_for_status()
|
||||||
|
name = Path(urlparse(file_path).path).name or None
|
||||||
|
return _build_txt_or_json_result(response.content, name)
|
||||||
|
|
||||||
|
if file_path and file_path != arguments.get("file_path"):
|
||||||
|
arguments = dict(arguments)
|
||||||
|
arguments["file_path"] = file_path
|
||||||
|
|
||||||
|
file_info, data, _ = _extract_file_payload(arguments)
|
||||||
|
if data is None:
|
||||||
|
raise ValueError("missing file content")
|
||||||
|
return _build_txt_or_json_result(data, file_info.get("name"))
|
||||||
|
|
||||||
|
|
||||||
async def _build_url_data_uri(arguments: Dict[str, Any]) -> str:
|
async def _build_url_data_uri(arguments: Dict[str, Any]) -> str:
|
||||||
url = arguments.get("url")
|
url = arguments.get("url")
|
||||||
if not url:
|
if not url:
|
||||||
@@ -519,6 +580,8 @@ async def handle_call_tool(name: str, arguments: Dict[str, Any]) -> List[TextCon
|
|||||||
if not file_path:
|
if not file_path:
|
||||||
raise ValueError("missing file_path")
|
raise ValueError("missing file_path")
|
||||||
result = await asyncio.to_thread(_upload_file_to_minio_sync, file_path)
|
result = await asyncio.to_thread(_upload_file_to_minio_sync, file_path)
|
||||||
|
elif name == "parse_json":
|
||||||
|
result = await _parse_json(arguments)
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"unknown tool name: {name}")
|
raise ValueError(f"unknown tool name: {name}")
|
||||||
return [TextContent(type="text", text=result)]
|
return [TextContent(type="text", text=result)]
|
||||||
|
|||||||
@@ -9,9 +9,9 @@
|
|||||||
"lzwcai-file-tools-mcp"
|
"lzwcai-file-tools-mcp"
|
||||||
],
|
],
|
||||||
"env": {
|
"env": {
|
||||||
"minio_endpoint": "http://192.168.11.24:9000",
|
"minio_endpoint": "http://hyy-minio.awin25.com:1800",
|
||||||
"minio_access_key": "cXk8WPR3ix86J9aGK6tH",
|
"minio_access_key": "orOXTOpVfRtYzovP",
|
||||||
"minio_secret_key": "FSH8g3tx8bTR4w8BZmwl35WvWbOXZvfvCcivRRJE"
|
"minio_secret_key": "4EOMjjbrji1DHW0EBSlYA7JqBnJUy0aj"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "lzwcai-file-tools-mcp"
|
name = "lzwcai-file-tools-mcp"
|
||||||
version = "0.1.0"
|
version = "0.1.3"
|
||||||
description = "File tools MCP server"
|
description = "File tools MCP server"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
3
k3cloud_mcp/k3cloud_mcp/__init__.py
Normal file
3
k3cloud_mcp/k3cloud_mcp/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""K3Cloud MCP package."""
|
||||||
|
|
||||||
|
__all__ = ["main"]
|
||||||
82
k3cloud_mcp/k3cloud_mcp/client.py
Normal file
82
k3cloud_mcp/k3cloud_mcp/client.py
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import json
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from .config import Config
|
||||||
|
from .signing import build_headers
|
||||||
|
|
||||||
|
|
||||||
|
def build_save_url(path: str) -> str:
|
||||||
|
"""Build the Save API URL."""
|
||||||
|
return f"{Config.BASE_URL.rstrip('/')}{path}"
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_payload(payload: Any, field_name: str) -> str:
|
||||||
|
if payload is None:
|
||||||
|
raise ValueError(f"{field_name} 不能为空")
|
||||||
|
if isinstance(payload, str):
|
||||||
|
payload_value = payload.strip()
|
||||||
|
if not payload_value:
|
||||||
|
raise ValueError(f"{field_name} 不能为空字符串")
|
||||||
|
return payload_value
|
||||||
|
try:
|
||||||
|
return json.dumps(payload, ensure_ascii=False)
|
||||||
|
except TypeError as exc:
|
||||||
|
raise TypeError(
|
||||||
|
f"{field_name} 必须是可序列化的 JSON 对象或 JSON 字符串"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def save(formid: str, data_payload: Any, timeout: int = 30) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Call the K3Cloud Save API.
|
||||||
|
|
||||||
|
`data_payload` can be a JSON object or a pre-serialized JSON string.
|
||||||
|
"""
|
||||||
|
save_service_path = "/Kingdee.BOS.WebApi.ServicesStub.DynamicFormService.Save.common.kdsvc"
|
||||||
|
normalized_formid = str(formid or "").strip()
|
||||||
|
if not normalized_formid:
|
||||||
|
raise ValueError("formid 不能为空")
|
||||||
|
payload_value = _serialize_payload(data_payload, "data_payload")
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"formid": normalized_formid,
|
||||||
|
"data": payload_value,
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.post(
|
||||||
|
build_save_url(save_service_path),
|
||||||
|
json=payload,
|
||||||
|
headers=build_headers(save_service_path),
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def view(formid: str, payload: Any, timeout: int = 30) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Call the K3Cloud View API.
|
||||||
|
|
||||||
|
`payload` can be a JSON object or a pre-serialized JSON string.
|
||||||
|
"""
|
||||||
|
view_service_path = "/Kingdee.BOS.WebApi.ServicesStub.DynamicFormService.View.common.kdsvc"
|
||||||
|
normalized_formid = str(formid or "").strip()
|
||||||
|
if not normalized_formid:
|
||||||
|
raise ValueError("formid 不能为空")
|
||||||
|
payload_value = _serialize_payload(payload, "payload")
|
||||||
|
|
||||||
|
request_body = {
|
||||||
|
"formid": normalized_formid,
|
||||||
|
"data": payload_value,
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.post(
|
||||||
|
build_save_url(view_service_path),
|
||||||
|
json=request_body,
|
||||||
|
headers=build_headers(view_service_path),
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
18
k3cloud_mcp/k3cloud_mcp/config.py
Normal file
18
k3cloud_mcp/k3cloud_mcp/config.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
def _require_env(name: str) -> str:
|
||||||
|
value = os.getenv(name, "").strip()
|
||||||
|
if not value:
|
||||||
|
raise ValueError(f"missing required environment variable: {name}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
BASE_URL = _require_env("K3CLOUD_BASE_URL")
|
||||||
|
ACCT_ID = _require_env("K3CLOUD_ACCT_ID")
|
||||||
|
APP_ID = _require_env("K3CLOUD_APP_ID")
|
||||||
|
USERNAME = _require_env("K3CLOUD_USERNAME")
|
||||||
|
APP_SECRET = _require_env("K3CLOUD_APP_SECRET")
|
||||||
|
LCID = int(os.getenv("K3CLOUD_LCID", "2052"))
|
||||||
|
ORG_NUM = int(os.getenv("K3CLOUD_ORG_NUM", "0"))
|
||||||
34
k3cloud_mcp/k3cloud_mcp/demo_save.json
Normal file
34
k3cloud_mcp/k3cloud_mcp/demo_save.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"Model": {
|
||||||
|
"FID": 0,
|
||||||
|
"FBillTypeID": {
|
||||||
|
"FNUMBER": "SKDLX02_SYS"
|
||||||
|
},
|
||||||
|
"FBillNo": "1211",
|
||||||
|
"FPAYORGID": {
|
||||||
|
"FNumber": "100"
|
||||||
|
},
|
||||||
|
"FDATE": "2026-05-02",
|
||||||
|
"FCONTACTUNITTYPE": "BD_Empinfo",
|
||||||
|
"FCONTACTUNIT": {
|
||||||
|
"FNumber": "220804"
|
||||||
|
},
|
||||||
|
"FPAYUNITTYPE": "BD_Empinfo",
|
||||||
|
"FCURRENCYID": {
|
||||||
|
"FNumber": "PRE001"
|
||||||
|
},
|
||||||
|
"FRECEIVEBILLENTRY": [
|
||||||
|
{
|
||||||
|
"FEntryID": 0,
|
||||||
|
"FRECAMOUNTFOR_E": 123.0,
|
||||||
|
"FRECTOTALAMOUNTFOR": 123456.0,
|
||||||
|
"FSETTLETYPEID": {
|
||||||
|
"FNumber": "JSFS01_SYS"
|
||||||
|
},
|
||||||
|
"FPURPOSEID": {
|
||||||
|
"FNumber": "SFKYT004"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
72
k3cloud_mcp/k3cloud_mcp/demo_save.py
Normal file
72
k3cloud_mcp/k3cloud_mcp/demo_save.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
CURRENT_DIR = Path(__file__).resolve().parent
|
||||||
|
PROJECT_ROOT = CURRENT_DIR.parent
|
||||||
|
DEFAULT_PAYLOAD_PATH = CURRENT_DIR / "demo_save.json"
|
||||||
|
MCP_SERVER_PATH = PROJECT_ROOT / "mcp-server.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_project_root_on_path() -> None:
|
||||||
|
root = str(PROJECT_ROOT)
|
||||||
|
if root not in sys.path:
|
||||||
|
sys.path.insert(0, root)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_payload(payload_path: Path) -> Any:
|
||||||
|
raw_text = payload_path.read_text(encoding="utf-8").strip()
|
||||||
|
if not raw_text:
|
||||||
|
raise ValueError(f"payload 文件为空: {payload_path}")
|
||||||
|
return json.loads(raw_text)
|
||||||
|
|
||||||
|
|
||||||
|
def _bootstrap_env_from_mcp_server() -> None:
|
||||||
|
if not MCP_SERVER_PATH.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
data = json.loads(MCP_SERVER_PATH.read_text(encoding="utf-8"))
|
||||||
|
env_map = data.get("mcpServers", {}).get("k3cloud-mcp", {}).get("env", {})
|
||||||
|
for key, value in env_map.items():
|
||||||
|
if key not in os.environ and value is not None:
|
||||||
|
os.environ[key] = str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="读取同级 demo.json 并直接调用 k3cloud_mcp.client.save()"
|
||||||
|
)
|
||||||
|
parser.add_argument("formid", help="K3Cloud 表单标识,例如 BD_Customer")
|
||||||
|
parser.add_argument(
|
||||||
|
"--payload",
|
||||||
|
default=str(DEFAULT_PAYLOAD_PATH),
|
||||||
|
help="payload JSON 文件路径,默认读取同级 demo.json",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--timeout",
|
||||||
|
type=int,
|
||||||
|
default=30,
|
||||||
|
help="请求超时时间,单位秒,默认 30",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
payload_path = Path(args.payload).expanduser().resolve()
|
||||||
|
_ensure_project_root_on_path()
|
||||||
|
_bootstrap_env_from_mcp_server()
|
||||||
|
|
||||||
|
from k3cloud_mcp.client import save
|
||||||
|
|
||||||
|
result = save(
|
||||||
|
formid=args.formid,
|
||||||
|
data_payload=_load_payload(payload_path),
|
||||||
|
timeout=args.timeout,
|
||||||
|
)
|
||||||
|
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
6
k3cloud_mcp/k3cloud_mcp/demo_view.json
Normal file
6
k3cloud_mcp/k3cloud_mcp/demo_view.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"CreateOrgId": 0,
|
||||||
|
"Number": "SKD00126864",
|
||||||
|
"Id": "",
|
||||||
|
"IsSortBySeq": "false"
|
||||||
|
}
|
||||||
72
k3cloud_mcp/k3cloud_mcp/demo_view.py
Normal file
72
k3cloud_mcp/k3cloud_mcp/demo_view.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
CURRENT_DIR = Path(__file__).resolve().parent
|
||||||
|
PROJECT_ROOT = CURRENT_DIR.parent
|
||||||
|
DEFAULT_PAYLOAD_PATH = CURRENT_DIR / "demo_view.json"
|
||||||
|
MCP_SERVER_PATH = PROJECT_ROOT / "mcp-server.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_project_root_on_path() -> None:
|
||||||
|
root = str(PROJECT_ROOT)
|
||||||
|
if root not in sys.path:
|
||||||
|
sys.path.insert(0, root)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_payload(payload_path: Path) -> Any:
|
||||||
|
raw_text = payload_path.read_text(encoding="utf-8").strip()
|
||||||
|
if not raw_text:
|
||||||
|
raise ValueError(f"payload 文件为空: {payload_path}")
|
||||||
|
return json.loads(raw_text)
|
||||||
|
|
||||||
|
|
||||||
|
def _bootstrap_env_from_mcp_server() -> None:
|
||||||
|
if not MCP_SERVER_PATH.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
data = json.loads(MCP_SERVER_PATH.read_text(encoding="utf-8"))
|
||||||
|
env_map = data.get("mcpServers", {}).get("k3cloud-mcp", {}).get("env", {})
|
||||||
|
for key, value in env_map.items():
|
||||||
|
if key not in os.environ and value is not None:
|
||||||
|
os.environ[key] = str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="读取 payload JSON 并直接调用 k3cloud_mcp.client.view()"
|
||||||
|
)
|
||||||
|
parser.add_argument("formid", help="K3Cloud 表单标识,例如 BD_Customer")
|
||||||
|
parser.add_argument(
|
||||||
|
"--payload",
|
||||||
|
default=str(DEFAULT_PAYLOAD_PATH),
|
||||||
|
help="payload JSON 文件路径,默认读取同级 demo_view.json",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--timeout",
|
||||||
|
type=int,
|
||||||
|
default=30,
|
||||||
|
help="请求超时时间,单位秒,默认 30",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
payload_path = Path(args.payload).expanduser().resolve()
|
||||||
|
_ensure_project_root_on_path()
|
||||||
|
_bootstrap_env_from_mcp_server()
|
||||||
|
|
||||||
|
from k3cloud_mcp.client import view
|
||||||
|
|
||||||
|
result = view(
|
||||||
|
formid=args.formid,
|
||||||
|
payload=_load_payload(payload_path),
|
||||||
|
timeout=args.timeout,
|
||||||
|
)
|
||||||
|
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
46
k3cloud_mcp/k3cloud_mcp/main.py
Normal file
46
k3cloud_mcp/k3cloud_mcp/main.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Sequence
|
||||||
|
|
||||||
|
from mcp.server import Server
|
||||||
|
from mcp.server.stdio import stdio_server
|
||||||
|
from mcp.types import TextContent, Tool
|
||||||
|
|
||||||
|
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__)
|
||||||
|
|
||||||
|
|
||||||
|
async def serve() -> None:
|
||||||
|
server = Server("k3cloud_mcp")
|
||||||
|
|
||||||
|
@server.list_tools()
|
||||||
|
async def list_tools() -> list[Tool]:
|
||||||
|
return tools
|
||||||
|
|
||||||
|
@server.call_tool()
|
||||||
|
async def call_tool(name: str, arguments: dict) -> Sequence[TextContent]:
|
||||||
|
return await handle_call_tool(name, arguments)
|
||||||
|
|
||||||
|
options = server.create_initialization_options()
|
||||||
|
async with stdio_server() as (read_stream, write_stream):
|
||||||
|
await server.run(read_stream, write_stream, options)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
try:
|
||||||
|
asyncio.run(serve())
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.info("Server interrupted by user")
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Server runtime error: %s", exc)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
103
k3cloud_mcp/k3cloud_mcp/signing.py
Normal file
103
k3cloud_mcp/k3cloud_mcp/signing.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import random
|
||||||
|
import time
|
||||||
|
from typing import Dict
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
from .config import Config
|
||||||
|
|
||||||
|
|
||||||
|
def _sdk_base64_encode(data_bytes: bytes) -> str:
|
||||||
|
return base64.b64encode(data_bytes).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _sdk_base64_decode(data_str: str) -> bytes:
|
||||||
|
return base64.b64decode(data_str)
|
||||||
|
|
||||||
|
|
||||||
|
def _sdk_hmac_sha256(content: str, sign_key: str) -> str:
|
||||||
|
signature = hmac.new(
|
||||||
|
sign_key.encode("utf-8"),
|
||||||
|
content.encode("utf-8"),
|
||||||
|
hashlib.sha256,
|
||||||
|
).digest()
|
||||||
|
sign_hex = signature.hex()
|
||||||
|
return _sdk_base64_encode(sign_hex.encode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def _rot(value: str) -> str:
|
||||||
|
def encode_char(ch: str) -> str:
|
||||||
|
if ch.islower():
|
||||||
|
return chr((ord(ch) - 97 + 13) % 26 + 97)
|
||||||
|
if ch.isupper():
|
||||||
|
return chr((ord(ch) - 65 + 13) % 26 + 65)
|
||||||
|
return ch
|
||||||
|
|
||||||
|
return "".join(encode_char(char) for char in value)
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_code() -> str:
|
||||||
|
rand = str(random.randint(1000, 9999))
|
||||||
|
return f"0054s397{rand[0]}p6234378{rand[1]}o09pn7q3{rand[2]}r5qropr7{rand[3]}"
|
||||||
|
|
||||||
|
|
||||||
|
def _extend_byte_array(origin: str, extend_type: int = 0) -> bytearray:
|
||||||
|
if extend_type == 0:
|
||||||
|
return bytearray(_rot(origin), encoding="utf-8")
|
||||||
|
gene_str = "".join(origin[index * 9 : index * 9 + 8] for index in range(4))
|
||||||
|
return bytearray(_rot(gene_str), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _xor_code(byte_array: bytearray) -> bytearray:
|
||||||
|
pwd_array = _extend_byte_array(_generate_code(), extend_type=1)
|
||||||
|
return bytearray(byte ^ pwd_array[index] for index, byte in enumerate(byte_array))
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_app_secret(encoded_secret: str) -> str:
|
||||||
|
if len(encoded_secret) != 32:
|
||||||
|
return ""
|
||||||
|
base64_decode = _sdk_base64_decode(encoded_secret)
|
||||||
|
base64_xor = _xor_code(bytearray(base64_decode))
|
||||||
|
return _sdk_base64_encode(base64_xor)
|
||||||
|
|
||||||
|
|
||||||
|
def build_headers(service_path: str) -> Dict[str, str]:
|
||||||
|
"""Build K3Cloud request headers for the target service path."""
|
||||||
|
parts = Config.APP_ID.split("_")
|
||||||
|
if len(parts) == 2:
|
||||||
|
client_id = parts[0]
|
||||||
|
client_sec = _decode_app_secret(parts[1])
|
||||||
|
else:
|
||||||
|
client_id = Config.APP_ID
|
||||||
|
client_sec = ""
|
||||||
|
|
||||||
|
timestamp = str(int(time.time()))
|
||||||
|
nonce = str(int(time.time()))
|
||||||
|
path_encoded = quote(service_path, encoding="utf-8").replace("/", "%2F")
|
||||||
|
|
||||||
|
api_sign_str = f"POST\n{path_encoded}\n\nx-api-nonce:{nonce}\nx-api-timestamp:{timestamp}\n"
|
||||||
|
api_signature = _sdk_hmac_sha256(api_sign_str, client_sec)
|
||||||
|
|
||||||
|
app_data_str = (
|
||||||
|
f"{Config.ACCT_ID},{Config.USERNAME},{Config.LCID},{Config.ORG_NUM}"
|
||||||
|
)
|
||||||
|
app_data_b64 = _sdk_base64_encode(app_data_str.encode("utf-8"))
|
||||||
|
kd_signature = _sdk_hmac_sha256(
|
||||||
|
Config.APP_ID + app_data_str,
|
||||||
|
Config.APP_SECRET,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Api-ClientID": client_id,
|
||||||
|
"X-Api-Auth-Version": "2.0",
|
||||||
|
"X-Api-Timestamp": timestamp,
|
||||||
|
"X-Api-Nonce": nonce,
|
||||||
|
"X-Api-SignHeaders": "x-api-timestamp,x-api-nonce",
|
||||||
|
"X-Api-Signature": api_signature,
|
||||||
|
"X-KD-AppKey": Config.APP_ID,
|
||||||
|
"X-KD-AppData": app_data_b64,
|
||||||
|
"X-KD-Signature": kd_signature,
|
||||||
|
}
|
||||||
105
k3cloud_mcp/k3cloud_mcp/tools.py
Normal file
105
k3cloud_mcp/k3cloud_mcp/tools.py
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import json
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
from mcp.types import TextContent, Tool
|
||||||
|
|
||||||
|
from .client import save, view
|
||||||
|
|
||||||
|
tools = [
|
||||||
|
Tool(
|
||||||
|
name="save_form",
|
||||||
|
description="调用金蝶云星空 DynamicFormService.Save 接口保存单据。",
|
||||||
|
inputSchema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"formid": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "业务对象表单 Id,例如 SAL_QUOTATION",
|
||||||
|
},
|
||||||
|
"data_payload": {
|
||||||
|
"description": "Save 接口的 data 参数,支持 JSON 对象或 JSON 字符串",
|
||||||
|
},
|
||||||
|
"timeout": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "HTTP 超时时间(秒),默认 30",
|
||||||
|
"default": 30,
|
||||||
|
"minimum": 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["formid", "data_payload"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Tool(
|
||||||
|
name="view_form",
|
||||||
|
description="调用金蝶云星空 DynamicFormService.View 接口查看单据。",
|
||||||
|
inputSchema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"formid": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "业务对象表单 Id,例如 SAL_QUOTATION",
|
||||||
|
},
|
||||||
|
"payload": {
|
||||||
|
"description": "View 接口的 payload 参数,支持 JSON 对象或 JSON 字符串",
|
||||||
|
},
|
||||||
|
"timeout": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "HTTP 超时时间(秒),默认 30",
|
||||||
|
"default": 30,
|
||||||
|
"minimum": 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["formid", "payload"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_payload(value: object, field_name: str) -> Any:
|
||||||
|
if value is None:
|
||||||
|
raise ValueError(f"missing {field_name}")
|
||||||
|
if isinstance(value, str):
|
||||||
|
normalized = value.strip()
|
||||||
|
if not normalized:
|
||||||
|
raise ValueError(f"missing {field_name}")
|
||||||
|
try:
|
||||||
|
return json.loads(normalized)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return normalized
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_call_tool(name: str, arguments: Dict[str, object]) -> List[TextContent]:
|
||||||
|
try:
|
||||||
|
if name not in {"save_form", "view_form"}:
|
||||||
|
raise ValueError(f"unknown tool name: {name}")
|
||||||
|
|
||||||
|
formid = str(arguments.get("formid", "")).strip()
|
||||||
|
if not formid:
|
||||||
|
raise ValueError("missing formid")
|
||||||
|
|
||||||
|
timeout_value = arguments.get("timeout", 30)
|
||||||
|
timeout = int(timeout_value)
|
||||||
|
if timeout <= 0:
|
||||||
|
raise ValueError("timeout must be greater than 0")
|
||||||
|
|
||||||
|
if name == "save_form":
|
||||||
|
result = save(
|
||||||
|
formid=formid,
|
||||||
|
data_payload=_normalize_payload(arguments.get("data_payload"), "data_payload"),
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
result = view(
|
||||||
|
formid=formid,
|
||||||
|
payload=_normalize_payload(arguments.get("payload"), "payload"),
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
TextContent(
|
||||||
|
type="text",
|
||||||
|
text=json.dumps(result, ensure_ascii=False, indent=2),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
except Exception as exc:
|
||||||
|
return [TextContent(type="text", text=f"Failed to call tool {name}: {exc}")]
|
||||||
47
k3cloud_mcp/k3cloud_mcp/userful_save.json
Normal file
47
k3cloud_mcp/k3cloud_mcp/userful_save.json
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"NeedUpDateFields": [],
|
||||||
|
"NeedReturnFields": [],
|
||||||
|
"IsDeleteEntry": "true",
|
||||||
|
"SubSystemId": "",
|
||||||
|
"IsVerifyBaseDataField": "false",
|
||||||
|
"IsEntryBatchFill": "true",
|
||||||
|
"ValidateFlag": "true",
|
||||||
|
"NumberSearch": "true",
|
||||||
|
"IsAutoAdjustField": "false",
|
||||||
|
"InterationFlags": "",
|
||||||
|
"IgnoreInterationFlag": "",
|
||||||
|
"IsControlPrecision": "false",
|
||||||
|
"ValidateRepeatJson": "false",
|
||||||
|
"Model": {
|
||||||
|
"FID": 0,
|
||||||
|
"FBillTypeID": {
|
||||||
|
"FNUMBER": "SKDLX02_SYS" "--其他业务收款单"
|
||||||
|
},
|
||||||
|
"FBillNo": "3", "--单据编号"
|
||||||
|
"FPAYORGID": {
|
||||||
|
"FNumber": "100" "--收款组织"
|
||||||
|
},
|
||||||
|
"FDATE": "2026-05-02", "--业务日期"
|
||||||
|
"FCONTACTUNITTYPE": "BD_Empinfo", "--往来单位类型"
|
||||||
|
"FCONTACTUNIT": {
|
||||||
|
"FNumber": "220804" "--往来单位"
|
||||||
|
},
|
||||||
|
"FPAYUNITTYPE": "BD_Empinfo", "--付款单位类型"
|
||||||
|
"FCURRENCYID": {
|
||||||
|
"FNumber": "PRE001" "--币别"
|
||||||
|
},
|
||||||
|
"FRECEIVEBILLENTRY": [
|
||||||
|
{
|
||||||
|
"FEntryID": 0,
|
||||||
|
"FRECAMOUNTFOR_E": 123.0, "--收款金额"
|
||||||
|
"FRECTOTALAMOUNTFOR": 123456.0, "--应收金额"
|
||||||
|
"FSETTLETYPEID": {
|
||||||
|
"FNumber": "JSFS01_SYS" "--结算方式 JSFS01_SYS 现金、JSFS04_SYS 电汇"
|
||||||
|
},
|
||||||
|
"FPURPOSEID": {
|
||||||
|
"FNumber": "SFKYT004" "--收款用途 SFKYT10 内部往来-费用往来、SFKYT004 借支还款"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
631
k3cloud_mcp/k3cloud_mcp/view_result.json
Normal file
631
k3cloud_mcp/k3cloud_mcp/view_result.json
Normal file
@@ -0,0 +1,631 @@
|
|||||||
|
{
|
||||||
|
"Result": {
|
||||||
|
"ResponseStatus": {
|
||||||
|
"IsSuccess": true
|
||||||
|
},
|
||||||
|
"Result": {
|
||||||
|
"Id": 263989,
|
||||||
|
"BILLNo": "SKD00126001",
|
||||||
|
"DOCUMENTSTATUS": "C",
|
||||||
|
"FCreatorId_Id": 121428040,
|
||||||
|
"FCreatorId": {
|
||||||
|
"Id": 121428040,
|
||||||
|
"Name": "杨苏梅",
|
||||||
|
"UserAccount": "杨苏梅"
|
||||||
|
},
|
||||||
|
"APPROVERID_Id": 676841452,
|
||||||
|
"APPROVERID": {
|
||||||
|
"Id": 676841452,
|
||||||
|
"Name": "郑旭君",
|
||||||
|
"UserAccount": "郑旭君"
|
||||||
|
},
|
||||||
|
"FCreateDate": "2025-01-14T14:56:23.627",
|
||||||
|
"SETTLEORGID_Id": 1,
|
||||||
|
"SETTLEORGID": {
|
||||||
|
"Id": 1,
|
||||||
|
"MultiLanguageText": [
|
||||||
|
{
|
||||||
|
"PkId": 1,
|
||||||
|
"LocaleId": 2052,
|
||||||
|
"Name": "海圆圆集团"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"PkId": 100001,
|
||||||
|
"LocaleId": 1033,
|
||||||
|
"Name": "海圆圆集团"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"PkId": 100002,
|
||||||
|
"LocaleId": 3076,
|
||||||
|
"Name": "海圆圆集团"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Name": [
|
||||||
|
{
|
||||||
|
"Key": 2052,
|
||||||
|
"Value": "海圆圆集团"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Key": 1033,
|
||||||
|
"Value": "海圆圆集团"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Key": 3076,
|
||||||
|
"Value": "海圆圆集团"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Number": "100",
|
||||||
|
"FRadio": null,
|
||||||
|
"ParentOrg_Id": 1,
|
||||||
|
"ParentOrg": {
|
||||||
|
"Id": 1,
|
||||||
|
"Number": "100",
|
||||||
|
"MultiLanguageText": [
|
||||||
|
{
|
||||||
|
"PkId": 1,
|
||||||
|
"LocaleId": 2052,
|
||||||
|
"Name": "海圆圆集团"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"PkId": 100001,
|
||||||
|
"LocaleId": 1033,
|
||||||
|
"Name": "海圆圆集团"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"PkId": 100002,
|
||||||
|
"LocaleId": 3076,
|
||||||
|
"Name": "海圆圆集团"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Name": [
|
||||||
|
{
|
||||||
|
"Key": 2052,
|
||||||
|
"Value": "海圆圆集团"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Key": 1033,
|
||||||
|
"Value": "海圆圆集团"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Key": 3076,
|
||||||
|
"Value": "海圆圆集团"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ApproveDate": "2025-01-15T08:53:57.68",
|
||||||
|
"SALEORGID_Id": 0,
|
||||||
|
"SALEORGID": null,
|
||||||
|
"EXCHANGETYPE_Id": 1,
|
||||||
|
"EXCHANGETYPE": {
|
||||||
|
"Id": 1,
|
||||||
|
"msterID": 1,
|
||||||
|
"MultiLanguageText": [
|
||||||
|
{
|
||||||
|
"PkId": 1,
|
||||||
|
"LocaleId": 2052,
|
||||||
|
"Name": "固定汇率"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Name": [
|
||||||
|
{
|
||||||
|
"Key": 2052,
|
||||||
|
"Value": "固定汇率"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Number": "HLTX01_SYS",
|
||||||
|
"Digits": 4,
|
||||||
|
"ReverseDigits": 4
|
||||||
|
},
|
||||||
|
"RECEIVEAMOUNTFOR": 820000.0,
|
||||||
|
"MAINBOOKCURID_Id": 1,
|
||||||
|
"MAINBOOKCURID": {
|
||||||
|
"Id": 1,
|
||||||
|
"msterID": 1,
|
||||||
|
"MultiLanguageText": [
|
||||||
|
{
|
||||||
|
"PkId": 2,
|
||||||
|
"LocaleId": 2052,
|
||||||
|
"Name": "人民币"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Name": [
|
||||||
|
{
|
||||||
|
"Key": 2052,
|
||||||
|
"Value": "人民币"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Number": "PRE001",
|
||||||
|
"Sysmbol": "¥",
|
||||||
|
"PriceDigits": 6,
|
||||||
|
"AmountDigits": 2,
|
||||||
|
"IsShowCSymbol": true,
|
||||||
|
"FormatOrder": "1",
|
||||||
|
"RoundType": "1"
|
||||||
|
},
|
||||||
|
"RECEIVEAMOUNT": 820000.0,
|
||||||
|
"CURRENCYID_Id": 1,
|
||||||
|
"CURRENCYID": {
|
||||||
|
"Id": 1,
|
||||||
|
"msterID": 1,
|
||||||
|
"MultiLanguageText": [
|
||||||
|
{
|
||||||
|
"PkId": 2,
|
||||||
|
"LocaleId": 2052,
|
||||||
|
"Name": "人民币"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Name": [
|
||||||
|
{
|
||||||
|
"Key": 2052,
|
||||||
|
"Value": "人民币"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Number": "PRE001",
|
||||||
|
"Sysmbol": "¥",
|
||||||
|
"PriceDigits": 6,
|
||||||
|
"AmountDigits": 2,
|
||||||
|
"IsShowCSymbol": true,
|
||||||
|
"FormatOrder": "1",
|
||||||
|
"RoundType": "1"
|
||||||
|
},
|
||||||
|
"DATE": "2025-01-02T00:00:00",
|
||||||
|
"FModifyDate": "2025-01-15T08:53:55.51",
|
||||||
|
"ModifierId_Id": 676841452,
|
||||||
|
"ModifierId": {
|
||||||
|
"Id": 676841452,
|
||||||
|
"Name": "郑旭君",
|
||||||
|
"UserAccount": "郑旭君"
|
||||||
|
},
|
||||||
|
"EXCHANGERATE": 1.0,
|
||||||
|
"WRITTENOFFSTATUS": "A",
|
||||||
|
"SALEERID_Id": 0,
|
||||||
|
"SALEERID": null,
|
||||||
|
"SALEGROUPID_Id": 0,
|
||||||
|
"SALEGROUPID": null,
|
||||||
|
"SALEDEPTID_Id": 0,
|
||||||
|
"SALEDEPTID": null,
|
||||||
|
"BillTypeID_Id": "670b3608188c425a9f696d7ccb47843d",
|
||||||
|
"BillTypeID": {
|
||||||
|
"Id": "670b3608188c425a9f696d7ccb47843d",
|
||||||
|
"MultiLanguageText": [
|
||||||
|
{
|
||||||
|
"PkId": "557479e6741c42469036c5548c9d9407",
|
||||||
|
"LocaleId": 2052,
|
||||||
|
"Name": "其他业务收款单"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Name": [
|
||||||
|
{
|
||||||
|
"Key": 2052,
|
||||||
|
"Value": "其他业务收款单"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Number": "SKDLX02_SYS"
|
||||||
|
},
|
||||||
|
"REALRECAMOUNTFOR": 820000.0,
|
||||||
|
"REALRECAMOUNT": 820000.0,
|
||||||
|
"ACCOUNTSYSTEM_Id": 0,
|
||||||
|
"ACCOUNTSYSTEM": null,
|
||||||
|
"CancelDate": null,
|
||||||
|
"CancelStatus": "A",
|
||||||
|
"CancellerId_Id": 0,
|
||||||
|
"CancellerId": null,
|
||||||
|
"CONTACTUNITTYPE": "BD_Empinfo",
|
||||||
|
"CONTACTUNIT_Id": 279577322,
|
||||||
|
"CONTACTUNIT": {
|
||||||
|
"Id": 279577322,
|
||||||
|
"msterID": 279577322,
|
||||||
|
"MultiLanguageText": [
|
||||||
|
{
|
||||||
|
"PkId": 102684,
|
||||||
|
"LocaleId": 2052,
|
||||||
|
"Name": "吴喜燕"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Name": [
|
||||||
|
{
|
||||||
|
"Key": 2052,
|
||||||
|
"Value": "吴喜燕"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Number": "220804"
|
||||||
|
},
|
||||||
|
"PAYUNITTYPE": "BD_Empinfo",
|
||||||
|
"PAYUNIT_Id": 279577322,
|
||||||
|
"PAYUNIT": {
|
||||||
|
"Id": 279577322,
|
||||||
|
"msterID": 279577322,
|
||||||
|
"MultiLanguageText": [
|
||||||
|
{
|
||||||
|
"PkId": 102684,
|
||||||
|
"LocaleId": 2052,
|
||||||
|
"Name": "吴喜燕"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Name": [
|
||||||
|
{
|
||||||
|
"Key": 2052,
|
||||||
|
"Value": "吴喜燕"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Number": "220804"
|
||||||
|
},
|
||||||
|
"BUSINESSTYPE": "3",
|
||||||
|
"CreditCheckResult": "0",
|
||||||
|
"ISINIT": false,
|
||||||
|
"Department_Id": 0,
|
||||||
|
"Department": null,
|
||||||
|
"FPAYORGID_Id": 1,
|
||||||
|
"FPAYORGID": {
|
||||||
|
"Id": 1,
|
||||||
|
"MultiLanguageText": [
|
||||||
|
{
|
||||||
|
"PkId": 1,
|
||||||
|
"LocaleId": 2052,
|
||||||
|
"Name": "海圆圆集团"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"PkId": 100001,
|
||||||
|
"LocaleId": 1033,
|
||||||
|
"Name": "海圆圆集团"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"PkId": 100002,
|
||||||
|
"LocaleId": 3076,
|
||||||
|
"Name": "海圆圆集团"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Name": [
|
||||||
|
{
|
||||||
|
"Key": 2052,
|
||||||
|
"Value": "海圆圆集团"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Key": 1033,
|
||||||
|
"Value": "海圆圆集团"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Key": 3076,
|
||||||
|
"Value": "海圆圆集团"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Number": "100",
|
||||||
|
"FRadio": null,
|
||||||
|
"ParentOrg_Id": 1,
|
||||||
|
"ParentOrg": {
|
||||||
|
"Id": 1,
|
||||||
|
"Number": "100",
|
||||||
|
"MultiLanguageText": [
|
||||||
|
{
|
||||||
|
"PkId": 1,
|
||||||
|
"LocaleId": 2052,
|
||||||
|
"Name": "海圆圆集团"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"PkId": 100001,
|
||||||
|
"LocaleId": 1033,
|
||||||
|
"Name": "海圆圆集团"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"PkId": 100002,
|
||||||
|
"LocaleId": 3076,
|
||||||
|
"Name": "海圆圆集团"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Name": [
|
||||||
|
{
|
||||||
|
"Key": 2052,
|
||||||
|
"Value": "海圆圆集团"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Key": 1033,
|
||||||
|
"Value": "海圆圆集团"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Key": 3076,
|
||||||
|
"Value": "海圆圆集团"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"FIsSameOrg": true,
|
||||||
|
"FSOURCESYSTEM": "0",
|
||||||
|
"FCASHSALE": false,
|
||||||
|
"SETTLECUR_Id": 1,
|
||||||
|
"SETTLECUR": {
|
||||||
|
"Id": 1,
|
||||||
|
"msterID": 1,
|
||||||
|
"MultiLanguageText": [
|
||||||
|
{
|
||||||
|
"PkId": 2,
|
||||||
|
"LocaleId": 2052,
|
||||||
|
"Name": "人民币"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Name": [
|
||||||
|
{
|
||||||
|
"Key": 2052,
|
||||||
|
"Value": "人民币"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Number": "PRE001",
|
||||||
|
"Sysmbol": "¥",
|
||||||
|
"PriceDigits": 6,
|
||||||
|
"AmountDigits": 2,
|
||||||
|
"IsShowCSymbol": true,
|
||||||
|
"FormatOrder": "1",
|
||||||
|
"RoundType": "1"
|
||||||
|
},
|
||||||
|
"SETTLERATE": 1.0,
|
||||||
|
"RECAMOUNTFOR": 820000.0,
|
||||||
|
"RECAMOUNT": 820000.0,
|
||||||
|
"ISB2C": false,
|
||||||
|
"WBSETTLENO": " ",
|
||||||
|
"IsWriteOff": false,
|
||||||
|
"MatchMethodID": 0,
|
||||||
|
"FScanPoint_Id": 0,
|
||||||
|
"FScanPoint": null,
|
||||||
|
"FKDPAYNO": " ",
|
||||||
|
"FREMARK": " ",
|
||||||
|
"FTHIRDBILLNO": " ",
|
||||||
|
"FSETTLEMAINBOOKID_Id": 1,
|
||||||
|
"FSETTLEMAINBOOKID": {
|
||||||
|
"Id": 1,
|
||||||
|
"msterID": 1,
|
||||||
|
"MultiLanguageText": [
|
||||||
|
{
|
||||||
|
"PkId": 2,
|
||||||
|
"LocaleId": 2052,
|
||||||
|
"Name": "人民币"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Name": [
|
||||||
|
{
|
||||||
|
"Key": 2052,
|
||||||
|
"Value": "人民币"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Number": "PRE001",
|
||||||
|
"Sysmbol": "¥",
|
||||||
|
"PriceDigits": 6,
|
||||||
|
"AmountDigits": 2,
|
||||||
|
"IsShowCSymbol": true,
|
||||||
|
"FormatOrder": "1",
|
||||||
|
"RoundType": "1"
|
||||||
|
},
|
||||||
|
"FSETTLEEXCHANGETYPE_Id": 1,
|
||||||
|
"FSETTLEEXCHANGETYPE": {
|
||||||
|
"Id": 1,
|
||||||
|
"msterID": 1,
|
||||||
|
"MultiLanguageText": [
|
||||||
|
{
|
||||||
|
"PkId": 1,
|
||||||
|
"LocaleId": 2052,
|
||||||
|
"Name": "固定汇率"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Name": [
|
||||||
|
{
|
||||||
|
"Key": 2052,
|
||||||
|
"Value": "固定汇率"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Number": "HLTX01_SYS",
|
||||||
|
"Digits": 4,
|
||||||
|
"ReverseDigits": 4
|
||||||
|
},
|
||||||
|
"FOUTCONTACTID_Id": 0,
|
||||||
|
"FOUTCONTACTID": null,
|
||||||
|
"FOUTCONTACTTYPE": " ",
|
||||||
|
"FGYACCOUNTWATERID": 0,
|
||||||
|
"FISCARRYRATE": false,
|
||||||
|
"PRESETBASE1_Id": 0,
|
||||||
|
"PRESETBASE1": null,
|
||||||
|
"PRESETBASE2_Id": 0,
|
||||||
|
"PRESETBASE2": null,
|
||||||
|
"FPRESETASSISTANT1_Id": " ",
|
||||||
|
"FPRESETASSISTANT1": null,
|
||||||
|
"FPRESETASSISTANT2_Id": " ",
|
||||||
|
"FPRESETASSISTANT2": null,
|
||||||
|
"FPRESETTEXT1": " ",
|
||||||
|
"FPRESETTEXT2": " ",
|
||||||
|
"FISFROMSALORDER": false,
|
||||||
|
"FVirIsSameAcctOrg": false,
|
||||||
|
"SourceBillNumber": " ",
|
||||||
|
"RECEIVEBILLENTRY": [
|
||||||
|
{
|
||||||
|
"Id": 288025,
|
||||||
|
"Seq": 1,
|
||||||
|
"SETTLETYPEID_Id": 1,
|
||||||
|
"SETTLETYPEID": {
|
||||||
|
"Id": 1,
|
||||||
|
"msterID": 1,
|
||||||
|
"MultiLanguageText": [
|
||||||
|
{
|
||||||
|
"PkId": 1,
|
||||||
|
"LocaleId": 2052,
|
||||||
|
"Name": "现金"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Name": [
|
||||||
|
{
|
||||||
|
"Key": 2052,
|
||||||
|
"Value": "现金"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Number": "JSFS01_SYS",
|
||||||
|
"SETTLECATEGORY": "1",
|
||||||
|
"TYPE": "1",
|
||||||
|
"FRECBILLTYPE": " ",
|
||||||
|
"FACCOUNTTYPE": " "
|
||||||
|
},
|
||||||
|
"SETTLERECAMOUNTFOR": 820000.0,
|
||||||
|
"SETTLEDISTAMOUNTFOR": 0.0,
|
||||||
|
"RECTOTALAMOUNTFOR": 820000.0,
|
||||||
|
"SETTLERECAMOUNT": 820000.0,
|
||||||
|
"SETTLEDISTAMOUNT": 0.0,
|
||||||
|
"RECTOTALAMOUNT": 820000.0,
|
||||||
|
"WRITTENOFFSTATUS": "A",
|
||||||
|
"WRITTENOFFAMOUNTFOR": 0.0,
|
||||||
|
"COMMENT": "收到吴喜燕归还借支款",
|
||||||
|
"OPPOSITEBANKACCOUNT": " ",
|
||||||
|
"OPPOSITECCOUNTNAME": " ",
|
||||||
|
"RECEIVEITEM": " ",
|
||||||
|
"HANDLINGCHARGEFOR": 0.0,
|
||||||
|
"HANDLINGCHARGE": 0.0,
|
||||||
|
"REALRECAMOUNTFOR": 820000.0,
|
||||||
|
"FREALRECAMOUNT": 820000.0,
|
||||||
|
"ASSTOTALAMOUNTFOR": 0.0,
|
||||||
|
"RECEIVEITEMTYPE": " ",
|
||||||
|
"SaleOrderID": 0,
|
||||||
|
"ACCOUNTID_Id": 0,
|
||||||
|
"ACCOUNTID": null,
|
||||||
|
"OVERUNDERAMOUNTFOR": 0.0,
|
||||||
|
"OPPOSITEBANKNAME": " ",
|
||||||
|
"SETTLENO": " ",
|
||||||
|
"BLEND": false,
|
||||||
|
"PURPOSEID_Id": 266819,
|
||||||
|
"PURPOSEID": {
|
||||||
|
"Id": 266819,
|
||||||
|
"MultiLanguageText": [
|
||||||
|
{
|
||||||
|
"PkId": 100005,
|
||||||
|
"LocaleId": 2052,
|
||||||
|
"Name": "借支还款"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Name": [
|
||||||
|
{
|
||||||
|
"Key": 2052,
|
||||||
|
"Value": "借支还款"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Number": "SFKYT004",
|
||||||
|
"RECPAYTYPE": "0",
|
||||||
|
"BUSINESSTYPE": "3",
|
||||||
|
"FINMANEGEMENT": true,
|
||||||
|
"FPAYMENTADVANCE": false
|
||||||
|
},
|
||||||
|
"OVERUNDERAMOUNT": 0.0,
|
||||||
|
"FINNERACCOUNTID_Id": 0,
|
||||||
|
"FINNERACCOUNTID": null,
|
||||||
|
"ReFundAmount": 0.0,
|
||||||
|
"CashAccount_Id": 100402,
|
||||||
|
"CashAccount": {
|
||||||
|
"Id": 100402,
|
||||||
|
"msterID": 100402,
|
||||||
|
"MultiLanguageText": [
|
||||||
|
{
|
||||||
|
"PkId": 100001,
|
||||||
|
"LocaleId": 2052,
|
||||||
|
"Name": "海圆圆"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Name": [
|
||||||
|
{
|
||||||
|
"Key": 2052,
|
||||||
|
"Value": "海圆圆"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Number": "海圆圆"
|
||||||
|
},
|
||||||
|
"RECAMOUNTFOR_E": 820000.0,
|
||||||
|
"RECAMOUNT_E": 820000.0,
|
||||||
|
"FPOSTDATE": "2025-01-02T00:00:00",
|
||||||
|
"FISPOST": true,
|
||||||
|
"FMATERIALID_Id": 0,
|
||||||
|
"FMATERIALID": null,
|
||||||
|
"FSALEORDERNO": " ",
|
||||||
|
"FMATERIALSEQ": 0,
|
||||||
|
"FORDERENTRYID": 0,
|
||||||
|
"TOPAYMENTAMOUNTFOR": 0.0,
|
||||||
|
"FWRITTENOFFAMOUNT": 0.0,
|
||||||
|
"FNOTVERIFICATEAMOUNT": 820000.0,
|
||||||
|
"FPRICEUNITID_Id": 0,
|
||||||
|
"FPRICEUNITID": null,
|
||||||
|
"FPrice": 0.0,
|
||||||
|
"FQty": 0.0,
|
||||||
|
"COSTID_Id": 0,
|
||||||
|
"COSTID": null,
|
||||||
|
"COSTDEPARTMENTID_Id": 0,
|
||||||
|
"COSTDEPARTMENTID": null,
|
||||||
|
"FLINKROWID": " ",
|
||||||
|
"FSALEORDERBASE_Id": 0,
|
||||||
|
"FSALEORDERBASE": null,
|
||||||
|
"FRelateReFundAmount": 0.0,
|
||||||
|
"ENTRYTAXRATE": 0.0,
|
||||||
|
"TAXAMOUNTFOR": 0.0,
|
||||||
|
"TAXAMOUNT": 0.0,
|
||||||
|
"RECNOTAXAMOUNTFOR": 820000.0,
|
||||||
|
"RECNOTAXAMOUNT": 820000.0,
|
||||||
|
"WRITTENOFFTAXAMOUNTFOR": 0.0,
|
||||||
|
"FWRITTENOFFTAXAMOUNT": 0.0,
|
||||||
|
"FSALEORDERNUMBER": null,
|
||||||
|
"AR_ASSSALESORDER": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"RECEIVEBILLSRCENTRY": [
|
||||||
|
{
|
||||||
|
"Id": 0,
|
||||||
|
"Seq": 1,
|
||||||
|
"SRCBILLID": 0,
|
||||||
|
"AFTTAXTOTALAMOUNT": 0.0,
|
||||||
|
"SRCSETTLETYPEID_Id": 0,
|
||||||
|
"SRCSETTLETYPEID": null,
|
||||||
|
"SRCCURRENCYID_Id": 0,
|
||||||
|
"SRCCURRENCYID": null,
|
||||||
|
"EXPIRY": null,
|
||||||
|
"PLANRECAMOUNT": 0.0,
|
||||||
|
"REALRECAMOUNT": 0.0,
|
||||||
|
"SRCBILLTYPEID": "",
|
||||||
|
"SRCBILLNO": null,
|
||||||
|
"SRCSEQ": 0,
|
||||||
|
"SRCROWID": 0,
|
||||||
|
"ORDERBILLNO": null,
|
||||||
|
"FSRCMATERIALID_Id": 0,
|
||||||
|
"FSRCMATERIALID": null,
|
||||||
|
"FSRCMATERIALSEQ": 0,
|
||||||
|
"FSRCORDERENTRYID": 0,
|
||||||
|
"FSETTLEAMOUNT": 0.0,
|
||||||
|
"FREALRECAMOUNTFOR": 0.0,
|
||||||
|
"FSRCPRICEUNITID_Id": 0,
|
||||||
|
"FSRCPRICEUNITID": null,
|
||||||
|
"FSRCPRICE": 0.0,
|
||||||
|
"FSRCQTY": 0.0,
|
||||||
|
"SRCCOSTID_Id": 0,
|
||||||
|
"SRCCOSTID": null,
|
||||||
|
"SRCCOSTDEPARTMENTID_Id": 0,
|
||||||
|
"SRCCOSTDEPARTMENTID": null,
|
||||||
|
"FSRCLINKROWID": null,
|
||||||
|
"SRCREMARK": null,
|
||||||
|
"FASSORDERAMOUNT": 0.0,
|
||||||
|
"FSRCPURPOSEID_Id": 0,
|
||||||
|
"FSRCPURPOSEID": null,
|
||||||
|
"FRECEIVEBILLSRCENTRY_Link": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"RECEIVEBILLREC": [
|
||||||
|
{
|
||||||
|
"Id": 0,
|
||||||
|
"Seq": 1,
|
||||||
|
"BILLID_Id": 0,
|
||||||
|
"BILLID": null,
|
||||||
|
"PARLEFTAMOUNTFOR": 0.0,
|
||||||
|
"USEDAMOUNTFOR": 0.0,
|
||||||
|
"FInnerAccountID_B_Id": 0,
|
||||||
|
"FInnerAccountID_B": null,
|
||||||
|
"FTempOrgId_Id": 0,
|
||||||
|
"FTempOrgId": null,
|
||||||
|
"FBPBILLNUMBER": null,
|
||||||
|
"BILLPARAMOUNT": 0.0,
|
||||||
|
"PARLEFTAMOUNTSTD": 0.0,
|
||||||
|
"USEDAMOUNTSTD": 0.0,
|
||||||
|
"FBPBILLPARAMOUNT": 0.0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"BILLSKDRECEIVABLEENTRY": [],
|
||||||
|
"BOS_ConvertTakeDataInfo": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
22
k3cloud_mcp/mcp-server.json
Normal file
22
k3cloud_mcp/mcp-server.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"k3cloud-mcp": {
|
||||||
|
"disabled": false,
|
||||||
|
"type": "stdio",
|
||||||
|
"timeout": 30,
|
||||||
|
"command": "uvx",
|
||||||
|
"args": [
|
||||||
|
"k3cloud-mcp"
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"K3CLOUD_BASE_URL": "http://218.30.128.86:5366/k3cloud/",
|
||||||
|
"K3CLOUD_ACCT_ID": "69c1d4c23b97b0",
|
||||||
|
"K3CLOUD_APP_ID": "339175_429p68CF1rD+QVVG012K0/+H1L581Dno",
|
||||||
|
"K3CLOUD_USERNAME": "杜长远",
|
||||||
|
"K3CLOUD_APP_SECRET": "05bf79c2636a4bfa8063c0f1742ceeb1",
|
||||||
|
"K3CLOUD_LCID": "2052",
|
||||||
|
"K3CLOUD_ORG_NUM": "0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
k3cloud_mcp/pyproject.toml
Normal file
19
k3cloud_mcp/pyproject.toml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "k3cloud-mcp"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "K3Cloud MCP server"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
dependencies = [
|
||||||
|
"mcp[cli]>=1.6.0",
|
||||||
|
"requests>=2.25.0"
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
k3cloud-mcp = "k3cloud_mcp.main:main"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["k3cloud_mcp"]
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
{
|
|
||||||
"schema": "2.0",
|
|
||||||
"config": {
|
|
||||||
"update_multi": true,
|
|
||||||
"style": {
|
|
||||||
"text_size": {
|
|
||||||
"normal_v2": {
|
|
||||||
"default": "normal",
|
|
||||||
"pc": "normal",
|
|
||||||
"mobile": "heading"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"body": {
|
|
||||||
"direction": "vertical",
|
|
||||||
"elements": [
|
|
||||||
{
|
|
||||||
"tag": "form",
|
|
||||||
"elements": [
|
|
||||||
{
|
|
||||||
"tag": "markdown",
|
|
||||||
"content": "**<font color='blue-600'>确认单号:</font>** <font color='grey'>${order_number}</font>",
|
|
||||||
"text_align": "left",
|
|
||||||
"text_size": "normal"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tag": "markdown",
|
|
||||||
"content": "**<font color='blue-600'>用户:</font>** <person id=${user_id} show_name=true show_avatar=true style='normal'></person>",
|
|
||||||
"text_align": "left",
|
|
||||||
"text_size": "normal"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tag": "markdown",
|
|
||||||
"content": "**<font color='blue-600'>发生时间:</font>** <font color='grey'>${change_time}</font>",
|
|
||||||
"text_align": "left",
|
|
||||||
"text_size": "normal"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tag": "markdown",
|
|
||||||
"content": "**<font color='blue-600'>\\*请准确选择关于您的变动项:</font>**",
|
|
||||||
"text_align": "left",
|
|
||||||
"text_size": "normal",
|
|
||||||
"margin": "0px 0px 8px 0px"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tag": "multi_select_static",
|
|
||||||
"placeholder": {
|
|
||||||
"tag": "plain_text",
|
|
||||||
"content": "请选择资产变动项"
|
|
||||||
},
|
|
||||||
"options": "${asset_list}",
|
|
||||||
"type": "default",
|
|
||||||
"width": "fill",
|
|
||||||
"required": false,
|
|
||||||
"name": "input_assets",
|
|
||||||
"margin": "0px 0px 16px 0px",
|
|
||||||
"element_id": "cIiptD7Z4hCtAeR5Rb0b"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tag": "hr",
|
|
||||||
"margin": "0px 0px 0px 0px"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tag": "markdown",
|
|
||||||
"content": "**<font color='blue-600'>其他说明:</font>**",
|
|
||||||
"text_align": "left",
|
|
||||||
"text_size": "normal_v2",
|
|
||||||
"margin": "0px 0px 0px 0px"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tag": "input",
|
|
||||||
"placeholder": {
|
|
||||||
"tag": "plain_text",
|
|
||||||
"content": "请输入"
|
|
||||||
},
|
|
||||||
"default_value": "",
|
|
||||||
"width": "fill",
|
|
||||||
"name": "input_remark",
|
|
||||||
"margin": "0px 0px 0px 0px"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tag": "column_set",
|
|
||||||
"flex_mode": "flow",
|
|
||||||
"horizontal_spacing": "8px",
|
|
||||||
"horizontal_align": "right",
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"tag": "column",
|
|
||||||
"width": "auto",
|
|
||||||
"elements": [
|
|
||||||
{
|
|
||||||
"tag": "button",
|
|
||||||
"text": {
|
|
||||||
"tag": "plain_text",
|
|
||||||
"content": "确认"
|
|
||||||
},
|
|
||||||
"type": "primary_filled",
|
|
||||||
"width": "default",
|
|
||||||
"size": "medium",
|
|
||||||
"behaviors": [
|
|
||||||
{
|
|
||||||
"type": "callback",
|
|
||||||
"value": {
|
|
||||||
"action": "card.action.trigger"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"form_action_type": "submit",
|
|
||||||
"name": "confirm_button",
|
|
||||||
"margin": "4px 0px 4px 0px"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"vertical_spacing": "8px",
|
|
||||||
"horizontal_align": "left",
|
|
||||||
"vertical_align": "top"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tag": "column",
|
|
||||||
"width": "auto",
|
|
||||||
"elements": [
|
|
||||||
{
|
|
||||||
"tag": "button",
|
|
||||||
"text": {
|
|
||||||
"tag": "plain_text",
|
|
||||||
"content": "反馈问题"
|
|
||||||
},
|
|
||||||
"type": "default",
|
|
||||||
"width": "default",
|
|
||||||
"confirm": {
|
|
||||||
"title": {
|
|
||||||
"tag": "plain_text",
|
|
||||||
"content": "反馈误报并废除该确认单吗?"
|
|
||||||
},
|
|
||||||
"text": {
|
|
||||||
"tag": "plain_text",
|
|
||||||
"content": "${remark}"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"behaviors": [
|
|
||||||
{
|
|
||||||
"type": "callback",
|
|
||||||
"value": {
|
|
||||||
"action": "card.action.trigger"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"form_action_type": "submit",
|
|
||||||
"name": "feedback_button",
|
|
||||||
"margin": "4px 0px 4px 0px"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"vertical_spacing": "8px",
|
|
||||||
"horizontal_align": "left",
|
|
||||||
"vertical_align": "top"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"direction": "vertical",
|
|
||||||
"horizontal_align": "left",
|
|
||||||
"vertical_align": "top",
|
|
||||||
"padding": "12px 12px 12px 12px",
|
|
||||||
"margin": "0px 0px 0px 0px",
|
|
||||||
"name": "asset_confirmation_form"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tag": "hr",
|
|
||||||
"margin": "0px 0px 0px 0px"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"header": {
|
|
||||||
"title": {
|
|
||||||
"tag": "plain_text",
|
|
||||||
"content": "资产变动单"
|
|
||||||
},
|
|
||||||
"subtitle": {
|
|
||||||
"tag": "plain_text",
|
|
||||||
"content": ""
|
|
||||||
},
|
|
||||||
"text_tag_list": [
|
|
||||||
{
|
|
||||||
"tag": "text_tag",
|
|
||||||
"text": {
|
|
||||||
"tag": "plain_text",
|
|
||||||
"content": "待确认"
|
|
||||||
},
|
|
||||||
"color": "orange"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"template": "blue",
|
|
||||||
"icon": {
|
|
||||||
"tag": "standard_icon",
|
|
||||||
"token": "googledrive_outlined"
|
|
||||||
},
|
|
||||||
"padding": "12px 8px 12px 8px"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "lzwcai-lark-mcp"
|
name = "lzwcai-lark-mcp"
|
||||||
version = "0.1.3"
|
version = "0.1.17"
|
||||||
description = "Lark MCP server"
|
description = "Lark MCP server"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import asyncio
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
@@ -9,34 +11,73 @@ def main() -> None:
|
|||||||
config_path = Path(__file__).with_name("mcp-server.json")
|
config_path = Path(__file__).with_name("mcp-server.json")
|
||||||
if config_path.exists():
|
if config_path.exists():
|
||||||
config_data = json.loads(config_path.read_text(encoding="utf-8"))
|
config_data = json.loads(config_path.read_text(encoding="utf-8"))
|
||||||
|
servers = config_data.get("mcpServers", {})
|
||||||
env_data = (
|
env_data = (
|
||||||
config_data.get("mcpServers", {})
|
servers.get("lzwcai-lark-mcp", {}).get("env", {})
|
||||||
.get("lzwcai-mcpskills-lark-mcp", {})
|
or servers.get("lzwcai-mcpskills-lark-mcp", {}).get("env", {})
|
||||||
.get("env", {})
|
|
||||||
)
|
)
|
||||||
if env_data.get("app_id") and env_data.get("app_secret"):
|
if env_data.get("app_id") and env_data.get("app_secret"):
|
||||||
os.environ["app_id"] = env_data["app_id"]
|
os.environ["app_id"] = env_data["app_id"]
|
||||||
os.environ["app_secret"] = env_data["app_secret"]
|
os.environ["app_secret"] = env_data["app_secret"]
|
||||||
if not os.getenv("app_id") or not os.getenv("app_secret"):
|
if not os.getenv("app_id") or not os.getenv("app_secret"):
|
||||||
raise RuntimeError("missing app_id or app_secret")
|
raise RuntimeError("missing app_id or app_secret")
|
||||||
from lzwcai_lark_mcp.main import LarkMcpServer
|
if "mcp" not in sys.modules:
|
||||||
from lzwcai_lark_mcp.tools import send_asset_confirmation_card
|
mcp_module = types.ModuleType("mcp")
|
||||||
|
types_module = types.ModuleType("mcp.types")
|
||||||
async def _run() -> None:
|
class Tool:
|
||||||
server = LarkMcpServer()
|
def __init__(self, *args, **kwargs):
|
||||||
await server.ensure_token()
|
pass
|
||||||
user_id = "843ga2gb"
|
class TextContent:
|
||||||
result = send_asset_confirmation_card(
|
def __init__(self, *args, **kwargs):
|
||||||
server.tenant_access_token or "",
|
pass
|
||||||
|
types_module.Tool = Tool
|
||||||
|
types_module.TextContent = TextContent
|
||||||
|
mcp_module.types = types_module
|
||||||
|
sys.modules["mcp"] = mcp_module
|
||||||
|
sys.modules["mcp.types"] = types_module
|
||||||
|
from lzwcai_lark_mcp.tools import send_notion_card, send_stranger_card
|
||||||
|
app_id = os.getenv("app_id", "")
|
||||||
|
app_secret = os.getenv("app_secret", "")
|
||||||
|
auth_url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
|
||||||
|
response = requests.post(
|
||||||
|
auth_url,
|
||||||
|
json={"app_id": app_id, "app_secret": app_secret},
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
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}")
|
||||||
|
user_id = "gegg1d78"
|
||||||
|
receiver_ids = ["843ga2gb", "gegg1d78"]
|
||||||
|
person_id = "gegg1d78"
|
||||||
|
image_key = "img_v3_0210i_94bdf5de-5c89-49f0-a793-c504c7377c7g"
|
||||||
|
card_message_ids = send_notion_card(
|
||||||
|
token,
|
||||||
|
receiver_ids,
|
||||||
|
person_id,
|
||||||
|
image_key
|
||||||
|
)
|
||||||
|
print(card_message_ids)
|
||||||
|
result = send_stranger_card(
|
||||||
|
token,
|
||||||
user_id,
|
user_id,
|
||||||
"2026-02-13 10:30:00",
|
"CONF-20260301-001",
|
||||||
["华为i手机"],
|
"2026-03-01 10:30:00",
|
||||||
["红米手机"]
|
[
|
||||||
|
{"华为手机": "huawei_phone"},
|
||||||
|
{"红米手机": "redmi_phone"}
|
||||||
|
],
|
||||||
|
face_cap="img_v3_02vj_b25b040f-b6c1-49f4-a29d-a02c99a13a9g",
|
||||||
|
user_ids=["347f5e71", "gegg1d78"],
|
||||||
|
remark="如有误报请点击反馈"
|
||||||
)
|
)
|
||||||
print(result)
|
print(result)
|
||||||
|
|
||||||
asyncio.run(_run())
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
119
lzwcai_lark_mcp/test_send_card.py
Normal file
119
lzwcai_lark_mcp/test_send_card.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import lark_oapi as lark
|
||||||
|
from lark_oapi.api.im.v1 import *
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# 配置你的 App ID 和 App Secret
|
||||||
|
APP_ID = 'cli_a8d0e0c140169013'
|
||||||
|
APP_SECRET = 'yEc0E8Aoo8Mo9NPPzphidez51xB71HXW'
|
||||||
|
|
||||||
|
# 你的 Open ID (请确保这个 ID 是正确的)
|
||||||
|
RECEIVE_ID = "ou_5c041720bc5a15235d6026ef118d77c9"
|
||||||
|
RECEIVE_ID_TYPE = "open_id"
|
||||||
|
|
||||||
|
# 卡片 JSON 文件路径
|
||||||
|
CARD_JSON_PATH = r"/home/lzwc/project/warehouse/origin_scripts/卡片源代码(供参考,禁止直接改动).json"
|
||||||
|
|
||||||
|
def load_and_render_card():
|
||||||
|
# 1. 读取 JSON 文件
|
||||||
|
with open(CARD_JSON_PATH, "r", encoding="utf-8") as f:
|
||||||
|
card_content = f.read()
|
||||||
|
|
||||||
|
# 2. 准备替换的数据
|
||||||
|
# 注意:简单的字符串替换无法处理 "${asset_list}" 这种需要替换为 JSON 数组的情况
|
||||||
|
# 所以我们需要先解析 JSON,再遍历替换,或者用更巧妙的方法
|
||||||
|
|
||||||
|
# 构造选项列表
|
||||||
|
asset_options = [
|
||||||
|
{"text": {"tag": "plain_text", "content": "显示器"}, "value": "monitor"},
|
||||||
|
{"text": {"tag": "plain_text", "content": "键盘"}, "value": "keyboard"},
|
||||||
|
{"text": {"tag": "plain_text", "content": "鼠标"}, "value": "mouse"}
|
||||||
|
]
|
||||||
|
|
||||||
|
# 这里我们采用一种混合策略:先替换简单的字符串变量,再解析 JSON 替换复杂对象
|
||||||
|
|
||||||
|
# 替换简单变量
|
||||||
|
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
card_content = card_content.replace("${order_number}", "ORD-TEST-001")
|
||||||
|
card_content = card_content.replace("${user_id}", RECEIVE_ID)
|
||||||
|
card_content = card_content.replace("${change_time}", current_time)
|
||||||
|
card_content = card_content.replace("${remark}", "如果不属实,请点击此按钮反馈")
|
||||||
|
|
||||||
|
# 解析为 Python 对象
|
||||||
|
card_json = json.loads(card_content)
|
||||||
|
|
||||||
|
# 3. 替换复杂对象 (options)
|
||||||
|
# 我们需要找到那个 multi_select_static 组件并替换它的 options
|
||||||
|
# 同时,我们需要将 order_number 注入到按钮的 value 中,以便回调时能获取到
|
||||||
|
order_number_val = "ORD-TEST-001"
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 递归查找并替换 options="${asset_list}" 以及注入 order_number
|
||||||
|
def process_nodes(node):
|
||||||
|
if isinstance(node, dict):
|
||||||
|
# Check for options replacement
|
||||||
|
for key, value in node.items():
|
||||||
|
if key == "options" and value == "${asset_list}":
|
||||||
|
node[key] = asset_options
|
||||||
|
|
||||||
|
# Check for button behaviors
|
||||||
|
if node.get("tag") == "button":
|
||||||
|
behaviors = node.get("behaviors", [])
|
||||||
|
for behavior in behaviors:
|
||||||
|
if behavior.get("type") == "callback" and "value" in behavior:
|
||||||
|
# Inject order_number into the callback value
|
||||||
|
if isinstance(behavior["value"], dict):
|
||||||
|
behavior["value"]["order_number"] = order_number_val
|
||||||
|
|
||||||
|
# Recursively process children
|
||||||
|
for key, value in node.items():
|
||||||
|
process_nodes(value)
|
||||||
|
|
||||||
|
elif isinstance(node, list):
|
||||||
|
for item in node:
|
||||||
|
process_nodes(item)
|
||||||
|
|
||||||
|
process_nodes(card_json)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"替换变量失败: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return card_json
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# 加载并渲染卡片
|
||||||
|
card_json = load_and_render_card()
|
||||||
|
if not card_json:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 创建 Client
|
||||||
|
client = lark.Client.builder() \
|
||||||
|
.app_id(APP_ID) \
|
||||||
|
.app_secret(APP_SECRET) \
|
||||||
|
.log_level(lark.LogLevel.DEBUG) \
|
||||||
|
.build()
|
||||||
|
|
||||||
|
# 构造请求
|
||||||
|
request = CreateMessageRequest.builder() \
|
||||||
|
.receive_id_type(RECEIVE_ID_TYPE) \
|
||||||
|
.request_body(CreateMessageRequestBody.builder()
|
||||||
|
.receive_id(RECEIVE_ID)
|
||||||
|
.msg_type("interactive")
|
||||||
|
.content(json.dumps(card_json)) # 这里再次序列化为字符串
|
||||||
|
.build()) \
|
||||||
|
.build()
|
||||||
|
|
||||||
|
# 发送请求
|
||||||
|
response = client.im.v1.message.create(request)
|
||||||
|
|
||||||
|
# 处理响应
|
||||||
|
if not response.success():
|
||||||
|
print(f"发送失败: code: {response.code}, msg: {response.msg}, error: {response.error}")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"发送成功! message_id: {response.data.message_id}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
0
lzwcai_temi_mcp/__init__.py
Normal file
0
lzwcai_temi_mcp/__init__.py
Normal file
0
lzwcai_temi_mcp/lzwcai_temi_mcp/__init__.py
Normal file
0
lzwcai_temi_mcp/lzwcai_temi_mcp/__init__.py
Normal file
222
lzwcai_temi_mcp/lzwcai_temi_mcp/main.py
Normal file
222
lzwcai_temi_mcp/lzwcai_temi_mcp/main.py
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
from typing import Sequence
|
||||||
|
import logging
|
||||||
|
import asyncio
|
||||||
|
from mcp.server import Server
|
||||||
|
from mcp.server.stdio import stdio_server
|
||||||
|
from mcp.types import Tool, TextContent
|
||||||
|
from .mcp_mqtt import get_mcpmqtt_handler
|
||||||
|
from .nav_server import NavServer
|
||||||
|
|
||||||
|
# 配置日志
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
async def serve() -> None:
|
||||||
|
server = Server("terminal_temi_mcp")
|
||||||
|
mmhandler = get_mcpmqtt_handler()
|
||||||
|
nav_server = NavServer(mmhandler)
|
||||||
|
|
||||||
|
@server.list_tools()
|
||||||
|
async def list_tools() -> list[Tool]:
|
||||||
|
"""列出所有工具"""
|
||||||
|
return [
|
||||||
|
Tool(
|
||||||
|
name="recharge",
|
||||||
|
description="轮足机器人充电",
|
||||||
|
inputSchema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {},
|
||||||
|
"required": []
|
||||||
|
}
|
||||||
|
),
|
||||||
|
Tool(
|
||||||
|
name="terminate",
|
||||||
|
description="轮足机器人终止当前任务",
|
||||||
|
inputSchema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {},
|
||||||
|
"required": []
|
||||||
|
}
|
||||||
|
),
|
||||||
|
Tool(
|
||||||
|
name="goto",
|
||||||
|
description="轮足机器人导航到指定地点为用户引路。触发关键词:带我去、导航、引路、带路、怎么走、在哪里。",
|
||||||
|
inputSchema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"location": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "目标地点名称",
|
||||||
|
"minLength": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["location"]
|
||||||
|
}
|
||||||
|
),
|
||||||
|
Tool(
|
||||||
|
name="speak",
|
||||||
|
description="轮足机器人进行语音播报:告诉、提醒、告知、提示、通知",
|
||||||
|
inputSchema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"speech": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "要播报的语音内容",
|
||||||
|
"minLength": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["speech"]
|
||||||
|
}
|
||||||
|
),
|
||||||
|
Tool(
|
||||||
|
name="reception",
|
||||||
|
description="轮足机器人去接待客人:去接人、请迎接客人、去接待、迎接一下、带人过来",
|
||||||
|
inputSchema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"location": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "在这个位置接待贵宾",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "客人姓名",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"destination": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "接待贵宾到这个位置",
|
||||||
|
"minLength": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["location", "name", "destination"]
|
||||||
|
}
|
||||||
|
),
|
||||||
|
Tool(
|
||||||
|
name="notification",
|
||||||
|
description="轮足机器人去指定地点播放通知:通知、去那里说、去某地播报、传达消息",
|
||||||
|
inputSchema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"location": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "机器人需要前往的目标地点名称",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "到达地点后需要播放的文本内容",
|
||||||
|
"minLength": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["location", "text"]
|
||||||
|
}
|
||||||
|
),
|
||||||
|
Tool(
|
||||||
|
name="repose",
|
||||||
|
description="轮足机器人、助手、机器人去重新定位",
|
||||||
|
inputSchema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {},
|
||||||
|
"required": []
|
||||||
|
}
|
||||||
|
),
|
||||||
|
Tool(
|
||||||
|
name="patrol",
|
||||||
|
description="轮足机器人、助手、机器人去巡逻:巡逻、巡查、去检查一下、去看看、去巡视。支持按路线巡逻或随机巡逻。",
|
||||||
|
inputSchema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"locations": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "机器人巡逻经过的地点列表。如果不提供,则默认进行随机巡逻。",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flag": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "是否随机巡逻。True为随机巡逻,False为按locations指定的路线巡逻。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": []
|
||||||
|
}
|
||||||
|
),
|
||||||
|
Tool(
|
||||||
|
name="dance",
|
||||||
|
description="轮足机器人跳舞",
|
||||||
|
inputSchema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {},
|
||||||
|
"required": []
|
||||||
|
}
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
@server.call_tool()
|
||||||
|
async def call_tool(name: str, arguments: dict) -> Sequence[TextContent]:
|
||||||
|
"""处理工具调用"""
|
||||||
|
try:
|
||||||
|
result = ""
|
||||||
|
if name == "recharge":
|
||||||
|
result = await nav_server.recharge()
|
||||||
|
elif name == "terminate":
|
||||||
|
result = await nav_server.terminate()
|
||||||
|
elif name == "goto":
|
||||||
|
if "location" not in arguments:
|
||||||
|
raise ValueError("缺少必要参数: location")
|
||||||
|
result = await nav_server.goto(
|
||||||
|
location=arguments["location"]
|
||||||
|
)
|
||||||
|
elif name == "speak":
|
||||||
|
if "speech" not in arguments:
|
||||||
|
raise ValueError("缺少必要参数: speech")
|
||||||
|
result = await nav_server.speak(
|
||||||
|
speech=arguments["speech"]
|
||||||
|
)
|
||||||
|
elif name == "reception":
|
||||||
|
if "location" not in arguments:
|
||||||
|
raise ValueError("缺少必要参数: location")
|
||||||
|
result = await nav_server.reception(
|
||||||
|
location=arguments.get("location", "前台"),
|
||||||
|
name=arguments.get("name", "贵宾"),
|
||||||
|
destination=arguments.get("destination", "会议室")
|
||||||
|
)
|
||||||
|
elif name == "notification":
|
||||||
|
if "location" not in arguments or "text" not in arguments:
|
||||||
|
raise ValueError("缺少必要参数: location or text")
|
||||||
|
result = await nav_server.notification(
|
||||||
|
location=arguments["location"],
|
||||||
|
text=arguments["text"]
|
||||||
|
)
|
||||||
|
elif name == "repose":
|
||||||
|
result = await nav_server.repose()
|
||||||
|
elif name == "dance":
|
||||||
|
result = await nav_server.dance()
|
||||||
|
elif name == "patrol":
|
||||||
|
locations = arguments.get("locations", [])
|
||||||
|
flag = arguments.get("flag", True if not locations else False)
|
||||||
|
result = await nav_server.patrol(
|
||||||
|
locations=locations,
|
||||||
|
flag=flag
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"未知工具: {name}")
|
||||||
|
return [TextContent(type="text", text=result)]
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"工具调用失败: {str(e)}")
|
||||||
|
raise ValueError(f"执行失败: {str(e)}")
|
||||||
|
|
||||||
|
options = server.create_initialization_options()
|
||||||
|
async with stdio_server() as (read_stream, write_stream):
|
||||||
|
await server.run(read_stream, write_stream, options)
|
||||||
|
|
||||||
|
def main():
|
||||||
|
asyncio.run(serve())
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
113
lzwcai_temi_mcp/lzwcai_temi_mcp/mcp_mqtt.py
Normal file
113
lzwcai_temi_mcp/lzwcai_temi_mcp/mcp_mqtt.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import paho.mqtt.client as mqtt
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
from typing import Optional
|
||||||
|
import uuid
|
||||||
|
import threading
|
||||||
|
import requests
|
||||||
|
from os import getenv
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
MQTT_CLIENT_ID = f"MCPMQTT-{uuid.uuid4().hex[:8]}"
|
||||||
|
|
||||||
|
def getConfig_url(array):
|
||||||
|
url = "http://lzwcai-demp-corp-manager:8086/system/config/getConfig"
|
||||||
|
data = [array]
|
||||||
|
try:
|
||||||
|
response = requests.post(url, json=data, timeout=5)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()['data']
|
||||||
|
return data[0]['configValue']
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error fetching config for {array}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
class MQTTHandler:
|
||||||
|
def __init__(self):
|
||||||
|
self.client = mqtt.Client()
|
||||||
|
self.tasks = {} # {task_id: task_data}
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
# 获取MQTT配置,提供默认值
|
||||||
|
self.mqtt_username = getenv('MQTT_USERNAME') or 'lzwc'
|
||||||
|
self.mqtt_password = getenv('MQTT_PASSWORD') or 'Lzwc@4187.'
|
||||||
|
mqtt_broker_raw = getenv('MQTT_BROKER') or 'emqx'
|
||||||
|
# 移除协议前缀,只保留主机名
|
||||||
|
self.mqtt_broker = mqtt_broker_raw.replace('tcp://', '').replace('mqtt://', '')
|
||||||
|
mqtt_port_str = getenv('MQTT_PORT')
|
||||||
|
self.mqtt_port = int(mqtt_port_str) if mqtt_port_str else 1883
|
||||||
|
|
||||||
|
# 记录配置信息
|
||||||
|
logger.info(f"MQTT配置 - Broker: {self.mqtt_broker}, Port: {self.mqtt_port}, Username: {self.mqtt_username}")
|
||||||
|
if not getenv('MQTT_BROKER'):
|
||||||
|
logger.warning("MQTT_BROKER环境变量未设置,使用默认值")
|
||||||
|
if not mqtt_port_str:
|
||||||
|
logger.warning("MQTT_PORT环境变量未设置,使用默认端口1883")
|
||||||
|
|
||||||
|
self.client.username_pw_set(self.mqtt_username, self.mqtt_password)
|
||||||
|
self.client.on_connect = self._on_connect
|
||||||
|
self.client.on_message = self._on_message
|
||||||
|
self.client.on_disconnect = self._on_disconnect
|
||||||
|
try:
|
||||||
|
logger.info(f"正在连接 MQTT 代理: {self.mqtt_broker}:{self.mqtt_port}")
|
||||||
|
self.client.connect(self.mqtt_broker, self.mqtt_port, 60)
|
||||||
|
self.client.loop_start()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"连接 MQTT 失败: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _on_connect(self, client, userdata, flags, rc):
|
||||||
|
"""MQTT 连接回调"""
|
||||||
|
if rc == 0:
|
||||||
|
logger.info("已成功连接到 MQTT 代理服务器")
|
||||||
|
else:
|
||||||
|
logger.error(f"连接 MQTT 代理服务器失败,返回码: {rc}")
|
||||||
|
|
||||||
|
def get_status(self, task_id: str = None):
|
||||||
|
"""获取任务状态
|
||||||
|
Args:
|
||||||
|
task_id: 可选参数,指定任务ID。如果为None则返回所有任务状态
|
||||||
|
Returns:
|
||||||
|
如果指定task_id则返回该任务的状态,否则返回所有任务状态
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
if task_id:
|
||||||
|
return self.tasks.get(task_id, {}).copy()
|
||||||
|
return {k: v.copy() for k, v in self.tasks.items()}
|
||||||
|
|
||||||
|
def _on_message(self, client, userdata, msg):
|
||||||
|
try:
|
||||||
|
payload = json.loads(msg.payload.decode())
|
||||||
|
logger.info(f"[MQTT] topic={msg.topic} msg: {payload}")
|
||||||
|
|
||||||
|
# task_id = payload.get('task_id')
|
||||||
|
# if task_id and task_id in self.tasks:
|
||||||
|
# with self._lock:
|
||||||
|
# self.tasks[task_id].update({
|
||||||
|
# 'status': payload.get('status', 'UNKNOWN'),
|
||||||
|
# 'description': payload.get('description', '')
|
||||||
|
# })
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[错误] 处理消息失败: {e}")
|
||||||
|
|
||||||
|
def _on_disconnect(self, client, userdata, rc):
|
||||||
|
"""MQTT 断开连接回调"""
|
||||||
|
if rc != 0:
|
||||||
|
logger.warning("意外断开与 MQTT 代理服务器的连接。")
|
||||||
|
try:
|
||||||
|
logger.info("尝试重新连接 MQTT...")
|
||||||
|
client.reconnect()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"重新连接 MQTT 失败: {e}")
|
||||||
|
|
||||||
|
# 单例模式
|
||||||
|
_instance: Optional[MQTTHandler] = None
|
||||||
|
|
||||||
|
def get_mcpmqtt_handler() -> MQTTHandler:
|
||||||
|
"""获取单例"""
|
||||||
|
global _instance
|
||||||
|
if _instance is None:
|
||||||
|
_instance = MQTTHandler()
|
||||||
|
return _instance
|
||||||
142
lzwcai_temi_mcp/lzwcai_temi_mcp/nav_server.py
Normal file
142
lzwcai_temi_mcp/lzwcai_temi_mcp/nav_server.py
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
from typing import Optional, Sequence, List, Dict, Any
|
||||||
|
import logging
|
||||||
|
import json
|
||||||
|
from .mcp_mqtt import get_mcpmqtt_handler
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class NavServer:
|
||||||
|
def __init__(self, mmhandler=None):
|
||||||
|
self.mmhandler = mmhandler or get_mcpmqtt_handler()
|
||||||
|
|
||||||
|
async def pub_cmd(self, device_id: str, action: str, params: Dict[str, Any]):
|
||||||
|
"""
|
||||||
|
发送MQTT命令
|
||||||
|
:param device_id: 设备ID
|
||||||
|
:param action: 动作指令 (对应 Kotlin 中的 action/cmd/type)
|
||||||
|
:param params: 其他参数 (将被合并到根 JSON 对象中)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
payload = {
|
||||||
|
"device_id": device_id,
|
||||||
|
"action": action
|
||||||
|
}
|
||||||
|
if params:
|
||||||
|
payload.update(params)
|
||||||
|
|
||||||
|
logger.info(f"Publishing command: {action}, payload: {payload}")
|
||||||
|
self.mmhandler.client.publish("robot/cmd", json.dumps(payload), qos=2)
|
||||||
|
return f"{action} 任务已经下达完成"
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to publish command: {str(e)}", exc_info=True)
|
||||||
|
return f"Failed to publish command: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
async def recharge(self):
|
||||||
|
"""轮足机器人充电"""
|
||||||
|
try:
|
||||||
|
params = {}
|
||||||
|
return await self.pub_cmd("temi-test", "recharge", params)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to call recharge mcp-tool: {str(e)} ", exc_info=True)
|
||||||
|
return f"Failed to call recharge mcp-tool: {str(e)}"
|
||||||
|
|
||||||
|
async def terminate(self):
|
||||||
|
"""轮足机器人终止当前任务"""
|
||||||
|
try:
|
||||||
|
params = {}
|
||||||
|
return await self.pub_cmd("temi-test", "terminate", params)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to call terminate mcp-tool: {str(e)} ", exc_info=True)
|
||||||
|
return f"Failed to call terminate mcp-tool: {str(e)}"
|
||||||
|
|
||||||
|
async def goto(self, location: str, flag: bool = False):
|
||||||
|
"""轮足导航到指定位置"""
|
||||||
|
try:
|
||||||
|
if not location:
|
||||||
|
return "Location is not specified."
|
||||||
|
|
||||||
|
# Kotlin 端对应 action: "goto"
|
||||||
|
# Kotlin 端参数: location (or target)
|
||||||
|
params = {
|
||||||
|
"location": location
|
||||||
|
}
|
||||||
|
return await self.pub_cmd("temi-test", "goto", 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)}"
|
||||||
|
|
||||||
|
async def speak(self, speech: str):
|
||||||
|
"""轮足机器人语音播报"""
|
||||||
|
try:
|
||||||
|
if not speech:
|
||||||
|
return "Speech content is not specified."
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"text": speech,
|
||||||
|
"lang": "zh"
|
||||||
|
}
|
||||||
|
return await self.pub_cmd("temi-test", "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, location: str = "前台", name: str = "贵宾", destination: str = "会议室"):
|
||||||
|
"""轮足机器人移动到指定位置迎宾"""
|
||||||
|
try:
|
||||||
|
params = {
|
||||||
|
"location": location,
|
||||||
|
"text": f"您好,{name},我是接待机器人。",
|
||||||
|
"destination": destination
|
||||||
|
}
|
||||||
|
return await self.pub_cmd("temi-test", "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)}"
|
||||||
|
|
||||||
|
async def notification(self, location: str, text: str):
|
||||||
|
"""轮足机器人移动到指定位置并播放通知文本"""
|
||||||
|
try:
|
||||||
|
if not location or not text:
|
||||||
|
return "Location or text is not specified."
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"location": location,
|
||||||
|
"text": text
|
||||||
|
}
|
||||||
|
return await self.pub_cmd("temi-test", "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)}"
|
||||||
|
|
||||||
|
async def repose(self):
|
||||||
|
"""轮足机器人重新定位"""
|
||||||
|
try:
|
||||||
|
params = {}
|
||||||
|
return await self.pub_cmd("temi-test", "repose", 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)}"
|
||||||
|
|
||||||
|
async def dance(self):
|
||||||
|
"""轮足机器人跳舞"""
|
||||||
|
try:
|
||||||
|
params = {}
|
||||||
|
return await self.pub_cmd("temi-test", "dance", params)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to call dance mcp-tool: {str(e)} ", exc_info=True)
|
||||||
|
return f"Failed to call dance mcp-tool: {str(e)}"
|
||||||
|
|
||||||
|
|
||||||
|
async def patrol(self, locations: list = None, flag: bool = False):
|
||||||
|
"""轮足机器人巡逻 """
|
||||||
|
try:
|
||||||
|
params = {
|
||||||
|
"flag": True,
|
||||||
|
"locations": locations or []
|
||||||
|
}
|
||||||
|
return await self.pub_cmd("temi-test", "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)}"
|
||||||
|
|
||||||
13
lzwcai_temi_mcp/mcp-server.json
Normal file
13
lzwcai_temi_mcp/mcp-server.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"lzwcai_temi_mcp": {
|
||||||
|
"disabled": false,
|
||||||
|
"type": "stdio",
|
||||||
|
"timeout": 30,
|
||||||
|
"command": "uvx",
|
||||||
|
"args": [
|
||||||
|
"lzwcai_temi_mcp"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
26
lzwcai_temi_mcp/pyproject.toml
Normal file
26
lzwcai_temi_mcp/pyproject.toml
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "lzwcai_temi_mcp"
|
||||||
|
version = "0.1.12"
|
||||||
|
description = "MQTT-based navigation server for robot"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
dependencies = [
|
||||||
|
"fastapi>=0.95.0",
|
||||||
|
"uvicorn>=0.21.0",
|
||||||
|
"paho-mqtt>=2.0.0",
|
||||||
|
"pydantic>=1.10.0",
|
||||||
|
"python-dotenv>=0.21.0",
|
||||||
|
"mcp[cli]>=1.6.0",
|
||||||
|
"requests>=2.25.0"
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
lzwcai_temi_mcp = "lzwcai_temi_mcp.main:main"
|
||||||
|
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["lzwcai_temi_mcp"]
|
||||||
|
|
||||||
17
mcp.json
Normal file
17
mcp.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"lark-mcp": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@larksuiteoapi/lark-mcp",
|
||||||
|
"mcp",
|
||||||
|
"-a",
|
||||||
|
"cli_a8d0e0c140169013",
|
||||||
|
"-s",
|
||||||
|
"yEc0E8Aoo8Mo9NPPzphidez51xB71HXW"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
408
交接文档.md
Normal file
408
交接文档.md
Normal file
@@ -0,0 +1,408 @@
|
|||||||
|
# lzwcai-mcp 项目交接文档
|
||||||
|
|
||||||
|
本文档面向接手本仓库的技术人员,重点说明整体架构、各子项目职责、运行方式与配置要点,便于快速上手维护与扩展。
|
||||||
|
|
||||||
|
## 一、项目整体概览
|
||||||
|
|
||||||
|
- 仓库名称:`lzwcai-mcp`
|
||||||
|
- 项目类型:一组基于 MCP(Model Context Protocol)的独立服务,每个服务都是一个「技能包」,通过 `stdio` 与上层 MCP Agent 通信。
|
||||||
|
- 主要能力:
|
||||||
|
- Feishu/Lark 卡片发送、图片上传、OKR 查询等企业协同能力(`lzwcai_lark_mcp`)
|
||||||
|
- 文件与 URL 的 Base64 编解码、临时文件处理、MinIO 上传等通用文件工具(`file_tools`)
|
||||||
|
- 金蝶云星空单据保存与查询能力,封装 DynamicFormService.Save / View 接口(`k3cloud_mcp`)
|
||||||
|
- 访客信息查询与登记、门禁控制(`terminal_dhr_mcp`)
|
||||||
|
- Temi 轮足机器人导航与业务动作控制(`terminal_temi_mcp`)
|
||||||
|
- Go2 机器人运动控制、避障、服务管理等(`terminal_go2_mcp`)
|
||||||
|
|
||||||
|
各子项目都是独立的 Python 包,拥有各自的 `pyproject.toml` 与入口脚本,通过 JSON 配置文件被 MCP Agent 挂载为工具服务器。
|
||||||
|
|
||||||
|
## 二、代码结构概览
|
||||||
|
|
||||||
|
仓库根目录主要结构:
|
||||||
|
|
||||||
|
- `file_tools/`
|
||||||
|
- `file_tools/`:文件工具 MCP 服务器实现(入口 `file_tools.main`)。
|
||||||
|
- `pyproject.toml`:使用 `hatchling` 构建,脚本名 `lzwcai-file-tools-mcp`。
|
||||||
|
- `k3cloud_mcp/`
|
||||||
|
- `k3cloud_mcp/`:金蝶云星空 MCP 服务器实现。
|
||||||
|
- `mcp-server.json`:MCP Agent 挂载示例,包含 `k3cloud-mcp` 的启动方式与环境变量示例。
|
||||||
|
- `pyproject.toml`:使用 `hatchling` 构建,脚本名 `k3cloud-mcp`。
|
||||||
|
- `lzwcai_lark_mcp/`
|
||||||
|
- `lzwcai_lark_mcp/`:Lark 相关 MCP 服务器实现(入口 `lzwcai_lark_mcp.main`)。
|
||||||
|
- `pyproject.toml`:使用 `hatchling` 构建,脚本名 `lzwcai-lark-mcp`。
|
||||||
|
- `lzwcai_temi_mcp/`
|
||||||
|
- `lzwcai_temi_mcp/`:另一个 Temi 机器人相关 MCP 服务(目前主要用到 `main.py`、`mcp_mqtt.py`、`nav_server.py`)。
|
||||||
|
- `mcp-server.json`:MCP Agent 挂载示例。
|
||||||
|
- `pyproject.toml`:使用 `hatchling` 构建,脚本名 `lzwcai_temi_mcp`。
|
||||||
|
- `terminal_dhr_mcp/`
|
||||||
|
- `terminal_dhr_mcp/`:访客系统 + 门禁控制 MCP 服务器。
|
||||||
|
- `.env`:示例环境变量。
|
||||||
|
- `pyproject.toml`:基于 `setuptools` 的构建配置,脚本名 `terminal-dhr-mcp`。
|
||||||
|
- `terminal_go2_mcp/`
|
||||||
|
- `terminal_go2_mcp/`:Go2 机器人 MCP 服务器。
|
||||||
|
- `pyproject.toml`:使用 `hatchling` 构建,脚本名 `terminal_go2_mcp`。
|
||||||
|
- `terminal_temi_mcp/`
|
||||||
|
- `terminal_temi_mcp/`:Temi 轮足机器人 MCP 服务器。
|
||||||
|
- `pyproject.toml`:使用 `hatchling` 构建,脚本名 `terminal_temi_mcp`。
|
||||||
|
- 根目录其他文件:
|
||||||
|
- `README.md`:顶层简介与部分子项目说明。
|
||||||
|
- `mcp.json`:示例 MCP 服务器配置(目前包含一个 JS 实现的 `lark-mcp` 示例,与本仓库 Python 版本的 Lark MCP 可共存)。
|
||||||
|
- `LICENSE`、`.gitignore` 等。
|
||||||
|
|
||||||
|
## 三、运行环境与基础依赖
|
||||||
|
|
||||||
|
总体建议:
|
||||||
|
|
||||||
|
- Python 版本:优先使用 **3.10+**。
|
||||||
|
- `terminal-dhr-mcp` 声明支持 3.8+,但其他项目为 3.10+,统一到 3.10 可以减少环境碎片。
|
||||||
|
- 依赖管理:建议使用虚拟环境(`venv` 或 `uv` 等)分别在各子项目目录下执行安装。
|
||||||
|
|
||||||
|
核心依赖:
|
||||||
|
|
||||||
|
- 统一:
|
||||||
|
- `mcp[cli]` 或 `mcp`:MCP 协议实现。
|
||||||
|
- `python-dotenv`:从 `.env` 加载配置。
|
||||||
|
- Lark 相关(`lzwcai_lark_mcp`):
|
||||||
|
- `requests`:调用 Feishu/Lark HTTP API。
|
||||||
|
- `openpyxl`:从 Excel 中解析 `image_key` 及图片路径。
|
||||||
|
- 文件工具(`file_tools`):
|
||||||
|
- `httpx`:下载远程文件。
|
||||||
|
- `minio`:将文件上传到 MinIO 对象存储。
|
||||||
|
- `openpyxl`:Excel 图片解析。
|
||||||
|
- 金蝶云星空(`k3cloud_mcp`):
|
||||||
|
- `requests`:调用 K3Cloud Web API。
|
||||||
|
- 访客/门禁(`terminal_dhr_mcp`):
|
||||||
|
- `httpx`、`pydantic`、`python-dotenv` 等,用于 HTTP 调用与配置。
|
||||||
|
- 机器人相关(`terminal_temi_mcp`、`terminal_go2_mcp`):
|
||||||
|
- `fastapi`、`uvicorn`(部分场景)、`paho-mqtt`、`pydantic`、`requests`。
|
||||||
|
|
||||||
|
## 四、各子项目详细说明
|
||||||
|
|
||||||
|
### 4.1 Lark MCP(`lzwcai_lark_mcp`)
|
||||||
|
|
||||||
|
- 入口:
|
||||||
|
- 包:`lzwcai_lark_mcp`
|
||||||
|
- 入口函数:`lzwcai_lark_mcp.main:main`
|
||||||
|
- MCP Server 名称:`lzwcai-mcpskills-lark-mcp`
|
||||||
|
- 核心文件:
|
||||||
|
- `main.py`:MCP server 启动逻辑,负责:
|
||||||
|
- 初始化 `Server`。
|
||||||
|
- 通过 `_request_token` 根据 `app_id` / `app_secret` 获取 Lark `tenant_access_token`,带过期时间缓存。
|
||||||
|
- 注册 `list_tools` / `call_tool`,在调用工具前自动确保 token 有效。
|
||||||
|
- `config.py`:
|
||||||
|
- 从环境变量读取:
|
||||||
|
- `app_id`
|
||||||
|
- `app_secret`
|
||||||
|
- `tools.py`:
|
||||||
|
- 定义对外暴露的 `Tool` 列表。
|
||||||
|
- 实现具体业务函数,如:
|
||||||
|
- 图片上传:`upload_image_by_url` / `upload_image_by_file`
|
||||||
|
- Excel 定位图片源:`_resolve_image_source_from_excel`
|
||||||
|
- OKR 查询:`get_user_okr_list`、`batch_get_okr`
|
||||||
|
- 资产确认/复核/陌生人处理/反馈等多种交互卡片:`send_confirmation_card`、`send_review_card`、`send_stranger_card`、`send_feedback_card`
|
||||||
|
- 入库通知卡片:`send_notion_card` / `send_card_message`
|
||||||
|
- `handle_call_tool`:根据 `name` 分发到对应的 Python 函数,处理参数校验、错误返回等。
|
||||||
|
- 外部依赖与接口:
|
||||||
|
- Feishu/Lark Open API:
|
||||||
|
- 认证:`/open-apis/auth/v3/tenant_access_token/internal`
|
||||||
|
- 消息/图片:`/open-apis/im/v1/images`、`/open-apis/im/v1/messages`
|
||||||
|
- 通讯录:`/open-apis/contact/v3/users/{user_id}`
|
||||||
|
- OKR:`/open-apis/okr/v1/users/{user_id}/okrs`、`/open-apis/okr/v1/okrs/batch_get`
|
||||||
|
- 配置要点:
|
||||||
|
- 必需环境变量:
|
||||||
|
- `app_id`:Lark 应用的 app id。
|
||||||
|
- `app_secret`:Lark 应用的 app secret。
|
||||||
|
- 可选:`LOG_LEVEL`(通过 logging.basicConfig 读取)。
|
||||||
|
|
||||||
|
### 4.2 文件工具 MCP(`file_tools`)
|
||||||
|
|
||||||
|
- 入口:
|
||||||
|
- 包:`file_tools`
|
||||||
|
- 入口函数:`file_tools.main:main`
|
||||||
|
- MCP Server 名称:`lzwcai-mcpskills-file-tools-mcp`
|
||||||
|
- 核心文件:
|
||||||
|
- `main.py`:
|
||||||
|
- 初始化 MCP `Server`,注册 `list_tools` / `call_tool`。
|
||||||
|
- `tools.py`:
|
||||||
|
- 工具列表:
|
||||||
|
- `file_to_json`:将文件对象或本地路径转成 JSON 结构(带 base64 内容)。
|
||||||
|
- `file_to_data_uri`:将文件转为 `data URI`。
|
||||||
|
- `file_path_to_data_uri`:本地文件路径 -> `data URI`。
|
||||||
|
- `url_to_data_uri`:URL 下载并转为 `data URI`。
|
||||||
|
- `url_to_temp_file`:URL 下载为本地临时文件,返回路径。
|
||||||
|
- `excel_image_key_to_temp_file`:从 Excel 中按 `image_key` 找到图片并写入临时文件。
|
||||||
|
- `upload_file_to_minio`:将文件上传到 MinIO,返回访问 URL。
|
||||||
|
- 工具内部大量使用 `Path`、`tempfile`、`httpx`、`minio` 等。
|
||||||
|
- 配置要点:
|
||||||
|
- MinIO 相关配置位于 `config.py`(`minio_endpoint`、`minio_access_key`、`minio_secret_key`),通过环境变量读取。
|
||||||
|
- 当前 `upload_file_to_minio` 里的 bucket 名称固定为 `lzwcai`(写在 `tools.py`),如需多环境隔离建议改为可配置项。
|
||||||
|
|
||||||
|
### 4.3 金蝶云星空 MCP(`k3cloud_mcp`)
|
||||||
|
|
||||||
|
- 入口:
|
||||||
|
- 包:`k3cloud_mcp`
|
||||||
|
- 入口函数:`k3cloud_mcp.main:main`
|
||||||
|
- MCP Server 名称:`k3cloud-mcp`
|
||||||
|
- 核心文件:
|
||||||
|
- `main.py`:
|
||||||
|
- 初始化 MCP `Server`。
|
||||||
|
- 注册 `list_tools` / `call_tool`。
|
||||||
|
- 运行 `stdio_server`,供上层 MCP Agent 调用。
|
||||||
|
- `tools.py`:
|
||||||
|
- 当前暴露两个工具:`save_form`、`view_form`。
|
||||||
|
- `save_form` 参数:
|
||||||
|
- `formid`:业务对象表单 Id,例如 `SAL_QUOTATION`。
|
||||||
|
- `data_payload`:可传 JSON 对象或 JSON 字符串。
|
||||||
|
- `timeout`:HTTP 超时时间,默认 30 秒。
|
||||||
|
- `view_form` 参数:
|
||||||
|
- `formid`:业务对象表单 Id,例如 `SAL_QUOTATION`。
|
||||||
|
- `payload`:可传 JSON 对象或 JSON 字符串。
|
||||||
|
- `timeout`:HTTP 超时时间,默认 30 秒。
|
||||||
|
- `handle_call_tool()` 负责做参数校验、分发到底层 `save()` / `view()`,并把响应序列化为文本返回。
|
||||||
|
- `client.py`:
|
||||||
|
- 封装 `save(formid, data_payload, timeout=30)` 与 `view(formid, payload, timeout=30)`。
|
||||||
|
- 分别调用 `DynamicFormService.Save.common.kdsvc` 与 `DynamicFormService.View.common.kdsvc` 接口。
|
||||||
|
- 会自动将对象型 payload 转为 JSON 字符串后再提交。
|
||||||
|
- `signing.py`:
|
||||||
|
- 负责生成 K3Cloud 所需签名头。
|
||||||
|
- 包含 `APP_ID` 拆分、`APP_SECRET` 解码、HMAC 签名等逻辑。
|
||||||
|
- `config.py`:
|
||||||
|
- 从环境变量读取 `K3CLOUD_*` 配置。
|
||||||
|
- 缺少必填项时会在导入阶段直接抛出异常。
|
||||||
|
- `demo_save.py` / `demo_save.json`:
|
||||||
|
- 用于本地直接测试 `save()` 函数。
|
||||||
|
- `demo_view.py` / `demo_view.json`:
|
||||||
|
- 用于本地直接测试 `view()` 函数。
|
||||||
|
- 外部依赖与接口:
|
||||||
|
- 金蝶云星空 Web API:
|
||||||
|
- 保存接口:`/Kingdee.BOS.WebApi.ServicesStub.DynamicFormService.Save.common.kdsvc`
|
||||||
|
- 查询接口:`/Kingdee.BOS.WebApi.ServicesStub.DynamicFormService.View.common.kdsvc`
|
||||||
|
- 配置要点:
|
||||||
|
- 必需环境变量:
|
||||||
|
- `K3CLOUD_BASE_URL`:K3Cloud 站点根地址。
|
||||||
|
- `K3CLOUD_ACCT_ID`:账套标识。
|
||||||
|
- `K3CLOUD_APP_ID`:应用标识。
|
||||||
|
- `K3CLOUD_USERNAME`:调用用户名。
|
||||||
|
- `K3CLOUD_APP_SECRET`:应用密钥。
|
||||||
|
- 可选环境变量:
|
||||||
|
- `K3CLOUD_LCID`:语言代码,默认 `2052`。
|
||||||
|
- `K3CLOUD_ORG_NUM`:组织编号,默认 `0`。
|
||||||
|
- `LOG_LEVEL`:日志级别。
|
||||||
|
- 本地测试方式:
|
||||||
|
- 保存接口联调:
|
||||||
|
- `python k3cloud_mcp/demo_save.py <formid> --payload k3cloud_mcp/demo_save.json`
|
||||||
|
- 查询接口联调:
|
||||||
|
- `python k3cloud_mcp/demo_view.py <formid> --payload k3cloud_mcp/demo_view.json`
|
||||||
|
- 当前测试脚本会优先使用当前进程环境变量;若未设置,则补读子项目目录下 `mcp-server.json` 中 `k3cloud-mcp.env` 的配置,便于本地快速验证。
|
||||||
|
|
||||||
|
### 4.4 访客系统 + 门禁 MCP(`terminal_dhr_mcp`)
|
||||||
|
|
||||||
|
- 入口:
|
||||||
|
- 包:`terminal_dhr_mcp`
|
||||||
|
- 入口函数:`terminal_dhr_mcp.main:main`
|
||||||
|
- MCP Server 名称:`terminal-dhr-mcp`
|
||||||
|
- 核心文件:
|
||||||
|
- `main.py`:
|
||||||
|
- 定义 `DhrMCPServer` 类,注册 `list_tools` / `call_tool`,调用 `tools.py` 中的逻辑。
|
||||||
|
- `tools.py`:
|
||||||
|
- 工具列表包括:
|
||||||
|
- `query_visitor`:根据 `user_id` 查询访客预约记录。
|
||||||
|
- `register_visitor`:登记新访客。
|
||||||
|
- `control_door`:控制门禁开关。
|
||||||
|
- `get_door_status`:查询门禁状态。
|
||||||
|
- `config.py`:
|
||||||
|
- 关键配置:
|
||||||
|
- `EMPLOYEE_ID`:从环境变量 `employeeId` 读取,用于请求访客系统。
|
||||||
|
- `BASE_URL`:访客系统服务地址。
|
||||||
|
- `VISITOR_QUERY_ENDPOINT`、`VISITOR_REGISTER_ENDPOINT`:固定后缀路径。
|
||||||
|
- 门禁相关:
|
||||||
|
- `DOOR_API_ENDPOINT`
|
||||||
|
- `DOOR_ENTERPRISE_ID`
|
||||||
|
- `DOOR_DEFAULT_ENTITY_ID`
|
||||||
|
- `LOG_LEVEL`、若干长度限制常量等。
|
||||||
|
- 典型环境配置:
|
||||||
|
- 可在 `terminal_dhr_mcp/.env` 中配置:
|
||||||
|
- `employeeId`
|
||||||
|
- `BASE_URL`
|
||||||
|
- `DOOR_ENTERPRISE_ID`
|
||||||
|
- `DOOR_DEFAULT_ENTITY_ID`
|
||||||
|
- `LOG_LEVEL`
|
||||||
|
|
||||||
|
### 4.5 Temi 轮足机器人 MCP(`terminal_temi_mcp`)
|
||||||
|
|
||||||
|
- 入口:
|
||||||
|
- 包:`terminal_temi_mcp`
|
||||||
|
- 入口函数:`terminal_temi_mcp.main:main`
|
||||||
|
- MCP Server 名称:`terminal_temi_mcp`
|
||||||
|
- 核心文件:
|
||||||
|
- `main.py`:
|
||||||
|
- 定义 `NavServer`,通过 `mcp_mqtt.get_mcpmqtt_handler()` 获取 MQTT 客户端。
|
||||||
|
- 封装业务命令:
|
||||||
|
- `nav_to`:导航到指定地点。
|
||||||
|
- `speak`:语音播报。
|
||||||
|
- `reception`:迎宾。
|
||||||
|
- `notification`(目前工具层注释掉)。
|
||||||
|
- `repose`:重新定位。
|
||||||
|
- `delivery`:配送任务。
|
||||||
|
- `patrol`:巡逻。
|
||||||
|
- 若干人脸识别 / 扫码相关函数目前未对外暴露。
|
||||||
|
- `list_tools` 中暴露的工具包括 `nav_to`、`speak`、`reception`、`repose`、`delivery`、`patrol` 等,对输入参数做了详细描述。
|
||||||
|
- `call_tool` 根据工具名分发到 `NavServer` 方法,并做参数校验与错误转换。
|
||||||
|
- `mcp_mqtt.py`:
|
||||||
|
- 封装 MQTT Broker 连接、客户端初始化与复用。
|
||||||
|
- 配置要点:
|
||||||
|
- `.env`(参考 README):
|
||||||
|
- `LOG_LEVEL`
|
||||||
|
- `MQTT_BROKER_ADDRESS`
|
||||||
|
- `MQTT_PORT`
|
||||||
|
- `MQTT_USERNAME`
|
||||||
|
- `MQTT_PASSWORD`
|
||||||
|
- MCP Agent 配置文件(示例在子项目 README 中)会把 `employeeId`、`userId` 作为环境变量注入。
|
||||||
|
|
||||||
|
### 4.6 Go2 机器人 MCP(`terminal_go2_mcp`)
|
||||||
|
|
||||||
|
- 入口:
|
||||||
|
- 包:`terminal_go2_mcp`
|
||||||
|
- 入口函数:`terminal_go2_mcp.main:main`
|
||||||
|
- MCP Server 名称:`terminal_go2_mcp`
|
||||||
|
- 核心文件:
|
||||||
|
- `main.py`:
|
||||||
|
- `TerminalGo2McpServer` 提供对 Go2 机器人的封装:
|
||||||
|
- 低层统一接口 `pubCmd(type, cmd, params)` 通过 MQTT 发布到 `unitree/go2/cmd`。
|
||||||
|
- 运动相关:`custom_action`、`ContinuousMove`、`move`、`euler`、`set_speed_level`、`pose` 等。
|
||||||
|
- 避障相关:`obstacle_avoidance_switch`、`obstacle_move`。
|
||||||
|
- 导航相关:`start_mapping`、`end_mapping`、`initialize_pose`、`pose_navigation`、`pause_navigation`、`resume_navigation` 等(部分在工具层已注释)。
|
||||||
|
- 充电:`start_recharge`、`stop_recharge`。
|
||||||
|
- 服务管理:`list_services`、`switch_service`、`set_report_freq`。
|
||||||
|
- `serve()` 中:
|
||||||
|
- 注册工具列表(Tool 定义),描述参数与取值范围。
|
||||||
|
- 在 `call_tool` 中根据 `name` 分发到对应协程函数。
|
||||||
|
- `mcp_mqtt.py`:
|
||||||
|
- 和 Temi 类似,管理 MQTT 客户端。
|
||||||
|
- 配置要点:
|
||||||
|
- MQTT 连接参数在 `mcp_mqtt.py` 或 `.env` 中通过环境变量读取(具体字段请在接手时查看该文件)。
|
||||||
|
|
||||||
|
### 4.7 Temi 机器人 MCP(旧版,`lzwcai_temi_mcp`)
|
||||||
|
|
||||||
|
- 入口:
|
||||||
|
- 包:`lzwcai_temi_mcp`
|
||||||
|
- 入口函数:`lzwcai_temi_mcp.main:main`
|
||||||
|
- MCP Server 名称:`lzwcai_temi_mcp`
|
||||||
|
- 核心文件:
|
||||||
|
- `main.py`:
|
||||||
|
- 当前暴露工具包含 `recharge`、`terminate`、`goto`、`speak`、`reception`、`notification`、`repose`、`patrol`、`dance`。
|
||||||
|
- `mcp_mqtt.py`:
|
||||||
|
- 管理 MQTT 连接与消息发布。
|
||||||
|
- `nav_server.py`:
|
||||||
|
- 封装导航与动作编排逻辑。
|
||||||
|
- 维护建议:
|
||||||
|
- 仓库内同时存在 `terminal_temi_mcp` 与 `lzwcai_temi_mcp` 两套 Temi 服务,接手时建议先和业务方确认实际在线使用的是哪一套,避免重复维护。
|
||||||
|
|
||||||
|
## 五、安装与运行方式(面向开发/调试)
|
||||||
|
|
||||||
|
以下步骤均建议在虚拟环境中执行。
|
||||||
|
|
||||||
|
1. 克隆仓库:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repo-url>
|
||||||
|
cd lzwcai-mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 进入某个子项目目录并安装:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd terminal_dhr_mcp
|
||||||
|
pip install -e .[dev]
|
||||||
|
# 或仅运行所需:
|
||||||
|
pip install -e .
|
||||||
|
```
|
||||||
|
|
||||||
|
其他子项目类似:
|
||||||
|
|
||||||
|
- `cd lzwcai_lark_mcp && pip install -e .`
|
||||||
|
- `cd file_tools && pip install -e .`
|
||||||
|
- `cd k3cloud_mcp && pip install -e .`
|
||||||
|
- `cd lzwcai_temi_mcp && pip install -e .`
|
||||||
|
- `cd terminal_temi_mcp && pip install -e .`
|
||||||
|
- `cd terminal_go2_mcp && pip install -e .`
|
||||||
|
|
||||||
|
3. 启动某个 MCP 服务器(本地调试):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Lark
|
||||||
|
lzwcai-lark-mcp
|
||||||
|
|
||||||
|
# 文件工具
|
||||||
|
lzwcai-file-tools-mcp
|
||||||
|
|
||||||
|
# 金蝶云星空
|
||||||
|
k3cloud-mcp
|
||||||
|
|
||||||
|
# 访客系统 + 门禁
|
||||||
|
terminal-dhr-mcp
|
||||||
|
|
||||||
|
# Temi 机器人(旧版)
|
||||||
|
lzwcai_temi_mcp
|
||||||
|
|
||||||
|
# Temi 机器人
|
||||||
|
terminal_temi_mcp
|
||||||
|
|
||||||
|
# Go2 机器人
|
||||||
|
terminal_go2_mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
4. 与 MCP Agent 集成:
|
||||||
|
|
||||||
|
- 顶层或子项目中提供示例配置文件,如:
|
||||||
|
- `lzwcai_lark_mcp/mcp-server.json`
|
||||||
|
- `terminal_dhr_mcp/mcp-server-dhr.json`
|
||||||
|
- `terminal_temi_mcp/mcp-server-temi.json`
|
||||||
|
- `terminal_go2_mcp/mcp-server-go2.json`
|
||||||
|
- `lzwcai_temi_mcp/mcp-server.json`
|
||||||
|
- `file_tools/mcp-server-file-tools.json`
|
||||||
|
- `k3cloud_mcp/mcp-server.json`
|
||||||
|
- 在上层 Agent 的配置(例如 IDE 插件或自研 Agent)中引入这些 JSON 即可通过 `stdio` 调起对应服务器。
|
||||||
|
|
||||||
|
## 六、配置与敏感信息管理
|
||||||
|
|
||||||
|
- 所有敏感信息(token、密钥、账号密码)均通过环境变量或 `.env` 文件注入,不应直接写入代码或仓库。
|
||||||
|
- 当前仓库中的部分 `mcp-server*.json` 已包含真实或准生产敏感配置示例;对外共享仓库前建议先做脱敏或改成占位符。
|
||||||
|
- 推荐做法:
|
||||||
|
- 针对每个子项目,在其根目录创建 `.env`,仅存放本项目相关配置。
|
||||||
|
- 在部署环境中,通过进程管理器(如 systemd、Docker、K8s)把 `.env` 或环境变量挂载进去。
|
||||||
|
- 接手时建议重点确认:
|
||||||
|
- Lark 应用的 `app_id`/`app_secret` 是否有效,是否具备所需 API 权限。
|
||||||
|
- K3Cloud 的 `BASE_URL`、账套、应用标识、用户名、密钥是否仍然有效,签名规则是否与现网一致。
|
||||||
|
- 访客系统 / 门禁接口 `BASE_URL`、企业 ID、门禁实体 ID 是否与现网一致。
|
||||||
|
- MQTT Broker 地址、账号密码是否与机器人当前网络环境匹配。
|
||||||
|
- MinIO 的 endpoint、access key、secret key、bucket 名称是否存在且有权限。
|
||||||
|
|
||||||
|
## 七、开发规范与注意事项
|
||||||
|
|
||||||
|
- 编码风格:
|
||||||
|
- Python 代码整体接近 PEP 8 风格。
|
||||||
|
- 部分子项目(如 `terminal_dhr_mcp`)在 `pyproject.toml` 中配置了 `black`、`isort`、`ruff`、`mypy` 等工具,对应规则可作为编码参考。
|
||||||
|
- 日志:
|
||||||
|
- 所有服务器都使用 `logging.basicConfig` 统一输出日志,日志级别可通过 `LOG_LEVEL` 环境变量控制。
|
||||||
|
- 出错时通常会在日志中打印详细异常,并向 MCP Agent 返回友好文本。
|
||||||
|
- 错误处理:
|
||||||
|
- 多数工具会对必需参数做显式校验,缺失时抛出 `ValueError` 或返回带错误信息的字符串。
|
||||||
|
- 对外 HTTP/MQTT 调用失败时,会记录错误日志,并抛出异常或返回失败说明。
|
||||||
|
- 工具定义:
|
||||||
|
- 工具使用 `mcp.types.Tool` 定义,`inputSchema` 为 JSON Schema 格式,方便上层 Agent 自动生成表单或进行参数校验。
|
||||||
|
- 若新增工具,请保持命名风格一致,并且在 `list_tools` 与 `call_tool` 实现中同步更新。
|
||||||
|
|
||||||
|
## 八、后续扩展建议
|
||||||
|
|
||||||
|
- 如需扩展新的硬件或业务系统,建议:
|
||||||
|
- 新增独立子项目目录(与现有结构一致),保持「一个 MCP 服务一个 Python 包」的模式。
|
||||||
|
- 优先复用现有 MQTT/HTTP 封装模式与错误处理风格。
|
||||||
|
- 在子项目内补充 README,简要说明工具列表与配置方式,并在顶层 `README.md` 中更新项目清单。
|
||||||
|
|
||||||
|
## 九、已知问题与接手优先项
|
||||||
|
|
||||||
|
- `file_tools/config.py` 默认从 `mcp-server-file-tools.json` 读取配置时,查找的 server key 是 `lzwcai-mcpskills-file-tools-mcp`,而当前示例文件使用的是 `lzwcai-file-tools-mcp`,可能导致本地自动注入环境变量失效。
|
||||||
|
- `terminal_temi_mcp` 与 `lzwcai_temi_mcp` 为并行存在的两套 Temi MCP 服务,能力与工具名不完全一致,建议先统一服务边界再继续演进。
|
||||||
|
|
||||||
|
如在接手过程中遇到不清楚的业务规则(例如具体资产变动业务、访客流程、机器人业务场景),建议先与业务方确认需求,再在对应子项目中补充或调整工具逻辑。
|
||||||
Reference in New Issue
Block a user