From ede63f6e92f4179a72688fbcef6b1e9863fb2ec6 Mon Sep 17 00:00:00 2001 From: tanjianbin <632190820@qq.com> Date: Wed, 6 May 2026 16:29:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(k3cloud):=20=E6=96=B0=E5=A2=9E=E9=87=91?= =?UTF-8?q?=E8=9D=B6=E4=BA=91=E6=98=9F=E7=A9=BA=20MCP=20=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增完整的 K3Cloud MCP 服务器实现,包含配置管理、API 客户端、工具定义和签名验证 更新文件工具 MCP 服务器的 MinIO 配置和上传路径前缀 --- file_tools/file_tools/tools.py | 2 +- file_tools/mcp-server-file-tools.json | 6 +- file_tools/pyproject.toml | 2 +- k3cloud_mcp/k3cloud_mcp/__init__.py | 3 + k3cloud_mcp/k3cloud_mcp/client.py | 50 +++++++++++++ k3cloud_mcp/k3cloud_mcp/config.py | 18 +++++ k3cloud_mcp/k3cloud_mcp/demo.json | 78 +++++++++++++++++++ k3cloud_mcp/k3cloud_mcp/demo.py | 72 ++++++++++++++++++ k3cloud_mcp/k3cloud_mcp/main.py | 46 ++++++++++++ k3cloud_mcp/k3cloud_mcp/signing.py | 103 ++++++++++++++++++++++++++ k3cloud_mcp/k3cloud_mcp/tools.py | 75 +++++++++++++++++++ k3cloud_mcp/mcp-server.json | 22 ++++++ k3cloud_mcp/pyproject.toml | 19 +++++ 13 files changed, 491 insertions(+), 5 deletions(-) create mode 100644 k3cloud_mcp/k3cloud_mcp/__init__.py create mode 100644 k3cloud_mcp/k3cloud_mcp/client.py create mode 100644 k3cloud_mcp/k3cloud_mcp/config.py create mode 100644 k3cloud_mcp/k3cloud_mcp/demo.json create mode 100644 k3cloud_mcp/k3cloud_mcp/demo.py create mode 100644 k3cloud_mcp/k3cloud_mcp/main.py create mode 100644 k3cloud_mcp/k3cloud_mcp/signing.py create mode 100644 k3cloud_mcp/k3cloud_mcp/tools.py create mode 100644 k3cloud_mcp/mcp-server.json create mode 100644 k3cloud_mcp/pyproject.toml diff --git a/file_tools/file_tools/tools.py b/file_tools/file_tools/tools.py index 8030152..599a15c 100644 --- a/file_tools/file_tools/tools.py +++ b/file_tools/file_tools/tools.py @@ -387,7 +387,7 @@ def _upload_file_to_minio_sync(file_path: str) -> str: secure = endpoint_secure or False client = Minio(endpoint, access_key=access_key, secret_key=secret_key, secure=secure) bucket = "lzwcai" - prefix = "tmp" + prefix = "upload" if not client.bucket_exists(bucket): client.make_bucket(bucket) date_str = datetime.now().strftime("%Y-%m-%d") diff --git a/file_tools/mcp-server-file-tools.json b/file_tools/mcp-server-file-tools.json index ad78554..a2c7e85 100644 --- a/file_tools/mcp-server-file-tools.json +++ b/file_tools/mcp-server-file-tools.json @@ -9,9 +9,9 @@ "lzwcai-file-tools-mcp" ], "env": { - "minio_endpoint": "http://192.168.11.24:9000", - "minio_access_key": "cXk8WPR3ix86J9aGK6tH", - "minio_secret_key": "FSH8g3tx8bTR4w8BZmwl35WvWbOXZvfvCcivRRJE" + "minio_endpoint": "http://hyy-minio.awin25.com:1800", + "minio_access_key": "orOXTOpVfRtYzovP", + "minio_secret_key": "4EOMjjbrji1DHW0EBSlYA7JqBnJUy0aj" } } } diff --git a/file_tools/pyproject.toml b/file_tools/pyproject.toml index 9156580..6c9e2a6 100644 --- a/file_tools/pyproject.toml +++ b/file_tools/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "lzwcai-file-tools-mcp" -version = "0.1.0" +version = "0.1.1" description = "File tools MCP server" requires-python = ">=3.10" dependencies = [ diff --git a/k3cloud_mcp/k3cloud_mcp/__init__.py b/k3cloud_mcp/k3cloud_mcp/__init__.py new file mode 100644 index 0000000..c383ccf --- /dev/null +++ b/k3cloud_mcp/k3cloud_mcp/__init__.py @@ -0,0 +1,3 @@ +"""K3Cloud MCP package.""" + +__all__ = ["main"] diff --git a/k3cloud_mcp/k3cloud_mcp/client.py b/k3cloud_mcp/k3cloud_mcp/client.py new file mode 100644 index 0000000..6688aa0 --- /dev/null +++ b/k3cloud_mcp/k3cloud_mcp/client.py @@ -0,0 +1,50 @@ +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 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 不能为空") + if data_payload is None: + raise ValueError("data_payload 不能为空") + + if isinstance(data_payload, str): + payload_value = data_payload.strip() + if not payload_value: + raise ValueError("data_payload 不能为空字符串") + else: + try: + payload_value = json.dumps(data_payload, ensure_ascii=False) + except TypeError as exc: + raise TypeError("data_payload 必须是可序列化的 JSON 对象或 JSON 字符串") from exc + + 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() diff --git a/k3cloud_mcp/k3cloud_mcp/config.py b/k3cloud_mcp/k3cloud_mcp/config.py new file mode 100644 index 0000000..417de38 --- /dev/null +++ b/k3cloud_mcp/k3cloud_mcp/config.py @@ -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")) diff --git a/k3cloud_mcp/k3cloud_mcp/demo.json b/k3cloud_mcp/k3cloud_mcp/demo.json new file mode 100644 index 0000000..68801f2 --- /dev/null +++ b/k3cloud_mcp/k3cloud_mcp/demo.json @@ -0,0 +1,78 @@ +{ + "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": "" + }, + "FBillNo": "1", + "FPAYORGID": { + "FNumber": "" + }, + "FDATE": "1900-01-01", + "FCONTACTUNITTYPE": "", + "FCONTACTUNIT": { + "FNumber": "" + }, + "FPAYUNITTYPE": "", + "FPAYUNIT": { + "FNumber": "" + }, + "FCURRENCYID": { + "FNumber": "" + }, + "FSETTLECUR": { + "FNUMBER": "" + }, + "FDOCUMENTSTATUS": "", + "FBUSINESSTYPE": "", + "FCancelStatus": "", + "FSETTLEMAINBOOKID": { + "FNUMBER": "" + }, + "FRECEIVEBILLENTRY": [ + { + "FEntryID": 0, + "FSETTLETYPEID": { + "FNumber": "" + }, + "FPURPOSEID": { + "FNumber": "SFKYT01_SYS" + }, + "FPOSTDATE": "1900-01-01", + "FASSSALESORDER": [ + { + "FDetailID": 0 + } + ] + } + ], + "FRECEIVEBILLSRCENTRY": [ + { + "FEntryID": 0 + } + ], + "FBILLRECEIVABLEENTRY": [ + { + "FEntryID": 0 + } + ], + "FBILLSKDRECENTRY": [ + { + "FEntryID": 0 + } + ] + } +} \ No newline at end of file diff --git a/k3cloud_mcp/k3cloud_mcp/demo.py b/k3cloud_mcp/k3cloud_mcp/demo.py new file mode 100644 index 0000000..1b6fbfa --- /dev/null +++ b/k3cloud_mcp/k3cloud_mcp/demo.py @@ -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.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() diff --git a/k3cloud_mcp/k3cloud_mcp/main.py b/k3cloud_mcp/k3cloud_mcp/main.py new file mode 100644 index 0000000..534b163 --- /dev/null +++ b/k3cloud_mcp/k3cloud_mcp/main.py @@ -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() diff --git a/k3cloud_mcp/k3cloud_mcp/signing.py b/k3cloud_mcp/k3cloud_mcp/signing.py new file mode 100644 index 0000000..8f8c43d --- /dev/null +++ b/k3cloud_mcp/k3cloud_mcp/signing.py @@ -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, + } diff --git a/k3cloud_mcp/k3cloud_mcp/tools.py b/k3cloud_mcp/k3cloud_mcp/tools.py new file mode 100644 index 0000000..16bd940 --- /dev/null +++ b/k3cloud_mcp/k3cloud_mcp/tools.py @@ -0,0 +1,75 @@ +import json +from typing import Any, Dict, List + +from mcp.types import TextContent, Tool + +from .client import save + +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"], + }, + ) +] + + +def _normalize_payload(value: object) -> Any: + if value is None: + raise ValueError("missing data_payload") + if isinstance(value, str): + normalized = value.strip() + if not normalized: + raise ValueError("missing data_payload") + 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 != "save_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") + + result = save( + formid=formid, + data_payload=_normalize_payload(arguments.get("data_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}")] diff --git a/k3cloud_mcp/mcp-server.json b/k3cloud_mcp/mcp-server.json new file mode 100644 index 0000000..58078df --- /dev/null +++ b/k3cloud_mcp/mcp-server.json @@ -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" + } + } + } +} diff --git a/k3cloud_mcp/pyproject.toml b/k3cloud_mcp/pyproject.toml new file mode 100644 index 0000000..d8dd33f --- /dev/null +++ b/k3cloud_mcp/pyproject.toml @@ -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"]