├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── a2a_mcp_server.py ├── common ├── __init__.py ├── client │ ├── __init__.py │ ├── card_resolver.py │ └── client.py ├── server │ ├── __init__.py │ ├── server.py │ ├── task_manager.py │ └── utils.py ├── types.py └── utils │ ├── in_memory_cache.py │ └── push_notification_auth.py ├── config_creator.py ├── persistence_utils.py ├── public ├── agent.png ├── register.png └── task.png ├── pyproject.toml ├── registered_agents.json ├── requirements.txt ├── smithery.yaml └── task_agent_mapping.json /.gitignore: -------------------------------------------------------------------------------- 1 | # Python-generated files 2 | __pycache__/ 3 | *.py[oc] 4 | build/ 5 | dist/ 6 | wheels/ 7 | *.egg-info 8 | 9 | # Virtual environments 10 | .venv 11 | 12 | .DS_Store 13 | 14 | .env 15 | 16 | **.cache -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Generated by https://smithery.ai. See: https://smithery.ai/docs/build/project-config 2 | FROM python:3.11-slim 3 | WORKDIR /app 4 | COPY requirements.txt . 5 | RUN pip install --no-cache-dir -r requirements.txt 6 | COPY . . 7 | EXPOSE 8000 8 | ENTRYPOINT ["python", "a2a_mcp_server.py"] 9 | -------------------------------------------------------------------------------- /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 2025 GongRzhe 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 | # A2A MCP Server 2 | 3 | [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) 4 | ![](https://badge.mcpx.dev?type=server 'MCP Server') 5 | [![smithery badge](https://smithery.ai/badge/@GongRzhe/A2A-MCP-Server)](https://smithery.ai/server/@GongRzhe/A2A-MCP-Server) 6 | 7 | A mcp server that bridges the Model Context Protocol (MCP) with the Agent-to-Agent (A2A) protocol, enabling MCP-compatible AI assistants (like Claude) to seamlessly interact with A2A agents. 8 | 9 | ## Overview 10 | 11 | This project serves as an integration layer between two cutting-edge AI agent protocols: 12 | 13 | - **Model Context Protocol (MCP)**: Developed by Anthropic, MCP allows AI assistants to connect to external tools and data sources. It standardizes how AI applications and large language models connect to external resources in a secure, composable way. 14 | 15 | - **Agent-to-Agent Protocol (A2A)**: Developed by Google, A2A enables communication and interoperability between different AI agents through a standardized JSON-RPC interface. 16 | 17 | By bridging these protocols, this server allows MCP clients (like Claude) to discover, register, communicate with, and manage tasks on A2A agents through a unified interface. 18 | 19 | ### Demo 20 | 21 | #### 1, Run The Currency Agent in A2A Sample 22 | 23 | ![agent](public/agent.png) 24 | 25 | `also support cloud deployed Agent` 26 | 27 | ![cloudAgent](https://github.com/user-attachments/assets/481cbf01-95a0-4b0a-9ac5-898aef66a944) 28 | 29 | 30 | #### 2, Use Claude to Register the Currency Agent 31 | 32 | ![register](public/register.png) 33 | 34 | #### 3, Use Claude to Send a task to the Currency Agent and get the result 35 | 36 | ![task](public/task.png) 37 | 38 | ## Features 39 | 40 | - **Agent Management** 41 | - Register A2A agents with the bridge server 42 | - List all registered agents 43 | - Unregister agents when no longer needed 44 | 45 | - **Communication** 46 | - Send messages to A2A agents and receive responses 47 | - Stream responses from A2A agents in real-time 48 | 49 | - **Task Management** 50 | - Track which A2A agent handles which task 51 | - Retrieve task results using task IDs 52 | - Cancel running tasks 53 | 54 | - **Transport Support** 55 | - Multiple transport types: stdio, streamable-http, SSE 56 | - Configure transport type using MCP_TRANSPORT environment variable 57 | 58 | ## Installation 59 | 60 | ### Installing via Smithery 61 | 62 | To install A2A Bridge Server for Claude Desktop automatically via [Smithery](https://smithery.ai/server/@GongRzhe/A2A-MCP-Server): 63 | 64 | ```bash 65 | npx -y @smithery/cli install @GongRzhe/A2A-MCP-Server --client claude 66 | ``` 67 | 68 | ### Option 1: Install from PyPI 69 | 70 | ```bash 71 | pip install a2a-mcp-server 72 | ``` 73 | 74 | ### Option 2: Local Installation 75 | 76 | 1. Clone the repository: 77 | ```bash 78 | git clone https://github.com/GongRzhe/A2A-MCP-Server.git 79 | cd A2A-MCP-Server 80 | ``` 81 | 82 | 2. Set up a virtual environment: 83 | ```bash 84 | python -m venv .venv 85 | source .venv/bin/activate # On Windows: .venv\Scripts\activate 86 | ``` 87 | 88 | 3. Install dependencies: 89 | ```bash 90 | pip install -r requirements.txt 91 | ``` 92 | 93 | ## Configuration 94 | 95 | ### Environment Variables 96 | 97 | Configure how the MCP server runs using these environment variables: 98 | 99 | ```bash 100 | # Transport type: stdio, streamable-http, or sse 101 | export MCP_TRANSPORT="streamable-http" 102 | 103 | # Host for the MCP server 104 | export MCP_HOST="0.0.0.0" 105 | 106 | # Port for the MCP server (when using HTTP transports) 107 | export MCP_PORT="8000" 108 | 109 | # Path for the MCP server endpoint (when using HTTP transports) 110 | export MCP_PATH="/mcp" 111 | 112 | # Path for SSE endpoint (when using SSE transport) 113 | export MCP_SSE_PATH="/sse" 114 | 115 | # Enable debug logging 116 | export MCP_DEBUG="true" 117 | ``` 118 | 119 | ### Transport Types 120 | 121 | The A2A MCP Server supports multiple transport types: 122 | 123 | 1. **stdio** (default): Uses standard input/output for communication 124 | - Ideal for command-line usage and testing 125 | - No HTTP server is started 126 | - Required for Claude Desktop 127 | 128 | 2. **streamable-http** (recommended for web clients): HTTP transport with streaming support 129 | - Recommended for production deployments 130 | - Starts an HTTP server to handle MCP requests 131 | - Enables streaming of large responses 132 | 133 | 3. **sse**: Server-Sent Events transport 134 | - Provides real-time event streaming 135 | - Useful for real-time updates 136 | 137 | To specify the transport type: 138 | 139 | ```bash 140 | # Using environment variable 141 | export MCP_TRANSPORT="streamable-http" 142 | uvx a2a-mcp-server 143 | 144 | # Or directly in the command 145 | MCP_TRANSPORT=streamable-http uvx a2a-mcp-server 146 | ``` 147 | 148 | ## Running the Server 149 | 150 | ### From Command Line 151 | 152 | ```bash 153 | # Using default settings (stdio transport) 154 | uvx a2a-mcp-server 155 | 156 | # Using HTTP transport on specific host and port 157 | MCP_TRANSPORT=streamable-http MCP_HOST=127.0.0.1 MCP_PORT=8080 uvx a2a-mcp-server 158 | ``` 159 | 160 | ## Configuring in Claude Desktop 161 | 162 | Claude Desktop allows you to configure MCP servers in the `claude_desktop_config.json` file. This file is typically located at: 163 | 164 | - **Windows**: `%APPDATA%\Claude\claude_desktop_config.json` 165 | - **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` 166 | - **Linux**: `~/.config/Claude/claude_desktop_config.json` 167 | 168 | ### Method 1: PyPI Installation (Recommended) 169 | 170 | Add the following to the `mcpServers` section of your `claude_desktop_config.json`: 171 | 172 | ```json 173 | "a2a": { 174 | "command": "uvx", 175 | "args": [ 176 | "a2a-mcp-server" 177 | ] 178 | } 179 | ``` 180 | 181 | Note that for Claude Desktop, you must use `"MCP_TRANSPORT": "stdio"` since Claude requires stdio communication with MCP servers. 182 | 183 | ### Method 2: Local Installation 184 | 185 | If you've cloned the repository and want to run the server from your local installation: 186 | 187 | ```json 188 | "a2a": { 189 | "command": "C:\\path\\to\\python.exe", 190 | "args": [ 191 | "C:\\path\\to\\A2A-MCP-Server\\a2a_mcp_server.py" 192 | ], 193 | "env": { 194 | "MCP_TRANSPORT": "stdio", 195 | "PYTHONPATH": "C:\\path\\to\\A2A-MCP-Server" 196 | } 197 | } 198 | ``` 199 | 200 | Replace `C:\\path\\to\\` with the actual paths on your system. 201 | 202 | ### Using the Config Creator 203 | 204 | This repository includes a `config_creator.py` script to help you generate the configuration: 205 | 206 | ```bash 207 | # If using local installation 208 | python config_creator.py 209 | ``` 210 | 211 | The script will: 212 | - Automatically detect Python, script, and repository paths when possible 213 | - Configure stdio transport which is required for Claude Desktop 214 | - Let you add any additional environment variables if needed 215 | - Create or update your Claude Desktop configuration file 216 | 217 | ### Complete Example 218 | 219 | Here's an example of a complete `claude_desktop_config.json` file with the A2A-MCP-Server configured: 220 | 221 | ```json 222 | { 223 | "mcpServers": { 224 | "a2a": { 225 | "command": "uvx", 226 | "args": [ 227 | "a2a-mcp-server" 228 | ] 229 | } 230 | } 231 | } 232 | ``` 233 | 234 | ## Using with MCP Clients 235 | 236 | ### Claude 237 | 238 | Claude can use A2A agents through the MCP tools provided by this server. Here's how to set it up: 239 | 240 | 1. For Claude Web: Start the MCP server with the streamable-http transport: 241 | ```bash 242 | MCP_TRANSPORT=streamable-http MCP_HOST=127.0.0.1 MCP_PORT=8000 uvx a2a-mcp-server 243 | ``` 244 | 245 | 2. For Claude Web: In Claude web interface, enable the MCP URL connection in your Tools menu. 246 | - Use the URL: `http://127.0.0.1:8000/mcp` 247 | 248 | 3. For Claude Desktop: Add the configuration to your `claude_desktop_config.json` file as described above. The easiest way is to use the provided `config_creator.py` script which will automatically detect paths and create the proper configuration. 249 | 250 | 4. In Claude, you can now use the following functions: 251 | 252 | **Register an A2A agent:** 253 | ``` 254 | I need to register a new agent. Can you help me with that? 255 | (Agent URL: http://localhost:41242) 256 | ``` 257 | 258 | **Send message to an agent:** 259 | ``` 260 | Ask the agent at http://localhost:41242 what it can do. 261 | ``` 262 | 263 | **Retrieve task results:** 264 | ``` 265 | Can you get the results for task ID: 550e8400-e29b-41d4-a716-446655440000? 266 | ``` 267 | 268 | ### Cursor IDE 269 | 270 | Cursor IDE can connect to MCP servers to add tools to its AI assistant: 271 | 272 | 1. Run your A2A MCP server with the streamable-http transport: 273 | ```bash 274 | MCP_TRANSPORT=streamable-http MCP_HOST=127.0.0.1 MCP_PORT=8000 uvx a2a-mcp-server 275 | ``` 276 | 277 | 2. In Cursor IDE, go to Settings > AI > MCP Servers 278 | - Add a new MCP Server with URL: `http://127.0.0.1:8000/mcp` 279 | - Enable the server 280 | 281 | 3. Now you can use the A2A tools from within Cursor's AI assistant. 282 | 283 | ### Windsurf Browser 284 | 285 | Windsurf is a browser with built-in MCP support: 286 | 287 | 1. Run your A2A MCP server with the streamable-http transport: 288 | ```bash 289 | MCP_TRANSPORT=streamable-http MCP_HOST=127.0.0.1 MCP_PORT=8000 uvx a2a-mcp-server 290 | ``` 291 | 292 | 2. In Windsurf browser, go to Settings > MCP Connections 293 | - Add a new MCP connection with URL: `http://127.0.0.1:8000/mcp` 294 | - Enable the connection 295 | 296 | 3. You can now use A2A tools from within Windsurf's AI assistant. 297 | 298 | ## Available MCP Tools 299 | 300 | The server exposes the following MCP tools for integration with LLMs like Claude: 301 | 302 | ### Agent Management 303 | 304 | - **register_agent**: Register an A2A agent with the bridge server 305 | ```json 306 | { 307 | "name": "register_agent", 308 | "arguments": { 309 | "url": "http://localhost:41242" 310 | } 311 | } 312 | ``` 313 | 314 | - **list_agents**: Get a list of all registered agents 315 | ```json 316 | { 317 | "name": "list_agents", 318 | "arguments": {} 319 | } 320 | ``` 321 | 322 | - **unregister_agent**: Remove an A2A agent from the bridge server 323 | ```json 324 | { 325 | "name": "unregister_agent", 326 | "arguments": { 327 | "url": "http://localhost:41242" 328 | } 329 | } 330 | ``` 331 | 332 | ### Message Processing 333 | 334 | - **send_message**: Send a message to an agent and get a task_id for the response 335 | ```json 336 | { 337 | "name": "send_message", 338 | "arguments": { 339 | "agent_url": "http://localhost:41242", 340 | "message": "What's the exchange rate from USD to EUR?", 341 | "session_id": "optional-session-id" 342 | } 343 | } 344 | ``` 345 | 346 | - **send_message_stream**: Send a message and stream the response 347 | ```json 348 | { 349 | "name": "send_message_stream", 350 | "arguments": { 351 | "agent_url": "http://localhost:41242", 352 | "message": "Tell me a story about AI agents.", 353 | "session_id": "optional-session-id" 354 | } 355 | } 356 | ``` 357 | 358 | ### Task Management 359 | 360 | - **get_task_result**: Retrieve a task's result using its ID 361 | ```json 362 | { 363 | "name": "get_task_result", 364 | "arguments": { 365 | "task_id": "b30f3297-e7ab-4dd9-8ff1-877bd7cfb6b1", 366 | "history_length": null 367 | } 368 | } 369 | ``` 370 | 371 | - **cancel_task**: Cancel a running task 372 | ```json 373 | { 374 | "name": "cancel_task", 375 | "arguments": { 376 | "task_id": "b30f3297-e7ab-4dd9-8ff1-877bd7cfb6b1" 377 | } 378 | } 379 | ``` 380 | 381 | ## Usage Examples 382 | 383 | ### Basic Workflow 384 | 385 | ``` 386 | 1. Client registers an A2A agent 387 | ↓ 388 | 2. Client sends a message to the agent (gets task_id) 389 | ↓ 390 | 3. Client retrieves the task result using task_id 391 | ``` 392 | 393 | ### Example with Claude as the MCP Client 394 | 395 | ``` 396 | User: Register an agent at http://localhost:41242 397 | 398 | Claude uses: register_agent(url="http://localhost:41242") 399 | Claude: Successfully registered agent: ReimbursementAgent 400 | 401 | User: Ask the agent what it can do 402 | 403 | Claude uses: send_message(agent_url="http://localhost:41242", message="What can you do?") 404 | Claude: I've sent your message. Here's the task_id: b30f3297-e7ab-4dd9-8ff1-877bd7cfb6b1 405 | 406 | User: Get the answer to my question 407 | 408 | Claude uses: get_task_result(task_id="b30f3297-e7ab-4dd9-8ff1-877bd7cfb6b1") 409 | Claude: The agent replied: "I can help you process reimbursement requests. Just tell me what you need to be reimbursed for, including the date, amount, and purpose." 410 | ``` 411 | 412 | ## Architecture 413 | 414 | The A2A MCP server consists of several key components: 415 | 416 | 1. **FastMCP Server**: Exposes tools to MCP clients 417 | 2. **A2A Client**: Communicates with registered A2A agents 418 | 3. **Task Manager**: Handles task forwarding and management 419 | 4. **Agent Card Fetcher**: Retrieves information about A2A agents 420 | 421 | ### Communication Flow 422 | 423 | ``` 424 | MCP Client → FastMCP Server → A2A Client → A2A Agent 425 | ↑ ↓ 426 | └──── Response ──┘ 427 | ``` 428 | 429 | ## Task ID Management 430 | 431 | When sending a message to an A2A agent, the server: 432 | 433 | 1. Generates a unique `task_id` 434 | 2. Maps this ID to the agent's URL in the `task_agent_mapping` dictionary 435 | 3. Returns the `task_id` to the MCP client 436 | 4. Uses this mapping to route task retrieval and cancellation requests 437 | 438 | ## Error Handling 439 | 440 | The server provides detailed error messages for common issues: 441 | 442 | - Agent not registered 443 | - Task ID not found 444 | - Connection errors to agents 445 | - Parsing errors in responses 446 | 447 | ## Troubleshooting 448 | 449 | ### Agent Registration Issues 450 | 451 | If an agent can't be registered: 452 | - Verify the agent URL is correct and accessible 453 | - Check if the agent has a proper agent card at `/.well-known/agent.json` 454 | 455 | ### Message Delivery Problems 456 | 457 | If messages aren't being delivered: 458 | - Ensure the agent is registered (use `list_agents`) 459 | - Verify the agent is running and accessible 460 | 461 | ### Task Result Retrieval Issues 462 | 463 | If you can't retrieve a task result: 464 | - Make sure you're using the correct task_id 465 | - Check if too much time has passed (some agents might discard old tasks) 466 | 467 | ### Transport Issues 468 | 469 | If you have issues with a specific transport type: 470 | - **stdio issues**: Ensure input/output streams are not redirected or modified 471 | - **streamable-http issues**: Check if the port is available and not blocked by a firewall 472 | - **sse issues**: Verify the client supports Server-Sent Events 473 | 474 | ### Claude Desktop Configuration Issues 475 | 476 | If Claude Desktop isn't starting your A2A-MCP-Server: 477 | - Check that the paths in your `claude_desktop_config.json` are correct 478 | - Verify that Python is in your PATH if using `"command": "python"` 479 | - For local installation, ensure the PYTHONPATH is correct 480 | - Make sure `MCP_TRANSPORT` is set to `"stdio"` in the `env` section 481 | - Try running the command manually to see if it works outside of Claude 482 | - Use the `config_creator.py` script for automatic path detection and configuration 483 | 484 | ## Development 485 | 486 | ### Adding New Tool Methods 487 | 488 | To add new capabilities to the server, add methods decorated with `@mcp.tool()` in the `a2a_mcp_server.py` file. 489 | 490 | ### Custom Task Manager 491 | 492 | The server uses a custom `A2AServerTaskManager` class that extends `InMemoryTaskManager`. You can customize its behavior by modifying this class. 493 | 494 | ## Project Structure 495 | 496 | ``` 497 | a2a-mcp-server/ 498 | ├── a2a_mcp_server.py # Main server implementation 499 | ├── common/ # A2A protocol code (from google/A2A) 500 | │ ├── client/ # A2A client implementation 501 | │ ├── server/ # A2A server implementation 502 | │ ├── types.py # Common type definitions 503 | │ └── utils/ # Utility functions 504 | ├── config_creator.py # Script to help create Claude Desktop configuration 505 | ├── .gitignore # Git ignore file 506 | ├── pyproject.toml # Project metadata and dependencies 507 | ├── README.md # This file 508 | └── requirements.txt # Project dependencies 509 | ``` 510 | 511 | ## License 512 | 513 | This project is licensed under the Apache License, Version 2.0 - see the [LICENSE](LICENSE) file for details. 514 | 515 | The code in the `common/` directory is from the [Google A2A project](https://github.com/google/A2A) and is also licensed under the Apache License, Version 2.0. 516 | 517 | ## Acknowledgments 518 | 519 | - Anthropic for the [Model Context Protocol](https://modelcontextprotocol.io/) 520 | - Google for the [Agent-to-Agent Protocol](https://github.com/google/A2A) 521 | - Contributors to the FastMCP library 522 | -------------------------------------------------------------------------------- /a2a_mcp_server.py: -------------------------------------------------------------------------------- 1 | """ 2 | Enhanced MCP A2A Bridge with improved task management and result retrieval. 3 | 4 | This script implements a bridge between the MCP protocol and A2A protocol, 5 | allowing MCP clients to interact with A2A agents. 6 | 7 | Supports multiple transport types: 8 | - stdio: Standard input/output transport 9 | - streamable-http: Recommended HTTP transport 10 | - sse: Server-Sent Events transport 11 | 12 | Configure the transport type using the MCP_TRANSPORT environment variable. 13 | """ 14 | 15 | import asyncio 16 | import os 17 | import uuid 18 | from typing import Any, AsyncGenerator, Dict, List, Optional, Union 19 | import json 20 | import logging 21 | import atexit 22 | 23 | import httpx 24 | # Set required environment variable for FastMCP 2.8.1+ 25 | os.environ.setdefault('FASTMCP_LOG_LEVEL', 'INFO') 26 | from fastmcp import Context, FastMCP 27 | from pydantic import BaseModel, Field 28 | 29 | from common.types import ( 30 | AgentCard, 31 | AgentCapabilities, 32 | AgentSkill, 33 | Artifact, 34 | DataPart, 35 | Message, 36 | Part, 37 | TextPart, 38 | TaskState, 39 | TaskStatus, 40 | TaskStatusUpdateEvent, 41 | TaskArtifactUpdateEvent, 42 | JSONRPCResponse, 43 | SendTaskRequest, 44 | SendTaskResponse, 45 | SendTaskStreamingRequest, 46 | SendTaskStreamingResponse, 47 | GetTaskRequest, 48 | GetTaskResponse, 49 | TaskQueryParams, 50 | CancelTaskRequest, 51 | CancelTaskResponse 52 | ) 53 | from common.client.client import A2AClient 54 | from common.server.task_manager import InMemoryTaskManager 55 | from persistence_utils import save_to_json, load_from_json 56 | 57 | # Set up logging 58 | logging.basicConfig(level=logging.INFO) 59 | logger = logging.getLogger(__name__) 60 | 61 | # Create a FastMCP server 62 | mcp = FastMCP("A2A Bridge Server") 63 | 64 | # File paths for persistent storage 65 | DATA_DIR = os.environ.get("A2A_MCP_DATA_DIR", ".") 66 | REGISTERED_AGENTS_FILE = os.path.join(DATA_DIR, "registered_agents.json") 67 | TASK_AGENT_MAPPING_FILE = os.path.join(DATA_DIR, "task_agent_mapping.json") 68 | 69 | # Load stored data from disk if it exists 70 | stored_agents = load_from_json(REGISTERED_AGENTS_FILE) 71 | stored_tasks = load_from_json(TASK_AGENT_MAPPING_FILE) 72 | 73 | # Initialize in-memory dictionaries with stored data 74 | registered_agents = {} 75 | task_agent_mapping = {} 76 | 77 | # Register function to save data on exit 78 | def save_data_on_exit(): 79 | logger.info("Saving data before exit...") 80 | # Convert registered_agents to a serializable format 81 | agents_data = {url: agent.model_dump() for url, agent in registered_agents.items()} 82 | save_to_json(agents_data, REGISTERED_AGENTS_FILE) 83 | save_to_json(task_agent_mapping, TASK_AGENT_MAPPING_FILE) 84 | logger.info("Data saved successfully") 85 | 86 | atexit.register(save_data_on_exit) 87 | 88 | # Periodically save data (every 5 minutes) 89 | async def periodic_save(): 90 | while True: 91 | await asyncio.sleep(300) # 5 minutes 92 | logger.info("Performing periodic data save...") 93 | agents_data = {url: agent.model_dump() for url, agent in registered_agents.items()} 94 | save_to_json(agents_data, REGISTERED_AGENTS_FILE) 95 | save_to_json(task_agent_mapping, TASK_AGENT_MAPPING_FILE) 96 | logger.info("Periodic save completed") 97 | 98 | # Load transport configuration from environment variables 99 | DEFAULT_TRANSPORT = "stdio" 100 | TRANSPORT_TYPES = ["stdio", "streamable-http", "sse"] 101 | 102 | # MCP server configuration 103 | MCP_TRANSPORT = os.environ.get("MCP_TRANSPORT", DEFAULT_TRANSPORT).lower() 104 | MCP_HOST = os.environ.get("MCP_HOST", "0.0.0.0") 105 | MCP_PORT = int(os.environ.get("MCP_PORT", "8000")) 106 | MCP_PATH = os.environ.get("MCP_PATH", "/mcp") # For streamable-http 107 | MCP_SSE_PATH = os.environ.get("MCP_SSE_PATH", "/sse") # For sse 108 | 109 | # A2A server configuration 110 | A2A_HOST = os.environ.get("A2A_HOST", "0.0.0.0") 111 | A2A_PORT = int(os.environ.get("A2A_PORT", "41241")) 112 | 113 | # Validate transport type 114 | if MCP_TRANSPORT not in TRANSPORT_TYPES: 115 | print(f"Warning: Invalid transport type '{MCP_TRANSPORT}'. Using default: {DEFAULT_TRANSPORT}") 116 | MCP_TRANSPORT = DEFAULT_TRANSPORT 117 | 118 | class AgentInfo(BaseModel): 119 | """Information about an A2A agent.""" 120 | url: str = Field(description="URL of the A2A agent") 121 | name: str = Field(description="Name of the A2A agent") 122 | description: str = Field(description="Description of the A2A agent") 123 | 124 | class A2ABridgeTaskManager(InMemoryTaskManager): 125 | """Task manager that forwards tasks to A2A agents.""" 126 | 127 | def __init__(self): 128 | super().__init__() 129 | self.agent_clients = {} # Maps agent URLs to A2AClient instances 130 | 131 | def get_or_create_client(self, agent_url: str) -> A2AClient: 132 | """Get an existing client or create a new one.""" 133 | if agent_url not in self.agent_clients: 134 | self.agent_clients[agent_url] = A2AClient(url=agent_url) # Use named parameter 135 | return self.agent_clients[agent_url] 136 | 137 | async def on_send_task(self, request: SendTaskRequest) -> SendTaskResponse: 138 | """Handle a task send request by forwarding to the appropriate A2A agent.""" 139 | task_id = request.params.id 140 | # Extract the agent URL from metadata 141 | agent_url = request.params.metadata.get("agent_url") if request.params.metadata else None 142 | 143 | if not agent_url: 144 | # No agent URL provided, return error 145 | return SendTaskResponse( 146 | id=request.id, 147 | error={ 148 | "code": -32602, 149 | "message": "Agent URL not provided in task metadata", 150 | } 151 | ) 152 | 153 | client = self.get_or_create_client(agent_url) 154 | 155 | # Forward the message to the A2A agent 156 | try: 157 | # Create payload as a single dictionary 158 | payload = { 159 | "id": task_id, 160 | "message": request.params.message, 161 | } 162 | if request.params.sessionId: 163 | payload["sessionId"] = request.params.sessionId 164 | if request.params.metadata: 165 | payload["metadata"] = request.params.metadata 166 | 167 | result = await client.send_task(payload) 168 | 169 | # Store the task result 170 | self.tasks[task_id] = result 171 | 172 | # Return the response 173 | return SendTaskResponse(id=request.id, result=result) 174 | except Exception as e: 175 | return SendTaskResponse( 176 | id=request.id, 177 | error={ 178 | "code": -32603, 179 | "message": f"Error forwarding task to A2A agent: {str(e)}", 180 | } 181 | ) 182 | 183 | async def on_send_task_subscribe( 184 | self, request: SendTaskStreamingRequest 185 | ) -> AsyncGenerator[SendTaskStreamingResponse, None] | JSONRPCResponse: 186 | """Handle a task subscription request by forwarding to the appropriate A2A agent.""" 187 | task_id = request.params.id 188 | # Extract the agent URL from metadata 189 | agent_url = request.params.metadata.get("agent_url") if request.params.metadata else None 190 | 191 | if not agent_url: 192 | # No agent URL provided, return error 193 | return JSONRPCResponse( 194 | id=request.id, 195 | error={ 196 | "code": -32602, 197 | "message": "Agent URL not provided in task metadata", 198 | } 199 | ) 200 | 201 | client = self.get_or_create_client(agent_url) 202 | 203 | # Set up SSE consumer 204 | sse_event_queue = await self.setup_sse_consumer(task_id=task_id) 205 | 206 | # Start forwarding the task in a background task 207 | asyncio.create_task(self._forward_task_stream( 208 | client=client, 209 | request=request, 210 | task_id=task_id, 211 | )) 212 | 213 | # Return the SSE consumer 214 | return self.dequeue_events_for_sse( 215 | request_id=request.id, 216 | task_id=task_id, 217 | sse_event_queue=sse_event_queue, 218 | ) 219 | 220 | async def _forward_task_stream( 221 | self, client: A2AClient, request: SendTaskStreamingRequest, task_id: str 222 | ): 223 | """Forward a task stream to an A2A agent and relay the responses.""" 224 | try: 225 | # Create payload as a single dictionary 226 | payload = { 227 | "id": task_id, 228 | "message": request.params.message, 229 | } 230 | if request.params.sessionId: 231 | payload["sessionId"] = request.params.sessionId 232 | if request.params.metadata: 233 | payload["metadata"] = request.params.metadata 234 | 235 | # Send the task and subscribe to updates 236 | stream = client.send_task_subscribe(payload) 237 | 238 | # Process the stream events 239 | async for event in stream: 240 | # Forward the event to our SSE queue 241 | await self.enqueue_events_for_sse(task_id, event) 242 | 243 | # If this is the final event, break 244 | if hasattr(event, "final") and event.final: 245 | break 246 | 247 | except Exception as e: 248 | # Create an error event and enqueue it 249 | error_event = TaskStatusUpdateEvent( 250 | id=task_id, 251 | status=TaskStatus( 252 | state=TaskState.FAILED, 253 | message=Message( 254 | role="agent", 255 | parts=[TextPart(text=f"Error forwarding task: {str(e)}")], 256 | ), 257 | ), 258 | final=True, 259 | ) 260 | await self.enqueue_events_for_sse(task_id, error_event) 261 | 262 | async def fetch_agent_card(url: str) -> AgentCard: 263 | """ 264 | Fetch the agent card from the agent's URL. 265 | First try the main URL, then the well-known location. 266 | """ 267 | async with httpx.AsyncClient() as client: 268 | # First try the main endpoint 269 | try: 270 | response = await client.get(url) 271 | if response.status_code == 200: 272 | try: 273 | data = response.json() 274 | if isinstance(data, dict) and "name" in data and "url" in data: 275 | return AgentCard(**data) 276 | except json.JSONDecodeError: 277 | pass # Not a valid JSON response, try the well-known URL 278 | except Exception: 279 | pass # Connection error, try the well-known URL 280 | 281 | # Try the well-known location 282 | well_known_url = f"{url.rstrip('/')}/.well-known/agent.json" 283 | try: 284 | response = await client.get(well_known_url) 285 | if response.status_code == 200: 286 | try: 287 | data = response.json() 288 | return AgentCard(**data) 289 | except json.JSONDecodeError: 290 | raise ValueError(f"Invalid JSON in agent card from {well_known_url}") 291 | except httpx.RequestError as e: 292 | raise ValueError(f"Failed to fetch agent card from {well_known_url}: {str(e)}") 293 | 294 | # If we can't get the agent card, create a minimal one with default values 295 | return AgentCard( 296 | name="Unknown Agent", 297 | url=url, 298 | version="0.1.0", 299 | capabilities=AgentCapabilities(streaming=False), 300 | skills=[ 301 | AgentSkill( 302 | id="unknown", 303 | name="Unknown Skill", 304 | description="Unknown agent capabilities", 305 | ) 306 | ], 307 | ) 308 | 309 | 310 | @mcp.tool() 311 | async def register_agent(url: str, ctx: Context) -> Dict[str, Any]: 312 | """ 313 | Register an A2A agent with the bridge server. 314 | 315 | Args: 316 | url: URL of the A2A agent 317 | 318 | Returns: 319 | Dictionary with registration status 320 | """ 321 | try: 322 | # Fetch the agent card directly 323 | agent_card = await fetch_agent_card(url) 324 | 325 | # Store the agent information 326 | agent_info = AgentInfo( 327 | url=url, 328 | name=agent_card.name, 329 | description=agent_card.description or "No description provided", 330 | ) 331 | 332 | registered_agents[url] = agent_info 333 | 334 | # Save to disk immediately 335 | agents_data = {url: agent.model_dump() for url, agent in registered_agents.items()} 336 | save_to_json(agents_data, REGISTERED_AGENTS_FILE) 337 | 338 | await ctx.info(f"Successfully registered agent: {agent_card.name}") 339 | return { 340 | "status": "success", 341 | "agent": agent_info.model_dump(), 342 | } 343 | except Exception as e: 344 | return { 345 | "status": "error", 346 | "message": f"Failed to register agent: {str(e)}", 347 | } 348 | 349 | 350 | @mcp.tool() 351 | async def list_agents() -> List[Dict[str, Any]]: 352 | """ 353 | List all registered A2A agents. 354 | 355 | Returns: 356 | List of registered agents 357 | """ 358 | return [agent.model_dump() for agent in registered_agents.values()] 359 | 360 | 361 | @mcp.tool() 362 | async def unregister_agent(url: str, ctx: Context = None) -> Dict[str, Any]: 363 | """ 364 | Unregister an A2A agent from the bridge server. 365 | 366 | Args: 367 | url: URL of the A2A agent to unregister 368 | 369 | Returns: 370 | Dictionary with unregistration status 371 | """ 372 | if url not in registered_agents: 373 | return { 374 | "status": "error", 375 | "message": f"Agent not registered: {url}", 376 | } 377 | 378 | try: 379 | # Get agent name before removing it 380 | agent_name = registered_agents[url].name 381 | 382 | # Remove from registered agents 383 | del registered_agents[url] 384 | 385 | # Clean up any task mappings related to this agent 386 | # Create a list of task_ids to remove to avoid modifying the dictionary during iteration 387 | tasks_to_remove = [] 388 | for task_id, agent_url in task_agent_mapping.items(): 389 | if agent_url == url: 390 | tasks_to_remove.append(task_id) 391 | 392 | # Now remove the task mappings 393 | for task_id in tasks_to_remove: 394 | del task_agent_mapping[task_id] 395 | 396 | # Save changes to disk immediately 397 | agents_data = {url: agent.model_dump() for url, agent in registered_agents.items()} 398 | save_to_json(agents_data, REGISTERED_AGENTS_FILE) 399 | save_to_json(task_agent_mapping, TASK_AGENT_MAPPING_FILE) 400 | 401 | if ctx: 402 | await ctx.info(f"Successfully unregistered agent: {agent_name}") 403 | 404 | return { 405 | "status": "success", 406 | "message": f"Successfully unregistered agent: {agent_name}", 407 | "removed_tasks": len(tasks_to_remove), 408 | } 409 | except Exception as e: 410 | return { 411 | "status": "error", 412 | "message": f"Error unregistering agent: {str(e)}", 413 | } 414 | 415 | 416 | @mcp.tool() 417 | async def send_message( 418 | agent_url: str, 419 | message: str, 420 | session_id: Optional[str] = None, 421 | ctx: Context = None, 422 | ) -> Dict[str, Any]: 423 | """ 424 | Send a message to an A2A agent. 425 | 426 | Args: 427 | agent_url: URL of the A2A agent 428 | message: Message to send 429 | session_id: Optional session ID for multi-turn conversations 430 | 431 | Returns: 432 | Agent's response with task_id for future reference 433 | """ 434 | if agent_url not in registered_agents: 435 | return { 436 | "status": "error", 437 | "message": f"Agent not registered: {agent_url}", 438 | } 439 | 440 | # Create a client for the agent 441 | client = A2AClient(url=agent_url) 442 | 443 | try: 444 | # Generate a task ID 445 | task_id = str(uuid.uuid4()) 446 | 447 | # Store the mapping of task_id to agent_url for later reference 448 | task_agent_mapping[task_id] = agent_url 449 | 450 | # Create the message 451 | a2a_message = Message( 452 | role="user", 453 | parts=[TextPart(text=message)], 454 | ) 455 | 456 | if ctx: 457 | await ctx.info(f"Sending message to agent: {message}") 458 | 459 | # Create payload as a single dictionary 460 | payload = { 461 | "id": task_id, 462 | "message": a2a_message, 463 | } 464 | if session_id: 465 | payload["sessionId"] = session_id 466 | 467 | # Send the task with the payload 468 | result = await client.send_task(payload) 469 | 470 | # Save task mapping to disk 471 | save_to_json(task_agent_mapping, TASK_AGENT_MAPPING_FILE) 472 | 473 | # Debug: Print the raw response for analysis 474 | if ctx: 475 | await ctx.info(f"Raw response: {result}") 476 | 477 | # Create a response dictionary with as much info as we can extract 478 | response = { 479 | "status": "success", 480 | "task_id": task_id, 481 | } 482 | 483 | # Add any available fields from the result 484 | if hasattr(result, "sessionId"): 485 | response["session_id"] = result.sessionId 486 | else: 487 | response["session_id"] = None 488 | 489 | # Try to get the state 490 | try: 491 | if hasattr(result, "status") and hasattr(result.status, "state"): 492 | response["state"] = result.status.state 493 | else: 494 | response["state"] = "unknown" 495 | except Exception as e: 496 | response["state"] = f"error_getting_state: {str(e)}" 497 | 498 | # Try to extract response message 499 | try: 500 | if hasattr(result, "status") and hasattr(result.status, "message") and result.status.message: 501 | response_text = "" 502 | for part in result.status.message.parts: 503 | if part.type == "text": 504 | response_text += part.text 505 | if response_text: 506 | response["message"] = response_text 507 | except Exception as e: 508 | response["message_error"] = f"Error extracting message: {str(e)}" 509 | 510 | # Try to get artifacts 511 | try: 512 | if hasattr(result, "artifacts") and result.artifacts: 513 | artifacts_data = [] 514 | for artifact in result.artifacts: 515 | artifact_data = { 516 | "name": artifact.name if hasattr(artifact, "name") else "unnamed_artifact", 517 | "contents": [], 518 | } 519 | 520 | for part in artifact.parts: 521 | if part.type == "text": 522 | artifact_data["contents"].append({ 523 | "type": "text", 524 | "text": part.text, 525 | }) 526 | elif part.type == "data": 527 | artifact_data["contents"].append({ 528 | "type": "data", 529 | "data": part.data, 530 | }) 531 | 532 | artifacts_data.append(artifact_data) 533 | 534 | response["artifacts"] = artifacts_data 535 | except Exception as e: 536 | response["artifacts_error"] = f"Error extracting artifacts: {str(e)}" 537 | 538 | return response 539 | except Exception as e: 540 | return { 541 | "status": "error", 542 | "message": f"Error sending message: {str(e)}", 543 | } 544 | 545 | 546 | @mcp.tool() 547 | async def get_task_result( 548 | task_id: str, 549 | history_length: Optional[int] = None, 550 | ctx: Context = None, 551 | ) -> Dict[str, Any]: 552 | """ 553 | Retrieve the result of a task from an A2A agent. 554 | 555 | Args: 556 | task_id: ID of the task to retrieve 557 | history_length: Optional number of history items to include (null for all) 558 | 559 | Returns: 560 | Task result including status, message, and artifacts if available 561 | """ 562 | if task_id not in task_agent_mapping: 563 | return { 564 | "status": "error", 565 | "message": f"Task ID not found: {task_id}", 566 | } 567 | 568 | agent_url = task_agent_mapping[task_id] 569 | 570 | # Create a client for the agent 571 | client = A2AClient(url=agent_url) 572 | 573 | try: 574 | # Create the request payload 575 | payload = { 576 | "id": task_id, 577 | "historyLength": history_length 578 | } 579 | 580 | if ctx: 581 | await ctx.info(f"Retrieving task result for task_id: {task_id}") 582 | 583 | # Send the get task request 584 | result = await client.get_task(payload) 585 | 586 | # Debug: Print the raw response for analysis 587 | if ctx: 588 | await ctx.info(f"Raw task result: {result}") 589 | 590 | # Create a response dictionary with as much info as we can extract 591 | response = { 592 | "status": "success", 593 | "task_id": task_id, 594 | } 595 | 596 | # Try to extract task data 597 | try: 598 | if hasattr(result, "result"): 599 | task = result.result 600 | 601 | # Add basic task info 602 | if hasattr(task, "sessionId"): 603 | response["session_id"] = task.sessionId 604 | else: 605 | response["session_id"] = None 606 | 607 | # Add task status 608 | if hasattr(task, "status"): 609 | status = task.status 610 | if hasattr(status, "state"): 611 | response["state"] = status.state 612 | 613 | # Extract message from status 614 | if hasattr(status, "message") and status.message: 615 | response_text = "" 616 | for part in status.message.parts: 617 | if part.type == "text": 618 | response_text += part.text 619 | if response_text: 620 | response["message"] = response_text 621 | 622 | # Extract artifacts 623 | if hasattr(task, "artifacts") and task.artifacts: 624 | artifacts_data = [] 625 | for artifact in task.artifacts: 626 | artifact_data = { 627 | "name": artifact.name if hasattr(artifact, "name") else "unnamed_artifact", 628 | "contents": [], 629 | } 630 | 631 | for part in artifact.parts: 632 | if part.type == "text": 633 | artifact_data["contents"].append({ 634 | "type": "text", 635 | "text": part.text, 636 | }) 637 | elif part.type == "data": 638 | artifact_data["contents"].append({ 639 | "type": "data", 640 | "data": part.data, 641 | }) 642 | 643 | artifacts_data.append(artifact_data) 644 | 645 | response["artifacts"] = artifacts_data 646 | 647 | # Extract message history if available 648 | if hasattr(task, "history") and task.history: 649 | history_data = [] 650 | for message in task.history: 651 | message_data = { 652 | "role": message.role, 653 | "parts": [], 654 | } 655 | 656 | for part in message.parts: 657 | if part.type == "text": 658 | message_data["parts"].append({ 659 | "type": "text", 660 | "text": part.text, 661 | }) 662 | elif hasattr(part, "data"): 663 | message_data["parts"].append({ 664 | "type": "data", 665 | "data": part.data, 666 | }) 667 | 668 | history_data.append(message_data) 669 | 670 | response["history"] = history_data 671 | else: 672 | response["error"] = "No result in response" 673 | 674 | except Exception as e: 675 | response["parsing_error"] = f"Error parsing task result: {str(e)}" 676 | 677 | return response 678 | except Exception as e: 679 | return { 680 | "status": "error", 681 | "message": f"Error retrieving task result: {str(e)}", 682 | } 683 | 684 | 685 | @mcp.tool() 686 | async def cancel_task( 687 | task_id: str, 688 | ctx: Context = None, 689 | ) -> Dict[str, Any]: 690 | """ 691 | Cancel a running task on an A2A agent. 692 | 693 | Args: 694 | task_id: ID of the task to cancel 695 | 696 | Returns: 697 | Cancellation result 698 | """ 699 | if task_id not in task_agent_mapping: 700 | return { 701 | "status": "error", 702 | "message": f"Task ID not found: {task_id}", 703 | } 704 | 705 | agent_url = task_agent_mapping[task_id] 706 | 707 | # Create a client for the agent 708 | client = A2AClient(url=agent_url) 709 | 710 | try: 711 | # Create the request payload 712 | payload = { 713 | "id": task_id 714 | } 715 | 716 | if ctx: 717 | await ctx.info(f"Cancelling task: {task_id}") 718 | 719 | # Send the cancel task request 720 | result = await client.cancel_task(payload) 721 | 722 | # Debug: Print the raw response for analysis 723 | if ctx: 724 | await ctx.info(f"Raw cancellation result: {result}") 725 | 726 | # Create a response dictionary 727 | if hasattr(result, "error"): 728 | return { 729 | "status": "error", 730 | "task_id": task_id, 731 | "message": result.error.message, 732 | "code": result.error.code 733 | } 734 | elif hasattr(result, "result"): 735 | return { 736 | "status": "success", 737 | "task_id": task_id, 738 | "message": "Task cancelled successfully" 739 | } 740 | else: 741 | return { 742 | "status": "unknown", 743 | "task_id": task_id, 744 | "message": "Unexpected response format" 745 | } 746 | 747 | except Exception as e: 748 | return { 749 | "status": "error", 750 | "message": f"Error cancelling task: {str(e)}", 751 | } 752 | 753 | 754 | @mcp.tool() 755 | async def send_message_stream( 756 | agent_url: str, 757 | message: str, 758 | session_id: Optional[str] = None, 759 | ctx: Context = None, 760 | ) -> Dict[str, Any]: 761 | """ 762 | Send a message to an A2A agent and stream the response. 763 | 764 | Args: 765 | agent_url: URL of the A2A agent 766 | message: Message to send 767 | session_id: Optional session ID for multi-turn conversations 768 | 769 | Returns: 770 | Stream of agent's responses 771 | """ 772 | if agent_url not in registered_agents: 773 | return { 774 | "status": "error", 775 | "message": f"Agent not registered: {agent_url}", 776 | } 777 | 778 | # Create a client for the agent 779 | client = A2AClient(url=agent_url) 780 | 781 | try: 782 | # Generate a task ID 783 | task_id = str(uuid.uuid4()) 784 | 785 | # Store the mapping of task_id to agent_url for later reference 786 | task_agent_mapping[task_id] = agent_url 787 | 788 | # Save the task mapping to disk 789 | save_to_json(task_agent_mapping, TASK_AGENT_MAPPING_FILE) 790 | 791 | # Create the message 792 | a2a_message = Message( 793 | role="user", 794 | parts=[TextPart(text=message)], 795 | ) 796 | 797 | if ctx: 798 | await ctx.info(f"Sending message to agent (streaming): {message}") 799 | 800 | # Start progress indication 801 | if ctx: 802 | await ctx.info("Processing...") 803 | 804 | # Dictionary to accumulate streaming responses 805 | complete_response = { 806 | "status": "success", 807 | "task_id": task_id, 808 | "session_id": session_id, 809 | "state": "working", 810 | "messages": [], 811 | "artifacts": [], 812 | } 813 | 814 | # Create payload as a single dictionary 815 | payload = { 816 | "id": task_id, 817 | "message": a2a_message, 818 | } 819 | if session_id: 820 | payload["sessionId"] = session_id 821 | 822 | # Send the task and subscribe to updates 823 | stream = client.send_task_streaming(payload) 824 | 825 | # Process and report stream events 826 | try: 827 | all_events = [] 828 | 829 | async for event in stream: 830 | # Save all events for debugging 831 | all_events.append({ 832 | "type": str(type(event)), 833 | "dir": str(dir(event)), 834 | }) 835 | 836 | if hasattr(event, "result"): 837 | if hasattr(event.result, "status"): 838 | # It's a TaskStatusUpdateEvent 839 | status_event = event.result 840 | 841 | # Update the state 842 | if hasattr(status_event, "status") and hasattr(status_event.status, "state"): 843 | complete_response["state"] = status_event.status.state 844 | 845 | # Extract any message 846 | if hasattr(status_event, "status") and hasattr(status_event.status, "message") and status_event.status.message: 847 | message_text = "" 848 | for part in status_event.status.message.parts: 849 | if part.type == "text": 850 | message_text += part.text 851 | 852 | if message_text: 853 | complete_response["messages"].append(message_text) 854 | if ctx: 855 | await ctx.info(f"Agent: {message_text}") 856 | 857 | # If this is the final event, set session ID 858 | if hasattr(status_event, "final") and status_event.final: 859 | complete_response["session_id"] = getattr(status_event, "sessionId", session_id) 860 | 861 | elif hasattr(event.result, "artifact"): 862 | # It's a TaskArtifactUpdateEvent 863 | artifact_event = event.result 864 | 865 | # Extract artifact content 866 | artifact_data = { 867 | "name": artifact_event.artifact.name if hasattr(artifact_event.artifact, "name") else "unnamed", 868 | "contents": [], 869 | } 870 | 871 | for part in artifact_event.artifact.parts: 872 | if part.type == "text": 873 | artifact_data["contents"].append({ 874 | "type": "text", 875 | "text": part.text, 876 | }) 877 | elif part.type == "data": 878 | artifact_data["contents"].append({ 879 | "type": "data", 880 | "data": part.data, 881 | }) 882 | 883 | complete_response["artifacts"].append(artifact_data) 884 | 885 | if ctx: 886 | await ctx.info(f"Received artifact: {artifact_data['name']}") 887 | else: 888 | # Unknown event type, try to extract what we can 889 | complete_response["unknown_events"] = complete_response.get("unknown_events", []) + [ 890 | { 891 | "type": str(type(event)), 892 | "dir": str(dir(event)) 893 | } 894 | ] 895 | 896 | # Include debug info 897 | complete_response["_debug_info"] = { 898 | "all_events": all_events 899 | } 900 | 901 | return complete_response 902 | except Exception as e: 903 | return { 904 | "status": "error", 905 | "message": f"Error processing stream events: {str(e)}", 906 | "_debug_info": { 907 | "all_events": all_events 908 | } 909 | } 910 | 911 | except Exception as e: 912 | return { 913 | "status": "error", 914 | "message": f"Error sending message (stream): {str(e)}", 915 | } 916 | 917 | 918 | class CustomA2AServer: 919 | """ 920 | A minimal A2A server implementation that uses the task manager. 921 | """ 922 | 923 | def __init__( 924 | self, 925 | agent_card: AgentCard, 926 | task_manager: A2ABridgeTaskManager, 927 | host: str = "0.0.0.0", 928 | port: int = 41241, 929 | ): 930 | self.agent_card = agent_card 931 | self.task_manager = task_manager 932 | self.host = host 933 | self.port = port 934 | 935 | async def start_async(self): 936 | """Start the A2A server asynchronously.""" 937 | # In a real implementation, this would start a FastAPI server 938 | # For now, just log that it's "started" 939 | print(f"A2A server 'started' at {self.host}:{self.port}") 940 | # Keep the server "running" 941 | while True: 942 | await asyncio.sleep(3600) # Sleep for an hour 943 | 944 | def start(self): 945 | """Start the A2A server.""" 946 | asyncio.create_task(self.start_async()) 947 | 948 | 949 | def setup_a2a_server(): 950 | """Set up the A2A server with our task manager.""" 951 | # Create a sample agent card 952 | agent_card = AgentCard( 953 | name="MCP Bridge Agent", 954 | description="A bridge between MCP and A2A protocols", 955 | url=f"http://{A2A_HOST}:{A2A_PORT}", 956 | version="0.1.0", 957 | # Add the required capabilities field 958 | capabilities=AgentCapabilities( 959 | streaming=True, 960 | pushNotifications=False, 961 | stateTransitionHistory=False, 962 | ), 963 | # Add the required skills field with at least one skill 964 | skills=[ 965 | AgentSkill( 966 | id="mcp-bridge", 967 | name="MCP Bridge", 968 | description="Allows MCP clients to communicate with A2A agents", 969 | tags=["bridge", "proxy", "mcp", "a2a"], 970 | examples=[ 971 | "Send a message to an A2A agent", 972 | "Register an A2A agent with the bridge", 973 | "List all registered A2A agents", 974 | ], 975 | inputModes=["text/plain"], 976 | outputModes=["text/plain", "application/json"], 977 | ) 978 | ], 979 | ) 980 | 981 | # Create our custom task manager 982 | task_manager = A2ABridgeTaskManager() 983 | 984 | # Create and return the A2A server 985 | return CustomA2AServer( 986 | agent_card=agent_card, 987 | task_manager=task_manager, 988 | host=A2A_HOST, 989 | port=A2A_PORT, 990 | ) 991 | 992 | 993 | async def main_async(): 994 | """ 995 | Main async function to start both the MCP and A2A servers. 996 | """ 997 | # Load stored data into memory 998 | load_registered_agents() 999 | 1000 | # Start periodic save task 1001 | asyncio.create_task(periodic_save()) 1002 | 1003 | # Set up and start the A2A server 1004 | a2a_server = setup_a2a_server() 1005 | a2a_task = asyncio.create_task(a2a_server.start_async()) 1006 | 1007 | # Start the MCP server with the configured transport 1008 | print(f"Starting MCP server with {MCP_TRANSPORT} transport...") 1009 | 1010 | if MCP_TRANSPORT == "stdio": 1011 | # Use stdio transport (default) 1012 | mcp_task = asyncio.create_task( 1013 | mcp.run_async(transport="stdio") 1014 | ) 1015 | elif MCP_TRANSPORT == "streamable-http": 1016 | # Use streamable-http transport 1017 | mcp_task = asyncio.create_task( 1018 | mcp.run_async( 1019 | transport="streamable-http", 1020 | host=MCP_HOST, 1021 | port=MCP_PORT, 1022 | path=MCP_PATH, 1023 | ) 1024 | ) 1025 | elif MCP_TRANSPORT == "sse": 1026 | # Use sse transport (deprecated but still supported) 1027 | mcp_task = asyncio.create_task( 1028 | mcp.run_async( 1029 | transport="sse", 1030 | host=MCP_HOST, 1031 | port=MCP_PORT, 1032 | path=MCP_SSE_PATH, 1033 | ) 1034 | ) 1035 | 1036 | # Run both servers 1037 | await asyncio.gather(a2a_task, mcp_task) 1038 | 1039 | 1040 | def load_registered_agents(): 1041 | """Load registered agents from stored data on startup.""" 1042 | global registered_agents, task_agent_mapping 1043 | 1044 | logger.info("Loading saved data...") 1045 | 1046 | # Load agents data 1047 | agents_data = load_from_json(REGISTERED_AGENTS_FILE) 1048 | for url, agent_data in agents_data.items(): 1049 | registered_agents[url] = AgentInfo(**agent_data) 1050 | 1051 | # Load task mappings 1052 | task_agent_mapping = load_from_json(TASK_AGENT_MAPPING_FILE) 1053 | 1054 | logger.info(f"Loaded {len(registered_agents)} agents and {len(task_agent_mapping)} task mappings") 1055 | 1056 | 1057 | def main(): 1058 | """ 1059 | Main entry point. 1060 | """ 1061 | # Print the configuration for debugging 1062 | print(f"MCP Bridge Configuration:") 1063 | print(f"- Transport: {MCP_TRANSPORT}") 1064 | print(f"- Host: {MCP_HOST}") 1065 | print(f"- Port: {MCP_PORT}") 1066 | if MCP_TRANSPORT == "streamable-http": 1067 | print(f"- Path: {MCP_PATH}") 1068 | elif MCP_TRANSPORT == "sse": 1069 | print(f"- SSE Path: {MCP_SSE_PATH}") 1070 | print(f"A2A Server Configuration:") 1071 | print(f"- Host: {A2A_HOST}") 1072 | print(f"- Port: {A2A_PORT}") 1073 | print(f"Data Storage:") 1074 | print(f"- Registered Agents: {REGISTERED_AGENTS_FILE}") 1075 | print(f"- Task Agent Mapping: {TASK_AGENT_MAPPING_FILE}") 1076 | 1077 | asyncio.run(main_async()) 1078 | 1079 | 1080 | if __name__ == "__main__": 1081 | main() 1082 | -------------------------------------------------------------------------------- /common/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GongRzhe/A2A-MCP-Server/bc515a5a1b7679272364bf2356ad646b76a83a9a/common/__init__.py -------------------------------------------------------------------------------- /common/client/__init__.py: -------------------------------------------------------------------------------- 1 | from .card_resolver import A2ACardResolver 2 | from .client import A2AClient 3 | 4 | 5 | __all__ = ['A2ACardResolver', 'A2AClient'] 6 | -------------------------------------------------------------------------------- /common/client/card_resolver.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import httpx 4 | 5 | from common.types import ( 6 | A2AClientJSONError, 7 | AgentCard, 8 | ) 9 | 10 | 11 | class A2ACardResolver: 12 | def __init__(self, base_url, agent_card_path='/.well-known/agent.json'): 13 | self.base_url = base_url.rstrip('/') 14 | self.agent_card_path = agent_card_path.lstrip('/') 15 | 16 | def get_agent_card(self) -> AgentCard: 17 | with httpx.Client() as client: 18 | response = client.get(self.base_url + '/' + self.agent_card_path) 19 | response.raise_for_status() 20 | try: 21 | return AgentCard(**response.json()) 22 | except json.JSONDecodeError as e: 23 | raise A2AClientJSONError(str(e)) from e 24 | -------------------------------------------------------------------------------- /common/client/client.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from collections.abc import AsyncIterable 4 | from typing import Any 5 | 6 | import httpx 7 | 8 | from httpx._types import TimeoutTypes 9 | from httpx_sse import connect_sse 10 | 11 | from common.types import ( 12 | A2AClientHTTPError, 13 | A2AClientJSONError, 14 | AgentCard, 15 | CancelTaskRequest, 16 | CancelTaskResponse, 17 | GetTaskPushNotificationRequest, 18 | GetTaskPushNotificationResponse, 19 | GetTaskRequest, 20 | GetTaskResponse, 21 | JSONRPCRequest, 22 | SendTaskRequest, 23 | SendTaskResponse, 24 | SendTaskStreamingRequest, 25 | SendTaskStreamingResponse, 26 | SetTaskPushNotificationRequest, 27 | SetTaskPushNotificationResponse, 28 | ) 29 | 30 | 31 | class A2AClient: 32 | def __init__( 33 | self, 34 | agent_card: AgentCard = None, 35 | url: str = None, 36 | timeout: TimeoutTypes = 60.0, 37 | ): 38 | if agent_card: 39 | self.url = agent_card.url 40 | elif url: 41 | self.url = url 42 | else: 43 | raise ValueError('Must provide either agent_card or url') 44 | self.timeout = timeout 45 | 46 | async def send_task(self, payload: dict[str, Any]) -> SendTaskResponse: 47 | request = SendTaskRequest(params=payload) 48 | return SendTaskResponse(**await self._send_request(request)) 49 | 50 | async def send_task_streaming( 51 | self, payload: dict[str, Any] 52 | ) -> AsyncIterable[SendTaskStreamingResponse]: 53 | request = SendTaskStreamingRequest(params=payload) 54 | with httpx.Client(timeout=None) as client: 55 | with connect_sse( 56 | client, 'POST', self.url, json=request.model_dump() 57 | ) as event_source: 58 | try: 59 | for sse in event_source.iter_sse(): 60 | yield SendTaskStreamingResponse(**json.loads(sse.data)) 61 | except json.JSONDecodeError as e: 62 | raise A2AClientJSONError(str(e)) from e 63 | except httpx.RequestError as e: 64 | raise A2AClientHTTPError(400, str(e)) from e 65 | 66 | async def _send_request(self, request: JSONRPCRequest) -> dict[str, Any]: 67 | async with httpx.AsyncClient() as client: 68 | try: 69 | # Image generation could take time, adding timeout 70 | response = await client.post( 71 | self.url, json=request.model_dump(), timeout=self.timeout 72 | ) 73 | response.raise_for_status() 74 | return response.json() 75 | except httpx.HTTPStatusError as e: 76 | raise A2AClientHTTPError(e.response.status_code, str(e)) from e 77 | except json.JSONDecodeError as e: 78 | raise A2AClientJSONError(str(e)) from e 79 | 80 | async def get_task(self, payload: dict[str, Any]) -> GetTaskResponse: 81 | request = GetTaskRequest(params=payload) 82 | return GetTaskResponse(**await self._send_request(request)) 83 | 84 | async def cancel_task(self, payload: dict[str, Any]) -> CancelTaskResponse: 85 | request = CancelTaskRequest(params=payload) 86 | return CancelTaskResponse(**await self._send_request(request)) 87 | 88 | async def set_task_callback( 89 | self, payload: dict[str, Any] 90 | ) -> SetTaskPushNotificationResponse: 91 | request = SetTaskPushNotificationRequest(params=payload) 92 | return SetTaskPushNotificationResponse( 93 | **await self._send_request(request) 94 | ) 95 | 96 | async def get_task_callback( 97 | self, payload: dict[str, Any] 98 | ) -> GetTaskPushNotificationResponse: 99 | request = GetTaskPushNotificationRequest(params=payload) 100 | return GetTaskPushNotificationResponse( 101 | **await self._send_request(request) 102 | ) 103 | -------------------------------------------------------------------------------- /common/server/__init__.py: -------------------------------------------------------------------------------- 1 | from .server import A2AServer 2 | from .task_manager import InMemoryTaskManager, TaskManager 3 | 4 | 5 | __all__ = ['A2AServer', 'InMemoryTaskManager', 'TaskManager'] 6 | -------------------------------------------------------------------------------- /common/server/server.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | 4 | from collections.abc import AsyncIterable 5 | from typing import Any 6 | 7 | from pydantic import ValidationError 8 | from sse_starlette.sse import EventSourceResponse 9 | from starlette.applications import Starlette 10 | from starlette.requests import Request 11 | from starlette.responses import JSONResponse 12 | 13 | from common.server.task_manager import TaskManager 14 | from common.types import ( 15 | A2ARequest, 16 | AgentCard, 17 | CancelTaskRequest, 18 | GetTaskPushNotificationRequest, 19 | GetTaskRequest, 20 | InternalError, 21 | InvalidRequestError, 22 | JSONParseError, 23 | JSONRPCResponse, 24 | SendTaskRequest, 25 | SendTaskStreamingRequest, 26 | SetTaskPushNotificationRequest, 27 | TaskResubscriptionRequest, 28 | ) 29 | 30 | 31 | logger = logging.getLogger(__name__) 32 | 33 | 34 | class A2AServer: 35 | def __init__( 36 | self, 37 | host='0.0.0.0', 38 | port=5000, 39 | endpoint='/', 40 | agent_card: AgentCard = None, 41 | task_manager: TaskManager = None, 42 | ): 43 | self.host = host 44 | self.port = port 45 | self.endpoint = endpoint 46 | self.task_manager = task_manager 47 | self.agent_card = agent_card 48 | self.app = Starlette() 49 | self.app.add_route( 50 | self.endpoint, self._process_request, methods=['POST'] 51 | ) 52 | self.app.add_route( 53 | '/.well-known/agent.json', self._get_agent_card, methods=['GET'] 54 | ) 55 | 56 | def start(self): 57 | if self.agent_card is None: 58 | raise ValueError('agent_card is not defined') 59 | 60 | if self.task_manager is None: 61 | raise ValueError('request_handler is not defined') 62 | 63 | import uvicorn 64 | 65 | uvicorn.run(self.app, host=self.host, port=self.port) 66 | 67 | def _get_agent_card(self, request: Request) -> JSONResponse: 68 | return JSONResponse(self.agent_card.model_dump(exclude_none=True)) 69 | 70 | async def _process_request(self, request: Request): 71 | try: 72 | body = await request.json() 73 | json_rpc_request = A2ARequest.validate_python(body) 74 | 75 | if isinstance(json_rpc_request, GetTaskRequest): 76 | result = await self.task_manager.on_get_task(json_rpc_request) 77 | elif isinstance(json_rpc_request, SendTaskRequest): 78 | result = await self.task_manager.on_send_task(json_rpc_request) 79 | elif isinstance(json_rpc_request, SendTaskStreamingRequest): 80 | result = await self.task_manager.on_send_task_subscribe( 81 | json_rpc_request 82 | ) 83 | elif isinstance(json_rpc_request, CancelTaskRequest): 84 | result = await self.task_manager.on_cancel_task( 85 | json_rpc_request 86 | ) 87 | elif isinstance(json_rpc_request, SetTaskPushNotificationRequest): 88 | result = await self.task_manager.on_set_task_push_notification( 89 | json_rpc_request 90 | ) 91 | elif isinstance(json_rpc_request, GetTaskPushNotificationRequest): 92 | result = await self.task_manager.on_get_task_push_notification( 93 | json_rpc_request 94 | ) 95 | elif isinstance(json_rpc_request, TaskResubscriptionRequest): 96 | result = await self.task_manager.on_resubscribe_to_task( 97 | json_rpc_request 98 | ) 99 | else: 100 | logger.warning( 101 | f'Unexpected request type: {type(json_rpc_request)}' 102 | ) 103 | raise ValueError(f'Unexpected request type: {type(request)}') 104 | 105 | return self._create_response(result) 106 | 107 | except Exception as e: 108 | return self._handle_exception(e) 109 | 110 | def _handle_exception(self, e: Exception) -> JSONResponse: 111 | if isinstance(e, json.decoder.JSONDecodeError): 112 | json_rpc_error = JSONParseError() 113 | elif isinstance(e, ValidationError): 114 | json_rpc_error = InvalidRequestError(data=json.loads(e.json())) 115 | else: 116 | logger.error(f'Unhandled exception: {e}') 117 | json_rpc_error = InternalError() 118 | 119 | response = JSONRPCResponse(id=None, error=json_rpc_error) 120 | return JSONResponse( 121 | response.model_dump(exclude_none=True), status_code=400 122 | ) 123 | 124 | def _create_response( 125 | self, result: Any 126 | ) -> JSONResponse | EventSourceResponse: 127 | if isinstance(result, AsyncIterable): 128 | 129 | async def event_generator(result) -> AsyncIterable[dict[str, str]]: 130 | async for item in result: 131 | yield {'data': item.model_dump_json(exclude_none=True)} 132 | 133 | return EventSourceResponse(event_generator(result)) 134 | if isinstance(result, JSONRPCResponse): 135 | return JSONResponse(result.model_dump(exclude_none=True)) 136 | logger.error(f'Unexpected result type: {type(result)}') 137 | raise ValueError(f'Unexpected result type: {type(result)}') 138 | -------------------------------------------------------------------------------- /common/server/task_manager.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | 4 | from abc import ABC, abstractmethod 5 | from collections.abc import AsyncIterable 6 | 7 | from common.server.utils import new_not_implemented_error 8 | from common.types import ( 9 | Artifact, 10 | CancelTaskRequest, 11 | CancelTaskResponse, 12 | GetTaskPushNotificationRequest, 13 | GetTaskPushNotificationResponse, 14 | GetTaskRequest, 15 | GetTaskResponse, 16 | InternalError, 17 | JSONRPCError, 18 | JSONRPCResponse, 19 | PushNotificationConfig, 20 | SendTaskRequest, 21 | SendTaskResponse, 22 | SendTaskStreamingRequest, 23 | SendTaskStreamingResponse, 24 | SetTaskPushNotificationRequest, 25 | SetTaskPushNotificationResponse, 26 | Task, 27 | TaskIdParams, 28 | TaskNotCancelableError, 29 | TaskNotFoundError, 30 | TaskPushNotificationConfig, 31 | TaskQueryParams, 32 | TaskResubscriptionRequest, 33 | TaskSendParams, 34 | TaskState, 35 | TaskStatus, 36 | TaskStatusUpdateEvent, 37 | ) 38 | 39 | 40 | logger = logging.getLogger(__name__) 41 | 42 | 43 | class TaskManager(ABC): 44 | @abstractmethod 45 | async def on_get_task(self, request: GetTaskRequest) -> GetTaskResponse: 46 | pass 47 | 48 | @abstractmethod 49 | async def on_cancel_task( 50 | self, request: CancelTaskRequest 51 | ) -> CancelTaskResponse: 52 | pass 53 | 54 | @abstractmethod 55 | async def on_send_task(self, request: SendTaskRequest) -> SendTaskResponse: 56 | pass 57 | 58 | @abstractmethod 59 | async def on_send_task_subscribe( 60 | self, request: SendTaskStreamingRequest 61 | ) -> AsyncIterable[SendTaskStreamingResponse] | JSONRPCResponse: 62 | pass 63 | 64 | @abstractmethod 65 | async def on_set_task_push_notification( 66 | self, request: SetTaskPushNotificationRequest 67 | ) -> SetTaskPushNotificationResponse: 68 | pass 69 | 70 | @abstractmethod 71 | async def on_get_task_push_notification( 72 | self, request: GetTaskPushNotificationRequest 73 | ) -> GetTaskPushNotificationResponse: 74 | pass 75 | 76 | @abstractmethod 77 | async def on_resubscribe_to_task( 78 | self, request: TaskResubscriptionRequest 79 | ) -> AsyncIterable[SendTaskResponse] | JSONRPCResponse: 80 | pass 81 | 82 | 83 | class InMemoryTaskManager(TaskManager): 84 | def __init__(self): 85 | self.tasks: dict[str, Task] = {} 86 | self.push_notification_infos: dict[str, PushNotificationConfig] = {} 87 | self.lock = asyncio.Lock() 88 | self.task_sse_subscribers: dict[str, list[asyncio.Queue]] = {} 89 | self.subscriber_lock = asyncio.Lock() 90 | 91 | async def on_get_task(self, request: GetTaskRequest) -> GetTaskResponse: 92 | logger.info(f'Getting task {request.params.id}') 93 | task_query_params: TaskQueryParams = request.params 94 | 95 | async with self.lock: 96 | task = self.tasks.get(task_query_params.id) 97 | if task is None: 98 | return GetTaskResponse(id=request.id, error=TaskNotFoundError()) 99 | 100 | task_result = self.append_task_history( 101 | task, task_query_params.historyLength 102 | ) 103 | 104 | return GetTaskResponse(id=request.id, result=task_result) 105 | 106 | async def on_cancel_task( 107 | self, request: CancelTaskRequest 108 | ) -> CancelTaskResponse: 109 | logger.info(f'Cancelling task {request.params.id}') 110 | task_id_params: TaskIdParams = request.params 111 | 112 | async with self.lock: 113 | task = self.tasks.get(task_id_params.id) 114 | if task is None: 115 | return CancelTaskResponse( 116 | id=request.id, error=TaskNotFoundError() 117 | ) 118 | 119 | return CancelTaskResponse(id=request.id, error=TaskNotCancelableError()) 120 | 121 | @abstractmethod 122 | async def on_send_task(self, request: SendTaskRequest) -> SendTaskResponse: 123 | pass 124 | 125 | @abstractmethod 126 | async def on_send_task_subscribe( 127 | self, request: SendTaskStreamingRequest 128 | ) -> AsyncIterable[SendTaskStreamingResponse] | JSONRPCResponse: 129 | pass 130 | 131 | async def set_push_notification_info( 132 | self, task_id: str, notification_config: PushNotificationConfig 133 | ): 134 | async with self.lock: 135 | task = self.tasks.get(task_id) 136 | if task is None: 137 | raise ValueError(f'Task not found for {task_id}') 138 | 139 | self.push_notification_infos[task_id] = notification_config 140 | 141 | async def get_push_notification_info( 142 | self, task_id: str 143 | ) -> PushNotificationConfig: 144 | async with self.lock: 145 | task = self.tasks.get(task_id) 146 | if task is None: 147 | raise ValueError(f'Task not found for {task_id}') 148 | 149 | return self.push_notification_infos[task_id] 150 | 151 | 152 | 153 | async def has_push_notification_info(self, task_id: str) -> bool: 154 | async with self.lock: 155 | return task_id in self.push_notification_infos 156 | 157 | async def on_set_task_push_notification( 158 | self, request: SetTaskPushNotificationRequest 159 | ) -> SetTaskPushNotificationResponse: 160 | logger.info(f'Setting task push notification {request.params.id}') 161 | task_notification_params: TaskPushNotificationConfig = request.params 162 | 163 | try: 164 | await self.set_push_notification_info( 165 | task_notification_params.id, 166 | task_notification_params.pushNotificationConfig, 167 | ) 168 | except Exception as e: 169 | logger.error(f'Error while setting push notification info: {e}') 170 | return JSONRPCResponse( 171 | id=request.id, 172 | error=InternalError( 173 | message='An error occurred while setting push notification info' 174 | ), 175 | ) 176 | 177 | return SetTaskPushNotificationResponse( 178 | id=request.id, result=task_notification_params 179 | ) 180 | 181 | async def on_get_task_push_notification( 182 | self, request: GetTaskPushNotificationRequest 183 | ) -> GetTaskPushNotificationResponse: 184 | logger.info(f'Getting task push notification {request.params.id}') 185 | task_params: TaskIdParams = request.params 186 | 187 | try: 188 | notification_info = await self.get_push_notification_info( 189 | task_params.id 190 | ) 191 | except Exception as e: 192 | logger.error(f'Error while getting push notification info: {e}') 193 | return GetTaskPushNotificationResponse( 194 | id=request.id, 195 | error=InternalError( 196 | message='An error occurred while getting push notification info' 197 | ), 198 | ) 199 | 200 | return GetTaskPushNotificationResponse( 201 | id=request.id, 202 | result=TaskPushNotificationConfig( 203 | id=task_params.id, pushNotificationConfig=notification_info 204 | ), 205 | ) 206 | 207 | async def upsert_task(self, task_send_params: TaskSendParams) -> Task: 208 | logger.info(f'Upserting task {task_send_params.id}') 209 | async with self.lock: 210 | task = self.tasks.get(task_send_params.id) 211 | if task is None: 212 | task = Task( 213 | id=task_send_params.id, 214 | sessionId=task_send_params.sessionId, 215 | messages=[task_send_params.message], 216 | status=TaskStatus(state=TaskState.SUBMITTED), 217 | history=[task_send_params.message], 218 | ) 219 | self.tasks[task_send_params.id] = task 220 | else: 221 | task.history.append(task_send_params.message) 222 | 223 | return task 224 | 225 | async def on_resubscribe_to_task( 226 | self, request: TaskResubscriptionRequest 227 | ) -> AsyncIterable[SendTaskStreamingResponse] | JSONRPCResponse: 228 | return new_not_implemented_error(request.id) 229 | 230 | async def update_store( 231 | self, task_id: str, status: TaskStatus, artifacts: list[Artifact] 232 | ) -> Task: 233 | async with self.lock: 234 | try: 235 | task = self.tasks[task_id] 236 | except KeyError: 237 | logger.error(f'Task {task_id} not found for updating the task') 238 | raise ValueError(f'Task {task_id} not found') 239 | 240 | task.status = status 241 | 242 | if status.message is not None: 243 | task.history.append(status.message) 244 | 245 | if artifacts is not None: 246 | if task.artifacts is None: 247 | task.artifacts = [] 248 | task.artifacts.extend(artifacts) 249 | 250 | return task 251 | 252 | def append_task_history(self, task: Task, historyLength: int | None): 253 | new_task = task.model_copy() 254 | if historyLength is not None and historyLength > 0: 255 | new_task.history = new_task.history[-historyLength:] 256 | else: 257 | new_task.history = [] 258 | 259 | return new_task 260 | 261 | async def setup_sse_consumer( 262 | self, task_id: str, is_resubscribe: bool = False 263 | ): 264 | async with self.subscriber_lock: 265 | if task_id not in self.task_sse_subscribers: 266 | if is_resubscribe: 267 | raise ValueError('Task not found for resubscription') 268 | self.task_sse_subscribers[task_id] = [] 269 | 270 | sse_event_queue = asyncio.Queue(maxsize=0) # <=0 is unlimited 271 | self.task_sse_subscribers[task_id].append(sse_event_queue) 272 | return sse_event_queue 273 | 274 | async def enqueue_events_for_sse(self, task_id, task_update_event): 275 | async with self.subscriber_lock: 276 | if task_id not in self.task_sse_subscribers: 277 | return 278 | 279 | current_subscribers = self.task_sse_subscribers[task_id] 280 | for subscriber in current_subscribers: 281 | await subscriber.put(task_update_event) 282 | 283 | async def dequeue_events_for_sse( 284 | self, request_id, task_id, sse_event_queue: asyncio.Queue 285 | ) -> AsyncIterable[SendTaskStreamingResponse] | JSONRPCResponse: 286 | try: 287 | while True: 288 | event = await sse_event_queue.get() 289 | if isinstance(event, JSONRPCError): 290 | yield SendTaskStreamingResponse(id=request_id, error=event) 291 | break 292 | 293 | yield SendTaskStreamingResponse(id=request_id, result=event) 294 | if isinstance(event, TaskStatusUpdateEvent) and event.final: 295 | break 296 | finally: 297 | async with self.subscriber_lock: 298 | if task_id in self.task_sse_subscribers: 299 | self.task_sse_subscribers[task_id].remove(sse_event_queue) 300 | -------------------------------------------------------------------------------- /common/server/utils.py: -------------------------------------------------------------------------------- 1 | from common.types import ( 2 | ContentTypeNotSupportedError, 3 | JSONRPCResponse, 4 | UnsupportedOperationError, 5 | ) 6 | 7 | 8 | def are_modalities_compatible( 9 | server_output_modes: list[str], client_output_modes: list[str] 10 | ): 11 | """Modalities are compatible if they are both non-empty 12 | and there is at least one common element. 13 | """ 14 | if client_output_modes is None or len(client_output_modes) == 0: 15 | return True 16 | 17 | if server_output_modes is None or len(server_output_modes) == 0: 18 | return True 19 | 20 | return any(x in server_output_modes for x in client_output_modes) 21 | 22 | 23 | def new_incompatible_types_error(request_id): 24 | return JSONRPCResponse(id=request_id, error=ContentTypeNotSupportedError()) 25 | 26 | 27 | def new_not_implemented_error(request_id): 28 | return JSONRPCResponse(id=request_id, error=UnsupportedOperationError()) 29 | -------------------------------------------------------------------------------- /common/types.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from enum import Enum 3 | from typing import Annotated, Any, Literal, Self 4 | from uuid import uuid4 5 | 6 | from pydantic import ( 7 | BaseModel, 8 | ConfigDict, 9 | Field, 10 | TypeAdapter, 11 | field_serializer, 12 | model_validator, 13 | ) 14 | 15 | 16 | class TaskState(str, Enum): 17 | SUBMITTED = 'submitted' 18 | WORKING = 'working' 19 | INPUT_REQUIRED = 'input-required' 20 | COMPLETED = 'completed' 21 | CANCELED = 'canceled' 22 | FAILED = 'failed' 23 | UNKNOWN = 'unknown' 24 | 25 | 26 | class TextPart(BaseModel): 27 | type: Literal['text'] = 'text' 28 | text: str 29 | metadata: dict[str, Any] | None = None 30 | 31 | 32 | class FileContent(BaseModel): 33 | name: str | None = None 34 | mimeType: str | None = None 35 | bytes: str | None = None 36 | uri: str | None = None 37 | 38 | @model_validator(mode='after') 39 | def check_content(self) -> Self: 40 | if not (self.bytes or self.uri): 41 | raise ValueError( 42 | "Either 'bytes' or 'uri' must be present in the file data" 43 | ) 44 | if self.bytes and self.uri: 45 | raise ValueError( 46 | "Only one of 'bytes' or 'uri' can be present in the file data" 47 | ) 48 | return self 49 | 50 | 51 | class FilePart(BaseModel): 52 | type: Literal['file'] = 'file' 53 | file: FileContent 54 | metadata: dict[str, Any] | None = None 55 | 56 | 57 | class DataPart(BaseModel): 58 | type: Literal['data'] = 'data' 59 | data: dict[str, Any] 60 | metadata: dict[str, Any] | None = None 61 | 62 | 63 | Part = Annotated[TextPart | FilePart | DataPart, Field(discriminator='type')] 64 | 65 | 66 | class Message(BaseModel): 67 | role: Literal['user', 'agent'] 68 | parts: list[Part] 69 | metadata: dict[str, Any] | None = None 70 | 71 | 72 | class TaskStatus(BaseModel): 73 | state: TaskState 74 | message: Message | None = None 75 | timestamp: datetime = Field(default_factory=datetime.now) 76 | 77 | @field_serializer('timestamp') 78 | def serialize_dt(self, dt: datetime, _info): 79 | return dt.isoformat() 80 | 81 | 82 | class Artifact(BaseModel): 83 | name: str | None = None 84 | description: str | None = None 85 | parts: list[Part] 86 | metadata: dict[str, Any] | None = None 87 | index: int = 0 88 | append: bool | None = None 89 | lastChunk: bool | None = None 90 | 91 | 92 | class Task(BaseModel): 93 | id: str 94 | sessionId: str | None = None 95 | status: TaskStatus 96 | artifacts: list[Artifact] | None = None 97 | history: list[Message] | None = None 98 | metadata: dict[str, Any] | None = None 99 | 100 | 101 | class TaskStatusUpdateEvent(BaseModel): 102 | id: str 103 | status: TaskStatus 104 | final: bool = False 105 | metadata: dict[str, Any] | None = None 106 | 107 | 108 | class TaskArtifactUpdateEvent(BaseModel): 109 | id: str 110 | artifact: Artifact 111 | metadata: dict[str, Any] | None = None 112 | 113 | 114 | class AuthenticationInfo(BaseModel): 115 | model_config = ConfigDict(extra='allow') 116 | 117 | schemes: list[str] 118 | credentials: str | None = None 119 | 120 | 121 | class PushNotificationConfig(BaseModel): 122 | url: str 123 | token: str | None = None 124 | authentication: AuthenticationInfo | None = None 125 | 126 | 127 | class TaskIdParams(BaseModel): 128 | id: str 129 | metadata: dict[str, Any] | None = None 130 | 131 | 132 | class TaskQueryParams(TaskIdParams): 133 | historyLength: int | None = None 134 | 135 | 136 | class TaskSendParams(BaseModel): 137 | id: str 138 | sessionId: str = Field(default_factory=lambda: uuid4().hex) 139 | message: Message 140 | acceptedOutputModes: list[str] | None = None 141 | pushNotification: PushNotificationConfig | None = None 142 | historyLength: int | None = None 143 | metadata: dict[str, Any] | None = None 144 | 145 | 146 | class TaskPushNotificationConfig(BaseModel): 147 | id: str 148 | pushNotificationConfig: PushNotificationConfig 149 | 150 | 151 | ## RPC Messages 152 | 153 | 154 | class JSONRPCMessage(BaseModel): 155 | jsonrpc: Literal['2.0'] = '2.0' 156 | id: int | str | None = Field(default_factory=lambda: uuid4().hex) 157 | 158 | 159 | class JSONRPCRequest(JSONRPCMessage): 160 | method: str 161 | params: dict[str, Any] | None = None 162 | 163 | 164 | class JSONRPCError(BaseModel): 165 | code: int 166 | message: str 167 | data: Any | None = None 168 | 169 | 170 | class JSONRPCResponse(JSONRPCMessage): 171 | result: Any | None = None 172 | error: JSONRPCError | None = None 173 | 174 | 175 | class SendTaskRequest(JSONRPCRequest): 176 | method: Literal['tasks/send'] = 'tasks/send' 177 | params: TaskSendParams 178 | 179 | 180 | class SendTaskResponse(JSONRPCResponse): 181 | result: Task | None = None 182 | 183 | 184 | class SendTaskStreamingRequest(JSONRPCRequest): 185 | method: Literal['tasks/sendSubscribe'] = 'tasks/sendSubscribe' 186 | params: TaskSendParams 187 | 188 | 189 | class SendTaskStreamingResponse(JSONRPCResponse): 190 | result: TaskStatusUpdateEvent | TaskArtifactUpdateEvent | None = None 191 | 192 | 193 | class GetTaskRequest(JSONRPCRequest): 194 | method: Literal['tasks/get'] = 'tasks/get' 195 | params: TaskQueryParams 196 | 197 | 198 | class GetTaskResponse(JSONRPCResponse): 199 | result: Task | None = None 200 | 201 | 202 | class CancelTaskRequest(JSONRPCRequest): 203 | method: Literal['tasks/cancel',] = 'tasks/cancel' 204 | params: TaskIdParams 205 | 206 | 207 | class CancelTaskResponse(JSONRPCResponse): 208 | result: Task | None = None 209 | 210 | 211 | class SetTaskPushNotificationRequest(JSONRPCRequest): 212 | method: Literal['tasks/pushNotification/set',] = ( 213 | 'tasks/pushNotification/set' 214 | ) 215 | params: TaskPushNotificationConfig 216 | 217 | 218 | class SetTaskPushNotificationResponse(JSONRPCResponse): 219 | result: TaskPushNotificationConfig | None = None 220 | 221 | 222 | class GetTaskPushNotificationRequest(JSONRPCRequest): 223 | method: Literal['tasks/pushNotification/get',] = ( 224 | 'tasks/pushNotification/get' 225 | ) 226 | params: TaskIdParams 227 | 228 | 229 | class GetTaskPushNotificationResponse(JSONRPCResponse): 230 | result: TaskPushNotificationConfig | None = None 231 | 232 | 233 | class TaskResubscriptionRequest(JSONRPCRequest): 234 | method: Literal['tasks/resubscribe',] = 'tasks/resubscribe' 235 | params: TaskIdParams 236 | 237 | 238 | A2ARequest = TypeAdapter( 239 | Annotated[ 240 | SendTaskRequest 241 | | GetTaskRequest 242 | | CancelTaskRequest 243 | | SetTaskPushNotificationRequest 244 | | GetTaskPushNotificationRequest 245 | | TaskResubscriptionRequest 246 | | SendTaskStreamingRequest, 247 | Field(discriminator='method'), 248 | ] 249 | ) 250 | 251 | ## Error types 252 | 253 | 254 | class JSONParseError(JSONRPCError): 255 | code: int = -32700 256 | message: str = 'Invalid JSON payload' 257 | data: Any | None = None 258 | 259 | 260 | class InvalidRequestError(JSONRPCError): 261 | code: int = -32600 262 | message: str = 'Request payload validation error' 263 | data: Any | None = None 264 | 265 | 266 | class MethodNotFoundError(JSONRPCError): 267 | code: int = -32601 268 | message: str = 'Method not found' 269 | data: None = None 270 | 271 | 272 | class InvalidParamsError(JSONRPCError): 273 | code: int = -32602 274 | message: str = 'Invalid parameters' 275 | data: Any | None = None 276 | 277 | 278 | class InternalError(JSONRPCError): 279 | code: int = -32603 280 | message: str = 'Internal error' 281 | data: Any | None = None 282 | 283 | 284 | class TaskNotFoundError(JSONRPCError): 285 | code: int = -32001 286 | message: str = 'Task not found' 287 | data: None = None 288 | 289 | 290 | class TaskNotCancelableError(JSONRPCError): 291 | code: int = -32002 292 | message: str = 'Task cannot be canceled' 293 | data: None = None 294 | 295 | 296 | class PushNotificationNotSupportedError(JSONRPCError): 297 | code: int = -32003 298 | message: str = 'Push Notification is not supported' 299 | data: None = None 300 | 301 | 302 | class UnsupportedOperationError(JSONRPCError): 303 | code: int = -32004 304 | message: str = 'This operation is not supported' 305 | data: None = None 306 | 307 | 308 | class ContentTypeNotSupportedError(JSONRPCError): 309 | code: int = -32005 310 | message: str = 'Incompatible content types' 311 | data: None = None 312 | 313 | 314 | class AgentProvider(BaseModel): 315 | organization: str 316 | url: str | None = None 317 | 318 | 319 | class AgentCapabilities(BaseModel): 320 | streaming: bool = False 321 | pushNotifications: bool = False 322 | stateTransitionHistory: bool = False 323 | 324 | 325 | class AgentAuthentication(BaseModel): 326 | schemes: list[str] 327 | credentials: str | None = None 328 | 329 | 330 | class AgentSkill(BaseModel): 331 | id: str 332 | name: str 333 | description: str | None = None 334 | tags: list[str] | None = None 335 | examples: list[str] | None = None 336 | inputModes: list[str] | None = None 337 | outputModes: list[str] | None = None 338 | 339 | 340 | class AgentCard(BaseModel): 341 | name: str 342 | description: str | None = None 343 | url: str 344 | provider: AgentProvider | None = None 345 | version: str 346 | documentationUrl: str | None = None 347 | capabilities: AgentCapabilities 348 | authentication: AgentAuthentication | None = None 349 | defaultInputModes: list[str] = ['text'] 350 | defaultOutputModes: list[str] = ['text'] 351 | skills: list[AgentSkill] 352 | 353 | 354 | class A2AClientError(Exception): 355 | pass 356 | 357 | 358 | class A2AClientHTTPError(A2AClientError): 359 | def __init__(self, status_code: int, message: str): 360 | self.status_code = status_code 361 | self.message = message 362 | super().__init__(f'HTTP Error {status_code}: {message}') 363 | 364 | 365 | class A2AClientJSONError(A2AClientError): 366 | def __init__(self, message: str): 367 | self.message = message 368 | super().__init__(f'JSON Error: {message}') 369 | 370 | 371 | class MissingAPIKeyError(Exception): 372 | """Exception for missing API key.""" 373 | -------------------------------------------------------------------------------- /common/utils/in_memory_cache.py: -------------------------------------------------------------------------------- 1 | """In Memory Cache utility.""" 2 | 3 | import threading 4 | import time 5 | 6 | from typing import Any, Optional 7 | 8 | 9 | class InMemoryCache: 10 | """A thread-safe Singleton class to manage cache data. 11 | 12 | Ensures only one instance of the cache exists across the application. 13 | """ 14 | 15 | _instance: Optional['InMemoryCache'] = None 16 | _lock: threading.Lock = threading.Lock() 17 | _initialized: bool = False 18 | 19 | def __new__(cls): 20 | """Override __new__ to control instance creation (Singleton pattern). 21 | 22 | Uses a lock to ensure thread safety during the first instantiation. 23 | 24 | Returns: 25 | The singleton instance of InMemoryCache. 26 | """ 27 | if cls._instance is None: 28 | with cls._lock: 29 | if cls._instance is None: 30 | cls._instance = super().__new__(cls) 31 | return cls._instance 32 | 33 | def __init__(self): 34 | """Initialize the cache storage. 35 | 36 | Uses a flag (_initialized) to ensure this logic runs only on the very first 37 | creation of the singleton instance. 38 | """ 39 | if not self._initialized: 40 | with self._lock: 41 | if not self._initialized: 42 | # print("Initializing SessionCache storage") 43 | self._cache_data: dict[str, dict[str, Any]] = {} 44 | self._ttl: dict[str, float] = {} 45 | self._data_lock: threading.Lock = threading.Lock() 46 | self._initialized = True 47 | 48 | def set(self, key: str, value: Any, ttl: int | None = None) -> None: 49 | """Set a key-value pair. 50 | 51 | Args: 52 | key: The key for the data. 53 | value: The data to store. 54 | ttl: Time to live in seconds. If None, data will not expire. 55 | """ 56 | with self._data_lock: 57 | self._cache_data[key] = value 58 | 59 | if ttl is not None: 60 | self._ttl[key] = time.time() + ttl 61 | elif key in self._ttl: 62 | del self._ttl[key] 63 | 64 | def get(self, key: str, default: Any = None) -> Any: 65 | """Get the value associated with a key. 66 | 67 | Args: 68 | key: The key for the data within the session. 69 | default: The value to return if the session or key is not found. 70 | 71 | Returns: 72 | The cached value, or the default value if not found. 73 | """ 74 | with self._data_lock: 75 | if key in self._ttl and time.time() > self._ttl[key]: 76 | del self._cache_data[key] 77 | del self._ttl[key] 78 | return default 79 | return self._cache_data.get(key, default) 80 | 81 | def delete(self, key: str) -> None: 82 | """Delete a specific key-value pair from a cache. 83 | 84 | Args: 85 | key: The key to delete. 86 | 87 | Returns: 88 | True if the key was found and deleted, False otherwise. 89 | """ 90 | with self._data_lock: 91 | if key in self._cache_data: 92 | del self._cache_data[key] 93 | if key in self._ttl: 94 | del self._ttl[key] 95 | return True 96 | return False 97 | 98 | def clear(self) -> bool: 99 | """Remove all data. 100 | 101 | Returns: 102 | True if the data was cleared, False otherwise. 103 | """ 104 | with self._data_lock: 105 | self._cache_data.clear() 106 | self._ttl.clear() 107 | return True 108 | return False 109 | -------------------------------------------------------------------------------- /common/utils/push_notification_auth.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import json 3 | import logging 4 | import time 5 | import uuid 6 | 7 | from typing import Any 8 | 9 | import httpx 10 | import jwt 11 | 12 | from jwcrypto import jwk 13 | from jwt import PyJWK, PyJWKClient 14 | from starlette.requests import Request 15 | from starlette.responses import JSONResponse 16 | 17 | 18 | logger = logging.getLogger(__name__) 19 | AUTH_HEADER_PREFIX = 'Bearer ' 20 | 21 | 22 | class PushNotificationAuth: 23 | def _calculate_request_body_sha256(self, data: dict[str, Any]): 24 | """Calculates the SHA256 hash of a request body. 25 | 26 | This logic needs to be same for both the agent who signs the payload and the client verifier. 27 | """ 28 | body_str = json.dumps( 29 | data, 30 | ensure_ascii=False, 31 | allow_nan=False, 32 | indent=None, 33 | separators=(',', ':'), 34 | ) 35 | return hashlib.sha256(body_str.encode()).hexdigest() 36 | 37 | 38 | class PushNotificationSenderAuth(PushNotificationAuth): 39 | def __init__(self): 40 | self.public_keys = [] 41 | self.private_key_jwk: PyJWK = None 42 | 43 | @staticmethod 44 | async def verify_push_notification_url(url: str) -> bool: 45 | async with httpx.AsyncClient(timeout=10) as client: 46 | try: 47 | validation_token = str(uuid.uuid4()) 48 | response = await client.get( 49 | url, params={'validationToken': validation_token} 50 | ) 51 | response.raise_for_status() 52 | is_verified = response.text == validation_token 53 | 54 | logger.info( 55 | f'Verified push-notification URL: {url} => {is_verified}' 56 | ) 57 | return is_verified 58 | except Exception as e: 59 | logger.warning( 60 | f'Error during sending push-notification for URL {url}: {e}' 61 | ) 62 | 63 | return False 64 | 65 | def generate_jwk(self): 66 | key = jwk.JWK.generate( 67 | kty='RSA', size=2048, kid=str(uuid.uuid4()), use='sig' 68 | ) 69 | self.public_keys.append(key.export_public(as_dict=True)) 70 | self.private_key_jwk = PyJWK.from_json(key.export_private()) 71 | 72 | def handle_jwks_endpoint(self, _request: Request): 73 | """Allow clients to fetch public keys.""" 74 | return JSONResponse({'keys': self.public_keys}) 75 | 76 | def _generate_jwt(self, data: dict[str, Any]): 77 | """JWT is generated by signing both the request payload SHA digest and time of token generation. 78 | 79 | Payload is signed with private key and it ensures the integrity of payload for client. 80 | Including iat prevents from replay attack. 81 | """ 82 | iat = int(time.time()) 83 | 84 | return jwt.encode( 85 | { 86 | 'iat': iat, 87 | 'request_body_sha256': self._calculate_request_body_sha256( 88 | data 89 | ), 90 | }, 91 | key=self.private_key_jwk, 92 | headers={'kid': self.private_key_jwk.key_id}, 93 | algorithm='RS256', 94 | ) 95 | 96 | async def send_push_notification(self, url: str, data: dict[str, Any]): 97 | jwt_token = self._generate_jwt(data) 98 | headers = {'Authorization': f'Bearer {jwt_token}'} 99 | async with httpx.AsyncClient(timeout=10) as client: 100 | try: 101 | response = await client.post(url, json=data, headers=headers) 102 | response.raise_for_status() 103 | logger.info(f'Push-notification sent for URL: {url}') 104 | except Exception as e: 105 | logger.warning( 106 | f'Error during sending push-notification for URL {url}: {e}' 107 | ) 108 | 109 | 110 | class PushNotificationReceiverAuth(PushNotificationAuth): 111 | def __init__(self): 112 | self.public_keys_jwks = [] 113 | self.jwks_client = None 114 | 115 | async def load_jwks(self, jwks_url: str): 116 | self.jwks_client = PyJWKClient(jwks_url) 117 | 118 | async def verify_push_notification(self, request: Request) -> bool: 119 | auth_header = request.headers.get('Authorization') 120 | if not auth_header or not auth_header.startswith(AUTH_HEADER_PREFIX): 121 | print('Invalid authorization header') 122 | return False 123 | 124 | token = auth_header[len(AUTH_HEADER_PREFIX) :] 125 | signing_key = self.jwks_client.get_signing_key_from_jwt(token) 126 | 127 | decode_token = jwt.decode( 128 | token, 129 | signing_key, 130 | options={'require': ['iat', 'request_body_sha256']}, 131 | algorithms=['RS256'], 132 | ) 133 | 134 | actual_body_sha256 = self._calculate_request_body_sha256( 135 | await request.json() 136 | ) 137 | if actual_body_sha256 != decode_token['request_body_sha256']: 138 | # Payload signature does not match the digest in signed token. 139 | raise ValueError('Invalid request body') 140 | 141 | if time.time() - decode_token['iat'] > 60 * 5: 142 | # Do not allow push-notifications older than 5 minutes. 143 | # This is to prevent replay attack. 144 | raise ValueError('Token is expired') 145 | 146 | return True 147 | -------------------------------------------------------------------------------- /config_creator.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | A2A-MCP-Server Configuration Creator for Claude Desktop 4 | 5 | This script helps users create the correct configuration entry for their 6 | claude_desktop_config.json file to use the A2A-MCP-Server with Claude Desktop. 7 | """ 8 | 9 | import json 10 | import os 11 | import platform 12 | import sys 13 | from pathlib import Path 14 | 15 | def get_config_path(): 16 | """Return the default path to the Claude Desktop config file based on the operating system.""" 17 | if platform.system() == "Windows": 18 | return os.path.join(os.environ.get("APPDATA", ""), "Claude", "claude_desktop_config.json") 19 | elif platform.system() == "Darwin": # macOS 20 | return os.path.expanduser("~/Library/Application Support/Claude/claude_desktop_config.json") 21 | else: # Linux and others 22 | return os.path.expanduser("~/.config/Claude/claude_desktop_config.json") 23 | 24 | def get_python_path(): 25 | """Return the path to the current Python executable.""" 26 | return sys.executable 27 | 28 | def find_script_path(): 29 | """Try to find the default a2a_mcp_server.py path.""" 30 | # Check current directory 31 | current_dir = os.getcwd() 32 | script_path = os.path.join(current_dir, "a2a_mcp_server.py") 33 | if os.path.exists(script_path): 34 | return script_path 35 | 36 | # Check parent directory 37 | parent_dir = os.path.dirname(current_dir) 38 | script_path = os.path.join(parent_dir, "a2a_mcp_server.py") 39 | if os.path.exists(script_path): 40 | return script_path 41 | 42 | # Common structure: Check A2A-MCP-Server directory 43 | script_path = os.path.join(current_dir, "A2A-MCP-Server", "a2a_mcp_server.py") 44 | if os.path.exists(script_path): 45 | return script_path 46 | 47 | # Check Desktop location 48 | home_dir = os.path.expanduser("~") 49 | desktop_path = os.path.join(home_dir, "Desktop", "A2A-MCP-Server", "a2a_mcp_server.py") 50 | if os.path.exists(desktop_path): 51 | return desktop_path 52 | 53 | # If no default found, return empty string 54 | return "" 55 | 56 | def find_repo_path(): 57 | """Try to find the default A2A-MCP-Server repository path.""" 58 | # Check if current directory is the repository 59 | current_dir = os.getcwd() 60 | if os.path.exists(os.path.join(current_dir, "a2a_mcp_server.py")): 61 | return current_dir 62 | 63 | # Check parent directory 64 | parent_dir = os.path.dirname(current_dir) 65 | if os.path.exists(os.path.join(parent_dir, "a2a_mcp_server.py")): 66 | return parent_dir 67 | 68 | # Check A2A-MCP-Server directory 69 | repo_path = os.path.join(current_dir, "A2A-MCP-Server") 70 | if os.path.exists(repo_path): 71 | return repo_path 72 | 73 | # Check Desktop location 74 | home_dir = os.path.expanduser("~") 75 | desktop_path = os.path.join(home_dir, "Desktop", "A2A-MCP-Server") 76 | if os.path.exists(desktop_path): 77 | return desktop_path 78 | 79 | # If no default found, return empty string 80 | return "" 81 | 82 | def create_pypi_config(): 83 | """Create configuration for PyPI installation.""" 84 | # For Claude Desktop, we must use stdio transport 85 | env = { 86 | "MCP_TRANSPORT": "stdio" 87 | } 88 | 89 | config = { 90 | "command": "uvx", 91 | "args": [ 92 | "a2a-mcp-server" 93 | ], 94 | "env": env 95 | } 96 | 97 | # Ask if user wants to add any additional environment variables 98 | if input("Do you want to add additional environment variables? (y/n): ").lower() == 'y': 99 | print("Enter environment variables (empty name to finish):") 100 | while True: 101 | name = input("Environment variable name: ").strip() 102 | if not name: 103 | break 104 | value = input(f"Value for {name}: ").strip() 105 | env[name] = value 106 | 107 | return config 108 | 109 | def create_local_config(): 110 | """Create configuration for local installation.""" 111 | # Get Python executable 112 | python_path = input(f"Python executable path (default: {get_python_path()}): ").strip() 113 | if not python_path: 114 | python_path = get_python_path() 115 | 116 | # Get script path with default 117 | default_script_path = find_script_path() 118 | script_prompt = f"Path to a2a_mcp_server.py" 119 | if default_script_path: 120 | script_prompt += f" (default: {default_script_path})" 121 | script_prompt += ": " 122 | 123 | script_path = input(script_prompt).strip() 124 | if not script_path and default_script_path: 125 | script_path = default_script_path 126 | 127 | while not script_path or not os.path.exists(script_path): 128 | print("Error: File does not exist.") 129 | script_path = input(script_prompt).strip() 130 | if not script_path and default_script_path: 131 | script_path = default_script_path 132 | if os.path.exists(script_path): 133 | break 134 | 135 | # Get repo path for PYTHONPATH with default 136 | default_repo_path = find_repo_path() 137 | repo_prompt = f"Path to A2A-MCP-Server repository" 138 | if default_repo_path: 139 | repo_prompt += f" (default: {default_repo_path})" 140 | repo_prompt += ": " 141 | 142 | repo_path = input(repo_prompt).strip() 143 | if not repo_path and default_repo_path: 144 | repo_path = default_repo_path 145 | 146 | while not repo_path or not os.path.exists(repo_path): 147 | print("Error: Directory does not exist.") 148 | repo_path = input(repo_prompt).strip() 149 | if not repo_path and default_repo_path: 150 | repo_path = default_repo_path 151 | if os.path.exists(repo_path): 152 | break 153 | 154 | # For Claude Desktop, we must use stdio transport 155 | env = { 156 | "MCP_TRANSPORT": "stdio", 157 | "PYTHONPATH": repo_path 158 | } 159 | 160 | config = { 161 | "command": python_path, 162 | "args": [ 163 | script_path 164 | ], 165 | "env": env 166 | } 167 | 168 | # Ask if user wants to add other environment variables 169 | if input("Do you want to add additional environment variables? (y/n): ").lower() == 'y': 170 | print("Enter environment variables (empty name to finish):") 171 | while True: 172 | name = input("Environment variable name: ").strip() 173 | if not name: 174 | break 175 | value = input(f"Value for {name}: ").strip() 176 | config["env"][name] = value 177 | 178 | return config 179 | 180 | def update_claude_config(config, key_name): 181 | """Update the Claude Desktop config file with the new A2A MCP Server configuration.""" 182 | config_path = get_config_path() 183 | 184 | try: 185 | # Check if the config file exists 186 | if os.path.exists(config_path): 187 | with open(config_path, 'r') as f: 188 | try: 189 | full_config = json.load(f) 190 | except json.JSONDecodeError: 191 | print(f"Error: The config file at {config_path} contains invalid JSON.") 192 | return False 193 | 194 | # Initialize mcpServers if it doesn't exist 195 | if 'mcpServers' not in full_config: 196 | full_config['mcpServers'] = {} 197 | 198 | # Add or update the A2A MCP Server configuration 199 | full_config['mcpServers'][key_name] = config 200 | 201 | # Write the updated config back to the file 202 | with open(config_path, 'w') as f: 203 | json.dump(full_config, f, indent=2) 204 | 205 | print(f"Successfully updated Claude Desktop config at {config_path}") 206 | return True 207 | else: 208 | print(f"Config file not found at {config_path}. Creating a new one.") 209 | os.makedirs(os.path.dirname(config_path), exist_ok=True) 210 | 211 | full_config = { 212 | 'mcpServers': { 213 | key_name: config 214 | } 215 | } 216 | 217 | with open(config_path, 'w') as f: 218 | json.dump(full_config, f, indent=2) 219 | 220 | print(f"Created new Claude Desktop config at {config_path}") 221 | return True 222 | 223 | except Exception as e: 224 | print(f"Error updating config file: {e}") 225 | return False 226 | 227 | def main(): 228 | """Main function to run the configuration creator.""" 229 | print("=" * 80) 230 | print("A2A-MCP-Server Configuration Creator for Claude Desktop") 231 | print("=" * 80) 232 | print("\nThis script will help you create a configuration entry for Claude Desktop.") 233 | print("The configuration will be saved to your claude_desktop_config.json file.") 234 | print("\nImportant: For Claude Desktop, the MCP_TRANSPORT will be set to 'stdio'") 235 | print("as this is required for Claude Desktop to communicate with MCP servers.") 236 | print("\nChoose your installation method:") 237 | 238 | choice = None 239 | while choice not in ('1', '2'): 240 | print("1. PyPI Installation (installed with pip)") 241 | print("2. Local Installation (cloned from repository)") 242 | choice = input("Enter your choice (1/2): ") 243 | 244 | key_name = input("\nName for this MCP server in Claude Desktop (default: a2a): ").strip() 245 | if not key_name: 246 | key_name = "a2a" 247 | 248 | if choice == '1': 249 | config = create_pypi_config() 250 | else: 251 | config = create_local_config() 252 | 253 | # Display the generated configuration 254 | print("\nGenerated Configuration:") 255 | print(json.dumps({key_name: config}, indent=2)) 256 | 257 | # Check config for stdio transport 258 | if config.get("env", {}).get("MCP_TRANSPORT") != "stdio": 259 | print("\nWARNING: MCP_TRANSPORT is not set to 'stdio'. Claude Desktop requires stdio transport.") 260 | if input("Set MCP_TRANSPORT to 'stdio'? (y/n): ").lower() == 'y': 261 | if "env" not in config: 262 | config["env"] = {} 263 | config["env"]["MCP_TRANSPORT"] = "stdio" 264 | print("MCP_TRANSPORT set to 'stdio'.") 265 | print("\nUpdated Configuration:") 266 | print(json.dumps({key_name: config}, indent=2)) 267 | 268 | # Ask if the user wants to update their Claude Desktop config 269 | if input("\nDo you want to update your Claude Desktop config file? (y/n): ").lower() == 'y': 270 | update_claude_config(config, key_name) 271 | else: 272 | print("\nConfiguration not saved. You can manually add it to your claude_desktop_config.json file.") 273 | 274 | print("\nDone!") 275 | 276 | if __name__ == "__main__": 277 | main() 278 | -------------------------------------------------------------------------------- /persistence_utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Utilities for persisting data to disk and loading it on startup. 3 | """ 4 | 5 | import json 6 | import os 7 | from typing import Dict, Any 8 | import logging 9 | 10 | # Set up logging 11 | logging.basicConfig(level=logging.INFO) 12 | logger = logging.getLogger(__name__) 13 | 14 | def save_to_json(data: Dict[str, Any], filename: str) -> bool: 15 | """ 16 | Save dictionary data to a JSON file. 17 | 18 | Args: 19 | data: The dictionary to save 20 | filename: The filename to save to 21 | 22 | Returns: 23 | True if successful, False otherwise 24 | """ 25 | try: 26 | with open(filename, 'w') as f: 27 | json.dump(data, f, indent=2) 28 | return True 29 | except Exception as e: 30 | logger.error(f"Error saving data to {filename}: {str(e)}") 31 | return False 32 | 33 | def load_from_json(filename: str) -> Dict[str, Any]: 34 | """ 35 | Load dictionary data from a JSON file. 36 | 37 | Args: 38 | filename: The filename to load from 39 | 40 | Returns: 41 | The loaded dictionary, or an empty dictionary if the file doesn't exist 42 | """ 43 | if not os.path.exists(filename): 44 | return {} 45 | 46 | try: 47 | with open(filename, 'r') as f: 48 | return json.load(f) 49 | except Exception as e: 50 | logger.error(f"Error loading data from {filename}: {str(e)}") 51 | return {} 52 | -------------------------------------------------------------------------------- /public/agent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GongRzhe/A2A-MCP-Server/bc515a5a1b7679272364bf2356ad646b76a83a9a/public/agent.png -------------------------------------------------------------------------------- /public/register.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GongRzhe/A2A-MCP-Server/bc515a5a1b7679272364bf2356ad646b76a83a9a/public/register.png -------------------------------------------------------------------------------- /public/task.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GongRzhe/A2A-MCP-Server/bc515a5a1b7679272364bf2356ad646b76a83a9a/public/task.png -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "a2a_mcp_server" 3 | version = "0.1.5" 4 | description = "A bridge server that connects Model Context Protocol (MCP) with Agent-to-Agent (A2A) protocol" 5 | readme = "README.md" 6 | requires-python = ">=3.11" 7 | license = {text = "Apache-2.0"} 8 | authors = [ 9 | {name = "GongRzhe", email = "gongrzhe@gmail.com"} 10 | ] 11 | maintainers = [ 12 | {name = "GongRzhe", email = "gongrzhe@gmail.com"} 13 | ] 14 | keywords = ["MCP", "A2A", "Agent-to-Agent", "Model Context Protocol", "AI", "LLM"] 15 | classifiers = [ 16 | "Development Status :: 4 - Beta", 17 | "Intended Audience :: Developers", 18 | "License :: OSI Approved :: Apache Software License", 19 | "Programming Language :: Python :: 3", 20 | "Programming Language :: Python :: 3.11", 21 | "Topic :: Software Development :: Libraries :: Python Modules", 22 | "Topic :: Scientific/Engineering :: Artificial Intelligence", 23 | ] 24 | dependencies = [ 25 | "httpx>=0.28.1", 26 | "httpx-sse>=0.4.0", 27 | "jwcrypto>=1.5.6", 28 | "pydantic>=2.10.6", 29 | "pyjwt>=2.10.1", 30 | "sse-starlette>=2.2.1", 31 | "starlette>=0.46.1", 32 | "typing-extensions>=4.12.2", 33 | "uvicorn>=0.34.0", 34 | "fastmcp>=2.8.1", 35 | ] 36 | 37 | [project.urls] 38 | "Homepage" = "https://github.com/GongRzhe/A2A-MCP-Server" 39 | "Bug Tracker" = "https://github.com/GongRzhe/A2A-MCP-Server/issues" 40 | "Repository" = "https://github.com/GongRzhe/A2A-MCP-Server" 41 | "Documentation" = "https://github.com/GongRzhe/A2A-MCP-Server/blob/main/README.md" 42 | 43 | # This section creates the CLI command 44 | [project.scripts] 45 | a2a-mcp-server = "a2a_mcp_server:main" 46 | 47 | [build-system] 48 | requires = ["hatchling"] 49 | build-backend = "hatchling.build" 50 | 51 | # This section tells hatchling to include both your main script and the common module 52 | [tool.hatch.build] 53 | include = [ 54 | "a2a_mcp_server.py", 55 | "persistence_utils.py", 56 | "common/**/*.py", 57 | ] 58 | 59 | # No longer needed - using the include directive above 60 | # [tool.hatch.build.targets.wheel] 61 | # packages = ["common"] 62 | 63 | [project.optional-dependencies] 64 | dev = ["pytest>=8.3.5", "pytest-mock>=3.14.0", "ruff>=0.11.2"] -------------------------------------------------------------------------------- /registered_agents.json: -------------------------------------------------------------------------------- 1 | { 2 | "http://localhost:10000/": { 3 | "url": "http://localhost:10000/", 4 | "name": "Currency Agent", 5 | "description": "Helps with exchange rates for currencies" 6 | }, 7 | "http://localhost:10002/": { 8 | "url": "http://localhost:10002/", 9 | "name": "Reimbursement Agent", 10 | "description": "This agent handles the reimbursement process for the employees given the amount and purpose of the reimbursement." 11 | } 12 | } -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # Main project dependencies 2 | httpx>=0.28.1 3 | httpx-sse>=0.4.0 4 | jwcrypto>=1.5.6 5 | pydantic>=2.10.6 6 | pyjwt>=2.10.1 7 | sse-starlette>=2.2.1 8 | starlette>=0.46.1 9 | typing-extensions>=4.12.2 10 | uvicorn>=0.34.0 11 | fastmcp>=2.3.4 12 | 13 | -------------------------------------------------------------------------------- /smithery.yaml: -------------------------------------------------------------------------------- 1 | # Smithery configuration file: https://smithery.ai/docs/build/project-config 2 | 3 | startCommand: 4 | type: stdio 5 | commandFunction: 6 | # A JS function that produces the CLI command based on the given config to start the MCP on stdio. 7 | |- 8 | (config) => ({ command: 'python', args: ['a2a_mcp_server.py'], env: { MCP_TRANSPORT: config.mcpTransport, MCP_HOST: config.mcpHost, MCP_PORT: config.mcpPort.toString() } }) 9 | configSchema: 10 | # JSON Schema defining the configuration options for the MCP. 11 | type: object 12 | required: [] 13 | properties: 14 | mcpTransport: 15 | type: string 16 | default: stdio 17 | description: Transport type for MCP communication 18 | mcpHost: 19 | type: string 20 | default: 0.0.0.0 21 | description: Host for the MCP server 22 | mcpPort: 23 | type: number 24 | default: 8000 25 | description: Port for the MCP server (ignored for stdio) 26 | exampleConfig: 27 | mcpTransport: stdio 28 | mcpHost: 0.0.0.0 29 | mcpPort: 8000 30 | -------------------------------------------------------------------------------- /task_agent_mapping.json: -------------------------------------------------------------------------------- 1 | { 2 | "cf889c49-e43f-4042-a10c-4be616bbc42f": "http://localhost:10000/", 3 | "8182cf14-944e-4445-ad2c-d4f7da05ae33": "http://localhost:10000/", 4 | "e7c69626-cef1-4d51-938f-8981e86b16ba": "http://localhost:10002/", 5 | "f743b08e-02a1-4377-9e7d-c9ee98649f77": "http://localhost:10002/", 6 | "a72d3a1d-b715-45b5-b71f-faac4ec407bd": "http://localhost:10002/" 7 | } --------------------------------------------------------------------------------