Compare commits

...

8 commits

Author SHA1 Message Date
41e6aeacb9 🔖 更新版本号至0.5.4
Some checks failed
Pyright Lint / Pyright Lint (push) Has been cancelled
Ruff Lint / Ruff Lint (push) Has been cancelled
2026-06-29 14:30:19 +08:00
f68c195fa6 ♻️ fix lint problems 2026-06-29 14:19:35 +08:00
3bdacc7885 ♻️ fix lint problems 2026-06-29 13:47:13 +08:00
a061aa0a32 ♻️ fix lint problems 2026-06-29 13:45:23 +08:00
7d0ad43c7c 🔖 更新版本号至0.5.3 2026-06-29 13:38:52 +08:00
97e82e0c0e 🐛 调整 MCP HTTP 自动检测顺序,防止意外卡住 #28 2026-06-29 11:39:55 +08:00
bec3fda293 支持 MCP Streamable HTTP 传输协议 #28 2026-06-29 11:19:19 +08:00
b6af4ec334 添加MCP服务器全局工作目录配置 2026-06-29 10:23:21 +08:00
6 changed files with 1093 additions and 356 deletions

View file

@ -123,6 +123,7 @@ _✨ 支持多API预设、MCP协议、内置工具、联网搜索、视觉模型
| LLMCHAT__DEFAULT_PRESET | 否 | off | 默认使用的预设名称配置为off则为关闭 | | LLMCHAT__DEFAULT_PRESET | 否 | off | 默认使用的预设名称配置为off则为关闭 |
| LLMCHAT__RANDOM_TRIGGER_PROB | 否 | 0.05 | 默认随机触发概率 [0, 1] | | LLMCHAT__RANDOM_TRIGGER_PROB | 否 | 0.05 | 默认随机触发概率 [0, 1] |
| LLMCHAT__DEFAULT_PROMPT | 否 | 你的回答应该尽量简洁、幽默、可以使用一些语气词、颜文字。你应该拒绝回答任何政治相关的问题。 | 默认提示词 | | LLMCHAT__DEFAULT_PROMPT | 否 | 你的回答应该尽量简洁、幽默、可以使用一些语气词、颜文字。你应该拒绝回答任何政治相关的问题。 | 默认提示词 |
| LLMCHAT__MCP_SERVER_CWD | 否 | 无 | command类型MCP服务器全局工作目录cwd |
| LLMCHAT__BLACKLIST_USER_IDS | 否 | [] | 黑名单用户ID列表机器人将不会处理黑名单用户的消息 | | LLMCHAT__BLACKLIST_USER_IDS | 否 | [] | 黑名单用户ID列表机器人将不会处理黑名单用户的消息 |
| LLMCHAT__IGNORE_PREFIXES | 否 | [] | 需要忽略的消息前缀列表,匹配到这些前缀的消息不会处理 | | LLMCHAT__IGNORE_PREFIXES | 否 | [] | 需要忽略的消息前缀列表,匹配到这些前缀的消息不会处理 |
| LLMCHAT__MCP_SERVERS | 否 | {} | MCP服务器配置具体见下表 | | LLMCHAT__MCP_SERVERS | 否 | {} | MCP服务器配置具体见下表 |
@ -165,10 +166,11 @@ LLMCHAT__MCP_SERVERS同样为一个dictkey为服务器名称value配置的
| 配置项 | 必填 | 默认值 | 说明 | | 配置项 | 必填 | 默认值 | 说明 |
|:-----:|:----:|:----:|:----:| |:-----:|:----:|:----:|:----:|
| command | stdio服务器必填 | 无 | stdio服务器MCP命令 | | command | stdio服务器必填 | 无 | stdio服务器MCP命令 |
| arg | 否 | [] | stdio服务器MCP命令参数 | | args | 否 | [] | stdio服务器MCP命令参数 |
| env | 否 | {} | stdio服务器环境变量 | | env | 否 | {} | stdio服务器环境变量 |
| url | sse服务器必填 | 无 | sse服务器地址 | | url | 远程服务器必填 | 无 | 远程MCP服务器地址 |
| headers | 否 | {} | sse模式下http请求头用于认证或其他设置 | | headers | 否 | {} | 远程服务器http请求头用于认证或其他设置 |
| transport | 否 | 自动 | 远程MCP传输协议类型可选 `sse``streamable_http` ,不填则自动探测 |
以下为在 Claude.app 的MCP服务器配置基础上增加的字段 以下为在 Claude.app 的MCP服务器配置基础上增加的字段
| 配置项 | 必填 | 默认值 | 说明 | | 配置项 | 必填 | 默认值 | 说明 |
@ -254,6 +256,12 @@ LLMCHAT__MCP_SERVERS同样为一个dictkey为服务器名称value配置的
"formulahendry/mcp-server-code-runner" "formulahendry/mcp-server-code-runner"
] ]
}, },
"tavily": {
"friendly_name": "Tavily搜索",
"additional_prompt": "当你需要搜索最新的互联网信息时,请使用 tavily 工具。",
"url": "https://mcp.tavily.com/mcp/?tavilyApiKey=<your-api-key>",
"transport": "streamable_http"
}
} }
' '

