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:
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
lzwc19781970385781825785858token={"authorization": "Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoiMTAwMDAwMDEiLCJsb2dpbl91c2VyX2tleSI6IjJmNmViMWVkYTk3MGRlNzI1OTM1YTczNzY5YWZmODJmZDE3MmFmMGIiLCJhYmJyIjoiXHU3MDc1XHU2Y2ZkXHU0ZTA3XHU1ZGRkIiwiYXVkIjoiIiwiZXhwIjoxNzY3MzQ4OTQxLCJpYXQiOjE3NjY3NDQxNDEsImlzcyI6IiIsImp0aSI6IjUyOTIyNzc0ZTdmZDA3MjZkNGEyY2FkMTgyYzEzNjM4IiwibmJmIjoxNzY2NzQ0MTQxLCJzdWIiOiIifQ.S8cvKtUfojJu0JvA1aPgd6H9y5ccd7XOa7UHMqZzn5w"}
|
||||
Binary file not shown.
Binary file not shown.
@@ -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存储位置)
|
||||
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user