yapi-mcp 0.1.0__py3-none-any.whl → 0.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- yapi_mcp/__init__.py +5 -0
- yapi_mcp/__main__.py +6 -0
- config.py → yapi_mcp/config.py +48 -48
- yapi_mcp/server.py +185 -0
- {tools → yapi_mcp/tools}/__init__.py +1 -1
- {yapi → yapi_mcp/yapi}/__init__.py +1 -1
- yapi_mcp/yapi/client.py +259 -0
- {yapi → yapi_mcp/yapi}/errors.py +43 -21
- {yapi → yapi_mcp/yapi}/models.py +61 -60
- yapi_mcp-0.1.2.dist-info/METADATA +174 -0
- yapi_mcp-0.1.2.dist-info/RECORD +14 -0
- {yapi_mcp-0.1.0.dist-info → yapi_mcp-0.1.2.dist-info}/WHEEL +1 -1
- yapi_mcp-0.1.2.dist-info/entry_points.txt +2 -0
- {yapi_mcp-0.1.0.dist-info → yapi_mcp-0.1.2.dist-info}/licenses/LICENSE +21 -21
- __init__.py +0 -1
- server.py +0 -148
- yapi/client.py +0 -206
- yapi_mcp-0.1.0.dist-info/METADATA +0 -394
- yapi_mcp-0.1.0.dist-info/RECORD +0 -13
- yapi_mcp-0.1.0.dist-info/entry_points.txt +0 -2
yapi_mcp/__init__.py
ADDED
yapi_mcp/__main__.py
ADDED
config.py → yapi_mcp/config.py
RENAMED
|
@@ -1,48 +1,48 @@
|
|
|
1
|
-
"""Configuration management for YApi MCP Server."""
|
|
2
|
-
|
|
3
|
-
from pydantic import Field, HttpUrl
|
|
4
|
-
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
class ServerConfig(BaseSettings):
|
|
8
|
-
"""YApi MCP Server configuration loaded from environment variables or .env file."""
|
|
9
|
-
|
|
10
|
-
model_config = SettingsConfigDict(
|
|
11
|
-
env_file=".env",
|
|
12
|
-
env_file_encoding="utf-8",
|
|
13
|
-
case_sensitive=False,
|
|
14
|
-
)
|
|
15
|
-
|
|
16
|
-
yapi_server_url: HttpUrl = Field(
|
|
17
|
-
...,
|
|
18
|
-
description="YApi server base URL",
|
|
19
|
-
examples=["https://yapi.example.com"],
|
|
20
|
-
)
|
|
21
|
-
|
|
22
|
-
yapi_token: str = Field(
|
|
23
|
-
...,
|
|
24
|
-
min_length=1,
|
|
25
|
-
description="YApi authentication token (_yapi_token cookie)",
|
|
26
|
-
)
|
|
27
|
-
|
|
28
|
-
yapi_uid: str = Field(
|
|
29
|
-
...,
|
|
30
|
-
min_length=1,
|
|
31
|
-
description="YApi user ID (_yapi_uid cookie)",
|
|
32
|
-
)
|
|
33
|
-
|
|
34
|
-
yapi_cas: str | None = Field(
|
|
35
|
-
default=None,
|
|
36
|
-
description="Optional CAS authentication cookie (e.g., ZYBIPSCAS for custom deployments)",
|
|
37
|
-
)
|
|
38
|
-
|
|
39
|
-
@property
|
|
40
|
-
def cookies(self) -> dict[str, str]:
|
|
41
|
-
"""Return cookies dictionary for YApi API authentication."""
|
|
42
|
-
cookies = {
|
|
43
|
-
"_yapi_token": self.yapi_token,
|
|
44
|
-
"_yapi_uid": self.yapi_uid,
|
|
45
|
-
}
|
|
46
|
-
if self.yapi_cas:
|
|
47
|
-
cookies["ZYBIPSCAS"] = self.yapi_cas
|
|
48
|
-
return cookies
|
|
1
|
+
"""Configuration management for YApi MCP Server."""
|
|
2
|
+
|
|
3
|
+
from pydantic import Field, HttpUrl
|
|
4
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ServerConfig(BaseSettings):
|
|
8
|
+
"""YApi MCP Server configuration loaded from environment variables or .env file."""
|
|
9
|
+
|
|
10
|
+
model_config = SettingsConfigDict(
|
|
11
|
+
env_file=".env",
|
|
12
|
+
env_file_encoding="utf-8",
|
|
13
|
+
case_sensitive=False,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
yapi_server_url: HttpUrl = Field(
|
|
17
|
+
...,
|
|
18
|
+
description="YApi server base URL",
|
|
19
|
+
examples=["https://yapi.example.com"],
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
yapi_token: str = Field(
|
|
23
|
+
...,
|
|
24
|
+
min_length=1,
|
|
25
|
+
description="YApi authentication token (_yapi_token cookie)",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
yapi_uid: str = Field(
|
|
29
|
+
...,
|
|
30
|
+
min_length=1,
|
|
31
|
+
description="YApi user ID (_yapi_uid cookie)",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
yapi_cas: str | None = Field(
|
|
35
|
+
default=None,
|
|
36
|
+
description="Optional CAS authentication cookie (e.g., ZYBIPSCAS for custom deployments)",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def cookies(self) -> dict[str, str]:
|
|
41
|
+
"""Return cookies dictionary for YApi API authentication."""
|
|
42
|
+
cookies = {
|
|
43
|
+
"_yapi_token": self.yapi_token,
|
|
44
|
+
"_yapi_uid": self.yapi_uid,
|
|
45
|
+
}
|
|
46
|
+
if self.yapi_cas:
|
|
47
|
+
cookies["ZYBIPSCAS"] = self.yapi_cas
|
|
48
|
+
return cookies
|
yapi_mcp/server.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""YApi MCP Server - Main server module with fastmcp."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from functools import cache
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
from fastmcp import FastMCP
|
|
9
|
+
|
|
10
|
+
from yapi_mcp.config import ServerConfig
|
|
11
|
+
from yapi_mcp.yapi.client import YApiClient
|
|
12
|
+
from yapi_mcp.yapi.errors import map_http_error_to_mcp
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class MCPToolError(RuntimeError):
|
|
16
|
+
"""Base exception for MCP tool failures."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class MCPHTTPError(MCPToolError):
|
|
20
|
+
"""Exception raised when YApi returns an HTTP error."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class MCPValidationError(MCPToolError):
|
|
24
|
+
"""Exception raised when tool input validation fails."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class InvalidInterfacePathError(ValueError):
|
|
28
|
+
"""Raised when an interface path does not start with a slash."""
|
|
29
|
+
|
|
30
|
+
def __init__(self) -> None:
|
|
31
|
+
super().__init__("接口路径必须以 / 开头")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _http_error_to_tool_error(error: httpx.HTTPStatusError) -> MCPHTTPError:
|
|
35
|
+
mcp_error = map_http_error_to_mcp(error)
|
|
36
|
+
return MCPHTTPError(mcp_error.message)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _wrap_validation_error(error: ValueError) -> MCPValidationError:
|
|
40
|
+
message = f"参数验证失败: {error!s}"
|
|
41
|
+
return MCPValidationError(message)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _wrap_tool_error(prefix: str, error: Exception) -> MCPToolError:
|
|
45
|
+
message = f"{prefix}: {error!s}"
|
|
46
|
+
return MCPToolError(message)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _ensure_path_starts_with_slash(path: str) -> None:
|
|
50
|
+
if not path.startswith("/"):
|
|
51
|
+
raise InvalidInterfacePathError
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
SEARCH_INTERFACES_ERROR = "搜索接口失败"
|
|
55
|
+
GET_INTERFACE_ERROR = "获取接口失败"
|
|
56
|
+
SAVE_INTERFACE_ERROR = "保存接口失败"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# Initialize MCP server
|
|
60
|
+
mcp = FastMCP(
|
|
61
|
+
"YApi MCP Server",
|
|
62
|
+
version="0.1.0",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@cache
|
|
67
|
+
def get_config() -> ServerConfig:
|
|
68
|
+
"""Get or create ServerConfig instance (cached)."""
|
|
69
|
+
return ServerConfig()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# Tool implementations will be added in subsequent tasks (T019-T022)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@mcp.tool()
|
|
76
|
+
async def yapi_search_interfaces(
|
|
77
|
+
project_id: Annotated[int, "YApi 项目 ID"],
|
|
78
|
+
keyword: Annotated[str, "搜索关键词(匹配接口标题/路径/描述)"],
|
|
79
|
+
) -> str:
|
|
80
|
+
"""在指定 YApi 项目中搜索接口,支持按标题、路径、描述模糊匹配."""
|
|
81
|
+
config = get_config()
|
|
82
|
+
try:
|
|
83
|
+
async with YApiClient(str(config.yapi_server_url), config.cookies) as client:
|
|
84
|
+
results = await client.search_interfaces(project_id, keyword)
|
|
85
|
+
# Return JSON string with search results
|
|
86
|
+
return json.dumps(
|
|
87
|
+
[result.model_dump(by_alias=True) for result in results],
|
|
88
|
+
ensure_ascii=False,
|
|
89
|
+
indent=2,
|
|
90
|
+
)
|
|
91
|
+
except MCPToolError:
|
|
92
|
+
raise
|
|
93
|
+
except httpx.HTTPStatusError as exc:
|
|
94
|
+
raise _http_error_to_tool_error(exc) from exc
|
|
95
|
+
except Exception as exc:
|
|
96
|
+
prefix = SEARCH_INTERFACES_ERROR
|
|
97
|
+
raise _wrap_tool_error(prefix, exc) from exc
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@mcp.tool()
|
|
101
|
+
async def yapi_get_interface(
|
|
102
|
+
interface_id: Annotated[int, "接口 ID"],
|
|
103
|
+
) -> str:
|
|
104
|
+
"""获取 YApi 接口的完整定义(包括请求参数、响应结构、描述等)."""
|
|
105
|
+
config = get_config()
|
|
106
|
+
try:
|
|
107
|
+
async with YApiClient(str(config.yapi_server_url), config.cookies) as client:
|
|
108
|
+
interface = await client.get_interface(interface_id)
|
|
109
|
+
return json.dumps(
|
|
110
|
+
interface.model_dump(by_alias=True),
|
|
111
|
+
ensure_ascii=False,
|
|
112
|
+
indent=2,
|
|
113
|
+
)
|
|
114
|
+
except MCPToolError:
|
|
115
|
+
raise
|
|
116
|
+
except httpx.HTTPStatusError as exc:
|
|
117
|
+
raise _http_error_to_tool_error(exc) from exc
|
|
118
|
+
except Exception as exc:
|
|
119
|
+
prefix = GET_INTERFACE_ERROR
|
|
120
|
+
raise _wrap_tool_error(prefix, exc) from exc
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@mcp.tool()
|
|
124
|
+
async def yapi_save_interface(
|
|
125
|
+
catid: Annotated[int, "分类 ID (必需)"],
|
|
126
|
+
project_id: Annotated[int, "项目 ID (创建时必需)"] = 0,
|
|
127
|
+
interface_id: Annotated[int, "接口 ID (有值=更新,无值=创建)"] = 0,
|
|
128
|
+
title: Annotated[str, "接口标题 (创建时必需)"] = "",
|
|
129
|
+
path: Annotated[str, "接口路径 (创建时必需,以/开头)"] = "",
|
|
130
|
+
method: Annotated[str, "HTTP方法 (创建时必需)"] = "",
|
|
131
|
+
req_body: Annotated[str, "请求参数(JSON字符串)"] = "",
|
|
132
|
+
res_body: Annotated[str, "响应结构(JSON字符串)"] = "",
|
|
133
|
+
desc: Annotated[str, "接口描述"] = "",
|
|
134
|
+
req_body_type: Annotated[str, "请求体类型(form/json/raw/file)"] = "",
|
|
135
|
+
req_body_is_json_schema: Annotated[bool, "请求体是否为JSON Schema"] = True,
|
|
136
|
+
res_body_type: Annotated[str, "响应体类型(json/raw)"] = "",
|
|
137
|
+
res_body_is_json_schema: Annotated[bool, "响应体是否为JSON Schema"] = True,
|
|
138
|
+
) -> str:
|
|
139
|
+
"""保存 YApi 接口定义。interface_id 有值则更新,无值则创建新接口。"""
|
|
140
|
+
config = get_config()
|
|
141
|
+
try:
|
|
142
|
+
if path and not path.startswith("/"):
|
|
143
|
+
raise InvalidInterfacePathError
|
|
144
|
+
|
|
145
|
+
async with YApiClient(str(config.yapi_server_url), config.cookies) as client:
|
|
146
|
+
result = await client.save_interface(
|
|
147
|
+
catid=catid,
|
|
148
|
+
project_id=project_id if project_id else None,
|
|
149
|
+
interface_id=interface_id if interface_id else None,
|
|
150
|
+
title=title if title else None,
|
|
151
|
+
path=path if path else None,
|
|
152
|
+
method=method if method else None,
|
|
153
|
+
req_body=req_body,
|
|
154
|
+
res_body=res_body,
|
|
155
|
+
desc=desc,
|
|
156
|
+
req_body_type=req_body_type if req_body_type else None,
|
|
157
|
+
req_body_is_json_schema=req_body_is_json_schema,
|
|
158
|
+
res_body_type=res_body_type if res_body_type else None,
|
|
159
|
+
res_body_is_json_schema=res_body_is_json_schema,
|
|
160
|
+
)
|
|
161
|
+
action = result["action"]
|
|
162
|
+
iface_id = result["interface_id"]
|
|
163
|
+
message = "接口创建成功" if action == "created" else "接口更新成功"
|
|
164
|
+
return json.dumps(
|
|
165
|
+
{"action": action, "interface_id": iface_id, "message": message},
|
|
166
|
+
ensure_ascii=False,
|
|
167
|
+
)
|
|
168
|
+
except MCPToolError:
|
|
169
|
+
raise
|
|
170
|
+
except httpx.HTTPStatusError as exc:
|
|
171
|
+
raise _http_error_to_tool_error(exc) from exc
|
|
172
|
+
except ValueError as exc:
|
|
173
|
+
raise _wrap_validation_error(exc) from exc
|
|
174
|
+
except Exception as exc:
|
|
175
|
+
prefix = SAVE_INTERFACE_ERROR
|
|
176
|
+
raise _wrap_tool_error(prefix, exc) from exc
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def main() -> None:
|
|
180
|
+
"""Entry point for uvx yapi-mcp command."""
|
|
181
|
+
mcp.run()
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
if __name__ == "__main__":
|
|
185
|
+
main()
|
|
@@ -1 +1 @@
|
|
|
1
|
-
"""MCP tools implementation."""
|
|
1
|
+
"""MCP tools implementation."""
|
|
@@ -1 +1 @@
|
|
|
1
|
-
"""YApi API client module."""
|
|
1
|
+
"""YApi API client module."""
|
yapi_mcp/yapi/client.py
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""YApi API HTTP client implementation."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, NoReturn
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from .models import YApiErrorResponse, YApiInterface, YApiInterfaceSummary
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _raise_yapi_api_error(response: httpx.Response, error: YApiErrorResponse) -> NoReturn:
|
|
11
|
+
message = f"YApi API error: {error.errmsg} (code: {error.errcode})"
|
|
12
|
+
raise httpx.HTTPStatusError(
|
|
13
|
+
message,
|
|
14
|
+
request=response.request,
|
|
15
|
+
response=response,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class YApiClient:
|
|
20
|
+
"""Async HTTP client for YApi API with cookie-based authentication."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, base_url: str, cookies: dict[str, str], timeout: float = 10.0) -> None:
|
|
23
|
+
"""Initialize YApi client.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
base_url: YApi server base URL (e.g., "https://yapi.example.com")
|
|
27
|
+
cookies: Authentication cookies dict with _yapi_token, _yapi_uid, ZYBIPSCAS
|
|
28
|
+
timeout: Request timeout in seconds (default: 10.0)
|
|
29
|
+
"""
|
|
30
|
+
self.base_url = base_url.rstrip("/")
|
|
31
|
+
self.client = httpx.AsyncClient(
|
|
32
|
+
base_url=f"{self.base_url}/api",
|
|
33
|
+
cookies=cookies,
|
|
34
|
+
timeout=timeout,
|
|
35
|
+
follow_redirects=True,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
async def __aenter__(self) -> "YApiClient":
|
|
39
|
+
"""Async context manager entry."""
|
|
40
|
+
return self
|
|
41
|
+
|
|
42
|
+
async def __aexit__(self, *args: object) -> None:
|
|
43
|
+
"""Async context manager exit - close HTTP client."""
|
|
44
|
+
await self.client.aclose()
|
|
45
|
+
|
|
46
|
+
async def close(self) -> None:
|
|
47
|
+
"""Close the HTTP client connection."""
|
|
48
|
+
await self.client.aclose()
|
|
49
|
+
|
|
50
|
+
def _check_response(self, response: httpx.Response) -> None:
|
|
51
|
+
"""Check YApi API response for errors and raise appropriate exceptions.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
response: httpx Response object
|
|
55
|
+
|
|
56
|
+
Raises:
|
|
57
|
+
httpx.HTTPStatusError: For HTTP-level errors (4xx, 5xx)
|
|
58
|
+
"""
|
|
59
|
+
# First check HTTP status codes
|
|
60
|
+
response.raise_for_status()
|
|
61
|
+
|
|
62
|
+
# Then check YApi API-level errors (errcode != 0)
|
|
63
|
+
try:
|
|
64
|
+
data = response.json()
|
|
65
|
+
if isinstance(data, dict) and "errcode" in data and data["errcode"] != 0:
|
|
66
|
+
error = YApiErrorResponse(**data)
|
|
67
|
+
# YApi returns errcode != 0 for business logic errors
|
|
68
|
+
# Treat these as HTTP-equivalent errors
|
|
69
|
+
_raise_yapi_api_error(response, error)
|
|
70
|
+
except (ValueError, KeyError):
|
|
71
|
+
# Not a JSON response or doesn't have errcode - proceed normally
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
async def search_interfaces(
|
|
75
|
+
self, project_id: int, keyword: str
|
|
76
|
+
) -> list[YApiInterfaceSummary]:
|
|
77
|
+
"""Search interfaces in a YApi project.
|
|
78
|
+
|
|
79
|
+
使用 list_menu 接口获取项目下全量接口,突破 50 条限制。
|
|
80
|
+
支持按接口标题、路径、描述、分类名进行搜索。
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
project_id: YApi project ID
|
|
84
|
+
keyword: Search keyword (matches title, path, description, category name)
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
List of matching interface summaries
|
|
88
|
+
|
|
89
|
+
Raises:
|
|
90
|
+
httpx.HTTPStatusError: For authentication, permission, or server errors
|
|
91
|
+
"""
|
|
92
|
+
# 使用 list_menu 接口获取全量接口(无分页限制)
|
|
93
|
+
response = await self.client.get(
|
|
94
|
+
"/interface/list_menu",
|
|
95
|
+
params={"project_id": project_id},
|
|
96
|
+
)
|
|
97
|
+
self._check_response(response)
|
|
98
|
+
|
|
99
|
+
data = response.json()
|
|
100
|
+
categories = data.get("data", [])
|
|
101
|
+
|
|
102
|
+
# 展开树形结构为扁平列表,同时记录分类名
|
|
103
|
+
interfaces: list[dict] = []
|
|
104
|
+
for cat in categories:
|
|
105
|
+
cat_name = cat.get("name", "")
|
|
106
|
+
for iface in cat.get("list", []):
|
|
107
|
+
# 将分类名注入到接口数据中,用于搜索
|
|
108
|
+
iface["_cat_name"] = cat_name
|
|
109
|
+
interfaces.append(iface)
|
|
110
|
+
|
|
111
|
+
# 客户端关键词过滤(支持分类名搜索)
|
|
112
|
+
if keyword:
|
|
113
|
+
keyword_lower = keyword.lower()
|
|
114
|
+
interfaces = [
|
|
115
|
+
iface
|
|
116
|
+
for iface in interfaces
|
|
117
|
+
if keyword_lower in iface.get("title", "").lower()
|
|
118
|
+
or keyword_lower in iface.get("path", "").lower()
|
|
119
|
+
or keyword_lower in iface.get("desc", "").lower()
|
|
120
|
+
or keyword_lower in iface.get("_cat_name", "").lower()
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
return [YApiInterfaceSummary(**iface) for iface in interfaces]
|
|
124
|
+
|
|
125
|
+
async def get_interface(self, interface_id: int) -> YApiInterface:
|
|
126
|
+
"""Get complete interface definition by ID.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
interface_id: YApi interface ID
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
Complete interface definition
|
|
133
|
+
|
|
134
|
+
Raises:
|
|
135
|
+
httpx.HTTPStatusError: For authentication, not found, or server errors
|
|
136
|
+
"""
|
|
137
|
+
response = await self.client.get("/interface/get", params={"id": interface_id})
|
|
138
|
+
self._check_response(response)
|
|
139
|
+
|
|
140
|
+
data = response.json()
|
|
141
|
+
return YApiInterface(**data["data"])
|
|
142
|
+
|
|
143
|
+
async def save_interface(
|
|
144
|
+
self,
|
|
145
|
+
catid: int,
|
|
146
|
+
project_id: int | None = None,
|
|
147
|
+
interface_id: int | None = None,
|
|
148
|
+
title: str | None = None,
|
|
149
|
+
path: str | None = None,
|
|
150
|
+
method: str | None = None,
|
|
151
|
+
req_body: str = "",
|
|
152
|
+
res_body: str = "",
|
|
153
|
+
desc: str = "",
|
|
154
|
+
req_body_type: str | None = None,
|
|
155
|
+
req_body_is_json_schema: bool | None = None,
|
|
156
|
+
res_body_type: str | None = None,
|
|
157
|
+
res_body_is_json_schema: bool | None = None,
|
|
158
|
+
) -> dict[str, Any]:
|
|
159
|
+
"""Save interface definition (create or update).
|
|
160
|
+
|
|
161
|
+
If interface_id is provided, update the existing interface.
|
|
162
|
+
If interface_id is not provided, create a new interface.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
catid: Category ID (required for both create and update)
|
|
166
|
+
project_id: Project ID (required for create)
|
|
167
|
+
interface_id: Interface ID (if provided, update; otherwise create)
|
|
168
|
+
title: Interface title (required for create)
|
|
169
|
+
path: Interface path, must start with / (required for create)
|
|
170
|
+
method: HTTP method (required for create)
|
|
171
|
+
req_body: Request body definition (JSON string, optional)
|
|
172
|
+
res_body: Response body definition (JSON string, optional)
|
|
173
|
+
desc: Interface description (optional)
|
|
174
|
+
req_body_type: Request body type (form, json, raw, file)
|
|
175
|
+
req_body_is_json_schema: Whether req_body is JSON Schema format
|
|
176
|
+
res_body_type: Response body type (json, raw)
|
|
177
|
+
res_body_is_json_schema: Whether res_body is JSON Schema format
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
dict with keys: action ("created" or "updated"), interface_id (int)
|
|
181
|
+
|
|
182
|
+
Raises:
|
|
183
|
+
ValueError: When required parameters are missing for create mode
|
|
184
|
+
httpx.HTTPStatusError: For validation, permission, or server errors
|
|
185
|
+
"""
|
|
186
|
+
if interface_id is None:
|
|
187
|
+
# 创建模式:校验必填参数
|
|
188
|
+
missing = []
|
|
189
|
+
if project_id is None:
|
|
190
|
+
missing.append("project_id")
|
|
191
|
+
if title is None:
|
|
192
|
+
missing.append("title")
|
|
193
|
+
if path is None:
|
|
194
|
+
missing.append("path")
|
|
195
|
+
if method is None:
|
|
196
|
+
missing.append("method")
|
|
197
|
+
if missing:
|
|
198
|
+
msg = f"创建接口需要以下参数: {', '.join(missing)}"
|
|
199
|
+
raise ValueError(msg)
|
|
200
|
+
|
|
201
|
+
# 调用创建 API
|
|
202
|
+
payload: dict[str, Any] = {
|
|
203
|
+
"project_id": project_id,
|
|
204
|
+
"catid": catid,
|
|
205
|
+
"title": title,
|
|
206
|
+
"path": path,
|
|
207
|
+
"method": method.upper(), # type: ignore[union-attr]
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if req_body:
|
|
211
|
+
payload["req_body_other"] = req_body
|
|
212
|
+
payload["req_body_type"] = req_body_type if req_body_type else "json"
|
|
213
|
+
payload["req_body_is_json_schema"] = (
|
|
214
|
+
req_body_is_json_schema if req_body_is_json_schema is not None else True
|
|
215
|
+
)
|
|
216
|
+
if res_body:
|
|
217
|
+
payload["res_body"] = res_body
|
|
218
|
+
payload["res_body_type"] = res_body_type if res_body_type else "json"
|
|
219
|
+
payload["res_body_is_json_schema"] = (
|
|
220
|
+
res_body_is_json_schema if res_body_is_json_schema is not None else True
|
|
221
|
+
)
|
|
222
|
+
if desc:
|
|
223
|
+
payload["desc"] = desc
|
|
224
|
+
|
|
225
|
+
response = await self.client.post("/interface/add", json=payload)
|
|
226
|
+
self._check_response(response)
|
|
227
|
+
|
|
228
|
+
data = response.json()
|
|
229
|
+
return {"action": "created", "interface_id": int(data["data"]["_id"])}
|
|
230
|
+
|
|
231
|
+
# 更新模式
|
|
232
|
+
payload = {"id": interface_id, "catid": catid}
|
|
233
|
+
|
|
234
|
+
if title is not None:
|
|
235
|
+
payload["title"] = title
|
|
236
|
+
if path is not None:
|
|
237
|
+
payload["path"] = path
|
|
238
|
+
if method is not None:
|
|
239
|
+
payload["method"] = method.upper()
|
|
240
|
+
if req_body:
|
|
241
|
+
payload["req_body_other"] = req_body
|
|
242
|
+
if res_body:
|
|
243
|
+
payload["res_body"] = res_body
|
|
244
|
+
if desc is not None:
|
|
245
|
+
payload["desc"] = desc
|
|
246
|
+
# 类型标记参数独立设置(不依赖内容参数)
|
|
247
|
+
if req_body_type is not None:
|
|
248
|
+
payload["req_body_type"] = req_body_type
|
|
249
|
+
if req_body_is_json_schema is not None:
|
|
250
|
+
payload["req_body_is_json_schema"] = req_body_is_json_schema
|
|
251
|
+
if res_body_type is not None:
|
|
252
|
+
payload["res_body_type"] = res_body_type
|
|
253
|
+
if res_body_is_json_schema is not None:
|
|
254
|
+
payload["res_body_is_json_schema"] = res_body_is_json_schema
|
|
255
|
+
|
|
256
|
+
response = await self.client.post("/interface/up", json=payload)
|
|
257
|
+
self._check_response(response)
|
|
258
|
+
|
|
259
|
+
return {"action": "updated", "interface_id": interface_id}
|
{yapi → yapi_mcp/yapi}/errors.py
RENAMED
|
@@ -1,14 +1,33 @@
|
|
|
1
1
|
"""Error mapping from YApi/HTTP errors to MCP errors."""
|
|
2
2
|
|
|
3
|
-
from
|
|
3
|
+
from collections.abc import Mapping, MutableMapping
|
|
4
4
|
|
|
5
5
|
import httpx
|
|
6
6
|
|
|
7
|
+
ErrorData = MutableMapping[str, object]
|
|
8
|
+
|
|
9
|
+
HTTP_STATUS_UNAUTHORIZED = 401
|
|
10
|
+
HTTP_STATUS_NOT_FOUND = 404
|
|
11
|
+
HTTP_STATUS_FORBIDDEN = 403
|
|
12
|
+
HTTP_STATUS_SERVER_ERROR = 500
|
|
13
|
+
HTTP_STATUS_BAD_REQUEST = 400
|
|
14
|
+
|
|
15
|
+
MCP_CODE_AUTH_FAILED = -32001
|
|
16
|
+
MCP_CODE_NOT_FOUND = -32002
|
|
17
|
+
MCP_CODE_FORBIDDEN = -32003
|
|
18
|
+
MCP_CODE_SERVER_ERROR = -32000
|
|
19
|
+
MCP_CODE_INVALID_PARAMS = -32602
|
|
20
|
+
|
|
7
21
|
|
|
8
22
|
class MCPError(Exception):
|
|
9
23
|
"""MCP protocol error with error code and optional data."""
|
|
10
24
|
|
|
11
|
-
def __init__(
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
code: int,
|
|
28
|
+
message: str,
|
|
29
|
+
data: ErrorData | None = None,
|
|
30
|
+
) -> None:
|
|
12
31
|
"""Initialize MCP error.
|
|
13
32
|
|
|
14
33
|
Args:
|
|
@@ -21,9 +40,9 @@ class MCPError(Exception):
|
|
|
21
40
|
self.message = message
|
|
22
41
|
self.data = data
|
|
23
42
|
|
|
24
|
-
def to_dict(self) -> dict[str,
|
|
43
|
+
def to_dict(self) -> dict[str, object]:
|
|
25
44
|
"""Convert to MCP error response dict."""
|
|
26
|
-
result: dict[str,
|
|
45
|
+
result: dict[str, object] = {"code": self.code, "message": self.message}
|
|
27
46
|
if self.data is not None:
|
|
28
47
|
result["data"] = self.data
|
|
29
48
|
return result
|
|
@@ -49,49 +68,52 @@ def map_http_error_to_mcp(error: httpx.HTTPStatusError) -> MCPError:
|
|
|
49
68
|
status_code = error.response.status_code
|
|
50
69
|
|
|
51
70
|
# Try to extract YApi error details from response
|
|
52
|
-
error_data:
|
|
71
|
+
error_data: ErrorData = {"http_status": status_code}
|
|
53
72
|
try:
|
|
54
73
|
yapi_error = error.response.json()
|
|
55
|
-
if isinstance(yapi_error,
|
|
56
|
-
error_data["yapi_error"] = yapi_error
|
|
74
|
+
if isinstance(yapi_error, Mapping):
|
|
75
|
+
error_data["yapi_error"] = dict(yapi_error)
|
|
57
76
|
except Exception:
|
|
58
77
|
# If response is not JSON, include response text
|
|
59
78
|
error_data["response_text"] = error.response.text[:200]
|
|
60
79
|
|
|
61
80
|
# Map HTTP status codes to MCP error codes
|
|
62
|
-
if status_code ==
|
|
81
|
+
if status_code == HTTP_STATUS_UNAUTHORIZED:
|
|
63
82
|
return MCPError(
|
|
64
|
-
code
|
|
83
|
+
code=MCP_CODE_AUTH_FAILED,
|
|
65
84
|
message="认证失败: Cookie 无效或过期",
|
|
66
85
|
data=error_data,
|
|
67
86
|
)
|
|
68
|
-
if status_code ==
|
|
87
|
+
if status_code == HTTP_STATUS_NOT_FOUND:
|
|
88
|
+
errmsg = error_data.get("yapi_error", {}).get("errmsg", "Resource not found")
|
|
69
89
|
return MCPError(
|
|
70
|
-
code
|
|
71
|
-
message=f"资源不存在: {
|
|
90
|
+
code=MCP_CODE_NOT_FOUND,
|
|
91
|
+
message=f"资源不存在: {errmsg}",
|
|
72
92
|
data=error_data,
|
|
73
93
|
)
|
|
74
|
-
if status_code ==
|
|
94
|
+
if status_code == HTTP_STATUS_FORBIDDEN:
|
|
75
95
|
return MCPError(
|
|
76
|
-
code
|
|
96
|
+
code=MCP_CODE_FORBIDDEN,
|
|
77
97
|
message="权限不足: 无法操作该项目/接口",
|
|
78
98
|
data=error_data,
|
|
79
99
|
)
|
|
80
|
-
if status_code >=
|
|
100
|
+
if status_code >= HTTP_STATUS_SERVER_ERROR:
|
|
101
|
+
errmsg = error_data.get("yapi_error", {}).get("errmsg", "Internal server error")
|
|
81
102
|
return MCPError(
|
|
82
|
-
code
|
|
83
|
-
message=f"YApi 服务器错误: {
|
|
103
|
+
code=MCP_CODE_SERVER_ERROR,
|
|
104
|
+
message=f"YApi 服务器错误: {errmsg}",
|
|
84
105
|
data=error_data,
|
|
85
106
|
)
|
|
86
|
-
if status_code ==
|
|
107
|
+
if status_code == HTTP_STATUS_BAD_REQUEST:
|
|
108
|
+
errmsg = error_data.get("yapi_error", {}).get("errmsg", "Bad request")
|
|
87
109
|
return MCPError(
|
|
88
|
-
code
|
|
89
|
-
message=f"Invalid params: {
|
|
110
|
+
code=MCP_CODE_INVALID_PARAMS,
|
|
111
|
+
message=f"Invalid params: {errmsg}",
|
|
90
112
|
data=error_data,
|
|
91
113
|
)
|
|
92
114
|
# Other 4xx errors
|
|
93
115
|
return MCPError(
|
|
94
|
-
code
|
|
116
|
+
code=MCP_CODE_INVALID_PARAMS,
|
|
95
117
|
message=f"Invalid params: HTTP {status_code}",
|
|
96
118
|
data=error_data,
|
|
97
119
|
)
|