添加mcp配置

This commit is contained in:
2026-01-15 17:34:22 +08:00
parent 0e4a85551f
commit d3bf64ae61
21 changed files with 1601 additions and 275 deletions

View File

@@ -29,7 +29,11 @@ def import_router(app: FastAPI):
app.include_router(router)
logger.info(f"[导入路由] 路由导入完成 ✅")
async def import_mcp_server(app: FastAPI):
logger.info(f"[导入MCP] 开始导入MCP 🛣️")
from mcps import create_mcp_app
app.mount("/app" , await create_mcp_app())
logger.info(f"[导入MCP] MCP导入完成 ✅")
@asynccontextmanager
async def lifespan(app: FastAPI):
@@ -38,5 +42,6 @@ async def lifespan(app: FastAPI):
init_database()
import_router(app)
init_scheduler(app)
await import_mcp_server(app)
yield
logger.info(f"[生命周期] 应用关闭 🔧✅")

View File

@@ -2,9 +2,11 @@ from fastapi import FastAPI
from handler.exception import install as exception_install
from lifespan import lifespan
from mcps import create_mcp_app
app = FastAPI(lifespan=lifespan)
exception_install(app)
if __name__ == "__main__":

8
mcps/__init__.py Normal file
View File

@@ -0,0 +1,8 @@
from fastmcp import FastMCP
from mcps.test.test import weather_mcp
async def create_mcp_app():
main_mcp = FastMCP("MCP 主服务")
await main_mcp.import_server(weather_mcp , prefix="test")
return main_mcp.http_app()

3
mcps/test/test.py Normal file
View File

@@ -0,0 +1,3 @@
from fastmcp import FastMCP
weather_mcp = FastMCP(name="WeatherService")

View File