View file

@ -349,261 +349,266 @@ async def process_messages(context_id: int, is_group: bool = True):
logger.info( logger.info(
f"开始处理{chat_type}消息 {context_type}{context_id} 当前队列长度:{state.queue.qsize()}" f"开始处理{chat_type}消息 {context_type}{context_id} 当前队列长度:{state.queue.qsize()}"
) )
while not state.queue.empty(): try:
event = await state.queue.get() while not state.queue.empty():
if is_group: event = await state.queue.get()
logger.debug(f"从队列获取消息 群号:{context_id} 消息ID{event.message_id}")
group_id = context_id
else:
logger.debug(f"从队列获取消息 用户:{context_id} 消息ID{event.message_id}")
group_id = None
past_events_snapshot = []
mcp_client = MCPClient.get_instance(plugin_config.mcp_servers)
try:
# 构建系统提示,分成多行以满足行长限制
chat_type = "群聊" if is_group else "私聊"
bot_names = "".join(list(driver.config.nickname))
default_prompt = (state.group_prompt) or plugin_config.default_prompt
system_lines = [
f"我想要你帮我在{chat_type}中闲聊,大家一般叫你{bot_names}",
"我将会在后面的信息中告诉你每条信息的发送者和发送时间,你可以直接称呼发送者为他对应的昵称。",
"你的回复需要遵守以下几点规则:",
"- 你可以使用多条消息回复,每两条消息之间使用<botbr>分隔,<botbr>前后不需要包含额外的换行和空格。",
"- 除<botbr>外,消息中不应该包含其他类似的标记。",
"- 不要使用markdown或者html聊天软件不支持解析换行请用换行符。",
"- 你应该以普通人的方式发送消息,每条消息字数要尽量少一些,应该倾向于使用更多条的消息回复。",
"- 代码则不需要分段,用单独的一条消息发送。",
"- 请使用发送者的昵称称呼发送者,你可以礼貌地问候发送者,但只需要在"
"第一次回答这位发送者的问题时问候他。",
"- 你有引用某条消息的能力,使用[CQ:reply,id=消息id]来引用。",
"- 如果有多条消息,你应该优先回复提到你的,一段时间之前的就不要回复了,也可以直接选择不回复。",
"- 如果你选择完全不回复,你只需要直接输出一个<botbr>。",
"- 如果你需要思考的话,你应该尽量少思考,以节省时间。",
]
if is_group: if is_group:
system_lines += [ logger.debug(f"从队列获取消息 群号:{context_id} 消息ID{event.message_id}")
"- 你有at群成员的能力只需要在某条消息中插入[CQ:at,qq=QQ号]" group_id = context_id
"也就是CQ码。at发送者是非必要的你可以根据你自己的想法at某个人。", else:
logger.debug(f"从队列获取消息 用户:{context_id} 消息ID{event.message_id}")
group_id = None
past_events_snapshot = []
mcp_client = MCPClient.get_instance(
plugin_config.mcp_servers,
plugin_config.mcp_server_cwd,
)
try:
# 构建系统提示,分成多行以满足行长限制
chat_type = "群聊" if is_group else "私聊"
bot_names = "".join(list(driver.config.nickname))
default_prompt = (state.group_prompt) or plugin_config.default_prompt
system_lines = [
f"我想要你帮我在{chat_type}中闲聊,大家一般叫你{bot_names}",
"我将会在后面的信息中告诉你每条信息的发送者和发送时间,你可以直接称呼发送者为他对应的昵称。",
"你的回复需要遵守以下几点规则:",
"- 你可以使用多条消息回复,每两条消息之间使用<botbr>分隔,<botbr>前后不需要包含额外的换行和空格。",
"- 除<botbr>外,消息中不应该包含其他类似的标记。",
"- 不要使用markdown或者html聊天软件不支持解析换行请用换行符。",
"- 你应该以普通人的方式发送消息,每条消息字数要尽量少一些,应该倾向于使用更多条的消息回复。",
"- 代码则不需要分段,用单独的一条消息发送。",
"- 请使用发送者的昵称称呼发送者,你可以礼貌地问候发送者,但只需要在"
"第一次回答这位发送者的问题时问候他。",
"- 你有引用某条消息的能力,使用[CQ:reply,id=消息id]来引用。",
"- 如果有多条消息,你应该优先回复提到你的,一段时间之前的就不要回复了,也可以直接选择不回复。",
"- 如果你选择完全不回复,你只需要直接输出一个<botbr>。",
"- 如果你需要思考的话,你应该尽量少思考,以节省时间。",
] ]
system_lines += [ if is_group:
"下面是关于你性格的设定,如果设定中提到让你扮演某个人,或者设定中有提到名字,则优先使用设定中的名字。", system_lines += [
default_prompt, "- 你有at群成员的能力只需要在某条消息中插入[CQ:at,qq=QQ号]"
] "也就是CQ码。at发送者是非必要的你可以根据你自己的想法at某个人。",
]
systemPrompt = "\n".join(system_lines) system_lines += [
if preset.support_mcp: "下面是关于你性格的设定,如果设定中提到让你扮演某个人,或者设定中有提到名字,则优先使用设定中的名字。",
systemPrompt += "\n你也可以使用一些工具,下面是关于这些工具的额外说明:\n" default_prompt,
for mcp_name, mcp_config in plugin_config.mcp_servers.items(): ]
if mcp_config.additional_prompt:
systemPrompt += f"{mcp_name}{mcp_config.additional_prompt}"
systemPrompt += "\n"
logger.debug(f"构建系统提示词:\n{systemPrompt}") systemPrompt = "\n".join(system_lines)
if preset.support_mcp:
systemPrompt += "\n你也可以使用一些工具,下面是关于这些工具的额外说明:\n"
for mcp_name, mcp_config in plugin_config.mcp_servers.items():
if mcp_config.additional_prompt:
systemPrompt += f"{mcp_name}{mcp_config.additional_prompt}"
systemPrompt += "\n"
messages: list[ChatCompletionMessageParam] = [ logger.debug(f"构建系统提示词:\n{systemPrompt}")
{"role": "system", "content": systemPrompt}
]
while len(state.history) > 0 and state.history[0]["role"] != "user": messages: list[ChatCompletionMessageParam] = [
state.history.popleft() {"role": "system", "content": systemPrompt}
]
messages += list(state.history)[-plugin_config.history_size * 2 :] while len(state.history) > 0 and state.history[0]["role"] != "user":
state.history.popleft()
# 没有未处理的消息说明已经被处理了,跳过 messages += list(state.history)[-plugin_config.history_size * 2 :]
if state.past_events.__len__() < 1:
break
content: list[ChatCompletionContentPartParam] = [] # 没有未处理的消息说明已经被处理了,跳过
if state.past_events.__len__() < 1:
break
# 将机器人错过的消息推送给LLM content: list[ChatCompletionContentPartParam] = []
past_events_snapshot = list(state.past_events)
state.past_events.clear()
for ev in past_events_snapshot:
text_content = format_message(ev)
content.append({"type": "text", "text": text_content})
# 将消息中的图片转成 base64 # 将机器人错过的消息推送给LLM
if preset.support_image: past_events_snapshot = list(state.past_events)
base64_images = await process_images(ev) state.past_events.clear()
for base64_image in base64_images: for ev in past_events_snapshot:
content.append({"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{base64_image}"}}) text_content = format_message(ev)
content.append({"type": "text", "text": text_content})
new_messages: list[ChatCompletionMessageParam] = [ # 将消息中的图片转成 base64
{"role": "user", "content": content} if preset.support_image:
] base64_images = await process_images(ev)
for base64_image in base64_images:
content.append({"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{base64_image}"}})
logger.debug( new_messages: list[ChatCompletionMessageParam] = [
f"发送API请求 模型:{preset.model_name} 历史消息数:{len(messages)}" {"role": "user", "content": content}
) ]
client_config = { logger.debug(
"model": preset.model_name, f"发送API请求 模型:{preset.model_name} 历史消息数:{len(messages)}"
"max_tokens": preset.max_tokens, )
"temperature": preset.temperature,
"timeout": 60,
"extra_body": preset.extra_body,
}
if preset.support_mcp: client_config = {
available_tools = await mcp_client.get_available_tools(is_group) "model": preset.model_name,
client_config["tools"] = available_tools "max_tokens": preset.max_tokens,
"temperature": preset.temperature,
response = await client.chat.completions.create( "timeout": 60,
**client_config, "extra_body": preset.extra_body,
messages=messages + new_messages,
)
if response.usage is not None:
logger.debug(f"收到API响应 使用token数{response.usage.total_tokens}")
message = response.choices[0].message
# 处理响应并处理工具调用
while preset.support_mcp and message and message.tool_calls:
llm_reply: ChatCompletionMessageParam = {
"role": "assistant",
"content": message.content,
"tool_calls": [tool_call.model_dump() for tool_call in message.tool_calls]
} }
if preset.request_with_reasoning_content: if preset.support_mcp:
llm_reply["reasoning_content"] = message.reasoning_content# pyright: ignore[reportGeneralTypeIssues] available_tools = await mcp_client.get_available_tools(is_group)
client_config["tools"] = available_tools
# 发送LLM调用工具时的回复一般没有
if message.content:
await send_split_messages(handler, message.content)
# 处理每个工具调用
new_messages.append(llm_reply)
for tool_call in message.tool_calls:
logger.debug(f"处理工具调用:{tool_call.function.name} 参数:{tool_call.function.arguments}")
tool_name = tool_call.function.name
try:
tool_args = json.loads(tool_call.function.arguments)
except (json.JSONDecodeError, TypeError, ValueError) as e:
error_message = (
f"工具调用参数格式错误,无法解析 {tool_name} 的 arguments: {e!s}. "
f"原始参数: {tool_call.function.arguments}"
)
logger.warning(error_message)
new_messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": error_message,
})
continue
# 发送工具调用提示
await handler.send(Message(f"正在使用{mcp_client.get_friendly_name(tool_name)}"))
if is_group:
result = await mcp_client.call_tool(
tool_name,
tool_args,
group_id=event.group_id,
bot_id=str(event.self_id)
)
else:
result = await mcp_client.call_tool(
tool_name,
tool_args,
bot_id=str(event.self_id)
)
new_messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": str(result)
})
# 将工具调用的结果交给 LLM
response = await client.chat.completions.create( response = await client.chat.completions.create(
**client_config, **client_config,
messages=messages + new_messages, messages=messages + new_messages,
) )
if response.usage is not None:
logger.debug(f"收到API响应 使用token数{response.usage.total_tokens}")
message = response.choices[0].message message = response.choices[0].message
# 安全检查:确保 message 不为 None # 处理响应并处理工具调用
if not message: while preset.support_mcp and message and message.tool_calls:
logger.error("API 响应中的 message 为 None") llm_reply: ChatCompletionMessageParam = {
await handler.send(Message("服务暂时不可用,请稍后再试")) "role": "assistant",
return "content": message.content,
"tool_calls": [tool_call.model_dump() for tool_call in message.tool_calls]
}
reply, matched_reasoning_content = pop_reasoning_content( if preset.request_with_reasoning_content:
message.content llm_reply["reasoning_content"] = message.reasoning_content # pyright: ignore[reportGeneralTypeIssues]
)
reasoning_content: str | None = (
getattr(message, "reasoning_content", None)
or matched_reasoning_content
)
llm_reply: ChatCompletionMessageParam = { # 发送LLM调用工具时的回复一般没有
"role": "assistant", if message.content:
"content": reply, await send_split_messages(handler, message.content)
}
reply_images = getattr(message, "images", None) # 处理每个工具调用
new_messages.append(llm_reply)
if reply_images: for tool_call in message.tool_calls:
# openai的sdk里的assistant消息暂时没有images字段需要单独处理 logger.debug(f"处理工具调用:{tool_call.function.name} 参数:{tool_call.function.arguments}")
llm_reply["images"] = reply_images # pyright: ignore[reportGeneralTypeIssues]
if preset.request_with_reasoning_content: tool_name = tool_call.function.name
llm_reply["reasoning_content"] = reasoning_content# pyright: ignore[reportGeneralTypeIssues] try:
tool_args = json.loads(tool_call.function.arguments)
except (json.JSONDecodeError, TypeError, ValueError) as e:
error_message = (
f"工具调用参数格式错误,无法解析 {tool_name} 的 arguments: {e!s}. "
f"原始参数: {tool_call.function.arguments}"
)
logger.warning(error_message)
new_messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": error_message,
})
continue
new_messages.append(llm_reply) # 发送工具调用提示
await handler.send(Message(f"正在使用{mcp_client.get_friendly_name(tool_name)}"))
# 请求成功后再保存历史记录保证user和assistant穿插防止R1模型报错 if is_group:
for message in new_messages: result = await mcp_client.call_tool(
state.history.append(message) tool_name,
tool_args,
group_id=event.group_id,
bot_id=str(event.self_id)
)
else:
result = await mcp_client.call_tool(
tool_name,
tool_args,
bot_id=str(event.self_id)
)
if state.output_reasoning_content and reasoning_content: new_messages.append({
try: "role": "tool",
bot = get_bot(str(event.self_id)) "tool_call_id": tool_call.id,
if is_group: "content": str(result)
await bot.send_group_forward_msg( })
group_id=group_id,
messages=build_reasoning_forward_nodes(
bot.self_id, reasoning_content
),
)
else:
await bot.send_private_forward_msg(
user_id=context_id,
messages=build_reasoning_forward_nodes(
bot.self_id, reasoning_content
),
)
except Exception as e:
logger.error(f"合并转发消息发送失败:\n{e!s}\n")
assert reply is not None # 将工具调用的结果交给 LLM
await send_split_messages(handler, reply) response = await client.chat.completions.create(
**client_config,
messages=messages + new_messages,
)
if reply_images: message = response.choices[0].message
logger.debug(f"API响应 图片数:{len(reply_images)}")
for i, image in enumerate(reply_images, start=1):
logger.debug(f"正在发送第{i}张图片")
image_base64 = image["image_url"]["url"].removeprefix("data:image/png;base64,")
image_msg = MessageSegment.image(base64.b64decode(image_base64))
await handler.send(image_msg)
except Exception as e: # 安全检查:确保 message 不为 None
logger.opt(exception=e).error(f"API请求失败 {'群号' if is_group else '用户'}{context_id}") if not message:
# 如果在处理过程中出现异常恢复未处理的消息到state中 logger.error("API 响应中的 message 为 None")
state.past_events.extendleft(reversed(past_events_snapshot)) await handler.send(Message("服务暂时不可用,请稍后再试"))
await handler.send(Message(f"服务暂时不可用,请稍后再试\n{e!s}")) return
finally:
state.processing = False reply, matched_reasoning_content = pop_reasoning_content(
state.queue.task_done() message.content
# 不再需要每次都清理MCPClient因为它现在是单例 )
# await mcp_client.cleanup() reasoning_content: str | None = (
getattr(message, "reasoning_content", None)
or matched_reasoning_content
)
llm_reply: ChatCompletionMessageParam = {
"role": "assistant",
"content": reply,
}
reply_images = getattr(message, "images", None)
if reply_images:
# openai的sdk里的assistant消息暂时没有images字段需要单独处理
llm_reply["images"] = reply_images # pyright: ignore[reportGeneralTypeIssues]
if preset.request_with_reasoning_content:
llm_reply["reasoning_content"] = reasoning_content # pyright: ignore[reportGeneralTypeIssues]
new_messages.append(llm_reply)
# 请求成功后再保存历史记录保证user和assistant穿插防止R1模型报错
for message in new_messages:
state.history.append(message)
if state.output_reasoning_content and reasoning_content:
try:
bot = get_bot(str(event.self_id))
if is_group:
await bot.send_group_forward_msg(
group_id=group_id,
messages=build_reasoning_forward_nodes(
bot.self_id, reasoning_content
),
)
else:
await bot.send_private_forward_msg(
user_id=context_id,
messages=build_reasoning_forward_nodes(
bot.self_id, reasoning_content
),
)
except Exception as e:
logger.error(f"合并转发消息发送失败:\n{e!s}\n")
assert reply is not None
await send_split_messages(handler, reply)
if reply_images:
logger.debug(f"API响应 图片数:{len(reply_images)}")
for i, image in enumerate(reply_images, start=1):
logger.debug(f"正在发送第{i}张图片")
image_base64 = image["image_url"]["url"].removeprefix("data:image/png;base64,")
image_msg = MessageSegment.image(base64.b64decode(image_base64))
await handler.send(image_msg)
except Exception as e:
logger.opt(exception=e).error(f"API请求失败 {'群号' if is_group else '用户'}{context_id}")
# 如果在处理过程中出现异常恢复未处理的消息到state中
state.past_events.extendleft(reversed(past_events_snapshot))
await handler.send(Message(f"服务暂时不可用,请稍后再试\n{e!s}"))
finally:
state.queue.task_done()
# 不再需要每次都清理MCPClient因为它现在是单例
# await mcp_client.cleanup()
finally:
state.processing = False
# 预设切换命令 # 预设切换命令

