├── LICENSE ├── README.md ├── README_CN.md ├── example ├── nacos_mcp_example.py ├── nacos_server_example.py ├── nacos_simple_example.py └── simple-streamablehttp │ ├── event_store.py │ └── server.py ├── nacos_mcp_wrapper ├── __init__.py └── server │ ├── __init__.py │ ├── nacos_mcp.py │ ├── nacos_server.py │ ├── nacos_settings.py │ └── utils.py ├── requirements.txt └── setup.py /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nacos-mcp-wrapper-python 2 | 3 | [中文文档](./README_CN.md) 4 | 5 | ## Overview 6 | Nacos-mcp-wrapper-python is a sdk that helps you quickly register your Mcp Server to Nacos. Nacos is an easy-to-use platform designed for dynamic service discovery and configuration and service management. It helps you to build cloud native applications and microservices platform easily. By using Nacos to host your Mcp Server, it supports dynamic modifications of the descriptions for Mcp Server Tools and their corresponding parameters, assisting in the rapid evolution of your Mcp Server. 7 | 8 | ## Installation 9 | 10 | ### Environment 11 | Starting from version 1.0.0 of nacos-mcp-wrapper-python, the Nacos server version must be greater than 3.0.1. 12 | - python >=3.10 13 | ### Use pip 14 | ```bash 15 | pip install nacos-mcp-wrapper-python 16 | ``` 17 | 18 | ## Development 19 | We can use official MCP Python SDK to quickly build a mcp server: 20 | ```python 21 | # server.py 22 | from mcp.server.fastmcp import FastMCP 23 | 24 | # Create an MCP server 25 | mcp = FastMCP("Demo") 26 | 27 | 28 | # Add an addition tool 29 | @mcp.tool() 30 | def add(a: int, b: int) -> int: 31 | """Add two numbers""" 32 | return a + b 33 | 34 | mcp.run(transport="sse") 35 | ``` 36 | To quickly register your Mcp Server to Nacos, just replace the FashMCP with NacosMCP: 37 | 38 | ```python 39 | # server.py 40 | from nacos_mcp_wrapper.server.nacos_mcp import NacosMCP 41 | from nacos_mcp_wrapper.server.nacos_settings import NacosSettings 42 | 43 | # Create an MCP server 44 | # mcp = FastMCP("Demo") 45 | nacos_settings = NacosSettings() 46 | nacos_settings.SERVER_ADDR = " e.g.127.0.0.1:8848" 47 | mcp = NacosMCP("nacos-mcp-python",nacos_settings=nacos_settings) 48 | 49 | 50 | # Add an addition tool 51 | @mcp.tool() 52 | def add(a: int, b: int) -> int: 53 | """Add two numbers""" 54 | return a + b 55 | 56 | mcp.run(transport="sse") 57 | ``` 58 | After registering to Nacos, you can dynamically update the descriptions of Tools and the descriptions of parameters in the Mcp Server on Nacos without restarting your Mcp Server. 59 | 60 | ### Advanced Usage 61 | 62 | When building an MCP server using the official MCP Python SDK, for more control, you can directly use the low-level server implementation, for more control, you can use the low-level server implementation directly. This gives you full access to the protocol and allows you to customize every aspect of your server, including lifecycle management through the lifespan API. 63 | ```python 64 | import mcp.server.stdio 65 | import mcp.types as types 66 | import httpx 67 | from mcp.server.lowlevel import NotificationOptions, Server 68 | from mcp.server.models import InitializationOptions 69 | 70 | # Create a server instance 71 | server = Server("example-server") 72 | 73 | 74 | async def fetch_website( 75 | url: str, 76 | ) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]: 77 | headers = { 78 | "User-Agent": "MCP Test Server (github.com/modelcontextprotocol/python-sdk)" 79 | } 80 | async with httpx.AsyncClient(follow_redirects=True, headers=headers) as client: 81 | response = await client.get(url) 82 | response.raise_for_status() 83 | return [types.TextContent(type="text", text=response.text)] 84 | 85 | @server.call_tool() 86 | async def fetch_tool( 87 | name: str, arguments: dict 88 | ) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]: 89 | if name != "fetch": 90 | raise ValueError(f"Unknown tool: {name}") 91 | if "url" not in arguments: 92 | raise ValueError("Missing required argument 'url'") 93 | return await fetch_website(arguments["url"]) 94 | 95 | @server.list_tools() 96 | async def list_tools() -> list[types.Tool]: 97 | return [ 98 | types.Tool( 99 | name="fetch", 100 | description="Fetches a website and returns its content", 101 | inputSchema={ 102 | "type": "object", 103 | "required": ["url"], 104 | "properties": { 105 | "url": { 106 | "type": "string", 107 | "description": "URL to fetch", 108 | } 109 | }, 110 | }, 111 | ) 112 | ] 113 | 114 | 115 | async def run(): 116 | async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): 117 | await server.run( 118 | read_stream, 119 | write_stream, 120 | InitializationOptions( 121 | server_name="example", 122 | server_version="0.1.0", 123 | capabilities=server.get_capabilities( 124 | notification_options=NotificationOptions(), 125 | experimental_capabilities={}, 126 | ), 127 | ), 128 | ) 129 | 130 | 131 | if __name__ == "__main__": 132 | import asyncio 133 | 134 | asyncio.run(run()) 135 | ``` 136 | 137 | To quickly register your Mcp Server to Nacos, just replace the Server with NacosServer: 138 | 139 | ```python 140 | import mcp.server.stdio 141 | import mcp.types as types 142 | import httpx 143 | from mcp.server.lowlevel import NotificationOptions 144 | from mcp.server.models import InitializationOptions 145 | from nacos_mcp_wrapper.server.nacos_server import NacosServer 146 | from nacos_mcp_wrapper.server.nacos_settings import NacosSettings 147 | 148 | # Create a server instance 149 | # server = Server("example-server") 150 | nacos_settings = NacosSettings() 151 | nacos_settings.SERVER_ADDR = " e.g.127.0.0.1:8848" 152 | server = NacosServer("mcp-website-fetcher",nacos_settings=nacos_settings) 153 | 154 | async def fetch_website( 155 | url: str, 156 | ) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]: 157 | headers = { 158 | "User-Agent": "MCP Test Server (github.com/modelcontextprotocol/python-sdk)" 159 | } 160 | async with httpx.AsyncClient(follow_redirects=True, headers=headers) as client: 161 | response = await client.get(url) 162 | response.raise_for_status() 163 | return [types.TextContent(type="text", text=response.text)] 164 | 165 | @server.call_tool() 166 | async def fetch_tool( 167 | name: str, arguments: dict 168 | ) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]: 169 | if name != "fetch": 170 | raise ValueError(f"Unknown tool: {name}") 171 | if "url" not in arguments: 172 | raise ValueError("Missing required argument 'url'") 173 | return await fetch_website(arguments["url"]) 174 | 175 | @server.list_tools() 176 | async def list_tools() -> list[types.Tool]: 177 | return [ 178 | types.Tool( 179 | name="fetch", 180 | description="Fetches a website and returns its content", 181 | inputSchema={ 182 | "type": "object", 183 | "required": ["url"], 184 | "properties": { 185 | "url": { 186 | "type": "string", 187 | "description": "URL to fetch", 188 | } 189 | }, 190 | }, 191 | ) 192 | ] 193 | 194 | 195 | async def run(): 196 | await server.register_to_nacos("stdio") 197 | async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): 198 | await server.run( 199 | read_stream, 200 | write_stream, 201 | InitializationOptions( 202 | server_name="example", 203 | server_version="0.1.0", 204 | capabilities=server.get_capabilities( 205 | notification_options=NotificationOptions(), 206 | experimental_capabilities={}, 207 | ), 208 | ), 209 | ) 210 | 211 | 212 | if __name__ == "__main__": 213 | import asyncio 214 | 215 | asyncio.run(run()) 216 | 217 | ``` 218 | 219 | For more examples, please refer to the content under the `example` directory. 220 | 221 | -------------------------------------------------------------------------------- /README_CN.md: -------------------------------------------------------------------------------- 1 | # nacos-mcp-wrapper-python 2 | [English Document](./README.md) 3 | ## 概述 4 | Nacos-mcp-wrapper-python 是一个帮助你快速将 Mcp Server注册到 Nacos 的 SDK。 5 | 6 | Nacos 一个更易于构建云原生应用的动态服务发现、配置管理和服务管理平台。Nacos提供了一组简单易用的特性集,帮助您快速实现动态服务发现、服务配置、服务元数据及流量管理。 7 | 8 | 通过使用Nacos-mcp-wrapper-python 将Mcp Server 注册到Nacos,你可以通过Nacos实现 对 Mcp server Tools及其对应参数的描述进行动态修改,从而助力你的 Mcp Server快速演进。 9 | ## 安装 10 | 11 | ### 环境要求 12 | nacos-mcp-wrapper-python 1.0.0及以上版本,要求Nacos Sever版本 > 3.0.1 13 | - python >=3.10 14 | ### 使用 PIP 15 | ```bash 16 | pip install nacos-mcp-wrapper-python 17 | ``` 18 | 19 | ## 开发 20 | 我们可以使用官方社区的 MCP Python SDK 快速构建一个 MCP Server: 21 | ```python 22 | # server.py 23 | from mcp.server.fastmcp import FastMCP 24 | 25 | # Create an MCP server 26 | mcp = FastMCP("Demo") 27 | 28 | 29 | # Add an addition tool 30 | @mcp.tool() 31 | def add(a: int, b: int) -> int: 32 | """Add two numbers""" 33 | return a + b 34 | 35 | mcp.run(transport="sse") 36 | ``` 37 | 要快速将你的 Mcp Server 注册到 Nacos,只需将 FastMCP 替换为 NacosMCP: 38 | 39 | ```python 40 | # server.py 41 | from nacos_mcp_wrapper.server.nacos_mcp import NacosMCP 42 | from nacos_mcp_wrapper.server.nacos_settings import NacosSettings 43 | 44 | # Create an MCP server 45 | # mcp = FastMCP("Demo") 46 | nacos_settings = NacosSettings() 47 | nacos_settings.SERVER_ADDR = " e.g.127.0.0.1:8848" 48 | mcp = NacosMCP("nacos-mcp-python",nacos_settings=nacos_settings) 49 | 50 | 51 | # Add an addition tool 52 | @mcp.tool() 53 | def add(a: int, b: int) -> int: 54 | """Add two numbers""" 55 | return a + b 56 | 57 | mcp.run(transport="sse") 58 | ``` 59 | 在将 Mcp Server 注册到 Nacos 后,你可以在不重启 Mcp Server 的情况下,动态更新 Nacos 上 Mcp Server 中工具及其参数的描述。 60 | ### 进阶用法 61 | 62 | 在使用官方 MCP Python SDK 构建 MCP Server时,如果你需要控制服务器的细节,可以直接使用低级别的Server实现。这将允许你自定义服务器的各个方面,包括通过 lifespan API 进行生命周期管理。 63 | ```python 64 | import mcp.server.stdio 65 | import mcp.types as types 66 | import httpx 67 | from mcp.server.lowlevel import NotificationOptions, Server 68 | from mcp.server.models import InitializationOptions 69 | 70 | # Create a server instance 71 | server = Server("example-server") 72 | 73 | 74 | async def fetch_website( 75 | url: str, 76 | ) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]: 77 | headers = { 78 | "User-Agent": "MCP Test Server (github.com/modelcontextprotocol/python-sdk)" 79 | } 80 | async with httpx.AsyncClient(follow_redirects=True, headers=headers) as client: 81 | response = await client.get(url) 82 | response.raise_for_status() 83 | return [types.TextContent(type="text", text=response.text)] 84 | 85 | @server.call_tool() 86 | async def fetch_tool( 87 | name: str, arguments: dict 88 | ) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]: 89 | if name != "fetch": 90 | raise ValueError(f"Unknown tool: {name}") 91 | if "url" not in arguments: 92 | raise ValueError("Missing required argument 'url'") 93 | return await fetch_website(arguments["url"]) 94 | 95 | @server.list_tools() 96 | async def list_tools() -> list[types.Tool]: 97 | return [ 98 | types.Tool( 99 | name="fetch", 100 | description="Fetches a website and returns its content", 101 | inputSchema={ 102 | "type": "object", 103 | "required": ["url"], 104 | "properties": { 105 | "url": { 106 | "type": "string", 107 | "description": "URL to fetch", 108 | } 109 | }, 110 | }, 111 | ) 112 | ] 113 | 114 | 115 | async def run(): 116 | async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): 117 | await server.run( 118 | read_stream, 119 | write_stream, 120 | InitializationOptions( 121 | server_name="example", 122 | server_version="0.1.0", 123 | capabilities=server.get_capabilities( 124 | notification_options=NotificationOptions(), 125 | experimental_capabilities={}, 126 | ), 127 | ), 128 | ) 129 | 130 | 131 | if __name__ == "__main__": 132 | import asyncio 133 | 134 | asyncio.run(run()) 135 | ``` 136 | 137 | 要快速将你的 Mcp Server 注册到 Nacos,只需将 Server 替换为 NacosServer: 138 | ```python 139 | import mcp.server.stdio 140 | import mcp.types as types 141 | import httpx 142 | from mcp.server.lowlevel import NotificationOptions 143 | from mcp.server.models import InitializationOptions 144 | from nacos_mcp_wrapper.server.nacos_server import NacosServer 145 | from nacos_mcp_wrapper.server.nacos_settings import NacosSettings 146 | 147 | # Create a server instance 148 | # server = Server("example-server") 149 | nacos_settings = NacosSettings() 150 | nacos_settings.SERVER_ADDR = " e.g.127.0.0.1:8848" 151 | server = NacosServer("mcp-website-fetcher",nacos_settings=nacos_settings) 152 | 153 | async def fetch_website( 154 | url: str, 155 | ) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]: 156 | headers = { 157 | "User-Agent": "MCP Test Server (github.com/modelcontextprotocol/python-sdk)" 158 | } 159 | async with httpx.AsyncClient(follow_redirects=True, headers=headers) as client: 160 | response = await client.get(url) 161 | response.raise_for_status() 162 | return [types.TextContent(type="text", text=response.text)] 163 | 164 | @server.call_tool() 165 | async def fetch_tool( 166 | name: str, arguments: dict 167 | ) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]: 168 | if name != "fetch": 169 | raise ValueError(f"Unknown tool: {name}") 170 | if "url" not in arguments: 171 | raise ValueError("Missing required argument 'url'") 172 | return await fetch_website(arguments["url"]) 173 | 174 | @server.list_tools() 175 | async def list_tools() -> list[types.Tool]: 176 | return [ 177 | types.Tool( 178 | name="fetch", 179 | description="Fetches a website and returns its content", 180 | inputSchema={ 181 | "type": "object", 182 | "required": ["url"], 183 | "properties": { 184 | "url": { 185 | "type": "string", 186 | "description": "URL to fetch", 187 | } 188 | }, 189 | }, 190 | ) 191 | ] 192 | 193 | 194 | async def run(): 195 | await server.register_to_nacos("stdio") 196 | async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): 197 | await server.run( 198 | read_stream, 199 | write_stream, 200 | InitializationOptions( 201 | server_name="example", 202 | server_version="0.1.0", 203 | capabilities=server.get_capabilities( 204 | notification_options=NotificationOptions(), 205 | experimental_capabilities={}, 206 | ), 207 | ), 208 | ) 209 | 210 | 211 | if __name__ == "__main__": 212 | import asyncio 213 | 214 | asyncio.run(run()) 215 | 216 | ``` 217 | 218 | 更多示例和使用方式,请参阅 example 目录下的内容。 219 | -------------------------------------------------------------------------------- /example/nacos_mcp_example.py: -------------------------------------------------------------------------------- 1 | from nacos_mcp_wrapper.server.nacos_mcp import NacosMCP 2 | from nacos_mcp_wrapper.server.nacos_settings import NacosSettings 3 | 4 | # Create an MCP server instance 5 | nacos_settings = NacosSettings() 6 | nacos_settings.SERVER_ADDR = "127.0.0.1:8848" # e.g. 127.0.0.1:8848 7 | nacos_settings.USERNAME="" 8 | nacos_settings.PASSWORD="" 9 | mcp = NacosMCP("nacos-mcp-python", nacos_settings=nacos_settings, port=18001) 10 | 11 | # Register an addition tool 12 | @mcp.tool() 13 | def add(a: int, b: int) -> int: 14 | """Add two integers together""" 15 | return a + b 16 | 17 | # Register a subtraction tool 18 | @mcp.tool() 19 | def minus(a: int, b: int) -> int: 20 | """Subtract two numbers""" 21 | return a - b 22 | 23 | # Register a prompt function 24 | @mcp.prompt() 25 | def get_prompt(topic: str) -> str: 26 | """Get a personalized greeting""" 27 | return f"Hello, {topic}!" 28 | 29 | # Register a dynamic resource endpoint 30 | @mcp.resource("greeting://{name}") 31 | def get_greeting(name: str) -> str: 32 | """Get a personalized greeting""" 33 | return f"Hello, {name}!" 34 | 35 | if __name__ == "__main__": 36 | try: 37 | mcp.run(transport="sse") 38 | except Exception as e: 39 | print(f"Runtime error: {e}") -------------------------------------------------------------------------------- /example/nacos_server_example.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import anyio 3 | import click 4 | import httpx 5 | import mcp.types as types 6 | from mcp.server import Server 7 | 8 | from nacos_mcp_wrapper.server.nacos_server import NacosServer 9 | from nacos_mcp_wrapper.server.nacos_settings import NacosSettings 10 | 11 | 12 | async def fetch_website( 13 | url: str, 14 | ) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]: 15 | headers = { 16 | "User-Agent": "MCP Test Server (github.com/modelcontextprotocol/python-sdk)" 17 | } 18 | async with httpx.AsyncClient(follow_redirects=True, headers=headers) as client: 19 | response = await client.get(url) 20 | response.raise_for_status() 21 | return [types.TextContent(type="text", text=response.text)] 22 | 23 | 24 | @click.command() 25 | @click.option("--port", default=18002, help="Port to listen on for SSE") 26 | @click.option("--server_addr", default="127.0.0.1:8848", help="Nacos server address") 27 | @click.option( 28 | "--transport", 29 | type=click.Choice(["stdio", "sse"]), 30 | default="sse", 31 | help="Transport type", 32 | ) 33 | def main(port: int, transport: str, server_addr: str) -> int: 34 | nacos_settings = NacosSettings() 35 | nacos_settings.SERVER_ADDR = server_addr 36 | nacos_settings.USERNAME = "" 37 | nacos_settings.PASSWORD = "" 38 | # app = Server("mcp-website-fetcher") 39 | app = NacosServer("mcp-website-fetcher",nacos_settings=nacos_settings) 40 | 41 | @app.call_tool() 42 | async def fetch_tool( 43 | name: str, arguments: dict 44 | ) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]: 45 | if name != "fetch": 46 | raise ValueError(f"Unknown tool: {name}") 47 | if "url" not in arguments: 48 | raise ValueError("Missing required argument 'url'") 49 | return await fetch_website(arguments["url"]) 50 | 51 | @app.list_tools() 52 | async def list_tools() -> list[types.Tool]: 53 | return [ 54 | types.Tool( 55 | name="fetch", 56 | description="Fetches a website and returns its content", 57 | inputSchema={ 58 | "type": "object", 59 | "required": ["url"], 60 | "properties": { 61 | "url": { 62 | "type": "string", 63 | "description": "URL to fetch", 64 | } 65 | }, 66 | }, 67 | ) 68 | ] 69 | 70 | if transport == "sse": 71 | async def run_sse_sync(): 72 | from mcp.server.sse import SseServerTransport 73 | from starlette.applications import Starlette 74 | from starlette.routing import Mount, Route 75 | 76 | sse = SseServerTransport("/messages/") 77 | 78 | async def handle_sse(request): 79 | async with sse.connect_sse( 80 | request.scope, request.receive, request._send 81 | ) as streams: 82 | # 0 input stream, 1 output stream 83 | await app.run( 84 | streams[0], streams[1], app.create_initialization_options() 85 | ) 86 | 87 | starlette_app = Starlette( 88 | debug=True, 89 | routes=[ 90 | Route("/sse", endpoint=handle_sse), 91 | Mount("/messages/", app=sse.handle_post_message), 92 | ], 93 | ) 94 | 95 | import uvicorn 96 | 97 | await app.register_to_nacos("sse", port,"/sse") 98 | 99 | config = uvicorn.Config( 100 | starlette_app, 101 | host="0.0.0.0", 102 | port=port, 103 | ) 104 | server = uvicorn.Server(config) 105 | await server.serve() 106 | 107 | asyncio.run(run_sse_sync()) 108 | elif transport == "stdio": 109 | from mcp.server.stdio import stdio_server 110 | 111 | async def run_stdio_sync(): 112 | await app.register_to_nacos(transport="stdio") 113 | async with stdio_server() as streams: 114 | await app.run( 115 | streams[0], streams[1], app.create_initialization_options() 116 | ) 117 | 118 | anyio.run(run_stdio_sync) 119 | 120 | return 0 121 | 122 | 123 | if __name__ == "__main__": 124 | main() -------------------------------------------------------------------------------- /example/nacos_simple_example.py: -------------------------------------------------------------------------------- 1 | import click 2 | from nacos_mcp_wrapper.server.nacos_mcp import NacosMCP 3 | from nacos_mcp_wrapper.server.nacos_settings import NacosSettings 4 | from datetime import datetime 5 | 6 | 7 | @click.command() 8 | @click.option("--port", default=18003, help="Port to listen on for SSE") 9 | @click.option("--name", default="nacos-simple-mcp", help="The name of the MCP service") 10 | @click.option("--server_addr", default="127.0.0.1:8848", help="Nacos server address") 11 | def main(port: int, name: str, server_addr: str): 12 | # Registration settings for Nacos 13 | nacos_settings = NacosSettings() 14 | nacos_settings.SERVER_ADDR = server_addr 15 | nacos_settings.USERNAME = "" 16 | nacos_settings.PASSWORD = "" 17 | mcp = NacosMCP(name=name, nacos_settings=nacos_settings, port=port) 18 | 19 | @mcp.tool() 20 | def get_datetime() -> str: 21 | """Get current datetime as string""" 22 | return datetime.now().isoformat() # 返回字符串格式的时间 23 | 24 | try: 25 | mcp.run(transport="sse") 26 | except ValueError as e: 27 | print(f"Runtime errors: {e}") 28 | 29 | if __name__ == "__main__": 30 | main() -------------------------------------------------------------------------------- /example/simple-streamablehttp/event_store.py: -------------------------------------------------------------------------------- 1 | """ 2 | In-memory event store for demonstrating resumability functionality. 3 | 4 | This is a simple implementation intended for examples and testing, 5 | not for production use where a persistent storage solution would be more appropriate. 6 | """ 7 | 8 | import logging 9 | from collections import deque 10 | from dataclasses import dataclass 11 | from uuid import uuid4 12 | 13 | from mcp.server.streamable_http import ( 14 | EventCallback, 15 | EventId, 16 | EventMessage, 17 | EventStore, 18 | StreamId, 19 | ) 20 | from mcp.types import JSONRPCMessage 21 | 22 | logger = logging.getLogger(__name__) 23 | 24 | 25 | @dataclass 26 | class EventEntry: 27 | """ 28 | Represents an event entry in the event store. 29 | """ 30 | 31 | event_id: EventId 32 | stream_id: StreamId 33 | message: JSONRPCMessage 34 | 35 | 36 | class InMemoryEventStore(EventStore): 37 | """ 38 | Simple in-memory implementation of the EventStore interface for resumability. 39 | This is primarily intended for examples and testing, not for production use 40 | where a persistent storage solution would be more appropriate. 41 | 42 | This implementation keeps only the last N events per stream for memory efficiency. 43 | """ 44 | 45 | def __init__(self, max_events_per_stream: int = 100): 46 | """Initialize the event store. 47 | 48 | Args: 49 | max_events_per_stream: Maximum number of events to keep per stream 50 | """ 51 | self.max_events_per_stream = max_events_per_stream 52 | # for maintaining last N events per stream 53 | self.streams: dict[StreamId, deque[EventEntry]] = {} 54 | # event_id -> EventEntry for quick lookup 55 | self.event_index: dict[EventId, EventEntry] = {} 56 | 57 | async def store_event( 58 | self, stream_id: StreamId, message: JSONRPCMessage 59 | ) -> EventId: 60 | """Stores an event with a generated event ID.""" 61 | event_id = str(uuid4()) 62 | event_entry = EventEntry( 63 | event_id=event_id, stream_id=stream_id, message=message 64 | ) 65 | 66 | # Get or create deque for this stream 67 | if stream_id not in self.streams: 68 | self.streams[stream_id] = deque(maxlen=self.max_events_per_stream) 69 | 70 | # If deque is full, the oldest event will be automatically removed 71 | # We need to remove it from the event_index as well 72 | if len(self.streams[stream_id]) == self.max_events_per_stream: 73 | oldest_event = self.streams[stream_id][0] 74 | self.event_index.pop(oldest_event.event_id, None) 75 | 76 | # Add new event 77 | self.streams[stream_id].append(event_entry) 78 | self.event_index[event_id] = event_entry 79 | 80 | return event_id 81 | 82 | async def replay_events_after( 83 | self, 84 | last_event_id: EventId, 85 | send_callback: EventCallback, 86 | ) -> StreamId | None: 87 | """Replays events that occurred after the specified event ID.""" 88 | if last_event_id not in self.event_index: 89 | logger.warning(f"Event ID {last_event_id} not found in store") 90 | return None 91 | 92 | # Get the stream and find events after the last one 93 | last_event = self.event_index[last_event_id] 94 | stream_id = last_event.stream_id 95 | stream_events = self.streams.get(last_event.stream_id, deque()) 96 | 97 | # Events in deque are already in chronological order 98 | found_last = False 99 | for event in stream_events: 100 | if found_last: 101 | await send_callback(EventMessage(event.message, event.event_id)) 102 | elif event.event_id == last_event_id: 103 | found_last = True 104 | 105 | return stream_id 106 | -------------------------------------------------------------------------------- /example/simple-streamablehttp/server.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import contextlib 3 | import logging 4 | from collections.abc import AsyncIterator 5 | 6 | import anyio 7 | import mcp.types as types 8 | from mcp.server.streamable_http_manager import StreamableHTTPSessionManager 9 | from pydantic import AnyUrl 10 | from starlette.applications import Starlette 11 | from starlette.routing import Mount 12 | from starlette.types import Receive, Scope, Send 13 | 14 | from nacos_mcp_wrapper.server.nacos_server import NacosServer 15 | from nacos_mcp_wrapper.server.nacos_settings import NacosSettings 16 | from event_store import InMemoryEventStore 17 | 18 | # Configure logging 19 | logger = logging.getLogger(__name__) 20 | 21 | async def main( 22 | log_level: str="INFO", 23 | json_response: bool=True, 24 | port: int=7001, 25 | ) -> int: 26 | # Configure logging 27 | print("hahaha") 28 | logging.basicConfig( 29 | level=getattr(logging, log_level.upper()), 30 | format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", 31 | ) 32 | nacos_settings = NacosSettings() 33 | nacos_settings.SERVER_ADDR = "127.0.0.1:8848" # e.g. 127.0.0.1:8848 34 | nacos_settings.USERNAME = "" 35 | nacos_settings.PASSWORD = "" 36 | 37 | app = NacosServer("mcp-streamable-http-demo",nacos_settings=nacos_settings) 38 | 39 | 40 | @app.call_tool() 41 | async def call_tool( 42 | name: str, arguments: dict 43 | ) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]: 44 | ctx = app.request_context 45 | interval = arguments.get("interval", 1.0) 46 | count = arguments.get("count", 5) 47 | caller = arguments.get("caller", "unknown") 48 | 49 | # Send the specified number of notifications with the given interval 50 | for i in range(count): 51 | # Include more detailed message for resumability demonstration 52 | notification_msg = ( 53 | f"[{i+1}/{count}] Event from '{caller}' - " 54 | f"Use Last-Event-ID to resume if disconnected" 55 | ) 56 | await ctx.session.send_log_message( 57 | level="info", 58 | data=notification_msg, 59 | logger="notification_stream", 60 | # Associates this notification with the original request 61 | # Ensures notifications are sent to the correct response stream 62 | # Without this, notifications will either go to: 63 | # - a standalone SSE stream (if GET request is supported) 64 | # - nowhere (if GET request isn't supported) 65 | related_request_id=ctx.request_id, 66 | ) 67 | logger.debug(f"Sent notification {i+1}/{count} for caller: {caller}") 68 | if i < count - 1: # Don't wait after the last notification 69 | await anyio.sleep(interval) 70 | 71 | # This will send a resource notificaiton though standalone SSE 72 | # established by GET request 73 | await ctx.session.send_resource_updated(uri=AnyUrl("http:///test_resource")) 74 | return [ 75 | types.TextContent( 76 | type="text", 77 | text=( 78 | f"Sent {count} notifications with {interval}s interval" 79 | f" for caller: {caller}" 80 | ), 81 | ) 82 | ] 83 | 84 | @app.list_tools() 85 | async def list_tools() -> list[types.Tool]: 86 | return [ 87 | types.Tool( 88 | name="start-notification-stream", 89 | description=( 90 | "Sends a stream of notifications with configurable count" 91 | " and interval" 92 | ), 93 | inputSchema={ 94 | "type": "object", 95 | "required": ["interval", "count", "caller"], 96 | "properties": { 97 | "interval": { 98 | "type": "number", 99 | "description": "Interval between notifications in seconds", 100 | }, 101 | "count": { 102 | "type": "number", 103 | "description": "Number of notifications to send", 104 | }, 105 | "caller": { 106 | "type": "string", 107 | "description": ( 108 | "Identifier of the caller to include in notifications" 109 | ), 110 | }, 111 | }, 112 | }, 113 | ) 114 | ] 115 | 116 | # Create event store for resumability 117 | # The InMemoryEventStore enables resumability support for StreamableHTTP transport. 118 | # It stores SSE events with unique IDs, allowing clients to: 119 | # 1. Receive event IDs for each SSE message 120 | # 2. Resume streams by sending Last-Event-ID in GET requests 121 | # 3. Replay missed events after reconnection 122 | # Note: This in-memory implementation is for demonstration ONLY. 123 | # For production, use a persistent storage solution. 124 | event_store = InMemoryEventStore() 125 | 126 | # Create the session manager with our app and event store 127 | session_manager = StreamableHTTPSessionManager( 128 | app=app, 129 | event_store=event_store, # Enable resumability 130 | json_response=json_response, 131 | ) 132 | 133 | # ASGI handler for streamable HTTP connections 134 | async def handle_streamable_http( 135 | scope: Scope, receive: Receive, send: Send 136 | ) -> None: 137 | await session_manager.handle_request(scope, receive, send) 138 | 139 | @contextlib.asynccontextmanager 140 | async def lifespan(app: Starlette) -> AsyncIterator[None]: 141 | """Context manager for managing session manager lifecycle.""" 142 | async with session_manager.run(): 143 | logger.info("Application started with StreamableHTTP session manager!") 144 | try: 145 | yield 146 | finally: 147 | logger.info("Application shutting down...") 148 | 149 | # Create an ASGI application using the transport 150 | await app.register_to_nacos("streamable-http", port, "/mcp") 151 | starlette_app = Starlette( 152 | debug=True, 153 | routes=[ 154 | Mount("/mcp", app=handle_streamable_http), 155 | ], 156 | lifespan=lifespan, 157 | ) 158 | 159 | import uvicorn 160 | 161 | config = uvicorn.Config( 162 | starlette_app, 163 | host="127.0.0.1", 164 | port=port, 165 | ) 166 | server = uvicorn.Server(config) 167 | await server.serve() 168 | return 0 169 | 170 | 171 | asyncio.run(main()) 172 | -------------------------------------------------------------------------------- /nacos_mcp_wrapper/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nacos-group/nacos-mcp-wrapper-python/34442de68ff5ddb5ebd5010b1d9ddd82206750a3/nacos_mcp_wrapper/__init__.py -------------------------------------------------------------------------------- /nacos_mcp_wrapper/server/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nacos-group/nacos-mcp-wrapper-python/34442de68ff5ddb5ebd5010b1d9ddd82206750a3/nacos_mcp_wrapper/server/__init__.py -------------------------------------------------------------------------------- /nacos_mcp_wrapper/server/nacos_mcp.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Any 3 | 4 | import uvicorn 5 | from mcp import stdio_server 6 | from mcp.server import FastMCP 7 | from mcp.server.auth.provider import OAuthAuthorizationServerProvider 8 | from mcp.server.fastmcp.server import lifespan_wrapper 9 | from mcp.server.fastmcp.tools import Tool 10 | from mcp.server.lowlevel.server import lifespan as default_lifespan 11 | from mcp.server.streamable_http import EventStore 12 | 13 | from nacos_mcp_wrapper.server.nacos_server import NacosServer 14 | from nacos_mcp_wrapper.server.nacos_settings import NacosSettings 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | 19 | class NacosMCP(FastMCP): 20 | 21 | def __init__(self, 22 | name: str | None = None, 23 | nacos_settings: NacosSettings | None = None, 24 | instructions: str | None = None, 25 | auth_server_provider: OAuthAuthorizationServerProvider[ 26 | Any, Any, Any] 27 | | None = None, 28 | event_store: EventStore | None = None, 29 | *, 30 | tools: list[Tool] | None = None, 31 | **settings: Any, 32 | ): 33 | super().__init__(name, instructions, auth_server_provider, event_store, 34 | tools=tools, **settings) 35 | 36 | self._mcp_server = NacosServer( 37 | nacos_settings=nacos_settings, 38 | name=name or "FastMCP", 39 | instructions=instructions, 40 | lifespan=lifespan_wrapper(self, self.settings.lifespan) 41 | if self.settings.lifespan 42 | else default_lifespan, 43 | ) 44 | # Set up MCP protocol handlers 45 | self._setup_handlers() 46 | 47 | async def run_stdio_async(self) -> None: 48 | """Run the server using stdio transport.""" 49 | async with stdio_server() as (read_stream, write_stream): 50 | await self._mcp_server.register_to_nacos("stdio") 51 | await self._mcp_server.run( 52 | read_stream, 53 | write_stream, 54 | self._mcp_server.create_initialization_options(), 55 | ) 56 | 57 | async def run_sse_async(self, mount_path: str | None = None) -> None: 58 | """Run the server using SSE transport.""" 59 | starlette_app = self.sse_app(mount_path) 60 | await self._mcp_server.register_to_nacos("sse", self.settings.port, 61 | self.settings.sse_path) 62 | config = uvicorn.Config( 63 | starlette_app, 64 | host=self.settings.host, 65 | port=self.settings.port, 66 | log_level=self.settings.log_level.lower(), 67 | ) 68 | server = uvicorn.Server(config) 69 | await server.serve() 70 | 71 | async def run_streamable_http_async(self) -> None: 72 | """Run the server using StreamableHTTP transport.""" 73 | import uvicorn 74 | 75 | starlette_app = self.streamable_http_app() 76 | await self._mcp_server.register_to_nacos("streamable-http", 77 | self.settings.port, 78 | self.settings.streamable_http_path) 79 | config = uvicorn.Config( 80 | starlette_app, 81 | host=self.settings.host, 82 | port=self.settings.port, 83 | log_level=self.settings.log_level.lower(), 84 | ) 85 | server = uvicorn.Server(config) 86 | await server.serve() 87 | -------------------------------------------------------------------------------- /nacos_mcp_wrapper/server/nacos_server.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import logging 4 | from contextlib import AbstractAsyncContextManager 5 | from typing import Literal, Callable, Any 6 | 7 | import jsonref 8 | from maintainer.ai.model.nacos_mcp_info import McpToolMeta, McpServerDetailInfo, \ 9 | McpTool, McpServiceRef, McpToolSpecification, McpServerBasicInfo, \ 10 | McpEndpointSpec, McpServerRemoteServiceConfig 11 | from maintainer.ai.model.registry_mcp_info import ServerVersionDetail 12 | from maintainer.ai.nacos_mcp_service import NacosAIMaintainerService 13 | from maintainer.common.ai_maintainer_client_config_builder import \ 14 | AIMaintainerClientConfigBuilder 15 | from mcp import types, Tool 16 | from mcp.server import Server 17 | from mcp.server.lowlevel.server import LifespanResultT, RequestT 18 | from mcp.server.lowlevel.server import lifespan 19 | 20 | from v2.nacos import NacosNamingService, RegisterInstanceParam, \ 21 | ClientConfigBuilder, NacosException 22 | 23 | from nacos_mcp_wrapper.server.nacos_settings import NacosSettings 24 | from nacos_mcp_wrapper.server.utils import get_first_non_loopback_ip, \ 25 | jsonref_default, compare 26 | 27 | logger = logging.getLogger(__name__) 28 | 29 | TRANSPORT_MAP = { 30 | "stdio": "stdio", 31 | "sse": "mcp-sse", 32 | "streamable-http": "mcp-streamable", 33 | } 34 | 35 | class NacosServer(Server): 36 | def __init__( 37 | self, 38 | name: str, 39 | nacos_settings: NacosSettings | None = None, 40 | version: str | None = None, 41 | instructions: str | None = None, 42 | lifespan: Callable[ 43 | [Server[LifespanResultT, RequestT]], 44 | AbstractAsyncContextManager[LifespanResultT], 45 | ] = lifespan, 46 | ): 47 | if version is None: 48 | version = "1.0.0" 49 | super().__init__(name, version, instructions, lifespan) 50 | 51 | if nacos_settings == None: 52 | nacos_settings = NacosSettings() 53 | if nacos_settings.NAMESPACE == "": 54 | nacos_settings.NAMESPACE = "public" 55 | 56 | self._nacos_settings = nacos_settings 57 | if self._nacos_settings.SERVICE_IP is None: 58 | self._nacos_settings.SERVICE_IP = get_first_non_loopback_ip() 59 | 60 | 61 | 62 | ai_client_config_builder = AIMaintainerClientConfigBuilder() 63 | ai_client_config_builder.server_address( 64 | self._nacos_settings.SERVER_ADDR).access_key( 65 | self._nacos_settings.ACCESS_KEY).secret_key( 66 | self._nacos_settings.SECRET_KEY).username( 67 | self._nacos_settings.USERNAME).password( 68 | self._nacos_settings.PASSWORD).app_conn_labels( 69 | self._nacos_settings.APP_CONN_LABELS) 70 | 71 | if self._nacos_settings.CREDENTIAL_PROVIDER is not None: 72 | ai_client_config_builder.credentials_provider( 73 | self._nacos_settings.CREDENTIAL_PROVIDER) 74 | 75 | self._ai_client_config = ai_client_config_builder.build() 76 | 77 | naming_client_config_builder = ClientConfigBuilder() 78 | naming_client_config_builder.server_address( 79 | self._nacos_settings.SERVER_ADDR).namespace_id( 80 | self._nacos_settings.NAMESPACE).access_key( 81 | self._nacos_settings.ACCESS_KEY).secret_key( 82 | self._nacos_settings.SECRET_KEY).username( 83 | self._nacos_settings.USERNAME).password( 84 | self._nacos_settings.PASSWORD).app_conn_labels( 85 | self._nacos_settings.APP_CONN_LABELS) 86 | 87 | if self._nacos_settings.CREDENTIAL_PROVIDER is not None: 88 | naming_client_config_builder.credentials_provider( 89 | self._nacos_settings.CREDENTIAL_PROVIDER) 90 | 91 | self._naming_client_config = naming_client_config_builder.build() 92 | 93 | self._tmp_tools: dict[str, Tool] = {} 94 | self._tools_meta: dict[str, McpToolMeta] = {} 95 | self._tmp_tools_list_handler = None 96 | 97 | async def _list_tmp_tools(self) -> list[Tool]: 98 | """List all available tools.""" 99 | return [ 100 | Tool( 101 | name=info.name, 102 | description=info.description, 103 | inputSchema=info.inputSchema, 104 | ) 105 | for info in list(self._tmp_tools.values()) if self.is_tool_enabled( 106 | info.name) 107 | ] 108 | 109 | def is_tool_enabled(self, tool_name: str) -> bool: 110 | if self._tools_meta is None: 111 | return True 112 | if tool_name in self._tools_meta: 113 | mcp_tool_meta = self._tools_meta[tool_name] 114 | if mcp_tool_meta.enabled is not None: 115 | if not mcp_tool_meta.enabled: 116 | return False 117 | return True 118 | 119 | def update_tools(self,server_detail_info:McpServerDetailInfo): 120 | 121 | def update_args_description(_local_args:dict[str, Any], _nacos_args:dict[str, Any]): 122 | for key, value in _local_args.items(): 123 | if key in _nacos_args and "description" in _nacos_args[key]: 124 | _local_args[key]["description"] = _nacos_args[key][ 125 | "description"] 126 | 127 | tool_spec = server_detail_info.toolSpec 128 | if tool_spec is None: 129 | return 130 | if tool_spec.toolsMeta is None: 131 | self._tools_meta = {} 132 | else: 133 | self._tools_meta = tool_spec.toolsMeta 134 | if tool_spec.tools is None: 135 | return 136 | for tool in tool_spec.tools: 137 | if tool.name in self._tmp_tools: 138 | local_tool = self._tmp_tools[tool.name] 139 | if tool.description is not None: 140 | local_tool.description = tool.description 141 | 142 | local_args = local_tool.inputSchema["properties"] 143 | nacos_args = tool.inputSchema["properties"] 144 | update_args_description(local_args, nacos_args) 145 | continue 146 | 147 | 148 | async def init_tools_tmp(self): 149 | _tmp_tools = await self.request_handlers[ 150 | types.ListToolsRequest]( 151 | self) 152 | for _tmp_tool in _tmp_tools.root.tools: 153 | self._tmp_tools[_tmp_tool.name] = _tmp_tool 154 | self._tmp_tools_list_handler = self.request_handlers[ 155 | types.ListToolsRequest] 156 | 157 | for tool in self._tmp_tools.values(): 158 | resolved_data = jsonref.JsonRef.replace_refs(tool.inputSchema) 159 | resolved_data = json.dumps(resolved_data, default=jsonref_default) 160 | resolved_data = json.loads(resolved_data) 161 | tool.inputSchema = resolved_data 162 | 163 | def check_tools_compatible(self,server_detail_info:McpServerDetailInfo) -> bool: 164 | if (server_detail_info.toolSpec is None 165 | or server_detail_info.toolSpec.tools is None or len(server_detail_info.toolSpec.tools) == 0): 166 | return True 167 | tools_spec = server_detail_info.toolSpec 168 | tools_in_nacos = {} 169 | for tool in tools_spec.tools: 170 | tools_in_nacos[tool.name] = tool 171 | 172 | tools_in_local = {} 173 | for name,tool in self._tmp_tools.items(): 174 | tools_in_local[name] = McpTool( 175 | name=tool.name, 176 | description=tool.description, 177 | inputSchema=tool.inputSchema, 178 | ) 179 | if tools_in_nacos.keys() != tools_in_local.keys(): 180 | return False 181 | 182 | for name,tool in tools_in_nacos.items(): 183 | str_tools_in_nacos = tool.model_dump_json(exclude_none=True) 184 | str_tools_in_local = tools_in_local[name].model_dump_json(exclude_none=True) 185 | if not compare(str_tools_in_nacos, str_tools_in_local): 186 | return False 187 | 188 | return True 189 | 190 | 191 | def check_compatible(self,server_detail_info:McpServerDetailInfo) -> bool: 192 | if server_detail_info.version != self.version: 193 | return False 194 | if server_detail_info.protocol != self.type: 195 | return False 196 | if types.ListToolsRequest in self.request_handlers: 197 | checkToolsResult = self.check_tools_compatible(server_detail_info) 198 | if not checkToolsResult: 199 | return False 200 | mcp_service_ref = server_detail_info.remoteServerConfig.serviceRef 201 | if not self.is_service_ref_same(mcp_service_ref): 202 | return False 203 | 204 | return True 205 | 206 | def is_service_ref_same(self,mcp_service_ref:McpServiceRef) -> bool: 207 | if self.get_register_service_name() != mcp_service_ref.serviceName: 208 | return False 209 | if mcp_service_ref.groupName != self._nacos_settings.SERVICE_GROUP: 210 | return False 211 | if mcp_service_ref.namespaceId != self._nacos_settings.NAMESPACE: 212 | return False 213 | return True 214 | 215 | 216 | def get_register_service_name(self) -> str: 217 | if self._nacos_settings.SERVICE_NAME is not None: 218 | return self._nacos_settings.SERVICE_NAME 219 | else: 220 | return self.name + "::" + self.version 221 | 222 | async def subscribe(self): 223 | while True: 224 | try: 225 | await asyncio.sleep(30) 226 | except asyncio.TimeoutError: 227 | logging.debug("Timeout occurred") 228 | except asyncio.CancelledError: 229 | return 230 | 231 | try: 232 | server_detail_info = await self.mcp_service.get_mcp_server_detail( 233 | self._nacos_settings.NAMESPACE, 234 | self.name, 235 | self.version 236 | ) 237 | print(server_detail_info.model_dump_json()) 238 | if server_detail_info is not None: 239 | self.update_tools(server_detail_info) 240 | except Exception as e: 241 | logging.info( 242 | f"can not found McpServer info from nacos,{self.name},version:{self.version}") 243 | 244 | async def register_to_nacos(self, 245 | transport: Literal["stdio", "sse","streamable-http"] = "stdio", 246 | port: int = 8000, 247 | path: str = "/sse"): 248 | try: 249 | self.type = TRANSPORT_MAP.get(transport, None) 250 | self.mcp_service = await NacosAIMaintainerService.create_mcp_service( 251 | self._ai_client_config 252 | ) 253 | server_detail_info = None 254 | try: 255 | server_detail_info = await self.mcp_service.get_mcp_server_detail( 256 | self._nacos_settings.NAMESPACE, 257 | self.name, 258 | self.version 259 | ) 260 | except Exception as e: 261 | logging.info(f"can not found McpServer info from nacos,{self.name},version:{self.version}") 262 | 263 | if types.ListToolsRequest in self.request_handlers: 264 | await self.init_tools_tmp() 265 | self.list_tools()(self._list_tmp_tools) 266 | 267 | if server_detail_info is not None: 268 | if not self.check_compatible(server_detail_info): 269 | logging.error(f"mcp server info is not compatible,{self.name},version:{self.version}") 270 | raise NacosException( 271 | f"mcp server info is not compatible,{self.name},version:{self.version}" 272 | ) 273 | if types.ListToolsRequest in self.request_handlers: 274 | self.update_tools(server_detail_info) 275 | asyncio.create_task(self.subscribe()) 276 | return 277 | 278 | mcp_tool_specification = None 279 | if types.ListToolsRequest in self.request_handlers: 280 | tool_spec = [ 281 | McpTool( 282 | name=tool.name, 283 | description=tool.description, 284 | inputSchema=tool.inputSchema, 285 | ) 286 | for tool in list(self._tmp_tools.values()) 287 | ] 288 | mcp_tool_specification = McpToolSpecification( 289 | tools=tool_spec 290 | ) 291 | 292 | server_version_detail = ServerVersionDetail() 293 | server_version_detail.version = self.version 294 | server_basic_info = McpServerBasicInfo() 295 | server_basic_info.name = self.name 296 | server_basic_info.versionDetail = server_version_detail 297 | server_basic_info.description = self.instructions or self.name 298 | 299 | endpoint_spec = McpEndpointSpec() 300 | if self.type == "stdio": 301 | server_basic_info.protocol = self.type 302 | server_basic_info.frontProtocol = self.type 303 | else: 304 | endpoint_spec.type = "REF" 305 | data = { 306 | "serviceName": self.get_register_service_name(), 307 | "groupName": self._nacos_settings.SERVICE_GROUP, 308 | "namespaceId": self._nacos_settings.NAMESPACE, 309 | } 310 | endpoint_spec.data = data 311 | 312 | remote_server_config_info = McpServerRemoteServiceConfig() 313 | remote_server_config_info.exportPath = path 314 | server_basic_info.remoteServerConfig = remote_server_config_info 315 | server_basic_info.protocol = self.type 316 | server_basic_info.frontProtocol = self.type 317 | if self._nacos_settings.SERVICE_REGISTER: 318 | naming_client = await NacosNamingService.create_naming_service( 319 | self._naming_client_config) 320 | 321 | await naming_client.register_instance( 322 | request=RegisterInstanceParam( 323 | group_name=self._nacos_settings.SERVICE_GROUP, 324 | service_name=self.get_register_service_name(), 325 | ip=self._nacos_settings.SERVICE_IP, 326 | port=port, 327 | ephemeral=self._nacos_settings.SERVICE_EPHEMERAL, 328 | ) 329 | ) 330 | 331 | await self.mcp_service.create_mcp_server(self._nacos_settings.NAMESPACE, 332 | self.name, 333 | server_basic_info, 334 | mcp_tool_specification, 335 | endpoint_spec) 336 | except Exception as e: 337 | logging.error(f"Failed to register MCP server to Nacos: {e}") 338 | -------------------------------------------------------------------------------- /nacos_mcp_wrapper/server/nacos_settings.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from nacos.auth import CredentialsProvider 4 | from pydantic import Field 5 | from pydantic_settings import BaseSettings 6 | 7 | 8 | class NacosSettings(BaseSettings): 9 | 10 | SERVER_ADDR : str = Field( 11 | description="nacos server address", 12 | default="127.0.0.1:8848") 13 | 14 | SERVICE_REGISTER : bool = Field( 15 | description="whether to register service to nacos", 16 | default=True) 17 | 18 | SERVICE_EPHEMERAL : bool = Field( 19 | description="whether to register service as ephemeral", 20 | default=True) 21 | 22 | NAMESPACE : str = Field( 23 | description="nacos namespace", 24 | default="public") 25 | 26 | SERVICE_GROUP : str = Field( 27 | description="nacos service group", 28 | default="DEFAULT_GROUP") 29 | 30 | SERVICE_NAME : Optional[str] = Field( 31 | description="nacos service name", 32 | default=None) 33 | 34 | SERVICE_IP : Optional[str] = Field( 35 | description="nacos service ip", 36 | default=None) 37 | 38 | USERNAME : Optional[str] = Field( 39 | description="nacos username for authentication", 40 | default=None) 41 | 42 | PASSWORD : Optional[str] = Field( 43 | description="nacos password for authentication", 44 | default=None) 45 | 46 | ACCESS_KEY : Optional[str] = Field( 47 | description="nacos access key for aliyun ram authentication", 48 | default=None) 49 | 50 | SECRET_KEY : Optional[str] = Field( 51 | description="nacos secret key for aliyun ram authentication", 52 | default=None) 53 | 54 | CREDENTIAL_PROVIDER : Optional[CredentialsProvider] = Field( 55 | description="nacos credential provider for aliyun authentication", 56 | default=None) 57 | 58 | APP_CONN_LABELS : Optional[dict] = Field( 59 | description="nacos connection labels", 60 | default={}) 61 | 62 | class Config: 63 | env_prefix = "NACOS_MCP_SERVER_" 64 | 65 | -------------------------------------------------------------------------------- /nacos_mcp_wrapper/server/utils.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import socket 3 | import threading 4 | from enum import Enum 5 | import json 6 | 7 | import jsonref 8 | import psutil 9 | 10 | def get_first_non_loopback_ip(): 11 | for interface, addrs in psutil.net_if_addrs().items(): 12 | for addr in addrs: 13 | if addr.family == socket.AF_INET and not addr.address.startswith( 14 | '127.'): 15 | return addr.address 16 | return None 17 | 18 | def jsonref_default(obj): 19 | if isinstance(obj, jsonref.JsonRef): 20 | return obj.__subject__ 21 | raise TypeError( 22 | f"Object of type {obj.__class__.__name__} is not JSON serializable") 23 | 24 | class ConfigSuffix(Enum): 25 | TOOLS = "-mcp-tools.json" 26 | PROMPTS = "-mcp-prompt.json" 27 | RESOURCES = "-mcp-resource.json" 28 | MCP_SERVER = "-mcp-server.json" 29 | 30 | def compare(origin: str, target: str) -> bool: 31 | try: 32 | origin_node = json.loads(origin) 33 | target_node = json.loads(target) 34 | return compare_nodes(origin_node, target_node) 35 | except Exception as e: 36 | print(e) 37 | return False 38 | 39 | 40 | def compare_nodes(origin_node, target_node) -> bool: 41 | if origin_node is None and target_node is None: 42 | return True 43 | if origin_node is None or target_node is None: 44 | return False 45 | 46 | origin_properties = origin_node.get("properties") 47 | target_properties = target_node.get("properties") 48 | 49 | if (origin_properties is None and target_properties is not None) or ( 50 | origin_properties is not None and target_properties is None 51 | ): 52 | return False 53 | 54 | if origin_properties is not None and target_properties is not None: 55 | # 遍历原始 properties 56 | for key, value_node in origin_properties.items(): 57 | if not isinstance(value_node, dict): 58 | continue # 只处理 object 类型 59 | 60 | type_node = value_node.get("type") 61 | if not isinstance(type_node, str): 62 | continue 63 | 64 | type_ = type_node 65 | 66 | if key not in target_properties: 67 | return False 68 | 69 | target_value_node = target_properties[key] 70 | target_type_node = target_value_node.get("type") 71 | target_type = target_type_node if isinstance(target_type_node, str) else "" 72 | 73 | if type_ != target_type: 74 | return False 75 | 76 | # 如果是 object 类型,递归比较 77 | if type_ == "object": 78 | if not compare_nodes(value_node, target_value_node): 79 | return False 80 | # 如果是 array 类型,比较 items 内容 81 | elif type_ == "array": 82 | origin_items = value_node.get("items") 83 | target_items = target_value_node.get("items") 84 | if origin_items is not None and target_items is not None: 85 | if not compare_nodes(origin_items, target_items): 86 | return False 87 | 88 | # 检查新增字段 89 | for key in target_properties: 90 | if key not in origin_properties: 91 | return False 92 | 93 | origin_required = origin_node.get("required") 94 | target_required = target_node.get("required") 95 | 96 | if origin_required is not None and target_required is not None: 97 | if not isinstance(origin_required, list) or not isinstance(target_required, list): 98 | return False 99 | if len(origin_required) != len(target_required): 100 | return False 101 | 102 | origin_set = set() 103 | for node in origin_required: 104 | if isinstance(node, str): 105 | origin_set.add(node) 106 | else: 107 | return False 108 | 109 | target_set = set() 110 | for node in target_required: 111 | if isinstance(node, str): 112 | target_set.add(node) 113 | else: 114 | return False 115 | 116 | return origin_set == target_set 117 | else: 118 | return origin_required is None and target_required is None -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | psutil==7.0.0 2 | anyio==4.9.0 3 | mcp==1.9.2 4 | nacos-sdk-python==2.0.4 5 | pydantic==2.11.3 6 | pydantic-settings==2.9.1 7 | jsonref==1.1.0 8 | uvicorn==0.34.2 9 | nacos-maintainer-sdk-python==0.1.0 -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from setuptools import setup, find_packages 4 | 5 | here = os.path.abspath(os.path.dirname(__file__)) 6 | def read_requirements(): 7 | with open(os.path.join(here, 'requirements.txt'), encoding='utf-8') as f: 8 | return f.read().splitlines() 9 | 10 | setup( 11 | name='nacos-mcp-wrapper-python', 12 | version='1.0.0', # 项目的版本号 13 | packages=find_packages( 14 | exclude=["test", "*.tests", "*.tests.*", "tests.*", "tests"]), # 自动发现所有包 15 | url="https://github.com/nacos-group/nacos-mcp-wrapper-python", 16 | license="Apache License 2.0", 17 | install_requires=read_requirements(), 18 | author='nacos', 19 | description='Python sdk support mcp server auto register to nacos', # 项目的简短描述 20 | long_description=open('README.md').read(), # 项目的详细描述 21 | long_description_content_type='text/markdown', # 描述的格式 22 | classifiers=[ 23 | "Topic :: Software Development :: Libraries :: Python Modules", 24 | "Operating System :: OS Independent", 25 | "Programming Language :: Python", 26 | "Programming Language :: Python :: 3.10" 27 | ], 28 | python_requires='>=3.10', 29 | ) --------------------------------------------------------------------------------