@@ -3,7 +3,7 @@ name = "wecom-wnzs-adapter"
version = "0.1.0"
description = "企业微信万能助手适配器"
readme = "README.md"
requires-python = ">=3.12"
requires-python = ">=3.13"
dependencies = [
"casbin>=1.43.0",
"fastapi[standard]>=0.116.1",
@@ -16,13 +16,12 @@ dependencies = [
"sqlmodel>=0.0.24",
"uvicorn>=0.35.0",
"apscheduler>=3.11.0",
"pydantic<2.10",
"pickledb>=1.3.2",
"wecom-sdk>=1.0.0",
"xmltodict>=1.0.2",
"psycopg2-binary>=2.9.11",
"fastscheduler[fastapi]>=0.1.2",
"pydantic-settings>=2.11.0",
"fastmcp>=2.14.3",
]
[dependency-groups]

View File

@@ -1,5 +1,4 @@
from wecom_sdk import Wecom
__all__ = [
"Wecom"
]
from .modules.mixin import Wecom
__VERSION__ = "1.0.0"
__AUTHOR__ = "Jasar Ayiken"

View File

@@ -0,0 +1,3 @@
from typing import Literal
MESSAGE_TYPES: Literal["text", "image", "voice", "video", "textcard", "news", "mpnews"]

View File

@@ -0,0 +1,14 @@
class SDKException(Exception):
def __init__(self, errcode: int, message: str):
"""
通用错误返回类,用于抛出请求错误时的异常
- 若请求返回的errcode不为0则抛出此异常
@param errcode: 错误码
@param message: 错误信息
"""
self.errcode = str(errcode)
self.message = message
def __str__(self):
return f"Error Occured: {self.errcode} - {self.message}"

View File

@@ -0,0 +1,78 @@
from datetime import datetime, timedelta
from wecom.exceptions.general import SDKException
from wecom.schemas.token import (
AccessTokenInfo,
AccessTokenParams,
)
from wecom.utils.requests import HttpxRequest
BASE_URL: str = "https://qyapi.weixin.qq.com/cgi-bin"
class WecomBaseClient:
BASE_URL: str = BASE_URL
def __init__(self, corpid: str, corpsecret: str):
"""
企业微信SDK
@param corpid: 企业ID
@param corpsecret: 应用的凭证密钥
每个应用有独立的secret获取到的access_token只能本应用使用所以每个应用的access_token应该分开来获取
"""
self.corpid = corpid
self.corpsecret = corpsecret
self._access_token = None
self.access_token_valid_time = None
@property
async def access_token(self) -> str:
"""企业微信SDK的access_token"""
if (
self.access_token_valid_time
and datetime.now() < self.access_token_valid_time
):
return self._access_token
await self.__get_access_token()
return self._access_token
@access_token.setter
def access_token(self, value: str):
self._access_token = value
async def __get_access_token(self, refresh: bool = False) -> str:
"""
获取access_token
access_token的有效期通过返回的expires_in来传达正常情况下为7200秒2小时有效期内重复获取返回相同结果过期后获取会返回新的access_token。
由于企业微信每个应用的access_token是彼此独立的所以进行缓存时需要区分应用来进行存储。
详细说明https://work.weixin.qq.com/api/doc/90000/90135/91039
@return: access_token: str 或 None
"""
if (
not refresh
and self.access_token_valid_time
and datetime.now() < self.access_token_valid_time
):
return self.access_token
url = self.BASE_URL + "/gettoken"
params = AccessTokenParams(
corpid=self.corpid, corpsecret=self.corpsecret
).model_dump()
resp = AccessTokenInfo(**await HttpxRequest.get(url=url, params=params))
if resp.errcode == 0:
self.access_token_valid_time = datetime.now() + timedelta(
seconds=resp.expires_in
)
self.access_token = resp.access_token
return resp.access_token
else:
raise SDKException(resp.errcode, resp.errmsg)

View File

@@ -0,0 +1,82 @@
from wecom.exceptions.general import SDKException
from wecom.modules.base import WecomBaseClient
from wecom.schemas.departments import (
CreateDepartmentInfo,
CreateDepartmentParams,
DepartmentInfo,
UpdateDepartmentInfo,
UpdateDepartmentParams,
)
from wecom.utils.requests import HttpxRequest
class WecomDepartmentClient(WecomBaseClient):
async def create_departments(self, data: CreateDepartmentParams) -> int:
"""
创建部门
@param data: 创建部门的参数
@return: 部门id
"""
url = self.BASE_URL + "/department/create"
params = {"access_token": await self.access_token}
resp = CreateDepartmentInfo(
**await HttpxRequest.post(url=url, params=params, json=data)
)
if resp.errcode == 0:
return resp.id
else:
raise SDKException(resp.errcode, resp.errmsg)
async def delete_departments(self, id: int) -> bool:
"""
删除部门
@param id: 部门id
@return: 删除状态(Boolean)
"""
url = self.BASE_URL + "/department/delete"
params = {"access_token": await self.access_token, "id": id}
resp = await HttpxRequest.get(url=url, params=params)
if resp.errcode == 0:
return True
else:
raise SDKException(resp.errcode, resp.errmsg)
async def update_departments(self, data: UpdateDepartmentParams) -> bool:
"""
更新部门
@param data: 更新部门的参数
@return: 更新状态(Boolean)
"""
url = self.BASE_URL + "/department/update"
params = {"access_token": await self.access_token}
resp = UpdateDepartmentInfo(
**await HttpxRequest.post(url=url, params=params, json=data)
)
if resp.errcode == 0:
return True
else:
raise SDKException(resp.errcode, resp.errmsg)
async def get_departments(self, id: int = None) -> list[DepartmentInfo]:
"""
获取部门列表
@param id: 部门id。获取指定部门及其下的子部门。
如果不填,默认获取全量组织架构
@return: 部门列表
"""
url = self.BASE_URL + "/department/list"
params = {"access_token": await self.access_token, "id": id}
resp = DepartmentInfo(**await HttpxRequest.get(url=url, params=params))
if resp.errcode == 0:
return resp.department
else:
raise SDKException(resp.errcode, resp.errmsg)

View File

@@ -0,0 +1,60 @@
from typing import Literal
from wecom.exceptions.general import SDKException
from wecom.modules.base import WecomBaseClient
from wecom.schemas.message import (
MessageParams,
RecallMessageInfo,
RecallMessageParams,
SendMessageInfo,
)
from wecom.utils.requests import HttpxRequest
class WecomMessageClient(WecomBaseClient):
async def send_message(
self,
data: MessageParams,
) -> str:
"""
企业微信发送消息
@param data: 发送消息的参数
各类消息的参数详情 https://developer.work.weixin.qq.com/document/path/90236
@return: 消息ID
"""
url = self.BASE_URL + "/message/send"
params = {"access_token": await self.access_token}
data = data.model_dump()
resp = SendMessageInfo(
**await HttpxRequest.post(url=url, params=params, json=data)
)
if resp.errcode == 0:
return resp.msgid
else:
raise SDKException(resp.errcode, resp.errmsg)
async def recall_message(self, data: RecallMessageParams) -> bool:
"""
企业微信撤回消息
@param msgid: 消息ID
@return: 撤回状态(Boolean)
"""
data = data.model_dump()
url = self.BASE_URL + "/message/recall"
params = {"access_token": await self.access_token}
resp = RecallMessageInfo(
**await HttpxRequest.post(url=url, params=params, json=data)
)
if resp.errcode == 0:
return True
else:
raise SDKException(resp.errcode, resp.errmsg)

View File

@@ -0,0 +1,10 @@
from wecom.modules.base import WecomBaseClient
from wecom.modules.department import WecomDepartmentClient
from wecom.modules.message import WecomMessageClient
from wecom.modules.users import WecomUsersClient
class Wecom(
WecomDepartmentClient, WecomUsersClient, WecomMessageClient, WecomBaseClient
):
pass

View File

@@ -0,0 +1,95 @@
from wecom.exceptions.general import SDKException
from wecom.modules.base import WecomBaseClient
from wecom.schemas.departments import DepartmentInfo
from wecom.schemas.users import (
DepartmentUserDetailInfo,
DepartmentUserInfo,
UserInfo,
)
from wecom.utils.requests import HttpxRequest
class WecomUsersClient(WecomBaseClient):
async def get_user(self, userid: str) -> dict:
"""
读取成员
@param userid: 成员UserID。对应管理端的账号企业内必须唯一。不区分大小写长度为1~64个字节
@return: 成员信息
"""
url = self.BASE_URL + "/user/get"
params = {"access_token": await self.access_token, "userid": userid}
resp = UserInfo(**await HttpxRequest.get(url=url, params=params))
if resp.errcode == 0:
return resp.model_dump(exclude={"errcode", "errmsg"})
else:
raise SDKException(resp.errcode, resp.errmsg)
async def get_user_in_department_detail(self, department_id: str) -> dict:
"""
读取部门成员完整信息
@param department_id: 获取的部门id
@return: 部门成员信息
"""
url = self.BASE_URL + "/user/list"
params = {
"access_token": await self.access_token,
"department_id": department_id,
}
resp = DepartmentUserDetailInfo(
**await HttpxRequest.get(url=url, params=params)
)
if resp.errcode == 0:
return resp.model_dump(exclude={"errcode", "errmsg"})
else:
raise SDKException(resp.errcode, resp.errmsg)
async def get_user_in_department(self, department_id: int) -> dict:
"""
读取部门成员简要信息
@param department_id: 获取的部门id
@return: 部门成员信息
"""
url = self.BASE_URL + "/user/simplelist"
params = {
"access_token": await self.access_token,
"department_id": department_id,
}
resp = DepartmentUserInfo(**await HttpxRequest.get(url=url, params=params))
if resp.errcode == 0:
return resp.model_dump(exclude={"errcode", "errmsg"})
else:
raise SDKException(resp.errcode, resp.errmsg)
@staticmethod
def convert_userid(userid: str, decrypt: bool = False):
"""
学工号/企业微信ID转换方法
@param userid: 学工号/企业微信ID
@param decrypt: 是否解密
@return: 转换后的学工号/企业微信ID
"""
if decrypt:
year = str(int(userid[10:12]) + 1945)
no = str(int(userid[2:9]) - 115342)
no = no[1:7]
userid = year + no
else:
userid = (
"8"
+ userid[2:3]
+ str(int(userid[-6:]) + 1115342)
+ userid[8:9]
+ str(int(userid[0:4]) - 1945)
)
return userid

View File

@@ -0,0 +1,13 @@
from pydantic import BaseModel, ConfigDict
class BaseSchema(BaseModel):
model_config = ConfigDict(
extra="ignore",
use_enum_values=True,
from_attributes=True,
validate_assignment=True,
populate_by_name=True,
coerce_numbers_to_str=True,
arbitrary_types_allowed=True,
)

View File

@@ -0,0 +1,58 @@
from typing import AnyStr, List
from wecom.schemas.base import BaseSchema
class CreateDepartmentParams(BaseSchema):
"""
创建部门
@param name: 部门名称。长度限制为1~32个字节字符不能包括\:?”<>
@param name_en: 英文名称
@param parentid: 父部门id。根部门id为1
@param order: 在父部门中的次序值。order值小的排序靠前。
@param id: 部门id整型。指定时必须大于1不指定时则自动生成
"""
name: str
name_en: str | None = None
parentid: int
order: int | None = None
id: int | None = None
class UpdateDepartmentParams(CreateDepartmentParams): ...
class UpdateDepartmentInfo(BaseSchema):
errcode: int
errmsg: AnyStr
class CreateDepartmentInfo(BaseSchema):
errcode: int
errmsg: AnyStr
id: int
class DepartmentInfo(BaseSchema):
"""
部门单体响应数据
"""
id: int
name: AnyStr
name_en: AnyStr | None = None
department_leader: List[str] | None = None
parentid: int | None = None
order: int | None = None
class DepartmentInfo(BaseSchema):
"""
部门整体响应数据
"""
errcode: int
errmsg: AnyStr
department: List[DepartmentInfo]

View File

@@ -0,0 +1,94 @@
from typing import AnyStr, Literal
from wecom.schemas.base import BaseSchema
class MessageParams(BaseSchema):
"""
发送消息参数
各类消息的参数详情 https://developer.work.weixin.qq.com/document/path/90236
根据msgtype的不同选择对应的消息内容填充即可
@param touser: 指定接收消息的成员成员ID列表多个接收者用|分隔最多支持1000个
@param toparty: 指定接收消息的部门部门ID列表多个接收者用|分隔最多支持100个。
@param totag: 指定接收消息的标签标签ID列表多个接收者用|分隔最多支持100个。
@param msgtype: 消息类型此时固定为text
@param agentid: 企业应用的id整型。企业内部开发可在应用的设置页面查看第三方服务商可通过接口 获取企业授权信息 获取该参数值
@param safe: 表示是否是保密消息0表示可对外分享1表示不能分享且内容显示水印默认为0
@param enable_id_trans: 表示是否开启id转译0表示否1表示是默认0。仅第三方应用需要用到企业自建应用可以忽略。
@param enable_duplicate_check: 表示是否开启重复消息检查0表示否1表示是默认0
@param duplicate_check_interval: 表示是否重复消息检查的时间间隔默认1800s最大不超过4小时
touser、toparty、totag不能同时为空后面不再强调
"""
touser: AnyStr | None = None
toparty: AnyStr | None = None
totag: AnyStr | None = None
msgtype: Literal[
"text", "image", "voice", "video", "textcard", "news", "mpnews", "markdown"
]
agentid: int
# 各种类型的消息内容
text: dict | None = None
voice: dict | None = None
video: dict | None = None
file: dict | None = None
textcard: dict | None = None
news: dict | None = None
mpnews: dict | None = None
markdown: dict | None = None
safe: int = 0
enable_id_trans: int = 0
enable_duplicate_check: int = 0
duplicate_check_interval: int = 1800
class SendMessageInfo(BaseSchema):
"""
发送消息响应数据
"""
errcode: int
errmsg: AnyStr
invaliduser: AnyStr | None = None
invalidparty: AnyStr | None = None
invalidtag: AnyStr | None = None
unlicenseduser: AnyStr | None = None
msgid: AnyStr | None = None
response_code: AnyStr | None = None
class SendMessageInvalid(BaseSchema):
"""
发送消息失败响应数据
"""
errmsg: AnyStr
invaliduser: AnyStr | None = None
invalidparty: AnyStr | None = None
invalidtag: AnyStr | None = None
unlicenseduser: AnyStr | None = None
class RecallMessageParams(BaseSchema):
"""
撤回消息请求参数
"""
msgid: AnyStr
class RecallMessageInfo(BaseSchema):
"""
撤回消息响应数据
"""
errcode: int
errmsg: AnyStr

View File

@@ -0,0 +1,36 @@
from wecom.schemas.base import BaseSchema
class AccessTokenParams(BaseSchema):
"""
获取access_token的参数
@param corpid: 企业ID
@param corpsecret: 应用的凭证密钥
"""
corpid: str
corpsecret: str
class AccessTokenInfo(BaseSchema):
"""
获取access_token的返回数据
@param errcode: 返回码 出错返回码为0表示成功非0表示调用失败
@param errmsg: 对返回码的文本描述内容
@param access_token: 获取到的凭证 最长为512字节
@param expires_in: 凭证的有效时间(秒)
"""
errcode: int
errmsg: str
access_token: str | None = None
expires_in: int | None = None
class AccessTokenInvalid(BaseSchema):
"""
获取access_token失败时的返回数据
@param errmsg: 错误信息
"""
errmsg: str

View File

@@ -0,0 +1,39 @@
from typing import AnyStr, List
from wecom.schemas.base import BaseSchema
class UserInfo(BaseSchema):
"""
用户单体响应数据
"""
errcode: int
errmsg: AnyStr
userid: AnyStr | None = None
name: AnyStr | None = None
department: List[int] | None = None
position: AnyStr | None = None
moblie: AnyStr | None = None
gender: int | None = None
email: AnyStr | None = None
status: int | None = None
class UserSimpleInfo(BaseSchema):
userid: AnyStr
name: AnyStr
department: List[int]
open_userid: AnyStr | None = None
class DepartmentUserInfo(BaseSchema):
errcode: int
errmsg: AnyStr
userlist: List[UserSimpleInfo]
class DepartmentUserDetailInfo(BaseSchema):
errcode: int
errmsg: AnyStr
userlist: List[UserInfo]

View File

View File

@@ -0,0 +1,42 @@
import httpx
class HttpxRequest:
@classmethod
async def get(
cls, url: str, params: dict | None = None, headers: dict | None = None
) -> dict:
"""
发送GET请求
@param url: 请求URL
@param params: 请求参数
@param headers: 请求头
@return: 响应内容
"""
async with httpx.AsyncClient() as client:
response = await client.get(url, params=params, headers=headers)
response.raise_for_status()
return response.json()
@classmethod
async def post(
cls,
url: str,
params: dict | None = None,
data: dict | None = None,
json: dict | None = None,
headers: dict | None = None,
) -> dict:
"""
发送POST请求
@param url: 请求URL
@param params: 请求参数
@param headers: 请求头
@return: 响应内容
"""
async with httpx.AsyncClient() as client:
response = await client.post(
url, params=params, data=data, json=json, headers=headers
)
response.raise_for_status()
return response.json()

1218
uv.lock generated

File diff suppressed because it is too large Load Diff