feat(k3cloud): 新增金蝶云星空 MCP 服务器
新增完整的 K3Cloud MCP 服务器实现,包含配置管理、API 客户端、工具定义和签名验证 更新文件工具 MCP 服务器的 MinIO 配置和上传路径前缀
This commit is contained in:
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"]
|
||||
50
k3cloud_mcp/k3cloud_mcp/client.py
Normal file
50
k3cloud_mcp/k3cloud_mcp/client.py
Normal file
@@ -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()
|
||||
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"))
|
||||
78
k3cloud_mcp/k3cloud_mcp/demo.json
Normal file
78
k3cloud_mcp/k3cloud_mcp/demo.json
Normal file
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
72
k3cloud_mcp/k3cloud_mcp/demo.py
Normal file
72
k3cloud_mcp/k3cloud_mcp/demo.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.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()
|
||||
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,
|
||||
}
|
||||
75
k3cloud_mcp/k3cloud_mcp/tools.py
Normal file
75
k3cloud_mcp/k3cloud_mcp/tools.py
Normal file
@@ -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}")]
|
||||
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"]
|
||||
Reference in New Issue
Block a user