MCP Feishu Project Manager
by Roland0511
Verified
- mcp-feishu-proj
- src
- mcp_server
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
飞书项目Open API客户端
基于飞书项目Open API文档实现
"""
import os
import json
import time
import requests
from typing import Dict, List, Union, Optional, Any, Tuple, Literal
# Define custom type for work item types
WorkItemType = Literal["story", "version", "issue"]
class FSProjClient:
"""飞书项目Open API客户端"""
def __init__(
self,
base_url: str,
project_key: str,
plugin_id: str = None,
plugin_secret: str = None,
plugin_token: str = None,
user_key: str = None,
user_plugin_token: str = None,
refresh_token: str = None,
):
"""
初始化客户端
Args:
base_url: API基础URL,例如 https://project.feishu.cn
project_key: 项目标识
plugin_id: 插件ID
plugin_secret: 插件密钥
plugin_token: 插件身份凭证,如果提供则不需要plugin_id和plugin_secret
user_key: 用户标识,当使用插件身份凭证时需要
user_plugin_token: 用户身份凭证,如果提供则不需要user_key
refresh_token: 刷新token,用于刷新user_plugin_token
"""
self.base_url = base_url.rstrip("/")
self.project_key = project_key
self.plugin_id = plugin_id
self.plugin_secret = plugin_secret
self.plugin_token = plugin_token
self.plugin_token_expires_time = 0
self.user_key = user_key
self.user_plugin_token = user_plugin_token
self.refresh_token = refresh_token
self.session = requests.Session()
def _get_headers(self) -> Dict[str, str]:
"""
获取请求头
Returns:
请求头字典
"""
headers = {
"Content-Type": "application/json",
}
if self.user_plugin_token:
headers["X-PLUGIN-TOKEN"] = self.user_plugin_token
elif self.plugin_token:
headers["X-PLUGIN-TOKEN"] = self.plugin_token
if self.user_key:
headers["X-USER-KEY"] = self.user_key
return headers
def _request(
self,
method: str,
path: str,
params: Dict = None,
data: Dict = None,
json_data: Dict = None,
files: Dict = None,
headers: Dict = None,
idem_uuid: str = None,
) -> Dict:
"""
发送请求
Args:
method: 请求方法,GET, POST, PUT, DELETE等
path: 请求路径
params: URL参数
data: 表单数据
json_data: JSON数据
files: 文件数据
headers: 额外的请求头
idem_uuid: 幂等UUID
Returns:
响应数据
"""
url = f"{self.base_url}{path}"
req_headers = self._get_headers()
if headers:
req_headers.update(headers)
if idem_uuid:
req_headers["X-IDEM-UUID"] = idem_uuid
try:
response = self.session.request(
method=method,
url=url,
params=params,
data=data,
json=json_data,
files=files,
headers=req_headers,
)
response.raise_for_status()
if response.content:
return response.json()
return {}
except requests.exceptions.HTTPError as e:
if e.response.content:
try:
error_data = e.response.json()
raise Exception(f"API错误: {error_data.get('err_msg', str(e))}, 错误码: {error_data.get('err_code')}")
except json.JSONDecodeError:
raise Exception(f"HTTP错误: {str(e)}, 响应内容: {e.response.text}")
raise Exception(f"HTTP错误: {str(e)}")
except requests.exceptions.RequestException as e:
raise Exception(f"请求错误: {str(e)}")
except json.JSONDecodeError:
raise Exception(f"JSON解析错误: {response.text}")
# ===== 认证相关 =====
def get_plugin_token(self) -> str:
"""
获取plugin_token
Returns:
plugin_token
"""
if not self.plugin_id or not self.plugin_secret:
raise Exception("plugin_id和plugin_secret不能为空")
if self.plugin_token and time.time() < self.plugin_token_expires_time:
return self.plugin_token
data = {
"plugin_id": self.plugin_id,
"plugin_secret": self.plugin_secret,
}
response = self._request("POST", "/open_api/authen/plugin_token", json_data=data)
self.plugin_token = response.get("data", {}).get("token")
# expires_in为有效期秒数,expire_time为过期时间戳
expires_in = int(response.get("data", {}).get("expire_time"))
expires_time = time.time() + expires_in
self.plugin_token_expires_time = expires_time
return self.plugin_token
def get_auth_code(self, state: str = "111", cookie: str = None) -> str:
"""
获取code
Args:
state: 状态参数
cookie: 用户cookie
Returns:
auth_code
"""
if not self.plugin_id:
raise Exception("plugin_id不能为空")
data = {
"plugin_id": self.plugin_id,
"state": state,
}
headers = {"cookie": cookie} if cookie else {}
response = self._request(
"POST",
"/open_api/authen/auth_code",
json_data=data,
headers=headers
)
return response.get("data", {}).get("code")
def get_user_plugin_token(self, code: str) -> Dict:
"""
获取user_plugin_token
Args:
code: 授权码
Returns:
包含user_plugin_token和refresh_token的字典
"""
if not self.plugin_token:
raise Exception("plugin_token不能为空")
data = {
"code": code,
"grant_type": "authorization_code",
}
response = self._request(
"POST",
"/open_api/authen/user_plugin_token",
json_data=data
)
result = response.get("data", {})
self.user_plugin_token = result.get("user_plugin_token")
self.refresh_token = result.get("refresh_token")
return {
"user_plugin_token": self.user_plugin_token,
"refresh_token": self.refresh_token,
"expires_in": result.get("expires_in"),
}
def refresh_user_token(self, refresh_token: str = None, token_type: int = 1) -> Dict:
"""
刷新用户token
Args:
refresh_token: 刷新token,如果不提供则使用实例中的refresh_token
token_type: token类型,1表示user_plugin_token
Returns:
包含新token的字典
"""
if not self.plugin_token:
raise Exception("plugin_token不能为空")
refresh_token = refresh_token or self.refresh_token
if not refresh_token:
raise Exception("refresh_token不能为空")
data = {
"refresh_token": refresh_token,
"type": token_type,
}
response = self._request(
"POST",
"/open_api/authen/refresh_token",
json_data=data
)
result = response.get("data", {})
self.user_plugin_token = result.get("user_plugin_token")
self.refresh_token = result.get("refresh_token")
return {
"user_plugin_token": self.user_plugin_token,
"refresh_token": self.refresh_token,
"expires_in": result.get("expires_in"),
}
# ===== 视图相关 =====
def get_view_list(self, work_item_type_key: WorkItemType, created_by: str = "", page_num: int = 1, page_size: int = 100) -> Dict:
"""获取视图列表及配置信息
Args:
work_item_type_key: 工作项类型标识
created_by: 创建者标识
page_num: 页码
page_size: 每页数量
Returns:
视图列表及配置信息
"""
data = {
"work_item_type_key": work_item_type_key,
"page_num": page_num,
"page_size": page_size,
"created_by": created_by
}
response = self._request(
"POST",
f"/open_api/{self.project_key}/view_conf/list",
json_data=data
)
err_code = response.get("code", 0)
if err_code != 0:
raise Exception(f"获取视图列表及配置信息失败,错误码: {err_code}, 错误信息: {response.get('err_msg')}")
return response.get("data", {})
def get_view_detail(self, view_id: str, page_num: int = 1, page_size: int = 20) -> Dict:
"""获取视图工作项列表
Args:
view_id: 视图标识
page_num: 页码
page_size: 每页数量
"""
data = {
"page_num": page_num,
"page_size": page_size
}
response = self._request(
"GET",
f"/open_api/{self.project_key}/fix_view/{view_id}",
params=data
)
err_code = response.get("code", 0)
if err_code != 0:
raise Exception(f"获取视图工作项列表失败,错误码: {err_code}, 错误信息: {response.get('err_msg')}")
return response.get("data", {})
# ===== 工作项相关 =====
def get_workitem_detail(self, work_item_type_key: WorkItemType, work_item_ids: List[int]) -> List[Dict]:
"""获取工作项详情
Args:
work_item_type_key: 工作项类型标识
"""
data = {
"work_item_ids": work_item_ids
}
response = self._request(
"POST",
f"/open_api/{self.project_key}/work_item/{work_item_type_key}/query",
json_data=data
)
err_code = response.get("code", 0)
if err_code != 0:
raise Exception(f"获取工作项详情失败,错误码: {err_code}, 错误信息: {response.get('err_msg')}")
return response.get("data", {})
def get_work_item_type_meta(self, work_item_type_key: WorkItemType) -> Dict:
"""获取工作项类型元数据
- 在工作项详情的"fields"字段中各个字段的具体意义及信息可以在工作项类型元数据中获取
Args:
work_item_type_key: 工作项类型标识
Returns:
工作项类型元数据
"""
response = self._request(
"GET",
f"/open_api/{self.project_key}/work_item/{work_item_type_key}/meta"
)
err_code = response.get("code", 0)
if err_code != 0:
raise Exception(f"获取工作项类型元数据失败,错误码: {err_code}, 错误信息: {response.get('err_msg')}")
return response.get("data", {})