diff --git a/GUYMCP.md b/GUYMCP.md new file mode 100644 index 000000000..52aa6c2f8 --- /dev/null +++ b/GUYMCP.md @@ -0,0 +1,3 @@ +1.增加了guymcp目录,下含mcp服务端和客户端,以及mcp的配置文件。 + +2 修改原项目 bot.chatgpt 下chat_gpt_bot.py文件,加入mcp客户端的应答。 diff --git a/bot/chatgpt/.env b/bot/chatgpt/.env new file mode 100644 index 000000000..835cdd1f3 --- /dev/null +++ b/bot/chatgpt/.env @@ -0,0 +1,10 @@ +# Server Configuration +MCP_PORT=8020 +SERPER_API_KEY=xxx + +# Client Configuration +MCP_SERVER_URL=http://localhost:8020 + +OPENAI_API_KEY= +OPENAI_BASE_URL= +OPENAI_MODEL= diff --git a/bot/chatgpt/__init__.py b/bot/chatgpt/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bot/chatgpt/__pycache__/__init__.cpython-311.pyc b/bot/chatgpt/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 000000000..9984762ae Binary files /dev/null and b/bot/chatgpt/__pycache__/__init__.cpython-311.pyc differ diff --git a/bot/chatgpt/__pycache__/chat_gpt_bot.cpython-311.pyc b/bot/chatgpt/__pycache__/chat_gpt_bot.cpython-311.pyc new file mode 100644 index 000000000..966ea2180 Binary files /dev/null and b/bot/chatgpt/__pycache__/chat_gpt_bot.cpython-311.pyc differ diff --git a/bot/chatgpt/__pycache__/chat_gpt_session.cpython-311.pyc b/bot/chatgpt/__pycache__/chat_gpt_session.cpython-311.pyc new file mode 100644 index 000000000..4f4bd3529 Binary files /dev/null and b/bot/chatgpt/__pycache__/chat_gpt_session.cpython-311.pyc differ diff --git a/bot/chatgpt/__pycache__/client.cpython-311.pyc b/bot/chatgpt/__pycache__/client.cpython-311.pyc new file mode 100644 index 000000000..f0cef8f71 Binary files /dev/null and b/bot/chatgpt/__pycache__/client.cpython-311.pyc differ diff --git a/bot/chatgpt/__pycache__/client.cpython-313.pyc b/bot/chatgpt/__pycache__/client.cpython-313.pyc new file mode 100644 index 000000000..9875b2053 Binary files /dev/null and b/bot/chatgpt/__pycache__/client.cpython-313.pyc differ diff --git a/bot/chatgpt/chat_gpt_bot.py b/bot/chatgpt/chat_gpt_bot.py index 154a4229b..d84057582 100644 --- a/bot/chatgpt/chat_gpt_bot.py +++ b/bot/chatgpt/chat_gpt_bot.py @@ -1,9 +1,11 @@ # encoding:utf-8 - +# cd ../../guymcp 运行uv run server.py --host 127.0.0.1 --port 8020 启动MCP服务器 import time import openai -import openai.error +# import openai.error +# 因为新代码中asyncio.run,openai必须升级到1.0以上,openai.error.XXXError被弃用,需要更新代码 +# 更新代码中所有openai.error.XXXError引用为直接使用错误类:openai.XXXError import requests from common import const from bot.bot import Bot @@ -17,6 +19,11 @@ from config import conf, load_config from bot.baidu.baidu_wenxin_session import BaiduWenxinSession +import re +from bot.chatgpt.client import MCPClient +import asyncio + + # OpenAI对话模型API (可用) class ChatGPTBot(Bot, OpenAIImage): def __init__(self): @@ -51,6 +58,20 @@ def __init__(self): for key in remove_keys: self.args.pop(key, None) # 如果键不存在,使用 None 来避免抛出错误 + async def gy_getanswer(self,querystr) -> str: + # guy 新建函数用于调用MCPClient + client = MCPClient() + clean_result = 'null before call gychat_loop.' + try: + server_url = "http://127.0.0.1:8020/sse" + await client.connect_to_sse_server(server_url) + res = await client.gychat_loop(querystr) + clean_result = re.sub(r'\[.*?\]', '', res) + + finally: + await client.cleanup() + return clean_result + def reply(self, query, context=None): # acquire reply content if context.type == ContextType.TEXT: @@ -124,37 +145,55 @@ def reply_text(self, session: ChatGPTSession, api_key=None, args=None, retry_cou """ try: if conf().get("rate_limit_chatgpt") and not self.tb4chatgpt.get_token(): - raise openai.error.RateLimitError("RateLimitError: rate limit exceeded") + raise openai.RateLimitError("RateLimitError: rate limit exceeded") # if api_key == None, the default openai.api_key will be used if args is None: args = self.args - response = openai.ChatCompletion.create(api_key=api_key, messages=session.messages, **args) - # logger.debug("[CHATGPT] response={}".format(response)) + # 旧版的openai创建对话的方法因引入1.0以后,所以使用新版本的create方法 + # response = openai.ChatCompletion.create(api_key=api_key, messages=session.messages, **args) + response = openai.chat.completions.create( + model="Qwen/Qwen2.5-72B-Instruct", + # model=os.getenv("OPENAI_MODEL"), + max_tokens=1000, + messages=session.messages + ) + + # guy + guy_answer = "use get MCP answer" + user_content = next( + (msg["content"] for msg in reversed(session.messages) if msg.get("role") == "user"), + "没有找到用户消息" + ) + guy_answer = asyncio.run(self.gy_getanswer(user_content)) + logger.debug("[CHATGPT] response={}".format(response)) # logger.info("[ChatGPT] reply={}, total_tokens={}".format(response.choices[0]['message']['content'], response["usage"]["total_tokens"])) return { - "total_tokens": response["usage"]["total_tokens"], - "completion_tokens": response["usage"]["completion_tokens"], - "content": response.choices[0]["message"]["content"], + # "total_tokens": response["usage"]["total_tokens"], 旧版本的openai的返回值 + # "completion_tokens": response["usage"]["completion_tokens"], + # "content": response.choices[0]["message"]["content"], + "total_tokens": response.usage.total_tokens, + "completion_tokens": response.usage.completion_tokens, + "content":guy_answer, } except Exception as e: need_retry = retry_count < 2 result = {"completion_tokens": 0, "content": "我现在有点累了,等会再来吧"} - if isinstance(e, openai.error.RateLimitError): + if isinstance(e, openai.RateLimitError): logger.warn("[CHATGPT] RateLimitError: {}".format(e)) result["content"] = "提问太快啦,请休息一下再问我吧" if need_retry: time.sleep(20) - elif isinstance(e, openai.error.Timeout): + elif isinstance(e, openai.Timeout): logger.warn("[CHATGPT] Timeout: {}".format(e)) result["content"] = "我没有收到你的消息" if need_retry: time.sleep(5) - elif isinstance(e, openai.error.APIError): + elif isinstance(e, openai.APIError): logger.warn("[CHATGPT] Bad Gateway: {}".format(e)) result["content"] = "请再问我一次" if need_retry: time.sleep(10) - elif isinstance(e, openai.error.APIConnectionError): + elif isinstance(e, openai.APIConnectionError): logger.warn("[CHATGPT] APIConnectionError: {}".format(e)) result["content"] = "我连接不到你的网络" if need_retry: diff --git a/bot/chatgpt/client.py b/bot/chatgpt/client.py new file mode 100644 index 000000000..f5a2e9372 --- /dev/null +++ b/bot/chatgpt/client.py @@ -0,0 +1,176 @@ +# cd ../guymcp 运行uv run server.py --host 127.0.0.1 --port 8020 启动MCP服务器 +import asyncio +import json +import os +from typing import Optional +from contextlib import AsyncExitStack +import time +from mcp import ClientSession +from mcp.client.sse import sse_client + +from openai import AsyncOpenAI +from dotenv import load_dotenv + +load_dotenv() # load environment variables from .env + +class MCPClient: + def __init__(self): + # Initialize session and client objects + self.session: Optional[ClientSession] = None + self.exit_stack = AsyncExitStack() + self.openai = AsyncOpenAI(api_key=os.getenv("OPENAI_API_KEY"), base_url=os.getenv("OPENAI_BASE_URL")) + + async def connect_to_sse_server(self, server_url: str): + """Connect to an MCP server running with SSE transport""" + # Store the context managers so they stay alive + self._streams_context = sse_client(url=server_url) + streams = await self._streams_context.__aenter__() + + self._session_context = ClientSession(*streams) + self.session: ClientSession = await self._session_context.__aenter__() + + # Initialize + await self.session.initialize() + + # List available tools to verify connection + print("Initialized SSE client...") + print("Listing tools...") + response = await self.session.list_tools() + tools = response.tools + print("\nConnected to server with tools:", [tool.name for tool in tools]) + + async def cleanup(self): + """Properly clean up the session and streams""" + if self._session_context: + await self._session_context.__aexit__(None, None, None) + if self._streams_context: + await self._streams_context.__aexit__(None, None, None) + + async def process_query(self, query: str) -> str: + """Process a query using OpenAI API and available tools""" + messages = [ + { + "role": "user", + "content": query + } + ] + + response = await self.session.list_tools() + available_tools = [{ + "type": "function", + "function": { + "name": tool.name, + "description": tool.description, + "parameters": tool.inputSchema + } + } for tool in response.tools] + + # Initial OpenAI API call + completion = await self.openai.chat.completions.create( + model="Qwen/Qwen2.5-72B-Instruct", + # model=os.getenv("OPENAI_MODEL"), + max_tokens=1000, + messages=messages, + tools=available_tools + ) + + # Process response and handle tool calls + tool_results = [] + final_text = [] + + assistant_message = completion.choices[0].message + + if assistant_message.tool_calls: + for tool_call in assistant_message.tool_calls: + tool_name = tool_call.function.name + tool_args = json.loads(tool_call.function.arguments) + + # Execute tool call + result = await self.session.call_tool(tool_name, tool_args) + tool_results.append({"call": tool_name, "result": result}) + final_text.append(f"[Calling tool {tool_name} with args {tool_args}]") + + # Continue conversation with tool results + messages.extend([ + { + "role": "assistant", + "content": None, + "tool_calls": [tool_call] + }, + { + "role": "tool", + "tool_call_id": tool_call.id, + "content": result.content[0].text + } + ]) + + print(f"Tool {tool_name} returned: {result.content[0].text}") + print("messages", messages) + # Get next response from OpenAI + completion = await self.openai.chat.completions.create( + model=os.getenv("OPENAI_MODEL"), + max_tokens=1000, + messages=messages, + ) + if isinstance(completion.choices[0].message.content, (dict, list)): + final_text.append(str(completion.choices[0].message.content)) + else: + final_text.append(completion.choices[0].message.content) + else: + if isinstance(assistant_message.content, (dict, list)): + final_text.append(str(assistant_message.content)) + else: + final_text.append(assistant_message.content) + + return "\n".join(final_text) + + async def chat_loop(self): + """Run an interactive chat loop""" + print("\nMCP Client Started!") + print("Type your queries or 'quit' to exit.") + + while True: + try: + query = input("\nQuery: ").strip() + + if query.lower() == 'quit': + break + + response = await self.process_query(query) + print("\n" + response) + + except Exception as e: + print(f"\nError: {str(e)}") + async def gychat_loop(self,querystr) -> str: + """Run an interactive chat loop""" + print("\nMCP Client Started!") + print("Type your queries or 'quit' to exit.") + response = "响应初始化..." + try: + # query = input("\nQuery: ").strip() + query = querystr + if query.lower() == 'quit': + return + + response = await self.process_query(query) + print("\n" + response) + + except Exception as e: + print(f"\nError: {str(e)}") + finally: + return response +async def main(): + if len(sys.argv) < 2: + print("Usage: uv run client.py ") + sys.exit(1) + + client = MCPClient() + try: + await client.connect_to_sse_server(server_url=sys.argv[1]) + await client.chat_loop() + finally: + await client.cleanup() + +if __name__ == "__main__": + import sys + asyncio.run(main()) diff --git a/bot/chatgpt/old_chat_gpt_bot.py b/bot/chatgpt/old_chat_gpt_bot.py new file mode 100644 index 000000000..154a4229b --- /dev/null +++ b/bot/chatgpt/old_chat_gpt_bot.py @@ -0,0 +1,248 @@ +# encoding:utf-8 + +import time + +import openai +import openai.error +import requests +from common import const +from bot.bot import Bot +from bot.chatgpt.chat_gpt_session import ChatGPTSession +from bot.openai.open_ai_image import OpenAIImage +from bot.session_manager import SessionManager +from bridge.context import ContextType +from bridge.reply import Reply, ReplyType +from common.log import logger +from common.token_bucket import TokenBucket +from config import conf, load_config +from bot.baidu.baidu_wenxin_session import BaiduWenxinSession + +# OpenAI对话模型API (可用) +class ChatGPTBot(Bot, OpenAIImage): + def __init__(self): + super().__init__() + # set the default api_key + openai.api_key = conf().get("open_ai_api_key") + if conf().get("open_ai_api_base"): + openai.api_base = conf().get("open_ai_api_base") + proxy = conf().get("proxy") + if proxy: + openai.proxy = proxy + if conf().get("rate_limit_chatgpt"): + self.tb4chatgpt = TokenBucket(conf().get("rate_limit_chatgpt", 20)) + conf_model = conf().get("model") or "gpt-3.5-turbo" + self.sessions = SessionManager(ChatGPTSession, model=conf().get("model") or "gpt-3.5-turbo") + # o1相关模型不支持system prompt,暂时用文心模型的session + + self.args = { + "model": conf_model, # 对话模型的名称 + "temperature": conf().get("temperature", 0.9), # 值在[0,1]之间,越大表示回复越具有不确定性 + # "max_tokens":4096, # 回复最大的字符数 + "top_p": conf().get("top_p", 1), + "frequency_penalty": conf().get("frequency_penalty", 0.0), # [-2,2]之间,该值越大则更倾向于产生不同的内容 + "presence_penalty": conf().get("presence_penalty", 0.0), # [-2,2]之间,该值越大则更倾向于产生不同的内容 + "request_timeout": conf().get("request_timeout", None), # 请求超时时间,openai接口默认设置为600,对于难问题一般需要较长时间 + "timeout": conf().get("request_timeout", None), # 重试超时时间,在这个时间内,将会自动重试 + } + # o1相关模型固定了部分参数,暂时去掉 + if conf_model in [const.O1, const.O1_MINI]: + self.sessions = SessionManager(BaiduWenxinSession, model=conf().get("model") or const.O1_MINI) + remove_keys = ["temperature", "top_p", "frequency_penalty", "presence_penalty"] + for key in remove_keys: + self.args.pop(key, None) # 如果键不存在,使用 None 来避免抛出错误 + + def reply(self, query, context=None): + # acquire reply content + if context.type == ContextType.TEXT: + logger.info("[CHATGPT] query={}".format(query)) + + session_id = context["session_id"] + reply = None + clear_memory_commands = conf().get("clear_memory_commands", ["#清除记忆"]) + if query in clear_memory_commands: + self.sessions.clear_session(session_id) + reply = Reply(ReplyType.INFO, "记忆已清除") + elif query == "#清除所有": + self.sessions.clear_all_session() + reply = Reply(ReplyType.INFO, "所有人记忆已清除") + elif query == "#更新配置": + load_config() + reply = Reply(ReplyType.INFO, "配置已更新") + if reply: + return reply + session = self.sessions.session_query(query, session_id) + logger.debug("[CHATGPT] session query={}".format(session.messages)) + + api_key = context.get("openai_api_key") + model = context.get("gpt_model") + new_args = None + if model: + new_args = self.args.copy() + new_args["model"] = model + # if context.get('stream'): + # # reply in stream + # return self.reply_text_stream(query, new_query, session_id) + + reply_content = self.reply_text(session, api_key, args=new_args) + logger.debug( + "[CHATGPT] new_query={}, session_id={}, reply_cont={}, completion_tokens={}".format( + session.messages, + session_id, + reply_content["content"], + reply_content["completion_tokens"], + ) + ) + if reply_content["completion_tokens"] == 0 and len(reply_content["content"]) > 0: + reply = Reply(ReplyType.ERROR, reply_content["content"]) + elif reply_content["completion_tokens"] > 0: + self.sessions.session_reply(reply_content["content"], session_id, reply_content["total_tokens"]) + reply = Reply(ReplyType.TEXT, reply_content["content"]) + else: + reply = Reply(ReplyType.ERROR, reply_content["content"]) + logger.debug("[CHATGPT] reply {} used 0 tokens.".format(reply_content)) + return reply + + elif context.type == ContextType.IMAGE_CREATE: + ok, retstring = self.create_img(query, 0) + reply = None + if ok: + reply = Reply(ReplyType.IMAGE_URL, retstring) + else: + reply = Reply(ReplyType.ERROR, retstring) + return reply + else: + reply = Reply(ReplyType.ERROR, "Bot不支持处理{}类型的消息".format(context.type)) + return reply + + def reply_text(self, session: ChatGPTSession, api_key=None, args=None, retry_count=0) -> dict: + """ + call openai's ChatCompletion to get the answer + :param session: a conversation session + :param session_id: session id + :param retry_count: retry count + :return: {} + """ + try: + if conf().get("rate_limit_chatgpt") and not self.tb4chatgpt.get_token(): + raise openai.error.RateLimitError("RateLimitError: rate limit exceeded") + # if api_key == None, the default openai.api_key will be used + if args is None: + args = self.args + response = openai.ChatCompletion.create(api_key=api_key, messages=session.messages, **args) + # logger.debug("[CHATGPT] response={}".format(response)) + # logger.info("[ChatGPT] reply={}, total_tokens={}".format(response.choices[0]['message']['content'], response["usage"]["total_tokens"])) + return { + "total_tokens": response["usage"]["total_tokens"], + "completion_tokens": response["usage"]["completion_tokens"], + "content": response.choices[0]["message"]["content"], + } + except Exception as e: + need_retry = retry_count < 2 + result = {"completion_tokens": 0, "content": "我现在有点累了,等会再来吧"} + if isinstance(e, openai.error.RateLimitError): + logger.warn("[CHATGPT] RateLimitError: {}".format(e)) + result["content"] = "提问太快啦,请休息一下再问我吧" + if need_retry: + time.sleep(20) + elif isinstance(e, openai.error.Timeout): + logger.warn("[CHATGPT] Timeout: {}".format(e)) + result["content"] = "我没有收到你的消息" + if need_retry: + time.sleep(5) + elif isinstance(e, openai.error.APIError): + logger.warn("[CHATGPT] Bad Gateway: {}".format(e)) + result["content"] = "请再问我一次" + if need_retry: + time.sleep(10) + elif isinstance(e, openai.error.APIConnectionError): + logger.warn("[CHATGPT] APIConnectionError: {}".format(e)) + result["content"] = "我连接不到你的网络" + if need_retry: + time.sleep(5) + else: + logger.exception("[CHATGPT] Exception: {}".format(e)) + need_retry = False + self.sessions.clear_session(session.session_id) + + if need_retry: + logger.warn("[CHATGPT] 第{}次重试".format(retry_count + 1)) + return self.reply_text(session, api_key, args, retry_count + 1) + else: + return result + + +class AzureChatGPTBot(ChatGPTBot): + def __init__(self): + super().__init__() + openai.api_type = "azure" + openai.api_version = conf().get("azure_api_version", "2023-06-01-preview") + self.args["deployment_id"] = conf().get("azure_deployment_id") + + def create_img(self, query, retry_count=0, api_key=None): + text_to_image_model = conf().get("text_to_image") + if text_to_image_model == "dall-e-2": + api_version = "2023-06-01-preview" + endpoint = conf().get("azure_openai_dalle_api_base","open_ai_api_base") + # 检查endpoint是否以/结尾 + if not endpoint.endswith("/"): + endpoint = endpoint + "/" + url = "{}openai/images/generations:submit?api-version={}".format(endpoint, api_version) + api_key = conf().get("azure_openai_dalle_api_key","open_ai_api_key") + headers = {"api-key": api_key, "Content-Type": "application/json"} + try: + body = {"prompt": query, "size": conf().get("image_create_size", "256x256"),"n": 1} + submission = requests.post(url, headers=headers, json=body) + operation_location = submission.headers['operation-location'] + status = "" + while (status != "succeeded"): + if retry_count > 3: + return False, "图片生成失败" + response = requests.get(operation_location, headers=headers) + status = response.json()['status'] + retry_count += 1 + image_url = response.json()['result']['data'][0]['url'] + return True, image_url + except Exception as e: + logger.error("create image error: {}".format(e)) + return False, "图片生成失败" + elif text_to_image_model == "dall-e-3": + api_version = conf().get("azure_api_version", "2024-02-15-preview") + endpoint = conf().get("azure_openai_dalle_api_base","open_ai_api_base") + # 检查endpoint是否以/结尾 + if not endpoint.endswith("/"): + endpoint = endpoint + "/" + url = "{}openai/deployments/{}/images/generations?api-version={}".format(endpoint, conf().get("azure_openai_dalle_deployment_id","text_to_image"),api_version) + api_key = conf().get("azure_openai_dalle_api_key","open_ai_api_key") + headers = {"api-key": api_key, "Content-Type": "application/json"} + try: + body = {"prompt": query, "size": conf().get("image_create_size", "1024x1024"), "quality": conf().get("dalle3_image_quality", "standard")} + response = requests.post(url, headers=headers, json=body) + response.raise_for_status() # 检查请求是否成功 + data = response.json() + + # 检查响应中是否包含图像 URL + if 'data' in data and len(data['data']) > 0 and 'url' in data['data'][0]: + image_url = data['data'][0]['url'] + return True, image_url + else: + error_message = "响应中没有图像 URL" + logger.error(error_message) + return False, "图片生成失败" + + except requests.exceptions.RequestException as e: + # 捕获所有请求相关的异常 + try: + error_detail = response.json().get('error', {}).get('message', str(e)) + except ValueError: + error_detail = str(e) + error_message = f"{error_detail}" + logger.error(error_message) + return False, error_message + + except Exception as e: + # 捕获所有其他异常 + error_message = f"生成图像时发生错误: {e}" + logger.error(error_message) + return False, "图片生成失败" + else: + return False, "图片生成失败,未配置text_to_image参数" diff --git a/bot/chatgpt/old_chat_gpt_session.py b/bot/chatgpt/old_chat_gpt_session.py new file mode 100644 index 000000000..b5fc6fdf8 --- /dev/null +++ b/bot/chatgpt/old_chat_gpt_session.py @@ -0,0 +1,104 @@ +from bot.session_manager import Session +from common.log import logger +from common import const + +""" + e.g. [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "Who won the world series in 2020?"}, + {"role": "assistant", "content": "The Los Angeles Dodgers won the World Series in 2020."}, + {"role": "user", "content": "Where was it played?"} + ] +""" + + +class ChatGPTSession(Session): + def __init__(self, session_id, system_prompt=None, model="gpt-3.5-turbo"): + super().__init__(session_id, system_prompt) + self.model = model + self.reset() + + def discard_exceeding(self, max_tokens, cur_tokens=None): + precise = True + try: + cur_tokens = self.calc_tokens() + except Exception as e: + precise = False + if cur_tokens is None: + raise e + logger.debug("Exception when counting tokens precisely for query: {}".format(e)) + while cur_tokens > max_tokens: + if len(self.messages) > 2: + self.messages.pop(1) + elif len(self.messages) == 2 and self.messages[1]["role"] == "assistant": + self.messages.pop(1) + if precise: + cur_tokens = self.calc_tokens() + else: + cur_tokens = cur_tokens - max_tokens + break + elif len(self.messages) == 2 and self.messages[1]["role"] == "user": + logger.warn("user message exceed max_tokens. total_tokens={}".format(cur_tokens)) + break + else: + logger.debug("max_tokens={}, total_tokens={}, len(messages)={}".format(max_tokens, cur_tokens, len(self.messages))) + break + if precise: + cur_tokens = self.calc_tokens() + else: + cur_tokens = cur_tokens - max_tokens + return cur_tokens + + def calc_tokens(self): + return num_tokens_from_messages(self.messages, self.model) + + +# refer to https://github.com/openai/openai-cookbook/blob/main/examples/How_to_count_tokens_with_tiktoken.ipynb +def num_tokens_from_messages(messages, model): + """Returns the number of tokens used by a list of messages.""" + + if model in ["wenxin", "xunfei"] or model.startswith(const.GEMINI): + return num_tokens_by_character(messages) + + import tiktoken + + if model in ["gpt-3.5-turbo-0301", "gpt-35-turbo", "gpt-3.5-turbo-1106", "moonshot", const.LINKAI_35]: + return num_tokens_from_messages(messages, model="gpt-3.5-turbo") + elif model in ["gpt-4-0314", "gpt-4-0613", "gpt-4-32k", "gpt-4-32k-0613", "gpt-3.5-turbo-0613", + "gpt-3.5-turbo-16k", "gpt-3.5-turbo-16k-0613", "gpt-35-turbo-16k", "gpt-4-turbo-preview", + "gpt-4-1106-preview", const.GPT4_TURBO_PREVIEW, const.GPT4_VISION_PREVIEW, const.GPT4_TURBO_01_25, + const.GPT_4o, const.GPT_4O_0806, const.GPT_4o_MINI, const.LINKAI_4o, const.LINKAI_4_TURBO]: + return num_tokens_from_messages(messages, model="gpt-4") + elif model.startswith("claude-3"): + return num_tokens_from_messages(messages, model="gpt-3.5-turbo") + try: + encoding = tiktoken.encoding_for_model(model) + except KeyError: + logger.debug("Warning: model not found. Using cl100k_base encoding.") + encoding = tiktoken.get_encoding("cl100k_base") + if model == "gpt-3.5-turbo": + tokens_per_message = 4 # every message follows <|start|>{role/name}\n{content}<|end|>\n + tokens_per_name = -1 # if there's a name, the role is omitted + elif model == "gpt-4": + tokens_per_message = 3 + tokens_per_name = 1 + else: + logger.debug(f"num_tokens_from_messages() is not implemented for model {model}. Returning num tokens assuming gpt-3.5-turbo.") + return num_tokens_from_messages(messages, model="gpt-3.5-turbo") + num_tokens = 0 + for message in messages: + num_tokens += tokens_per_message + for key, value in message.items(): + num_tokens += len(encoding.encode(value)) + if key == "name": + num_tokens += tokens_per_name + num_tokens += 3 # every reply is primed with <|start|>assistant<|message|> + return num_tokens + + +def num_tokens_by_character(messages): + """Returns the number of tokens used by a list of messages.""" + tokens = 0 + for msg in messages: + tokens += len(msg["content"]) + return tokens diff --git a/bot/chatgpt/readme_guy.md b/bot/chatgpt/readme_guy.md new file mode 100644 index 000000000..676750df9 --- /dev/null +++ b/bot/chatgpt/readme_guy.md @@ -0,0 +1,8 @@ +1.仅修改在chat_gpt_bot.py中,引入clent.py定义的MCPClient类,用于加入mcp服务作为中介, + 在chat_gpt_bot.py自定义了gy_getanswer(self,querystr) + +2.本目录下的client.py 用于方便测试,是上两级guymcp目录下的client.py的副本,两者完全一致 + 对应导入为from bot.chatgpt.client import MCPClient + +3.test_client.py 用于测试client.py + diff --git a/bot/chatgpt/test_client.py b/bot/chatgpt/test_client.py new file mode 100644 index 000000000..2c32106be --- /dev/null +++ b/bot/chatgpt/test_client.py @@ -0,0 +1,48 @@ +# cd ../../guymcp 运行uv run server.py --host 127.0.0.1 --port 8020 启动MCP服务器 +import time + +import openai +# import openai.error +import requests +# # from common import const +# from bot.bot import Bot +# from bot.chatgpt.chat_gpt_session import ChatGPTSession +# from bot.openai.open_ai_image import OpenAIImage +# from bot.session_manager import SessionManager +# from bridge.context import ContextType +# from bridge.reply import Reply, ReplyType +# from common.log import logger +# from common.token_bucket import TokenBucket +# from config import conf, load_config +# from bot.baidu.baidu_wenxin_session import BaiduWenxinSession + +import re +# from bot.chatgpt.client import MCPClient +from client import MCPClient +import asyncio + + +async def gy_getanswer(querystr) -> str: +# guy + client = MCPClient() + print("=======>befor call gychat_loop.") + clean_result = 'null before call gychat_loop.' + try: + # server_url = "http://0.0.0.0:8020/sse" + server_url = "http://127.0.0.1:8020/sse" + await client.connect_to_sse_server(server_url) + # await client.chat_loop() + res = await client.gychat_loop(querystr) + print(res) + clean_result = re.sub(r'\[.*?\]', '', res) + + finally: + print(f"[****SUCESS call gychat_loop****]: {clean_result}") + await client.cleanup() + print("<=======after gychat_loop.") + print("<=======after cleanup.") + print(clean_result) + return clean_result + +guy_answer = asyncio.run(gy_getanswer('查询张三的电话号码')) +print("guy_answer:",guy_answer) \ No newline at end of file diff --git a/guymcp/.env b/guymcp/.env new file mode 100644 index 000000000..835cdd1f3 --- /dev/null +++ b/guymcp/.env @@ -0,0 +1,10 @@ +# Server Configuration +MCP_PORT=8020 +SERPER_API_KEY=xxx + +# Client Configuration +MCP_SERVER_URL=http://localhost:8020 + +OPENAI_API_KEY= +OPENAI_BASE_URL= +OPENAI_MODEL= diff --git a/guymcp/.gitkeep b/guymcp/.gitkeep new file mode 100644 index 000000000..9c558e357 --- /dev/null +++ b/guymcp/.gitkeep @@ -0,0 +1 @@ +. diff --git a/guymcp/README.md b/guymcp/README.md new file mode 100644 index 000000000..28c527819 --- /dev/null +++ b/guymcp/README.md @@ -0,0 +1,43 @@ +https://github.com/GobinFan/python-mcp-server-client/blob/main/README.md +# 创建项目目录 +uv init guymcp +cd guymcp + +# 创建并激活虚拟环境 +uv venv +source .venv/bin/activate # Windows: .venv\Scripts\activate + +# 安装依赖 +uv add "mcp[cli]" httpx +# 创建并激活虚拟环境 + +退出虚deactivate + +=============================文件说明 +.env 大模型连接配置文件 +server.py MCP服务端 +client.py MCP客户端 +requirements.txt 依赖包,通过pip3 install -r requirements.txt安装依赖 +contacts.txt 联系人测试数据 +tempbz.docx 考核表模板,用于测试 +pyproject_template.toml 修改父目录pyproject.toml的模板 +README.md 说明文件 + +=============================uv 安装,使用 +pip3 install -r requirements.txt +修改父目录pyproject.toml,加入[project]项目,不然uv run 会报错 +uv run server.py --host 127.0.0.1 --port 8020 +测试server curl -v http://127.0.0.1:8020/sse +uv run client.py http://127.0.0.1:8020/sse + +==============================不用uv 安装依赖的另一种方法.建议使用uv +pip3 install -r requirements.txt +python server.py --host 127.0.0.1 --port 8020 +python client.py http://127.0.0.1:8020/sse + +==============================功能演示 +按照安装说明,运行server.py,另开窗口运行client.py,输入提示词 +1.为程康生成公务员年度考核表,替换'name,sex,birthday,injob,party,dwzw,csgz'的值为'程康,男,1971.4,1989.12,无,国家工商总局重庆市江津区市场监管局第三所,企业监管' +2.查询张三电话号码 +3.查询所有姓李的电话 + diff --git a/guymcp/client.py b/guymcp/client.py new file mode 100644 index 000000000..fdab09073 --- /dev/null +++ b/guymcp/client.py @@ -0,0 +1,175 @@ +import asyncio +import json +import os +from typing import Optional +from contextlib import AsyncExitStack +import time +from mcp import ClientSession +from mcp.client.sse import sse_client + +from openai import AsyncOpenAI +from dotenv import load_dotenv + +load_dotenv() # load environment variables from .env + +class MCPClient: + def __init__(self): + # Initialize session and client objects + self.session: Optional[ClientSession] = None + self.exit_stack = AsyncExitStack() + self.openai = AsyncOpenAI(api_key=os.getenv("OPENAI_API_KEY"), base_url=os.getenv("OPENAI_BASE_URL")) + + async def connect_to_sse_server(self, server_url: str): + """Connect to an MCP server running with SSE transport""" + # Store the context managers so they stay alive + self._streams_context = sse_client(url=server_url) + streams = await self._streams_context.__aenter__() + + self._session_context = ClientSession(*streams) + self.session: ClientSession = await self._session_context.__aenter__() + + # Initialize + await self.session.initialize() + + # List available tools to verify connection + print("Initialized SSE client...") + print("Listing tools...") + response = await self.session.list_tools() + tools = response.tools + print("\nConnected to server with tools:", [tool.name for tool in tools]) + + async def cleanup(self): + """Properly clean up the session and streams""" + if self._session_context: + await self._session_context.__aexit__(None, None, None) + if self._streams_context: + await self._streams_context.__aexit__(None, None, None) + + async def process_query(self, query: str) -> str: + """Process a query using OpenAI API and available tools""" + messages = [ + { + "role": "user", + "content": query + } + ] + + response = await self.session.list_tools() + available_tools = [{ + "type": "function", + "function": { + "name": tool.name, + "description": tool.description, + "parameters": tool.inputSchema + } + } for tool in response.tools] + + # Initial OpenAI API call + completion = await self.openai.chat.completions.create( + model="Qwen/Qwen2.5-72B-Instruct", + # model=os.getenv("OPENAI_MODEL"), + max_tokens=1000, + messages=messages, + tools=available_tools + ) + + # Process response and handle tool calls + tool_results = [] + final_text = [] + + assistant_message = completion.choices[0].message + + if assistant_message.tool_calls: + for tool_call in assistant_message.tool_calls: + tool_name = tool_call.function.name + tool_args = json.loads(tool_call.function.arguments) + + # Execute tool call + result = await self.session.call_tool(tool_name, tool_args) + tool_results.append({"call": tool_name, "result": result}) + final_text.append(f"[Calling tool {tool_name} with args {tool_args}]") + + # Continue conversation with tool results + messages.extend([ + { + "role": "assistant", + "content": None, + "tool_calls": [tool_call] + }, + { + "role": "tool", + "tool_call_id": tool_call.id, + "content": result.content[0].text + } + ]) + + print(f"Tool {tool_name} returned: {result.content[0].text}") + print("messages", messages) + # Get next response from OpenAI + completion = await self.openai.chat.completions.create( + model=os.getenv("OPENAI_MODEL"), + max_tokens=1000, + messages=messages, + ) + if isinstance(completion.choices[0].message.content, (dict, list)): + final_text.append(str(completion.choices[0].message.content)) + else: + final_text.append(completion.choices[0].message.content) + else: + if isinstance(assistant_message.content, (dict, list)): + final_text.append(str(assistant_message.content)) + else: + final_text.append(assistant_message.content) + + return "\n".join(final_text) + + async def chat_loop(self): + """Run an interactive chat loop""" + print("\nMCP Client Started!") + print("Type your queries or 'quit' to exit.") + + while True: + try: + query = input("\nQuery: ").strip() + + if query.lower() == 'quit': + break + + response = await self.process_query(query) + print("\n" + response) + + except Exception as e: + print(f"\nError: {str(e)}") + async def gychat_loop(self,querystr) -> str: + """Run an interactive chat loop""" + print("\nMCP Client Started!") + print("Type your queries or 'quit' to exit.") + response = "响应初始化..." + try: + # query = input("\nQuery: ").strip() + query = querystr + if query.lower() == 'quit': + return + + response = await self.process_query(query) + print("\n" + response) + + except Exception as e: + print(f"\nError: {str(e)}") + finally: + return response +async def main(): + if len(sys.argv) < 2: + print("Usage: uv run client.py ") + sys.exit(1) + + client = MCPClient() + try: + await client.connect_to_sse_server(server_url=sys.argv[1]) + await client.chat_loop() + finally: + await client.cleanup() + +if __name__ == "__main__": + import sys + asyncio.run(main()) diff --git a/guymcp/contacts.txt b/guymcp/contacts.txt new file mode 100644 index 000000000..b45485592 --- /dev/null +++ b/guymcp/contacts.txt @@ -0,0 +1,5 @@ +张三 | 13996048383 | 北京市海淀区 | 技术部主管 +李四 | 13652589299 | 北京市海淀区 | 技术部主管 +王麻子 | 13500353307 | 北京市海淀区 | 技术部主管 +李二 | 13512352336 | 北京市海淀区 | 技术部主管 +李三 | 13668019077 | 北京市海淀区 | 技术部主管 diff --git a/guymcp/pyproject_template.toml b/guymcp/pyproject_template.toml new file mode 100644 index 000000000..be2c003cf --- /dev/null +++ b/guymcp/pyproject_template.toml @@ -0,0 +1,25 @@ +[project] +name = "guychat01" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.13" +dependencies = [ + "bs4>=0.0.2", + "httpx>=0.28.1", + "mcp[cli]>=1.6.0", + "openai>=1.70.0", + "python-docx>=1.1.2", + "requests>=2.32.3", +] +[tool.black] +line-length = 176 +target-version = ['py37'] +include = '\.pyi?$' +extend-exclude = '.+/(dist|.venv|venv|build|lib)/.+' + +[tool.isort] +profile = "black" + +[tool.uv.workspace] +members = ["mcp-server"] \ No newline at end of file diff --git a/guymcp/requirements.txt b/guymcp/requirements.txt new file mode 100644 index 000000000..e984ae6e8 --- /dev/null +++ b/guymcp/requirements.txt @@ -0,0 +1,6 @@ +bs4>=0.0.2 +httpx>=0.28.1 +mcp[cli]>=1.6.0 +openai>=1.70.0 +python-docx>=1.1.2 +requests>=2.32.3 diff --git a/guymcp/server.py b/guymcp/server.py new file mode 100644 index 000000000..5bdca9df4 --- /dev/null +++ b/guymcp/server.py @@ -0,0 +1,1293 @@ +from mcp.server.fastmcp import FastMCP +from dotenv import load_dotenv +import httpx +import json +import os +from bs4 import BeautifulSoup +from typing import Any +import httpx +from mcp.server.fastmcp import FastMCP +from starlette.applications import Starlette +from mcp.server.sse import SseServerTransport +from starlette.requests import Request +from starlette.routing import Mount, Route +from mcp.server import Server +import uvicorn + +import os +import io +import base64 +import shutil +from typing import Dict, List, Optional, Any, Union, Tuple +import json +from docx import Document +from docx.shared import Pt, Inches, RGBColor +from docx.enum.text import WD_PARAGRAPH_ALIGNMENT +from docx.enum.table import WD_TABLE_ALIGNMENT +from docx.enum.style import WD_STYLE_TYPE +from mcp.server.fastmcp import FastMCP +from docx.enum.text import WD_COLOR_INDEX +from docx.oxml.shared import OxmlElement, qn +from docx.oxml.ns import nsdecls +from docx.oxml import parse_xml +import sys +from openai import OpenAI +import re # 添加正则表达式模块 + +load_dotenv() + +mcp = FastMCP("docs") + +USER_AGENT = "docs-app/1.0" +SERPER_URL = "https://google.serper.dev/search" + +docs_urls = { + "langchain": "python.langchain.com/docs", + "llama-index": "docs.llamaindex.ai/en/stable", + "autogen": "microsoft.github.io/autogen/stable", + "agno": "docs.agno.com", + "openai-agents-sdk": "openai.github.io/openai-agents-python", + "mcp-doc": "modelcontextprotocol.io", + "camel-ai": "docs.camel-ai.org", + "crew-ai": "docs.crewai.com" +} + +async def search_web(query: str) -> dict | None: + payload = json.dumps({"q": query, "num": 2}) + + headers = { + "X-API-KEY": os.getenv("SERPER_API_KEY"), + "Content-Type": "application/json", + } + + async with httpx.AsyncClient() as client: + try: + response = await client.post( + SERPER_URL, headers=headers, data=payload, timeout=30.0 + ) + response.raise_for_status() + return response.json() + except httpx.TimeoutException: + return {"organic": []} + +async def fetch_url(url: str): + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, timeout=30.0) + soup = BeautifulSoup(response.text, "html.parser") + text = soup.get_text() + return text + except httpx.TimeoutException: + return "Timeout error" + +@mcp.tool() +async def get_docs(query: str, library: str): + """ + 搜索给定查询和库的最新文档。 + 支持 langchain、llama-index、autogen、agno、openai-agents-sdk、mcp-doc、camel-ai 和 crew-ai。 + + 参数: + query: 要搜索的查询 (例如 "React Agent") + library: 要搜索的库 (例如 "agno") + + 返回: + 文档中的文本 + """ + if library not in docs_urls: + raise ValueError(f"Library {library} not supported by this tool") + + query = f"site:{docs_urls[library]} {query}" + results = await search_web(query) + if len(results["organic"]) == 0: + return "No results found" + + text = "" + for result in results["organic"]: + text += await fetch_url(result["link"]) + + return text + +@mcp.tool(description="查询符合输入姓名的人的电话号码") +def queryphone(a: str) -> dict: + """ + 查询某人的电话号码 + Args: + a (str): 第一个字符串 + Returns: + dict: 包含查询结果的字典 + + """ + + result: dict + result = cxphone(a) + return result + +def cxphone(name: str) -> dict: + """ + 查找并返回contacts.txt中指定名字的行 + Args: + name (str): 要查找的名字 + Returns: + dict: 包含名字和电话号码的字典列表 + """ + results = [] + with open('contacts.txt', 'r', encoding='utf-8') as file: + for line in file: + parts = line.strip().split('|') + # 去掉所有的空格 + parts = [part.strip() for part in parts] + if len(parts) >= 2 and name in parts[0]: # 修改条件为包含name + results.append({"name": parts[0], "phone": parts[1]}) + if results: + return {"results": results} + else: + return {"error": f"未找到{name}的相关信息"} + +#guy +documents = {} + +# Helper Functions +def get_document_properties(doc_path: str) -> Dict[str, Any]: + """Get properties of a Word document.""" + if not os.path.exists(doc_path): + return {"error": f"Document {doc_path} does not exist"} + + try: + doc = Document(doc_path) + core_props = doc.core_properties + + return { + "title": core_props.title or "", + "author": core_props.author or "", + "subject": core_props.subject or "", + "keywords": core_props.keywords or "", + "created": str(core_props.created) if core_props.created else "", + "modified": str(core_props.modified) if core_props.modified else "", + "last_modified_by": core_props.last_modified_by or "", + "revision": core_props.revision or 0, + "page_count": len(doc.sections), + "word_count": sum(len(paragraph.text.split()) for paragraph in doc.paragraphs), + "paragraph_count": len(doc.paragraphs), + "table_count": len(doc.tables) + } + except Exception as e: + return {"error": f"Failed to get document properties: {str(e)}"} + +def extract_document_text(doc_path: str) -> str: + """Extract all text from a Word document.""" + if not os.path.exists(doc_path): + return f"Document {doc_path} does not exist" + + try: + doc = Document(doc_path) + text = [] + + for paragraph in doc.paragraphs: + text.append(paragraph.text) + + for table in doc.tables: + for row in table.rows: + for cell in row.cells: + for paragraph in cell.paragraphs: + text.append(paragraph.text) + + return "\n".join(text) + except Exception as e: + return f"Failed to extract text: {str(e)}" + +def get_document_structure(doc_path: str) -> Dict[str, Any]: + """Get the structure of a Word document.""" + if not os.path.exists(doc_path): + return {"error": f"Document {doc_path} does not exist"} + + try: + doc = Document(doc_path) + structure = { + "paragraphs": [], + "tables": [] + } + + # Get paragraphs + for i, para in enumerate(doc.paragraphs): + structure["paragraphs"].append({ + "index": i, + "text": para.text[:100] + ("..." if len(para.text) > 100 else ""), + "style": para.style.name if para.style else "Normal" + }) + + # Get tables + for i, table in enumerate(doc.tables): + table_data = { + "index": i, + "rows": len(table.rows), + "columns": len(table.columns), + "preview": [] + } + + # Get sample of table data + max_rows = min(3, len(table.rows)) + for row_idx in range(max_rows): + row_data = [] + max_cols = min(3, len(table.columns)) + for col_idx in range(max_cols): + try: + cell_text = table.cell(row_idx, col_idx).text + row_data.append(cell_text[:20] + ("..." if len(cell_text) > 20 else "")) + except IndexError: + row_data.append("N/A") + table_data["preview"].append(row_data) + + structure["tables"].append(table_data) + + return structure + except Exception as e: + return {"error": f"Failed to get document structure: {str(e)}"} + +def check_file_writeable(filepath: str) -> Tuple[bool, str]: + """ + Check if a file can be written to. + + Args: + filepath: Path to the file + + Returns: + Tuple of (is_writeable, error_message) + """ + # If file doesn't exist, check if directory is writeable + if not os.path.exists(filepath): + directory = os.path.dirname(filepath) + if not os.path.exists(directory): + return False, f"Directory {directory} does not exist" + if not os.access(directory, os.W_OK): + return False, f"Directory {directory} is not writeable" + return True, "" + + # If file exists, check if it's writeable + if not os.access(filepath, os.W_OK): + return False, f"File {filepath} is not writeable (permission denied)" + + # Try to open the file for writing to see if it's locked + try: + with open(filepath, 'a'): + pass + return True, "" + except IOError as e: + return False, f"File {filepath} is not writeable: {str(e)}" + except Exception as e: + return False, f"Unknown error checking file permissions: {str(e)}" + +def create_document_copy(source_path: str, dest_path: Optional[str] = None) -> Tuple[bool, str, Optional[str]]: + """ + Create a copy of a document. + + Args: + source_path: Path to the source document + dest_path: Optional path for the new document. If not provided, will use source_path + '_copy.docx' + + Returns: + Tuple of (success, message, new_filepath) + """ + if not os.path.exists(source_path): + return False, f"Source document {source_path} does not exist", None + + if not dest_path: + # Generate a new filename if not provided + base, ext = os.path.splitext(source_path) + dest_path = f"{base}_copy{ext}" + + try: + # Simple file copy + shutil.copy2(source_path, dest_path) + return True, f"Document copied to {dest_path}", dest_path + except Exception as e: + return False, f"Failed to copy document: {str(e)}", None + +def ensure_heading_style(doc): + """ + Ensure Heading styles exist in the document. + + Args: + doc: Document object + """ + for i in range(1, 10): # Create Heading 1 through Heading 9 + style_name = f'Heading {i}' + try: + # Try to access the style to see if it exists + style = doc.styles[style_name] + except KeyError: + # Create the style if it doesn't exist + try: + style = doc.styles.add_style(style_name, WD_STYLE_TYPE.PARAGRAPH) + if i == 1: + style.font.size = Pt(16) + style.font.bold = True + elif i == 2: + style.font.size = Pt(14) + style.font.bold = True + else: + style.font.size = Pt(12) + style.font.bold = True + except Exception: + # If style creation fails, we'll just use default formatting + pass + +def ensure_table_style(doc): + """ + Ensure Table Grid style exists in the document. + + Args: + doc: Document object + """ + try: + # Try to access the style to see if it exists + style = doc.styles['Table Grid'] + except KeyError: + # If style doesn't exist, we'll handle it at usage time + pass + +# MCP Tools +@mcp.tool() +async def create_document(filename: str, title: Optional[str] = None, author: Optional[str] = None) -> str: + """Create a new Word document with optional metadata. + + Args: + filename: Name of the document to create (with or without .docx extension) + title: Optional title for the document metadata + author: Optional author for the document metadata + """ + if not filename.endswith('.docx'): + filename += '.docx' + + # Check if file is writeable + is_writeable, error_message = check_file_writeable(filename) + if not is_writeable: + return f"Cannot create document: {error_message}" + + try: + doc = Document() + + # Set properties if provided + if title: + doc.core_properties.title = title + if author: + doc.core_properties.author = author + + # Ensure necessary styles exist + ensure_heading_style(doc) + ensure_table_style(doc) + + # Save the document + doc.save(filename) + + return f"Document {filename} created successfully" + except Exception as e: + return f"Failed to create document: {str(e)}" + +@mcp.tool() +async def add_heading(filename: str, text: str, level: int = 1) -> str: + """Add a heading to a Word document. + + Args: + filename: Path to the Word document + text: Heading text + level: Heading level (1-9, where 1 is the highest level) + """ + if not filename.endswith('.docx'): + filename += '.docx' + + if not os.path.exists(filename): + return f"Document {filename} does not exist" + + # Check if file is writeable + is_writeable, error_message = check_file_writeable(filename) + if not is_writeable: + # Suggest creating a copy + return f"Cannot modify document: {error_message}. Consider creating a copy first or creating a new document." + + try: + doc = Document(filename) + + # Ensure heading styles exist + ensure_heading_style(doc) + + # Try to add heading with style + try: + heading = doc.add_heading(text, level=level) + doc.save(filename) + return f"Heading '{text}' (level {level}) added to {filename}" + except Exception as style_error: + # If style-based approach fails, use direct formatting + paragraph = doc.add_paragraph(text) + paragraph.style = doc.styles['Normal'] + run = paragraph.runs[0] + run.bold = True + # Adjust size based on heading level + if level == 1: + run.font.size = Pt(16) + elif level == 2: + run.font.size = Pt(14) + else: + run.font.size = Pt(12) + + doc.save(filename) + return f"Heading '{text}' added to {filename} with direct formatting (style not available)" + except Exception as e: + return f"Failed to add heading: {str(e)}" + +@mcp.tool() +async def add_paragraph(filename: str, text: str, style: Optional[str] = None) -> str: + """Add a paragraph to a Word document. + + Args: + filename: Path to the Word document + text: Paragraph text + style: Optional paragraph style name + """ + if not filename.endswith('.docx'): + filename += '.docx' + + if not os.path.exists(filename): + return f"Document {filename} does not exist" + + # Check if file is writeable + is_writeable, error_message = check_file_writeable(filename) + if not is_writeable: + # Suggest creating a copy + return f"Cannot modify document: {error_message}. Consider creating a copy first or creating a new document." + + try: + doc = Document(filename) + paragraph = doc.add_paragraph(text) + + if style: + try: + paragraph.style = style + except KeyError: + # Style doesn't exist, use normal and report it + paragraph.style = doc.styles['Normal'] + doc.save(filename) + return f"Style '{style}' not found, paragraph added with default style to {filename}" + + doc.save(filename) + return f"Paragraph added to {filename}" + except Exception as e: + return f"Failed to add paragraph: {str(e)}" + +@mcp.tool() +async def add_table(filename: str, rows: int, cols: int, data: Optional[List[List[str]]] = None) -> str: + """Add a table to a Word document. + + Args: + filename: Path to the Word document + rows: Number of rows in the table + cols: Number of columns in the table + data: Optional 2D array of data to fill the table + """ + if not filename.endswith('.docx'): + filename += '.docx' + + if not os.path.exists(filename): + return f"Document {filename} does not exist" + + # Check if file is writeable + is_writeable, error_message = check_file_writeable(filename) + if not is_writeable: + # Suggest creating a copy + return f"Cannot modify document: {error_message}. Consider creating a copy first or creating a new document." + + try: + doc = Document(filename) + table = doc.add_table(rows=rows, cols=cols) + + # Try to set the table style + try: + table.style = 'Table Grid' + except KeyError: + # If style doesn't exist, add basic borders + # This is a simplified approach - complete border styling would require more code + pass + + # Fill table with data if provided + if data: + for i, row_data in enumerate(data): + if i >= rows: + break + for j, cell_text in enumerate(row_data): + if j >= cols: + break + table.cell(i, j).text = str(cell_text) + + doc.save(filename) + return f"Table ({rows}x{cols}) added to {filename}" + except Exception as e: + return f"Failed to add table: {str(e)}" + +@mcp.tool() +async def add_picture(filename: str, image_path: str, width: Optional[float] = None) -> str: + """Add an image to a Word document. + + Args: + filename: Path to the Word document + image_path: Path to the image file + width: Optional width in inches (proportional scaling) + """ + if not filename.endswith('.docx'): + filename += '.docx' + + # Validate document existence + if not os.path.exists(filename): + return f"Document {filename} does not exist" + + # Get absolute paths for better diagnostics + abs_filename = os.path.abspath(filename) + abs_image_path = os.path.abspath(image_path) + + # Validate image existence with improved error message + if not os.path.exists(abs_image_path): + return f"Image file not found: {abs_image_path}" + + # Check image file size + try: + image_size = os.path.getsize(abs_image_path) / 1024 # Size in KB + if image_size <= 0: + return f"Image file appears to be empty: {abs_image_path} (0 KB)" + except Exception as size_error: + return f"Error checking image file: {str(size_error)}" + + # Check if file is writeable + is_writeable, error_message = check_file_writeable(abs_filename) + if not is_writeable: + return f"Cannot modify document: {error_message}. Consider creating a copy first or creating a new document." + + try: + doc = Document(abs_filename) + # Additional diagnostic info + diagnostic = f"Attempting to add image ({abs_image_path}, {image_size:.2f} KB) to document ({abs_filename})" + + try: + if width: + doc.add_picture(abs_image_path, width=Inches(width)) + else: + doc.add_picture(abs_image_path) + doc.save(abs_filename) + return f"Picture {image_path} added to {filename}" + except Exception as inner_error: + # More detailed error for the specific operation + error_type = type(inner_error).__name__ + error_msg = str(inner_error) + return f"Failed to add picture: {error_type} - {error_msg or 'No error details available'}\nDiagnostic info: {diagnostic}" + except Exception as outer_error: + # Fallback error handling + error_type = type(outer_error).__name__ + error_msg = str(outer_error) + return f"Document processing error: {error_type} - {error_msg or 'No error details available'}" + +@mcp.tool() +async def get_document_info(filename: str) -> str: + """Get information about a Word document. + + Args: + filename: Path to the Word document + """ + if not filename.endswith('.docx'): + filename += '.docx' + + if not os.path.exists(filename): + return f"Document {filename} does not exist" + + try: + properties = get_document_properties(filename) + return json.dumps(properties, indent=2) + except Exception as e: + return f"Failed to get document info: {str(e)}" + +@mcp.tool() +async def get_document_text(filename: str) -> str: + """Extract all text from a Word document. + + Args: + filename: Path to the Word document + """ + if not filename.endswith('.docx'): + filename += '.docx' + + return extract_document_text(filename) + +@mcp.tool() +async def get_document_outline(filename: str) -> str: + """Get the structure of a Word document. + + Args: + filename: Path to the Word document + """ + if not filename.endswith('.docx'): + filename += '.docx' + + structure = get_document_structure(filename) + return json.dumps(structure, indent=2) + +@mcp.tool() +async def list_available_documents(directory: str = ".") -> str: + """List all .docx files in the specified directory. + + Args: + directory: Directory to search for Word documents + """ + try: + if not os.path.exists(directory): + return f"Directory {directory} does not exist" + + docx_files = [f for f in os.listdir(directory) if f.endswith('.docx')] + + if not docx_files: + return f"No Word documents found in {directory}" + + result = f"Found {len(docx_files)} Word documents in {directory}:\n" + for file in docx_files: + file_path = os.path.join(directory, file) + size = os.path.getsize(file_path) / 1024 # KB + result += f"- {file} ({size:.2f} KB)\n" + + return result + except Exception as e: + return f"Failed to list documents: {str(e)}" + +@mcp.tool() +async def copy_document(source_filename: str, destination_filename: Optional[str] = None) -> str: + """Create a copy of a Word document. + + Args: + source_filename: Path to the source document + destination_filename: Optional path for the copy. If not provided, a default name will be generated. + """ + if not source_filename.endswith('.docx'): + source_filename += '.docx' + + if destination_filename and not destination_filename.endswith('.docx'): + destination_filename += '.docx' + + success, message, new_path = create_document_copy(source_filename, destination_filename) + if success: + return message + else: + return f"Failed to copy document: {message}" + +# Resources +@mcp.resource("docx:{path}") +async def document_resource(path: str) -> str: + """Access Word document content.""" + if not path.endswith('.docx'): + path += '.docx' + + if not os.path.exists(path): + return f"Document {path} does not exist" + + return extract_document_text(path) +def find_paragraph_by_text(doc, text, partial_match=False): + """ + Find paragraphs containing specific text. + + Args: + doc: Document object + text: Text to search for + partial_match: If True, matches paragraphs containing the text; if False, matches exact text + + Returns: + List of paragraph indices that match the criteria + """ + matching_paragraphs = [] + + for i, para in enumerate(doc.paragraphs): + if partial_match and text in para.text: + matching_paragraphs.append(i) + elif not partial_match and para.text == text: + matching_paragraphs.append(i) + + return matching_paragraphs + +def find_and_replace_text(doc, old_text, new_text): + """ + Find and replace text throughout the document. + + Args: + doc: Document object + old_text: Text to find + new_text: Text to replace with + + Returns: + Number of replacements made + """ + count = 0 + + # Search in paragraphs + for para in doc.paragraphs: + if old_text in para.text: + for run in para.runs: + if old_text in run.text: + run.text = run.text.replace(old_text, new_text) + count += 1 + + # Search in tables + for table in doc.tables: + for row in table.rows: + for cell in row.cells: + for para in cell.paragraphs: + if old_text in para.text: + for run in para.runs: + if old_text in run.text: + run.text = run.text.replace(old_text, new_text) + count += 1 + + return count + +def set_cell_border(cell, **kwargs): + """ + Set cell border properties. + + Args: + cell: The cell to modify + **kwargs: Border properties (top, bottom, left, right, val, color) + """ + tc = cell._tc + tcPr = tc.get_or_add_tcPr() + + # Create border elements + for key, value in kwargs.items(): + if key in ['top', 'left', 'bottom', 'right']: + tag = 'w:{}'.format(key) + + element = OxmlElement(tag) + element.set(qn('w:val'), kwargs.get('val', 'single')) + element.set(qn('w:sz'), kwargs.get('sz', '4')) + element.set(qn('w:space'), kwargs.get('space', '0')) + element.set(qn('w:color'), kwargs.get('color', 'auto')) + + tcBorders = tcPr.first_child_found_in("w:tcBorders") + if tcBorders is None: + tcBorders = OxmlElement('w:tcBorders') + tcPr.append(tcBorders) + + tcBorders.append(element) + +def create_style(doc, style_name, style_type, base_style=None, font_properties=None, paragraph_properties=None): + """ + Create a new style in the document. + + Args: + doc: Document object + style_name: Name for the new style + style_type: Type of style (WD_STYLE_TYPE) + base_style: Optional base style to inherit from + font_properties: Dictionary of font properties (bold, italic, size, name, color) + paragraph_properties: Dictionary of paragraph properties (alignment, spacing) + + Returns: + The created style + """ + try: + # Check if style already exists + style = doc.styles.get_by_id(style_name, WD_STYLE_TYPE.PARAGRAPH) + return style + except: + # Create new style + new_style = doc.styles.add_style(style_name, style_type) + + # Set base style if specified + if base_style: + new_style.base_style = doc.styles[base_style] + + # Set font properties + if font_properties: + font = new_style.font + if 'bold' in font_properties: + font.bold = font_properties['bold'] + if 'italic' in font_properties: + font.italic = font_properties['italic'] + if 'size' in font_properties: + font.size = Pt(font_properties['size']) + if 'name' in font_properties: + font.name = font_properties['name'] + if 'color' in font_properties: + try: + # For RGB color + font.color.rgb = font_properties['color'] + except: + # For named color + font.color.theme_color = font_properties['color'] + + # Set paragraph properties + if paragraph_properties: + if 'alignment' in paragraph_properties: + new_style.paragraph_format.alignment = paragraph_properties['alignment'] + if 'spacing' in paragraph_properties: + new_style.paragraph_format.line_spacing = paragraph_properties['spacing'] + + return new_style + +# Add these MCP tools to the existing set + +@mcp.tool() +async def format_text(filename: str, paragraph_index: int, start_pos: int, end_pos: int, + bold: Optional[bool] = None, italic: Optional[bool] = None, + underline: Optional[bool] = None, color: Optional[str] = None, + font_size: Optional[int] = None, font_name: Optional[str] = None) -> str: + """Format a specific range of text within a paragraph. + + Args: + filename: Path to the Word document + paragraph_index: Index of the paragraph (0-based) + start_pos: Start position within the paragraph text + end_pos: End position within the paragraph text + bold: Set text bold (True/False) + italic: Set text italic (True/False) + underline: Set text underlined (True/False) + color: Text color (e.g., 'red', 'blue', etc.) + font_size: Font size in points + font_name: Font name/family + """ + if not filename.endswith('.docx'): + filename += '.docx' + + if not os.path.exists(filename): + return f"Document {filename} does not exist" + + # Check if file is writeable + is_writeable, error_message = check_file_writeable(filename) + if not is_writeable: + return f"Cannot modify document: {error_message}. Consider creating a copy first." + + try: + doc = Document(filename) + + # Validate paragraph index + if paragraph_index < 0 or paragraph_index >= len(doc.paragraphs): + return f"Invalid paragraph index. Document has {len(doc.paragraphs)} paragraphs (0-{len(doc.paragraphs)-1})." + + paragraph = doc.paragraphs[paragraph_index] + text = paragraph.text + + # Validate text positions + if start_pos < 0 or end_pos > len(text) or start_pos >= end_pos: + return f"Invalid text positions. Paragraph has {len(text)} characters." + + # Get the text to format + target_text = text[start_pos:end_pos] + + # Clear existing runs and create three runs: before, target, after + for run in paragraph.runs: + run.clear() + + # Add text before target + if start_pos > 0: + run_before = paragraph.add_run(text[:start_pos]) + + # Add target text with formatting + run_target = paragraph.add_run(target_text) + if bold is not None: + run_target.bold = bold + if italic is not None: + run_target.italic = italic + if underline is not None: + run_target.underline = underline + if color: + try: + # Try to set color by name + run_target.font.color.rgb = RGBColor.from_string(color) + except: + # If color name doesn't work, try predefined colors + color_map = { + 'red': WD_COLOR_INDEX.RED, + 'blue': WD_COLOR_INDEX.BLUE, + 'green': WD_COLOR_INDEX.GREEN, + 'yellow': WD_COLOR_INDEX.YELLOW, + 'black': WD_COLOR_INDEX.BLACK, + } + if color.lower() in color_map: + run_target.font.color.index = color_map[color.lower()] + if font_size: + run_target.font.size = Pt(font_size) + if font_name: + run_target.font.name = font_name + + # Add text after target + if end_pos < len(text): + run_after = paragraph.add_run(text[end_pos:]) + + doc.save(filename) + return f"Text '{target_text}' formatted successfully in paragraph {paragraph_index}." + except Exception as e: + return f"Failed to format text: {str(e)}" + +@mcp.tool() +async def search_and_replace(filename: str, find_text: str, replace_text: str) -> str: + """Search for text and replace all occurrences. + + Args: + filename: Path to the Word document + find_text: Text to search for + replace_text: Text to replace with + """ + if not filename.endswith('.docx'): + filename += '.docx' + + if not os.path.exists(filename): + return f"Document {filename} does not exist" + + # Check if file is writeable + is_writeable, error_message = check_file_writeable(filename) + if not is_writeable: + return f"Cannot modify document: {error_message}. Consider creating a copy first." + + try: + doc = Document(filename) + + # Perform find and replace + count = find_and_replace_text(doc, find_text, replace_text) + + if count > 0: + doc.save(filename) + return f"Replaced {count} occurrence(s) of '{find_text}' with '{replace_text}'." + else: + return f"No occurrences of '{find_text}' found." + except Exception as e: + return f"Failed to search and replace: {str(e)}" + +# guy 得到个人总结 +async def gy_get_grzj(question: str)-> str: + client = OpenAI( + base_url='https://api.siliconflow.cn/v1', + api_key='sk-ausgzyjuyhyuaaizdxtzqltuimudowdrxwokgjrcgmebnwnm' + ) + + # 发送带有流式输出的请求 + response = client.chat.completions.create( + # model="deepseek-ai/DeepSeek-V2.5", + model="Qwen/Qwen2.5-72B-Instruct", + messages=[ + {"role": "user", "content": question} + ] + ) + # 解析返回内容 + if response.choices: + # 提取代码块(带Markdown格式检测) + answer = response.choices[0].message.content + return answer + else: + answer = "未获得有效响应" + return answer + +#生成公务员年度考核表 +@mcp.tool() +async def gy_genetate_file(filename: str, find_text: str, replace_text: str) -> str: + """生成指定姓名人员的公务员年度考核表. + + Args: + filename: 指定的人员的姓名 + find_text: Text to search for + replace_text: Text to replace with + """ + await copy_document('tempbz.docx', filename +'.docx') + if not filename.endswith('.docx'): + filename += '.docx' + + if not os.path.exists(filename): + return f"Document {filename} does not exist" + + # Check if file is writeable + is_writeable, error_message = check_file_writeable(filename) + if not is_writeable: + return f"Cannot modify document: {error_message}. Consider creating a copy first." + + try: + doc = Document(filename) + + # 使用正则表达式分割 find_text 和 replace_text + find_texts = re.split(r'[,\s,]+', find_text.strip()) + replace_texts = re.split(r'[,\s,]+', replace_text.strip()) + + # Ensure both lists have the same length + if len(find_texts) != len(replace_texts): + return f"Error: Number of find texts ({len(find_texts)}) does not match number of replace texts ({len(replace_texts)})." + + # Perform find and replace for each pair + total_count = 0 + for find, replace in zip(find_texts, replace_texts): + count = find_and_replace_text(doc, find.strip(), replace.strip()) + total_count += count + + # guy + zj = await gy_get_grzj('写230字到234字的个人年度工作总结,身份是基层市场监管所一般工作人员,负责食品安全相关作') + cleaned_zj = zj.replace("\r\n", "\n") + + # 新增过滤逻辑 + # cleaned_zj = "\n".join([line for line in zj.split("\n") if line.strip() != ""]) + # cleaned_zj = cleaned_zj.replace('↓', '').replace('→', '').replace('←', '') # 显式替换常见箭头 + cleaned_zj = re.sub(r'[\u2190-\u21FF]', '', cleaned_zj) # 使用正则过滤Unicode箭头区字符 + # cleaned_zj = cleaned_zj.replace('\r', '') + # 其他特殊符号处理 + cleaned_zj = cleaned_zj.translate(str.maketrans({ + '\u3000': ' ', # 全角空格 + '\u00a0': ' ', # 不间断空格 + '\u2028': '\n' # 行分隔符转普通换行 + })) + cleaned_zj = re.sub(r'[\r\n]+', ' ', cleaned_zj) + print(cleaned_zj) + find_and_replace_text(doc,'GRZJA'.strip(),cleaned_zj[:210].strip()) + find_and_replace_text(doc,'GRZJB'.strip(),cleaned_zj[210:].strip()) + + if total_count > 0: + doc.save(filename) + return f"Replaced {total_count} occurrence(s) of specified texts." + else: + return f"No occurrences of specified texts found." + except Exception as e: + return f"Failed to search and replace: {str(e)}" + +@mcp.tool() +async def delete_paragraph(filename: str, paragraph_index: int) -> str: + """Delete a paragraph from a document. + + Args: + filename: Path to the Word document + paragraph_index: Index of the paragraph to delete (0-based) + """ + if not filename.endswith('.docx'): + filename += '.docx' + + if not os.path.exists(filename): + return f"Document {filename} does not exist" + + # Check if file is writeable + is_writeable, error_message = check_file_writeable(filename) + if not is_writeable: + return f"Cannot modify document: {error_message}. Consider creating a copy first." + + try: + doc = Document(filename) + + # Validate paragraph index + if paragraph_index < 0 or paragraph_index >= len(doc.paragraphs): + return f"Invalid paragraph index. Document has {len(doc.paragraphs)} paragraphs (0-{len(doc.paragraphs)-1})." + + # Delete the paragraph (by removing its content and setting it empty) + # Note: python-docx doesn't support true paragraph deletion, this is a workaround + paragraph = doc.paragraphs[paragraph_index] + p = paragraph._p + p.getparent().remove(p) + + doc.save(filename) + return f"Paragraph at index {paragraph_index} deleted successfully." + except Exception as e: + return f"Failed to delete paragraph: {str(e)}" + +@mcp.tool() +async def create_custom_style(filename: str, style_name: str, + bold: Optional[bool] = None, italic: Optional[bool] = None, + font_size: Optional[int] = None, font_name: Optional[str] = None, + color: Optional[str] = None, base_style: Optional[str] = None) -> str: + """Create a custom style in the document. + + Args: + filename: Path to the Word document + style_name: Name for the new style + bold: Set text bold (True/False) + italic: Set text italic (True/False) + font_size: Font size in points + font_name: Font name/family + color: Text color (e.g., 'red', 'blue') + base_style: Optional existing style to base this on + """ + if not filename.endswith('.docx'): + filename += '.docx' + + if not os.path.exists(filename): + return f"Document {filename} does not exist" + + # Check if file is writeable + is_writeable, error_message = check_file_writeable(filename) + if not is_writeable: + return f"Cannot modify document: {error_message}. Consider creating a copy first." + + try: + doc = Document(filename) + + # Build font properties dictionary + font_properties = {} + if bold is not None: + font_properties['bold'] = bold + if italic is not None: + font_properties['italic'] = italic + if font_size is not None: + font_properties['size'] = font_size + if font_name is not None: + font_properties['name'] = font_name + if color is not None: + font_properties['color'] = color + + # Create the style + new_style = create_style( + doc, + style_name, + WD_STYLE_TYPE.PARAGRAPH, + base_style=base_style, + font_properties=font_properties + ) + + doc.save(filename) + return f"Style '{style_name}' created successfully." + except Exception as e: + return f"Failed to create style: {str(e)}" + +@mcp.tool() +async def format_table(filename: str, table_index: int, + has_header_row: Optional[bool] = None, + border_style: Optional[str] = None, + shading: Optional[List[List[str]]] = None) -> str: + """Format a table with borders, shading, and structure. + + Args: + filename: Path to the Word document + table_index: Index of the table (0-based) + has_header_row: If True, formats the first row as a header + border_style: Style for borders ('none', 'single', 'double', 'thick') + shading: 2D list of cell background colors (by row and column) + """ + if not filename.endswith('.docx'): + filename += '.docx' + + if not os.path.exists(filename): + return f"Document {filename} does not exist" + + # Check if file is writeable + is_writeable, error_message = check_file_writeable(filename) + if not is_writeable: + return f"Cannot modify document: {error_message}. Consider creating a copy first." + + try: + doc = Document(filename) + + # Validate table index + if table_index < 0 or table_index >= len(doc.tables): + return f"Invalid table index. Document has {len(doc.tables)} tables (0-{len(doc.tables)-1})." + + table = doc.tables[table_index] + + # Format header row if requested + if has_header_row and table.rows: + header_row = table.rows[0] + for cell in header_row.cells: + for paragraph in cell.paragraphs: + if paragraph.runs: + for run in paragraph.runs: + run.bold = True + + # Apply border style if specified + if border_style: + val_map = { + 'none': 'nil', + 'single': 'single', + 'double': 'double', + 'thick': 'thick' + } + val = val_map.get(border_style.lower(), 'single') + + # Apply to all cells + for row in table.rows: + for cell in row.cells: + set_cell_border( + cell, + top=True, + bottom=True, + left=True, + right=True, + val=val, + color="000000" + ) + + # Apply cell shading if specified + if shading: + for i, row_colors in enumerate(shading): + if i >= len(table.rows): + break + for j, color in enumerate(row_colors): + if j >= len(table.rows[i].cells): + break + try: + # Apply shading to cell + cell = table.rows[i].cells[j] + shading_elm = parse_xml(f'') + cell._tc.get_or_add_tcPr().append(shading_elm) + except: + # Skip if color format is invalid + pass + + doc.save(filename) + return f"Table at index {table_index} formatted successfully." + except Exception as e: + return f"Failed to format table: {str(e)}" + +@mcp.tool() +async def add_page_break(filename: str) -> str: + """Add a page break to the document. + + Args: + filename: Path to the Word document + """ + if not filename.endswith('.docx'): + filename += '.docx' + + if not os.path.exists(filename): + return f"Document {filename} does not exist" + + # Check if file is writeable + is_writeable, error_message = check_file_writeable(filename) + if not is_writeable: + return f"Cannot modify document: {error_message}. Consider creating a copy first." + + try: + doc = Document(filename) + doc.add_page_break() + doc.save(filename) + return f"Page break added to {filename}." + except Exception as e: + return f"Failed to add page break: {str(e)}" + +## sse传输 +def create_starlette_app(mcp_server: Server, *, debug: bool = False) -> Starlette: + """Create a Starlette application that can serve the provided mcp server with SSE.""" + sse = SseServerTransport("/messages/") + + async def handle_sse(request: Request) -> None: + async with sse.connect_sse( + request.scope, + request.receive, + request._send, # noqa: SLF001 + ) as (read_stream, write_stream): + await mcp_server.run( + read_stream, + write_stream, + mcp_server.create_initialization_options(), + ) + + return Starlette( + debug=debug, + routes=[ + Route("/sse", endpoint=handle_sse), + Mount("/messages/", app=sse.handle_post_message), + ], + ) + +if __name__ == "__main__": + mcp_server = mcp._mcp_server + + import argparse + + parser = argparse.ArgumentParser(description='Run MCP SSE-based server') + parser.add_argument('--host', default='0.0.0.0', help='Host to bind to') + parser.add_argument('--port', type=int, default=8020, help='Port to listen on') + args = parser.parse_args() + + # Bind SSE request handling to MCP server + starlette_app = create_starlette_app(mcp_server, debug=True) + + uvicorn.run(starlette_app, host=args.host, port=args.port) \ No newline at end of file diff --git a/guymcp/tempbz.docx b/guymcp/tempbz.docx new file mode 100644 index 000000000..57e47f956 Binary files /dev/null and b/guymcp/tempbz.docx differ