feat(core): 支持多种请求体格式和Content-Type自动处理

- 新增FORM、FORMDATA、PATH、PARAMS参数类型支持
- 实现multipart/form-data格式的文件上传功能
- 优化Content-Type自动设置逻辑,避免手动设置boundary问题
- 添加Content-Type不匹配的诊断功能,帮助排查请求错误

feat(config): 使用环境变量配置业务平台URL

- 从环境变量LZWCAI_CORP_MANAGER_URL获取基础URL
- 移除硬编码的默认URL配置
- 添加URL配置验证和错误提示

chore: 更新版本号至0.2.0

- 版本从0.1.30升级到0.2.0
- 更新包信息和项目配置文件
This commit is contained in:
2025-12-26 19:58:38 +08:00
parent cfc00f649a
commit ac403b5e6f
15 changed files with 202 additions and 27 deletions

View File

@@ -1,6 +1,6 @@
Metadata-Version: 2.4
Name: lzwcai-mcp-api-converter
Version: 0.1.30
Version: 0.2.0
Summary: 基于FastMCP框架的动态API工具服务器自动将企业业务API配置转换为MCP协议工具支持多种传输方式、企业认证和参数验证为AI助手提供标准化的业务接口访问能力。
Requires-Python: >=3.10
Description-Content-Type: text/markdown

View File

@@ -0,0 +1 @@
lzwc19781970385781825785858token={"authorization": "Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoiMTAwMDAwMDEiLCJsb2dpbl91c2VyX2tleSI6IjJmNmViMWVkYTk3MGRlNzI1OTM1YTczNzY5YWZmODJmZDE3MmFmMGIiLCJhYmJyIjoiXHU3MDc1XHU2Y2ZkXHU0ZTA3XHU1ZGRkIiwiYXVkIjoiIiwiZXhwIjoxNzY3MzQ4OTQxLCJpYXQiOjE3NjY3NDQxNDEsImlzcyI6IiIsImp0aSI6IjUyOTIyNzc0ZTdmZDA3MjZkNGEyY2FkMTgyYzEzNjM4IiwibmJmIjoxNzY2NzQ0MTQxLCJzdWIiOiIifQ.S8cvKtUfojJu0JvA1aPgd6H9y5ccd7XOa7UHMqZzn5w"}

View File

