MCP 服务:ds 余额查询 + ros 邮件/QQ/服务器状态
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import requests
|
||||
|
||||
BASE_URL = 'https://api.deepseek.com'
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 内部辅助
|
||||
# ============================================================
|
||||
|
||||
def resolve_api_key(arguments):
|
||||
"""解析 API Key,优先级:参数 > DEEPSEEK_API_KEY 环境变量"""
|
||||
if arguments.get('api_key'):
|
||||
return arguments['api_key']
|
||||
|
||||
key = os.environ.get('DEEPSEEK_API_KEY', '')
|
||||
if key:
|
||||
return key
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 工具函数
|
||||
# ============================================================
|
||||
|
||||
def get_user_balance(api_key=None):
|
||||
"""查询 DeepSeek 账户余额"""
|
||||
key = resolve_api_key({'api_key': api_key} if api_key else {})
|
||||
if not key:
|
||||
raise Exception(
|
||||
'未找到 API Key,请通过以下任一方式提供:\n'
|
||||
'1. 调用工具时传入 api_key 参数\n'
|
||||
'2. 设置环境变量 DEEPSEEK_API_KEY'
|
||||
)
|
||||
|
||||
headers = {
|
||||
'Authorization': f'Bearer {key}',
|
||||
'Accept': 'application/json',
|
||||
}
|
||||
resp = requests.get(f'{BASE_URL}/user/balance', headers=headers, timeout=15)
|
||||
if resp.status_code != 200:
|
||||
raise Exception(f'API 请求失败 (HTTP {resp.status_code}): {resp.text}')
|
||||
return resp.json()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# MCP JSON-RPC 处理
|
||||
# ============================================================
|
||||
|
||||
TOOLS = [
|
||||
{
|
||||
"name": "get_user_balance",
|
||||
"description": "查询 DeepSeek API 账户余额,返回各币种的可用余额、赠送余额和充值余额。支持从参数或环境变量 DEEPSEEK_API_KEY 读取 API Key。",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"api_key": {
|
||||
"type": "string",
|
||||
"description": "DeepSeek API Key(可选,不传则从环境变量 DEEPSEEK_API_KEY 读取)"
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
TOOL_MAP = {
|
||||
"get_user_balance": get_user_balance
|
||||
}
|
||||
|
||||
|
||||
def handle_request(req):
|
||||
method = req.get("method", "")
|
||||
req_id = req.get("id")
|
||||
|
||||
if method == "initialize":
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": req_id,
|
||||
"result": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {
|
||||
"tools": {}
|
||||
},
|
||||
"serverInfo": {
|
||||
"name": "ds-balance-inquiry",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if method == "ping":
|
||||
return {"jsonrpc": "2.0", "id": req_id, "result": {}}
|
||||
|
||||
if method == "notifications/initialized":
|
||||
return None
|
||||
|
||||
if method == "tools/list":
|
||||
return {"jsonrpc": "2.0", "id": req_id, "result": {"tools": TOOLS}}
|
||||
|
||||
if method == "tools/call":
|
||||
tool_name = req["params"]["name"]
|
||||
arguments = req["params"].get("arguments", {})
|
||||
tool_func = TOOL_MAP.get(tool_name)
|
||||
if not tool_func:
|
||||
return {"jsonrpc": "2.0", "id": req_id,
|
||||
"error": {"code": -32601, "message": f"未知工具: {tool_name}"}}
|
||||
try:
|
||||
result = tool_func(**arguments)
|
||||
text = json.dumps(result, ensure_ascii=False, indent=2)
|
||||
return {"jsonrpc": "2.0", "id": req_id,
|
||||
"result": {"content": [{"type": "text", "text": text}]}}
|
||||
except Exception as e:
|
||||
return {"jsonrpc": "2.0", "id": req_id,
|
||||
"error": {"code": -32000, "message": str(e)}}
|
||||
|
||||
return {"jsonrpc": "2.0", "id": req_id,
|
||||
"error": {"code": -32601, "message": f"未知方法: {method}"}}
|
||||
|
||||
|
||||
def main():
|
||||
for line in sys.stdin:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
req = json.loads(line)
|
||||
resp = handle_request(req)
|
||||
if resp is None:
|
||||
continue
|
||||
sys.stdout.write(json.dumps(resp, ensure_ascii=False) + '\n')
|
||||
sys.stdout.flush()
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
+487
@@ -0,0 +1,487 @@
|
||||
import imaplib
|
||||
import smtplib
|
||||
import ssl
|
||||
import email
|
||||
import re
|
||||
import time
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from email.header import decode_header, Header
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
|
||||
IMAP_HOST = 'mail.rrrosmontis.icu'
|
||||
IMAP_PORT = 993
|
||||
SMTP_HOST = 'mail.rrrosmontis.icu'
|
||||
SMTP_PORT = 465
|
||||
|
||||
|
||||
def decode_mime_str(s):
|
||||
if not s:
|
||||
return ''
|
||||
parts = decode_header(s)
|
||||
decoded_parts = []
|
||||
for part, charset in parts:
|
||||
if isinstance(part, bytes):
|
||||
try:
|
||||
if charset:
|
||||
decoded_parts.append(part.decode(charset, errors='replace'))
|
||||
else:
|
||||
decoded_parts.append(part.decode('utf-8', errors='replace'))
|
||||
except Exception:
|
||||
decoded_parts.append(part.decode('utf-8', errors='replace'))
|
||||
else:
|
||||
decoded_parts.append(part)
|
||||
return ''.join(decoded_parts)
|
||||
|
||||
|
||||
def decode_attachment_names(raw):
|
||||
if not raw:
|
||||
return []
|
||||
parts = decode_header(raw)
|
||||
decoded_parts = []
|
||||
for part, charset in parts:
|
||||
if isinstance(part, bytes):
|
||||
try:
|
||||
if charset:
|
||||
decoded_parts.append(part.decode(charset, errors='replace'))
|
||||
else:
|
||||
decoded_parts.append(part.decode('utf-8', errors='replace'))
|
||||
except Exception:
|
||||
decoded_parts.append(part.decode('utf-8', errors='replace'))
|
||||
else:
|
||||
decoded_parts.append(part)
|
||||
combined = ''.join(decoded_parts)
|
||||
return [name.strip() for name in combined.split(',') if name.strip()]
|
||||
|
||||
|
||||
def extract_email_body(msg):
|
||||
if msg.is_multipart():
|
||||
for part in msg.walk():
|
||||
if part.get_content_type() == "text/html":
|
||||
payload = part.get_payload(decode=True)
|
||||
charset = part.get_content_charset() or 'utf-8'
|
||||
try:
|
||||
return payload.decode(charset, errors='replace')
|
||||
except Exception:
|
||||
return payload.decode('utf-8', errors='replace')
|
||||
for part in msg.walk():
|
||||
if part.get_content_type() == "text/plain":
|
||||
payload = part.get_payload(decode=True)
|
||||
charset = part.get_content_charset() or 'utf-8'
|
||||
try:
|
||||
return payload.decode(charset, errors='replace')
|
||||
except Exception:
|
||||
return payload.decode('utf-8', errors='replace')
|
||||
else:
|
||||
payload = msg.get_payload(decode=True)
|
||||
charset = msg.get_content_charset() or 'utf-8'
|
||||
try:
|
||||
return payload.decode(charset, errors='replace')
|
||||
except Exception:
|
||||
return payload.decode('utf-8', errors='replace')
|
||||
return ''
|
||||
|
||||
|
||||
def imap_connect(email_addr, password):
|
||||
context = ssl._create_unverified_context()
|
||||
conn = imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT, ssl_context=context)
|
||||
conn.login(email_addr, password)
|
||||
return conn
|
||||
|
||||
|
||||
def smtp_connect(email_addr, password):
|
||||
context = ssl._create_unverified_context()
|
||||
smtp = smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, context=context)
|
||||
smtp.ehlo()
|
||||
smtp.login(email_addr, password)
|
||||
return smtp
|
||||
|
||||
|
||||
def find_sent_folder(conn):
|
||||
typ, folder_data = conn.list()
|
||||
if typ != 'OK':
|
||||
return None
|
||||
sent_candidates = ['Sent', 'Sent Messages', 'Sent Items',
|
||||
'\u5df2\u53d1\u9001', '\u5df2\u53d1\u9001\u90ae\u4ef6']
|
||||
for line in folder_data:
|
||||
line_str = line.decode() if isinstance(line, bytes) else line
|
||||
folder_name = None
|
||||
match = re.search(r'"([^"]+)"$', line_str)
|
||||
if match:
|
||||
folder_name = match.group(1)
|
||||
else:
|
||||
parts = line_str.split()
|
||||
if parts:
|
||||
folder_name = parts[-1].strip('"')
|
||||
if folder_name:
|
||||
if any(folder_name.lower() == cand.lower() for cand in sent_candidates):
|
||||
return folder_name
|
||||
if 'sent' in folder_name.lower() or '\u5df2\u53d1\u9001' in folder_name:
|
||||
return folder_name
|
||||
return 'Sent'
|
||||
|
||||
|
||||
def list_mailboxes(email_addr, password):
|
||||
conn = imap_connect(email_addr, password)
|
||||
try:
|
||||
typ, data = conn.list()
|
||||
if typ != 'OK':
|
||||
return []
|
||||
mailboxes = []
|
||||
for line in data:
|
||||
line_str = line.decode() if isinstance(line, bytes) else line
|
||||
match = re.search(r'"([^"]+)"$', line_str)
|
||||
if match:
|
||||
mailboxes.append(match.group(1))
|
||||
return mailboxes
|
||||
finally:
|
||||
conn.logout()
|
||||
|
||||
|
||||
def list_messages(email_addr, password, folder='INBOX', page=1, per_page=20):
|
||||
conn = imap_connect(email_addr, password)
|
||||
try:
|
||||
conn.select(folder)
|
||||
result, data = conn.uid('search', None, "ALL")
|
||||
if result != 'OK':
|
||||
return [], False
|
||||
|
||||
uid_list = data[0].split()
|
||||
total = len(uid_list)
|
||||
|
||||
start = max(0, total - page * per_page)
|
||||
end = total - (page - 1) * per_page
|
||||
page_uids = uid_list[start:end] if start < end else []
|
||||
|
||||
messages = []
|
||||
for uid in page_uids:
|
||||
result, msg_data = conn.uid('fetch', uid, '(BODY.PEEK[HEADER])')
|
||||
if result != 'OK':
|
||||
continue
|
||||
raw_header = msg_data[0][1]
|
||||
msg = email.message_from_bytes(raw_header)
|
||||
messages.append({
|
||||
'uid': uid.decode() if isinstance(uid, bytes) else uid,
|
||||
'from': decode_mime_str(msg.get('From', '')),
|
||||
'subject': decode_mime_str(msg.get('Subject', '')),
|
||||
'date': msg.get('Date', ''),
|
||||
'attachments': decode_attachment_names(msg.get('X-Attachments', ''))
|
||||
})
|
||||
|
||||
messages.reverse()
|
||||
return messages, page * per_page < total
|
||||
finally:
|
||||
conn.logout()
|
||||
|
||||
|
||||
def read_message(email_addr, password, uid, folder='INBOX'):
|
||||
conn = imap_connect(email_addr, password)
|
||||
try:
|
||||
conn.select(folder)
|
||||
result, data = conn.uid('fetch', uid, '(RFC822)')
|
||||
if result != 'OK':
|
||||
return None
|
||||
raw_email = data[0][1]
|
||||
msg = email.message_from_bytes(raw_email)
|
||||
return {
|
||||
'uid': uid,
|
||||
'body': extract_email_body(msg),
|
||||
'subject': decode_mime_str(msg.get('Subject', '')),
|
||||
'from': decode_mime_str(msg.get('From', '')),
|
||||
'to': decode_mime_str(msg.get('To', '')),
|
||||
'date': msg.get('Date', ''),
|
||||
'attachments': decode_attachment_names(msg.get('X-Attachments', ''))
|
||||
}
|
||||
finally:
|
||||
conn.logout()
|
||||
|
||||
|
||||
def list_sent(email_addr, password, page=1, per_page=20):
|
||||
conn = imap_connect(email_addr, password)
|
||||
try:
|
||||
sent_folder = find_sent_folder(conn)
|
||||
if not sent_folder:
|
||||
return [], False
|
||||
try:
|
||||
conn.select(sent_folder)
|
||||
except Exception:
|
||||
return [], False
|
||||
|
||||
result, data = conn.uid('search', None, "ALL")
|
||||
if result != 'OK':
|
||||
return [], False
|
||||
|
||||
uid_list = data[0].split()
|
||||
total = len(uid_list)
|
||||
|
||||
start = max(0, total - page * per_page)
|
||||
end = total - (page - 1) * per_page
|
||||
page_uids = uid_list[start:end] if start < end else []
|
||||
|
||||
messages = []
|
||||
for uid in page_uids:
|
||||
result, msg_data = conn.uid('fetch', uid, '(BODY.PEEK[HEADER])')
|
||||
if result != 'OK':
|
||||
continue
|
||||
raw_header = msg_data[0][1]
|
||||
msg = email.message_from_bytes(raw_header)
|
||||
messages.append({
|
||||
'uid': uid.decode() if isinstance(uid, bytes) else uid,
|
||||
'from': decode_mime_str(msg.get('From', '')),
|
||||
'to': decode_mime_str(msg.get('To', '')),
|
||||
'subject': decode_mime_str(msg.get('Subject', '')),
|
||||
'date': msg.get('Date', ''),
|
||||
'attachments': decode_attachment_names(msg.get('X-Attachments', ''))
|
||||
})
|
||||
|
||||
messages.reverse()
|
||||
return messages, page * per_page < total
|
||||
finally:
|
||||
conn.logout()
|
||||
|
||||
|
||||
def send_email(email_addr, password, to_addr, subject, body):
|
||||
smtp = smtp_connect(email_addr, password)
|
||||
try:
|
||||
msg = MIMEMultipart()
|
||||
msg['From'] = email_addr
|
||||
msg['To'] = to_addr
|
||||
msg['Subject'] = subject
|
||||
msg.attach(MIMEText(body, 'html', 'utf-8'))
|
||||
smtp.send_message(msg)
|
||||
smtp.quit()
|
||||
|
||||
# 保存到已发送
|
||||
imap_conn = imap_connect(email_addr, password)
|
||||
try:
|
||||
sent_folder = find_sent_folder(imap_conn)
|
||||
if sent_folder:
|
||||
try:
|
||||
imap_conn.select(sent_folder)
|
||||
except Exception:
|
||||
try:
|
||||
imap_conn.create(sent_folder)
|
||||
imap_conn.select(sent_folder)
|
||||
except Exception:
|
||||
return True
|
||||
imap_conn.append(sent_folder, '\\Seen',
|
||||
imaplib.Time2Internaldate(time.time()),
|
||||
msg.as_bytes())
|
||||
imap_conn.close()
|
||||
finally:
|
||||
imap_conn.logout()
|
||||
return True
|
||||
except Exception as e:
|
||||
return str(e)
|
||||
|
||||
|
||||
def delete_message(email_addr, password, uid, folder='INBOX'):
|
||||
conn = imap_connect(email_addr, password)
|
||||
try:
|
||||
conn.select(folder)
|
||||
result, _ = conn.uid('store', uid, '+FLAGS', '\\Deleted')
|
||||
if result != 'OK':
|
||||
return False
|
||||
conn.expunge()
|
||||
return True
|
||||
finally:
|
||||
conn.logout()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# MCP JSON-RPC 处理
|
||||
# ============================================================
|
||||
|
||||
TOOLS = [
|
||||
{
|
||||
"name": "list_mailboxes",
|
||||
"description": "列出 IMAP 邮箱中的所有文件夹",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {"type": "string", "description": "邮箱地址"},
|
||||
"password": {"type": "string", "description": "邮箱密码,不传则从环境变量 ROS_MAIL_MCP_PASSWORD 读取"}
|
||||
},
|
||||
"required": ["email"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_messages",
|
||||
"description": "列出指定文件夹中的邮件(默认 INBOX),支持分页",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {"type": "string", "description": "邮箱地址"},
|
||||
"password": {"type": "string", "description": "邮箱密码,不传则从环境变量 ROS_MAIL_MCP_PASSWORD 读取"},
|
||||
"folder": {"type": "string", "description": "文件夹名,默认 INBOX"},
|
||||
"page": {"type": "integer", "description": "页码,从 1 开始,默认 1"},
|
||||
"per_page": {"type": "integer", "description": "每页数量,默认 20"}
|
||||
},
|
||||
"required": ["email"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "read_message",
|
||||
"description": "读取指定 UID 的邮件全文",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {"type": "string", "description": "邮箱地址"},
|
||||
"password": {"type": "string", "description": "邮箱密码,不传则从环境变量 ROS_MAIL_MCP_PASSWORD 读取"},
|
||||
"uid": {"type": "string", "description": "邮件 UID"},
|
||||
"folder": {"type": "string", "description": "文件夹名,默认 INBOX"}
|
||||
},
|
||||
"required": ["email", "uid"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_sent",
|
||||
"description": "列出已发送邮件,支持分页",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {"type": "string", "description": "邮箱地址"},
|
||||
"password": {"type": "string", "description": "邮箱密码,不传则从环境变量 ROS_MAIL_MCP_PASSWORD 读取"},
|
||||
"page": {"type": "integer", "description": "页码,从 1 开始,默认 1"},
|
||||
"per_page": {"type": "integer", "description": "每页数量,默认 20"}
|
||||
},
|
||||
"required": ["email"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "send_email",
|
||||
"description": "发送邮件(HTML 格式),发送后会保存到已发送文件夹",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {"type": "string", "description": "发件人邮箱地址"},
|
||||
"password": {"type": "string", "description": "发件人邮箱密码"},
|
||||
"to": {"type": "string", "description": "收件人邮箱地址"},
|
||||
"subject": {"type": "string", "description": "邮件主题"},
|
||||
"body": {"type": "string", "description": "邮件正文(HTML 格式)"}
|
||||
},
|
||||
"required": ["email", "to", "subject", "body"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "delete_message",
|
||||
"description": "删除指定 UID 的邮件",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {"type": "string", "description": "邮箱地址"},
|
||||
"password": {"type": "string", "description": "邮箱密码,不传则从环境变量 ROS_MAIL_MCP_PASSWORD 读取"},
|
||||
"uid": {"type": "string", "description": "邮件 UID"},
|
||||
"folder": {"type": "string", "description": "文件夹名,默认 INBOX"}
|
||||
},
|
||||
"required": ["email", "uid"]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
TOOL_MAP = {
|
||||
"list_mailboxes": list_mailboxes,
|
||||
"list_messages": list_messages,
|
||||
"read_message": read_message,
|
||||
"list_sent": list_sent,
|
||||
"send_email": send_email,
|
||||
"delete_message": delete_message
|
||||
}
|
||||
|
||||
|
||||
def resolve_password(arguments):
|
||||
if arguments.get('password'):
|
||||
return arguments['password']
|
||||
pw = os.environ.get('ROS_MAIL_MCP_PASSWORD', '')
|
||||
if pw:
|
||||
return pw
|
||||
return None
|
||||
|
||||
|
||||
def handle_request(req):
|
||||
method = req.get("method", "")
|
||||
req_id = req.get("id")
|
||||
|
||||
if method == "initialize":
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": req_id,
|
||||
"result": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {
|
||||
"tools": {}
|
||||
},
|
||||
"serverInfo": {
|
||||
"name": "ros-mail-mcp",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if method == "ping":
|
||||
return {"jsonrpc": "2.0", "id": req_id, "result": {}}
|
||||
|
||||
if method == "notifications/initialized":
|
||||
return None
|
||||
|
||||
if method == "tools/list":
|
||||
return {"jsonrpc": "2.0", "id": req_id, "result": {"tools": TOOLS}}
|
||||
|
||||
if method == "tools/call":
|
||||
tool_name = req["params"]["name"]
|
||||
arguments = req["params"].get("arguments", {})
|
||||
tool_func = TOOL_MAP.get(tool_name)
|
||||
if not tool_func:
|
||||
return {"jsonrpc": "2.0", "id": req_id,
|
||||
"error": {"code": -32601, "message": f"未知工具: {tool_name}"}}
|
||||
password = resolve_password(arguments)
|
||||
if not password:
|
||||
return {"jsonrpc": "2.0", "id": req_id,
|
||||
"error": {"code": -32602, "message": "缺少密码: 请在参数中传入 password 或设置环境变量 ROS_MAIL_MCP_PASSWORD"}}
|
||||
arguments['password'] = password
|
||||
# 参数名映射:schema 中的参数名 -> Python 函数参数名
|
||||
if 'email' in arguments and 'email_addr' not in arguments:
|
||||
arguments['email_addr'] = arguments.pop('email')
|
||||
if 'to' in arguments and 'to_addr' not in arguments:
|
||||
arguments['to_addr'] = arguments.pop('to')
|
||||
try:
|
||||
result = tool_func(**arguments)
|
||||
if isinstance(result, tuple):
|
||||
result = {"data": result[0], "has_more": result[1]}
|
||||
elif isinstance(result, list):
|
||||
result = {"mailboxes": result}
|
||||
elif isinstance(result, dict):
|
||||
pass
|
||||
elif isinstance(result, bool):
|
||||
result = {"success": result}
|
||||
text = json.dumps(result, ensure_ascii=False, indent=2)
|
||||
return {"jsonrpc": "2.0", "id": req_id,
|
||||
"result": {"content": [{"type": "text", "text": text}]}}
|
||||
except Exception as e:
|
||||
return {"jsonrpc": "2.0", "id": req_id,
|
||||
"error": {"code": -32000, "message": str(e)}}
|
||||
|
||||
return {"jsonrpc": "2.0", "id": req_id,
|
||||
"error": {"code": -32601, "message": f"未知方法: {method}"}}
|
||||
|
||||
|
||||
def main():
|
||||
for line in sys.stdin:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
req = json.loads(line)
|
||||
resp = handle_request(req)
|
||||
if resp is None:
|
||||
continue
|
||||
sys.stdout.write(json.dumps(resp, ensure_ascii=False) + '\n')
|
||||
sys.stdout.flush()
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,311 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import base64
|
||||
from typing import Optional, List
|
||||
|
||||
NAPCT_WS_URL = 'ws://127.0.0.1:3001'
|
||||
RECEIVE_TIMEOUT = 30
|
||||
|
||||
_ws = None
|
||||
_msg_queue: List[dict] = []
|
||||
|
||||
|
||||
def is_image_file(path: str) -> bool:
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
return ext in ('.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp')
|
||||
|
||||
|
||||
def is_video_file(path: str) -> bool:
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
return ext in ('.mp4', '.mov', '.avi', '.mkv', '.flv', '.wmv', '.webm')
|
||||
|
||||
|
||||
def build_message_segments(text: str, file_paths: Optional[List[str]] = None) -> list:
|
||||
"""构建 NapCat 消息段数组"""
|
||||
segments = []
|
||||
if text:
|
||||
segments.append({"type": "text", "data": {"text": text}})
|
||||
if file_paths:
|
||||
for path in file_paths:
|
||||
abs_path = os.path.abspath(path)
|
||||
if not os.path.exists(abs_path):
|
||||
raise Exception(f"文件不存在: {abs_path}")
|
||||
if is_image_file(abs_path):
|
||||
segments.append({"type": "image", "data": {"file": f"file://{abs_path}"}})
|
||||
elif is_video_file(abs_path):
|
||||
segments.append({"type": "video", "data": {"file": f"file://{abs_path}"}})
|
||||
else:
|
||||
segments.append({"type": "file", "data": {"file": f"file://{abs_path}"}})
|
||||
if not segments:
|
||||
segments.append({"type": "text", "data": {"text": ""}})
|
||||
return segments
|
||||
|
||||
|
||||
async def ensure_ws():
|
||||
global _ws
|
||||
if _ws is None or _ws.closed:
|
||||
import websockets
|
||||
_ws = await websockets.connect(NAPCT_WS_URL)
|
||||
return _ws
|
||||
|
||||
|
||||
async def ws_send_action(action: str, params: dict) -> dict:
|
||||
"""发送 action 到 NapCat 并等待响应"""
|
||||
ws = await ensure_ws()
|
||||
echo = f"mcp_{int(time.time() * 1000)}"
|
||||
payload = {"action": action, "params": params, "echo": echo}
|
||||
await ws.send(json.dumps(payload))
|
||||
# 等待对应的响应
|
||||
timeout_ts = time.time() + 15
|
||||
while time.time() < timeout_ts:
|
||||
try:
|
||||
raw = await asyncio.wait_for(ws.recv(), timeout=2)
|
||||
resp = json.loads(raw)
|
||||
if resp.get("echo") == echo:
|
||||
return resp
|
||||
# 不是我们的响应,放入消息队列
|
||||
_msg_queue.append(resp)
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
raise Exception("发送消息超时")
|
||||
|
||||
|
||||
async def send_private_message(user_id: int, text: str = "", file_paths: Optional[List[str]] = None) -> dict:
|
||||
"""发送私聊消息,可附带图片/视频"""
|
||||
segments = build_message_segments(text, file_paths)
|
||||
return await ws_send_action("send_private_msg", {
|
||||
"user_id": user_id,
|
||||
"message": segments
|
||||
})
|
||||
|
||||
|
||||
async def send_group_message(group_id: int, text: str = "", file_paths: Optional[List[str]] = None) -> dict:
|
||||
"""发送群聊消息,可附带图片/视频"""
|
||||
segments = build_message_segments(text, file_paths)
|
||||
return await ws_send_action("send_group_msg", {
|
||||
"group_id": group_id,
|
||||
"message": segments
|
||||
})
|
||||
|
||||
|
||||
async def receive_messages(count: int = 1, user_id: Optional[int] = None,
|
||||
group_id: Optional[int] = None, timeout: int = RECEIVE_TIMEOUT) -> list:
|
||||
"""接收 N 条消息,可按 user_id 或 group_id 过滤"""
|
||||
ws = await ensure_ws()
|
||||
results = []
|
||||
deadline = time.time() + timeout
|
||||
while len(results) < count and time.time() < deadline:
|
||||
remain = deadline - time.time()
|
||||
if remain <= 0:
|
||||
break
|
||||
try:
|
||||
raw = await asyncio.wait_for(ws.recv(), timeout=min(remain, 2))
|
||||
msg = json.loads(raw)
|
||||
if msg.get("post_type") != "message":
|
||||
continue
|
||||
msg_user_id = msg.get("user_id")
|
||||
msg_group_id = msg.get("group_id") if msg.get("message_type") == "group" else None
|
||||
if user_id is not None and msg_user_id != user_id:
|
||||
continue
|
||||
if group_id is not None and msg_group_id != group_id:
|
||||
continue
|
||||
|
||||
# 提取纯文本和媒体信息
|
||||
segments = msg.get("message", [])
|
||||
text_parts = []
|
||||
media_info = []
|
||||
for seg in segments:
|
||||
if seg.get("type") == "text":
|
||||
text_parts.append(seg.get("data", {}).get("text", ""))
|
||||
elif seg.get("type") in ("image", "video"):
|
||||
media_info.append({
|
||||
"type": seg.get("type"),
|
||||
"url": seg.get("data", {}).get("url", ""),
|
||||
"file": seg.get("data", {}).get("file", "")
|
||||
})
|
||||
|
||||
results.append({
|
||||
"message_id": msg.get("message_id"),
|
||||
"user_id": msg_user_id,
|
||||
"group_id": msg_group_id,
|
||||
"message_type": msg.get("message_type"),
|
||||
"text": "".join(text_parts).strip(),
|
||||
"media": media_info,
|
||||
"raw": msg
|
||||
})
|
||||
_msg_queue.clear()
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 同步包装器(供 stdio MCP 调用)
|
||||
# ============================================================
|
||||
|
||||
_loop = None
|
||||
|
||||
|
||||
def get_loop():
|
||||
global _loop
|
||||
if _loop is None or _loop.is_closed():
|
||||
_loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(_loop)
|
||||
return _loop
|
||||
|
||||
|
||||
def sync_send_private_message(user_id: int, text: str = "", file_paths: list = None):
|
||||
loop = get_loop()
|
||||
result = loop.run_until_complete(send_private_message(user_id, text, file_paths or []))
|
||||
loop.run_until_complete(_ws.close())
|
||||
return result
|
||||
|
||||
|
||||
def sync_send_group_message(group_id: int, text: str = "", file_paths: list = None):
|
||||
loop = get_loop()
|
||||
result = loop.run_until_complete(send_group_message(group_id, text, file_paths or []))
|
||||
loop.run_until_complete(_ws.close())
|
||||
return result
|
||||
|
||||
|
||||
def sync_receive_messages(count: int = 1, user_id: int = None,
|
||||
group_id: int = None, timeout: int = RECEIVE_TIMEOUT):
|
||||
loop = get_loop()
|
||||
result = loop.run_until_complete(receive_messages(count, user_id, group_id, timeout))
|
||||
loop.run_until_complete(_ws.close())
|
||||
return result
|
||||
|
||||
|
||||
# ============================================================
|
||||
# MCP JSON-RPC 处理
|
||||
# ============================================================
|
||||
|
||||
TOOLS = [
|
||||
{
|
||||
"name": "send_private_message",
|
||||
"description": "向指定 QQ 用户发送私聊消息,可附带任意类型的文件(图片/视频/文档等)。",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"user_id": {"type": "number", "description": "目标 QQ 号"},
|
||||
"text": {"type": "string", "description": "要发送的文字内容(可为空,但需要至少有文字或媒体文件之一)"},
|
||||
"file_paths": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "文件的本地绝对路径列表,图片用 image 类型,视频用 video 类型,其余后缀一律用 file 类型发送"
|
||||
}
|
||||
},
|
||||
"required": ["user_id"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "send_group_message",
|
||||
"description": "向指定 QQ 群发送消息,可附带任意类型的文件(图片/视频/文档等)。",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"group_id": {"type": "number", "description": "目标群号"},
|
||||
"text": {"type": "string", "description": "要发送的文字内容(可为空,但需要至少有文字或媒体文件之一)"},
|
||||
"file_paths": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "文件的本地绝对路径列表,图片用 image 类型,视频用 video 类型,其余后缀一律用 file 类型发送"
|
||||
}
|
||||
},
|
||||
"required": ["group_id"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "receive_messages",
|
||||
"description": "接收来自 NapCat 的消息,可按 QQ 号或群号过滤,接收指定条数后返回(有超时限制)。",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"count": {"type": "number", "description": "要接收的消息条数,默认 1"},
|
||||
"user_id": {"type": "number", "description": "按 QQ 号过滤(可选,不传则不过滤)"},
|
||||
"group_id": {"type": "number", "description": "按群号过滤(可选,不传则不过滤)"},
|
||||
"timeout": {"type": "number", "description": "超时秒数,默认 30"}
|
||||
},
|
||||
"required": []
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
TOOL_MAP = {
|
||||
"send_private_message": sync_send_private_message,
|
||||
"send_group_message": sync_send_group_message,
|
||||
"receive_messages": sync_receive_messages
|
||||
}
|
||||
|
||||
|
||||
def handle_request(req):
|
||||
method = req.get("method", "")
|
||||
req_id = req.get("id")
|
||||
|
||||
if method == "initialize":
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": req_id,
|
||||
"result": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {
|
||||
"tools": {}
|
||||
},
|
||||
"serverInfo": {
|
||||
"name": "ros-qq-messages",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if method == "ping":
|
||||
return {"jsonrpc": "2.0", "id": req_id, "result": {}}
|
||||
|
||||
if method == "notifications/initialized":
|
||||
return None
|
||||
|
||||
if method == "tools/list":
|
||||
return {"jsonrpc": "2.0", "id": req_id, "result": {"tools": TOOLS}}
|
||||
|
||||
if method == "tools/call":
|
||||
tool_name = req["params"]["name"]
|
||||
arguments = req["params"].get("arguments", {})
|
||||
tool_func = TOOL_MAP.get(tool_name)
|
||||
if not tool_func:
|
||||
return {"jsonrpc": "2.0", "id": req_id,
|
||||
"error": {"code": -32601, "message": f"未知工具: {tool_name}"}}
|
||||
try:
|
||||
result = tool_func(**arguments)
|
||||
text = json.dumps(result, ensure_ascii=False, indent=2)
|
||||
return {"jsonrpc": "2.0", "id": req_id,
|
||||
"result": {"content": [{"type": "text", "text": text}]}}
|
||||
except Exception as e:
|
||||
return {"jsonrpc": "2.0", "id": req_id,
|
||||
"error": {"code": -32000, "message": str(e)}}
|
||||
|
||||
return {"jsonrpc": "2.0", "id": req_id,
|
||||
"error": {"code": -32601, "message": f"未知方法: {method}"}}
|
||||
|
||||
|
||||
def main():
|
||||
for line in sys.stdin:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
req = json.loads(line)
|
||||
resp = handle_request(req)
|
||||
if resp is None:
|
||||
continue
|
||||
sys.stdout.write(json.dumps(resp, ensure_ascii=False) + '\n')
|
||||
sys.stdout.flush()
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,270 @@
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import requests
|
||||
|
||||
BASE_URL = 'https://status.rrrosmontis.icu'
|
||||
|
||||
# ============================================================
|
||||
# 内部辅助
|
||||
# ============================================================
|
||||
|
||||
_session = None
|
||||
|
||||
|
||||
def get_session():
|
||||
"""获取或创建带 cookie 的 requests Session"""
|
||||
global _session
|
||||
if _session is None:
|
||||
_session = requests.Session()
|
||||
return _session
|
||||
|
||||
|
||||
def ensure_login():
|
||||
"""确保已登录,如果未登录则自动登录"""
|
||||
s = get_session()
|
||||
resp = s.get(f'{BASE_URL}/api/me', timeout=10)
|
||||
data = resp.json()
|
||||
if data.get('logged_in'):
|
||||
return
|
||||
account = os.environ.get('ROS_SERVERS_STATUS_ACCOUNT', '')
|
||||
password = os.environ.get('ROS_SERVERS_STATUS_PASSWORD', '')
|
||||
if not account or not password:
|
||||
raise Exception('未登录,且环境变量 ROS_SERVERS_STATUS_ACCOUNT / ROS_SERVERS_STATUS_PASSWORD 未设置')
|
||||
payload = {'username': account, 'password': password}
|
||||
resp = s.post(f'{BASE_URL}/api/login', json=payload, timeout=10)
|
||||
result = resp.json()
|
||||
if result.get('status') != 'success':
|
||||
raise Exception(f"登录失败: {result.get('message', '未知错误')}")
|
||||
|
||||
|
||||
def api_get(path, params=None, need_auth=True):
|
||||
if need_auth:
|
||||
ensure_login()
|
||||
s = get_session()
|
||||
resp = s.get(f'{BASE_URL}{path}', params=params, timeout=15)
|
||||
return resp.json()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 工具函数
|
||||
# ============================================================
|
||||
|
||||
def get_nodes():
|
||||
"""获取所有节点基本信息列表"""
|
||||
return api_get('/api/nodes')
|
||||
|
||||
|
||||
def get_node_recent(uuid):
|
||||
"""获取指定节点最近1分钟实时状态(嵌套对象结构)"""
|
||||
return api_get(f'/api/recent/{uuid}')
|
||||
|
||||
|
||||
def get_load_records(uuid, hours=4, load_type='all'):
|
||||
"""获取指定节点负载历史记录(扁平化结构)"""
|
||||
params = {'uuid': uuid, 'hours': hours}
|
||||
if load_type and load_type != 'all':
|
||||
params['load_type'] = load_type
|
||||
return api_get('/api/records/load', params=params)
|
||||
|
||||
|
||||
def get_ping_records(uuid=None, task_id=None, hours=4):
|
||||
"""获取 Ping 历史记录,uuid 和 task_id 至少提供一个"""
|
||||
params = {'hours': hours}
|
||||
if uuid:
|
||||
params['uuid'] = uuid
|
||||
if task_id is not None:
|
||||
params['task_id'] = task_id
|
||||
return api_get('/api/records/ping', params=params)
|
||||
|
||||
|
||||
def get_ping_tasks():
|
||||
"""获取公开的 Ping 任务配置列表"""
|
||||
return api_get('/api/task/ping')
|
||||
|
||||
|
||||
def get_site_info():
|
||||
"""获取站点公开设置属性"""
|
||||
return api_get('/api/public', need_auth=False)
|
||||
|
||||
|
||||
def get_version():
|
||||
"""获取 Komari 服务端版本信息"""
|
||||
return api_get('/api/version', need_auth=False)
|
||||
|
||||
|
||||
def check_login():
|
||||
"""检查当前登录状态"""
|
||||
s = get_session()
|
||||
resp = s.get(f'{BASE_URL}/api/me', timeout=10)
|
||||
return resp.json()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# MCP JSON-RPC 处理
|
||||
# ============================================================
|
||||
|
||||
TOOLS = [
|
||||
{
|
||||
"name": "get_nodes",
|
||||
"description": "获取所有节点的基本信息列表(名称、CPU、内存、磁盘、价格、到期时间、流量限制等)。未登录时 hidden=true 的节点不可见。",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "get_node_recent",
|
||||
"description": "获取指定节点最近1分钟的实时状态数据(CPU/内存/磁盘/网络/负载/连接数/GPU/运行时间),数据结构为嵌套对象格式。",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"uuid": {"type": "string", "description": "节点的唯一标识符 UUID"}
|
||||
},
|
||||
"required": ["uuid"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "get_load_records",
|
||||
"description": "获取指定节点的负载历史记录(扁平化结构),可按指标类型筛选,用于生成图表和趋势分析。",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"uuid": {"type": "string", "description": "节点的唯一标识符 UUID"},
|
||||
"hours": {"type": "number", "description": "查询时间范围(小时),默认 4"},
|
||||
"load_type": {"type": "string", "description": "指标筛选类型: cpu / ram / swap / load / temp / disk / network / process / connections / all(默认)"}
|
||||
},
|
||||
"required": ["uuid"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "get_ping_records",
|
||||
"description": "获取 Ping 历史记录。uuid 和 task_id 至少提供一个:仅 uuid 返回该节点参与的任务记录,仅 task_id 返回该任务下所有节点记录,同时提供则交集查询。",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"uuid": {"type": "string", "description": "节点 UUID(可选,与 task_id 至少提供一个)"},
|
||||
"task_id": {"type": "number", "description": "Ping 任务 ID(可选,与 uuid 至少提供一个)"},
|
||||
"hours": {"type": "number", "description": "查询时间范围(小时),默认 4"}
|
||||
},
|
||||
"required": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "get_ping_tasks",
|
||||
"description": "获取公开的 Ping 任务配置列表,包含任务名称、类型、执行间隔、关联节点等。",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "get_site_info",
|
||||
"description": "获取站点公开设置(站点名、描述、主题、OAuth 配置、CORS、记录保留时间等),无需登录。",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "get_version",
|
||||
"description": "获取 Komari 服务端版本号和 Git Hash,无需登录。",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "check_login",
|
||||
"description": "检查当前登录状态,返回用户名、UUID、是否启用2FA、SSO 绑定等信息。",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
TOOL_MAP = {
|
||||
"get_nodes": get_nodes,
|
||||
"get_node_recent": get_node_recent,
|
||||
"get_load_records": get_load_records,
|
||||
"get_ping_records": get_ping_records,
|
||||
"get_ping_tasks": get_ping_tasks,
|
||||
"get_site_info": get_site_info,
|
||||
"get_version": get_version,
|
||||
"check_login": check_login
|
||||
}
|
||||
|
||||
|
||||
def handle_request(req):
|
||||
method = req.get("method", "")
|
||||
req_id = req.get("id")
|
||||
|
||||
if method == "initialize":
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": req_id,
|
||||
"result": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {
|
||||
"tools": {}
|
||||
},
|
||||
"serverInfo": {
|
||||
"name": "ros-servers-status",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if method == "ping":
|
||||
return {"jsonrpc": "2.0", "id": req_id, "result": {}}
|
||||
|
||||
if method == "notifications/initialized":
|
||||
return None
|
||||
|
||||
if method == "tools/list":
|
||||
return {"jsonrpc": "2.0", "id": req_id, "result": {"tools": TOOLS}}
|
||||
|
||||
if method == "tools/call":
|
||||
tool_name = req["params"]["name"]
|
||||
arguments = req["params"].get("arguments", {})
|
||||
tool_func = TOOL_MAP.get(tool_name)
|
||||
if not tool_func:
|
||||
return {"jsonrpc": "2.0", "id": req_id,
|
||||
"error": {"code": -32601, "message": f"未知工具: {tool_name}"}}
|
||||
try:
|
||||
result = tool_func(**arguments)
|
||||
text = json.dumps(result, ensure_ascii=False, indent=2)
|
||||
return {"jsonrpc": "2.0", "id": req_id,
|
||||
"result": {"content": [{"type": "text", "text": text}]}}
|
||||
except Exception as e:
|
||||
return {"jsonrpc": "2.0", "id": req_id,
|
||||
"error": {"code": -32000, "message": str(e)}}
|
||||
|
||||
return {"jsonrpc": "2.0", "id": req_id,
|
||||
"error": {"code": -32601, "message": f"未知方法: {method}"}}
|
||||
|
||||
|
||||
def main():
|
||||
for line in sys.stdin:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
req = json.loads(line)
|
||||
resp = handle_request(req)
|
||||
if resp is None:
|
||||
continue
|
||||
sys.stdout.write(json.dumps(resp, ensure_ascii=False) + '\n')
|
||||
sys.stdout.flush()
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user