View file

@ -24,8 +24,9 @@ class MCPServerConfig(BaseModel):
command: str | None = Field(None, description="stdio模式下MCP命令") command: str | None = Field(None, description="stdio模式下MCP命令")
args: list[str] | None = Field([], description="stdio模式下MCP命令参数") args: list[str] | None = Field([], description="stdio模式下MCP命令参数")
env: dict[str, str] | None = Field({}, description="stdio模式下MCP命令环境变量") env: dict[str, str] | None = Field({}, description="stdio模式下MCP命令环境变量")
url: str | None = Field(None, description="sse模式下MCP服务器地址") url: str | None = Field(None, description="远程MCP服务器地址")
headers: dict[str, str] | None = Field({}, description="sse模式下http请求头用于认证或其他设置") headers: dict[str, str] | None = Field({}, description="远程MCP服务器http请求头用于认证或其他设置")
transport: str | None = Field(None, description="远程MCP传输协议类型可选 'sse''streamable_http',默认自动检测")
# 额外字段 # 额外字段
friendly_name: str | None = Field(None, description="MCP服务器友好名称") friendly_name: str | None = Field(None, description="MCP服务器友好名称")
@ -48,6 +49,10 @@ class ScopedConfig(BaseModel):
"你的回答应该尽量简洁、幽默、可以使用一些语气词、颜文字。你应该拒绝回答任何政治相关的问题。", "你的回答应该尽量简洁、幽默、可以使用一些语气词、颜文字。你应该拒绝回答任何政治相关的问题。",
description="默认提示词", description="默认提示词",
) )
mcp_server_cwd: str | None = Field(
None,
description="command类型MCP服务器的全局工作目录cwd"
)
mcp_servers: dict[str, MCPServerConfig] = Field({}, description="MCP服务器配置") mcp_servers: dict[str, MCPServerConfig] = Field({}, description="MCP服务器配置")
blacklist_user_ids: set[int] = Field(set(), description="黑名单用户ID列表") blacklist_user_ids: set[int] = Field(set(), description="黑名单用户ID列表")
ignore_prefixes: list[str] = Field( ignore_prefixes: list[str] = Field(

View file

@ -3,9 +3,11 @@ from contextlib import AsyncExitStack
from time import monotonic from time import monotonic
from typing import Any, cast from typing import Any, cast
import httpx
from mcp import ClientSession, StdioServerParameters from mcp import ClientSession, StdioServerParameters
from mcp.client.sse import sse_client from mcp.client.sse import sse_client
from mcp.client.stdio import stdio_client from mcp.client.stdio import stdio_client
from mcp.client.streamable_http import streamable_http_client
from nonebot import logger from nonebot import logger
from .config import MCPServerConfig from .config import MCPServerConfig
@ -18,12 +20,20 @@ class MCPClient:
_SESSION_TTL_SECONDS = 600 _SESSION_TTL_SECONDS = 600
_SESSION_CLEANUP_INTERVAL_SECONDS = 60 _SESSION_CLEANUP_INTERVAL_SECONDS = 60
def __new__(cls, server_config: dict[str, MCPServerConfig] | None = None): def __new__(
cls,
server_config: dict[str, MCPServerConfig] | None = None,
default_command_cwd: str | None = None,
):
if cls._instance is None: if cls._instance is None:
cls._instance = super().__new__(cls) cls._instance = super().__new__(cls)
return cls._instance return cls._instance
def __init__(self, server_config: dict[str, MCPServerConfig] | None = None): def __init__(
self,
server_config: dict[str, MCPServerConfig] | None = None,
default_command_cwd: str | None = None,
):
if self._initialized: if self._initialized:
return return
@ -32,6 +42,7 @@ class MCPClient:
logger.info(f"正在初始化MCPClient单例共有{len(server_config)}个服务器配置") logger.info(f"正在初始化MCPClient单例共有{len(server_config)}个服务器配置")
self.server_config = server_config self.server_config = server_config
self.default_command_cwd = default_command_cwd
self.sessions = {} self.sessions = {}
self.exit_stack = AsyncExitStack() self.exit_stack = AsyncExitStack()
self._session_exit_stacks: dict[str, AsyncExitStack] = {} self._session_exit_stacks: dict[str, AsyncExitStack] = {}
@ -47,12 +58,16 @@ class MCPClient:
logger.debug("MCPClient单例初始化成功") logger.debug("MCPClient单例初始化成功")
@classmethod @classmethod
def get_instance(cls, server_config: dict[str, MCPServerConfig] | None = None): def get_instance(
cls,
server_config: dict[str, MCPServerConfig] | None = None,
default_command_cwd: str | None = None,
):
"""获取MCPClient实例""" """获取MCPClient实例"""
if cls._instance is None: if cls._instance is None:
if server_config is None: if server_config is None:
raise ValueError("server_config must be provided for first initialization") raise ValueError("server_config must be provided for first initialization")
cls._instance = cls(server_config) cls._instance = cls(server_config, default_command_cwd)
return cls._instance return cls._instance
@classmethod @classmethod
@ -75,13 +90,45 @@ class MCPClient:
config = self.server_config[server_name] config = self.server_config[server_name]
session_stack = AsyncExitStack() session_stack = AsyncExitStack()
if config.url: if config.url:
transport = await session_stack.enter_async_context( transport_type = config.transport
sse_client(url=config.url, headers=config.headers) if transport_type == "streamable_http":
) logger.debug(f"服务器[{server_name}]使用 streamable_http 传输协议")
http_client = await session_stack.enter_async_context(httpx.AsyncClient(headers=config.headers or {}))
read, write, _ = await session_stack.enter_async_context(
streamable_http_client(url=config.url, http_client=http_client)
)
transport = (read, write)
elif transport_type == "sse":
logger.debug(f"服务器[{server_name}]使用 sse 传输协议")
transport = await session_stack.enter_async_context(sse_client(url=config.url, headers=config.headers))
else:
# 未指定协议,自动探测:先尝试 sse失败则回退到 streamable_http
# sse 服务器对 streamable_http 的 POST 请求会卡住,反之 sse 连 streamable_http 服务器会快速返回 405
logger.debug(f"服务器[{server_name}]未指定传输协议,开始自动探测")
probe_stack = AsyncExitStack()
try:
read, write = await probe_stack.enter_async_context(sse_client(url=config.url, headers=config.headers))
await session_stack.enter_async_context(probe_stack)
transport = (read, write)
logger.debug(f"服务器[{server_name}]自动探测成功: 使用 sse 传输协议")
except Exception as e:
await probe_stack.aclose()
logger.debug(f"服务器[{server_name}]sse 探测失败({e}),回退到 streamable_http")
http_client = await session_stack.enter_async_context(httpx.AsyncClient(headers=config.headers or {}))
read, write, _ = await session_stack.enter_async_context(
streamable_http_client(url=config.url, http_client=http_client)
)
transport = (read, write)
logger.debug(f"服务器[{server_name}]自动探测成功: 使用 streamable_http 传输协议")
elif config.command: elif config.command:
transport = await session_stack.enter_async_context( stdio_params: dict[str, Any] = {
cast(Any, stdio_client(StdioServerParameters(**config.model_dump()))) "command": config.command,
) "args": config.args or [],
"env": config.env or {},
}
if self.default_command_cwd:
stdio_params["cwd"] = self.default_command_cwd
transport = await session_stack.enter_async_context(cast(Any, stdio_client(StdioServerParameters(**stdio_params))))
else: else:
raise ValueError("Server config must have either url or command") raise ValueError("Server config must have either url or command")
@ -179,8 +226,6 @@ class MCPClient:
logger.info(f"工具列表缓存完成,共缓存{len(available_tools)}个工具") logger.info(f"工具列表缓存完成,共缓存{len(available_tools)}个工具")
async def get_available_tools(self, is_group: bool): async def get_available_tools(self, is_group: bool):
"""获取可用工具列表,使用缓存机制""" """获取可用工具列表,使用缓存机制"""
await self.init_tools_cache() await self.init_tools_cache()

912
poetry.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "nonebot-plugin-llmchat" name = "nonebot-plugin-llmchat"
version = "0.5.2" version = "0.5.4"
description = "Nonebot AI group chat plugin supporting multiple API preset configurations" description = "Nonebot AI group chat plugin supporting multiple API preset configurations"
license = "GPL" license = "GPL"
authors = ["FuQuan i@fuquan.moe"] authors = ["FuQuan i@fuquan.moe"]
@ -18,7 +18,7 @@ aiofiles = ">=24.0.0"
nonebot-plugin-apscheduler = "^0.5.0" nonebot-plugin-apscheduler = "^0.5.0"
nonebot-adapter-onebot = "^2.0.0" nonebot-adapter-onebot = "^2.0.0"
nonebot-plugin-localstore = "^0.7.3" nonebot-plugin-localstore = "^0.7.3"
mcp = "^1.6.0" mcp = ">=1.24.0"
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
ruff = "^0.8.0" ruff = "^0.8.0"