@@ -76,7 +76,11 @@ class RequestType:
"""
HEADER = "header" # 请求头参数
QUERY = "query" # 查询参数URL参数
BODY = "body" # 请求体参数
BODY = "body" # 请求体参数JSON格式
FORM = "form" # 表单参数application/x-www-form-urlencoded
FORMDATA = "formdata" # 多部分表单参数multipart/form-data支持文件上传
PATH = "path" # 路径参数
PARAMS = "params" # 路径参数(别名)
LZWCAI_CONFIG = "lzwcaiConfig" # lzwcaiConfig参数新的用户ID存储位置

View File

@@ -47,10 +47,14 @@ def get_business_api_details(api_ids: List[int], auth_token: str = None) -> List
token = auth_token or default_token
# 接口URL - 支持环境变量配置
# 默认URL
default_url = "http://lzwcai-demp-corp-manager:8086/system/mcpServer/bizSys/api/getByIds"
# 从环境变量获取URL如果没有设置则使用默认URL
url = os.getenv("lzwcai_mcp_dyntoolapi_auth_url", default_url)
# 从环境变量获取基础URL必须配置
base_url = os.getenv("LZWCAI_CORP_MANAGER_URL",'http://lzwcai-demp-corp-manager:8086')
if not base_url:
raise ValueError("环境变量 LZWCAI_CORP_MANAGER_URL 未配置请设置业务平台基础URL例如: http://lzwcai-demp-corp-manager:8086")
# API路径
api_path = "/system/mcpServer/bizSys/api/getByIds"
url = base_url.rstrip("/") + api_path
# 请求头
headers = {

View File

@@ -574,11 +574,17 @@ class ApiClient:
full_url = RequestBuilder.build_url_with_path_params(full_url, path_params)
# 根据请求体内容设置Content-Type (如果未被显式设置)
# 注意httpx 会自动处理某些 Content-Type但显式设置可以避免歧义
if "content-type" not in headers:
if json_data is not None:
headers["Content-Type"] = "application/json"
# httpx会自动为form_data设置'application/x-www-form-urlencoded'
# httpx会自动为formdata_data设置'multipart/form-data'并添加boundary
headers["content-type"] = "application/json"
elif form_data is not None:
# application/x-www-form-urlencoded 类型
headers["content-type"] = "application/x-www-form-urlencoded"
elif formdata_data is not None:
# multipart/form-data 类型 - 不设置 Content-Type让 httpx 自动添加 boundary
# 如果手动设置会缺少 boundary 参数导致请求失败
pass
# 发送请求
logger.info(f"发送HTTP请求: {method} {full_url}")
@@ -665,15 +671,156 @@ class ApiClient:
def _contains_file(self, data: Dict[str, Any]) -> bool:
"""检查数据字典中是否包含文件类对象"""
"""
检查数据字典中是否包含文件类对象
支持的文件类型:
- bytes: 字节数据
- 具有 read 属性的对象(文件句柄)
- tuple: (filename, content) 或 (filename, content, content_type) 格式
"""
if not data:
return False
for value in data.values():
# 检查是否为字节流或具有read属性的对象文件句柄
if isinstance(value, bytes) or hasattr(value, 'read'):
# 检查是否为字节流
if isinstance(value, bytes):
return True
# 检查是否为文件句柄具有read属性
if hasattr(value, 'read'):
return True
# 检查是否为元组格式的文件 (filename, content) 或 (filename, content, content_type)
if isinstance(value, tuple) and len(value) >= 2:
return True
return False
def _prepare_multipart_data(self, formdata_data: Dict[str, Any]) -> List[Tuple[str, Any]]:
"""
准备 multipart/form-data 格式的数据
将普通字段和文件字段统一转换为 httpx 可接受的格式
httpx 的 files 参数支持两种格式:
1. Dict[str, tuple] - 每个字段只有一个值
2. List[Tuple[str, tuple]] - 支持同名多值参数(如 status[]
为了支持数组参数,我们使用列表格式
"""
prepared_data = []
for key, value in formdata_data.items():
if value is None:
continue
# 处理数组类型的值
if isinstance(value, list):
# 数组参数:为每个元素创建一个同名字段
for item in value:
if item is None:
# 空值也需要发送
prepared_data.append((key, (None, "")))
elif isinstance(item, tuple):
prepared_data.append((key, item))
elif isinstance(item, bytes):
prepared_data.append((key, (None, item, 'application/octet-stream')))
elif hasattr(item, 'read'):
prepared_data.append((key, item))
else:
prepared_data.append((key, (None, str(item) if not isinstance(item, str) else item)))
# 如果已经是元组格式(文件),直接使用
elif isinstance(value, tuple):
prepared_data.append((key, value))
# 如果是字节数据,包装为文件格式
elif isinstance(value, bytes):
prepared_data.append((key, (None, value, 'application/octet-stream')))
# 如果是文件句柄,直接使用
elif hasattr(value, 'read'):
prepared_data.append((key, value))
# 普通字段,转换为 (None, value) 格式
else:
prepared_data.append((key, (None, str(value) if not isinstance(value, str) else value)))
return prepared_data
def _diagnose_content_type_issue(
self,
status_code: int,
response_text: str,
headers: Dict[str, str],
json_data: Optional[Dict[str, Any]],
form_data: Optional[Dict[str, Any]],
formdata_data: Optional[Dict[str, Any]],
) -> None:
"""
诊断 Content-Type 相关问题
当请求失败时,检查是否可能是 Content-Type 不匹配导致的问题,
并给出相应的诊断建议。
Args:
status_code: HTTP 状态码
response_text: 响应内容
headers: 请求头
json_data: JSON 请求体数据
form_data: 表单数据
formdata_data: multipart 表单数据
"""
# 常见的 Content-Type 相关错误状态码
content_type_error_codes = [400, 415, 422]
if status_code not in content_type_error_codes:
return
# 获取当前设置的 Content-Type
current_content_type = headers.get("content-type", "").lower()
# 检查响应中是否包含 Content-Type 相关的错误信息
response_lower = response_text.lower() if response_text else ""
content_type_keywords = [
"content-type", "content type", "media type",
"unsupported media", "invalid content", "expected json",
"expected form", "multipart", "boundary"
]
has_content_type_error = any(kw in response_lower for kw in content_type_keywords)
if has_content_type_error or status_code == 415:
logger.warning("=" * 60)
logger.warning("⚠️ 可能的 Content-Type 问题诊断:")
logger.warning(f" 当前 Content-Type: {current_content_type or '未设置'}")
# 分析数据类型和建议
if json_data is not None:
logger.warning(" 数据类型: JSON")
if "application/json" not in current_content_type:
logger.warning(" 💡 建议: 请求体是 JSON 格式,但 Content-Type 可能不正确")
logger.warning(" 应该使用: application/json")
elif form_data is not None:
logger.warning(" 数据类型: Form (application/x-www-form-urlencoded)")
if "application/x-www-form-urlencoded" not in current_content_type:
logger.warning(" 💡 建议: 请求体是表单格式,但 Content-Type 可能不正确")
logger.warning(" 应该使用: application/x-www-form-urlencoded")
elif formdata_data is not None:
logger.warning(" 数据类型: FormData (multipart/form-data)")
if "multipart/form-data" not in current_content_type:
logger.warning(" 💡 建议: 请求体是 multipart 格式")
logger.warning(" Content-Type 应由 httpx 自动设置(包含 boundary")
logger.warning(" 如果手动设置了 Content-Type请移除它")
else:
logger.warning(" 数据类型: 无请求体")
if current_content_type:
logger.warning(" 💡 建议: 没有请求体但设置了 Content-Type可能导致问题")
# 检查常见的配置错误
if "boundary" in response_lower and formdata_data is not None:
logger.warning(" ⚠️ 检测到 boundary 相关错误:")
logger.warning(" multipart/form-data 需要 boundary 参数")
logger.warning(" 请确保不要手动设置 Content-Type让 httpx 自动处理")
logger.warning("=" * 60)
async def _send_request(
self,
method: str,
@@ -696,17 +843,21 @@ class ApiClient:
}
# 为有请求体的方法添加数据
# 优先级: json > formdata > form
if method.upper() in ["POST", "PUT", "PATCH", "DELETE"]:
if json_data is not None:
request_kwargs["json"] = json_data
elif form_data is not None:
request_kwargs["data"] = form_data
elif formdata_data is not None:
# 区分文件上传和普通formdata
if self._contains_file(formdata_data):
request_kwargs["files"] = formdata_data
else:
request_kwargs["data"] = formdata_data
# multipart/form-data 类型处理
# 使用统一的方法准备数据
prepared_data = self._prepare_multipart_data(formdata_data)
if prepared_data:
request_kwargs["files"] = prepared_data
# 移除手动设置的 content-type让 httpx 自动添加带 boundary 的
headers.pop("content-type", None)
elif form_data is not None:
# application/x-www-form-urlencoded 类型
request_kwargs["data"] = form_data
# 根据HTTP方法发送请求
request_func = getattr(client, method.lower(), None)
@@ -739,6 +890,16 @@ class ApiClient:
if e.response.headers:
logger.info(f"响应头: {dict(e.response.headers)}")
# Content-Type 不匹配诊断
self._diagnose_content_type_issue(
e.response.status_code,
response_text,
headers,
json_data,
form_data,
formdata_data
)
return {
"status": "error",
"status_code": e.response.status_code,

View File

@@ -26,13 +26,17 @@ logger = get_logger(__name__)
class AuthDataTransformer:
"""认证数据转换器"""
def __init__(self, base_url: str = "http://lzwcai-demp-corp-manager:8086"):
def __init__(self, base_url: str = None):
"""
初始化转换器
Args:
base_url: API基础URL默认为 http://lzwcai-demp-corp-manager:8086
base_url: API基础URL如果不提供则从环境变量 LZWCAI_CORP_MANAGER_URL 获取
"""
if base_url is None:
base_url = os.getenv("LZWCAI_CORP_MANAGER_URL",'http://lzwcai-demp-corp-manager:8086')
if not base_url:
raise ValueError("环境变量 LZWCAI_CORP_MANAGER_URL 未配置请设置业务平台基础URL")
self.base_url = base_url.rstrip('/')
self.session = requests.Session()
@@ -84,7 +88,7 @@ class AuthDataTransformer:
原始API响应数据失败时返回None
"""
# url = f"{self.base_url}/system/mcpServer/auth/info/{user_id}/{business_system_id}"
url = f"http://lzwcai-demp-corp-manager:8086/system/mcpServer/auth/info/{user_id}/{business_system_id}"
url = f"{self.base_url}/system/mcpServer/auth/info/{user_id}/{business_system_id}"
try:
response = self.session.get(url, timeout=30)
@@ -263,14 +267,14 @@ class AuthDataTransformer:
# 便捷函数
def get_auth_data(user_id: str, business_system_id: str, base_url: str = "http://lzwcai-demp-corp-manager:8086") -> Optional[Dict[Any, Any]]:
def get_auth_data(user_id: str, business_system_id: str, base_url: str = None) -> Optional[Dict[Any, Any]]:
"""
便捷函数:获取转换后的认证数据
Args:
user_id: 用户ID
business_system_id: 业务系统ID
base_url: API基础URL默认为 http://lzwcai-demp-corp-manager:8086
base_url: API基础URL如果不提供则从环境变量 LZWCAI_CORP_MANAGER_URL 获取
Returns:
转换后的认证数据JSON失败时返回None

View File

@@ -2,8 +2,9 @@ import os
os.environ["modelId"] = "1946471611735015425"
os.environ["bizSysId"] = "1970385781825785858"
os.environ["bizSysApiIds"] = "[\"1970386761072058369\",\"1970386761185304578\",\"1970386761583763457\"]"
os.environ["businessUuid"] = "997"
os.environ["bizSysApiIds"] = "[\"1970386761072058369\",\"1970386761185304578\",\"1970386761583763457\",\"1970386761420185602\"]"
os.environ["businessUuid"] = "u9ua9ss2l8c"
os.environ["LZWCAI_CORP_MANAGER_URL"] = "http://192.168.2.236:8088"
# 导入模块
from lzwcai_mcp_api_converter.src.create_mcp import run_main

View File

@@ -1,6 +1,6 @@
[project]
name = "lzwcai-mcp-api-converter"
version = "0.1.30"
version = "0.2.0"
description = "基于FastMCP框架的动态API工具服务器自动将企业业务API配置转换为MCP协议工具支持多种传输方式、企业认证和参数验证为AI助手提供标准化的业务接口访问能力。"
readme = "README.md"
requires-python = ">=3.10"