universal-mcp-agents 0.1.14__py3-none-any.whl → 0.1.16__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of universal-mcp-agents might be problematic. Click here for more details.

Files changed (47) hide show
  1. universal_mcp/agents/__init__.py +1 -1
  2. universal_mcp/agents/base.py +2 -1
  3. universal_mcp/agents/bigtool/__main__.py +4 -3
  4. universal_mcp/agents/bigtool/agent.py +1 -0
  5. universal_mcp/agents/bigtool/graph.py +7 -4
  6. universal_mcp/agents/bigtool/tools.py +4 -5
  7. universal_mcp/agents/builder/__main__.py +49 -23
  8. universal_mcp/agents/builder/builder.py +101 -102
  9. universal_mcp/agents/builder/helper.py +4 -6
  10. universal_mcp/agents/builder/prompts.py +92 -39
  11. universal_mcp/agents/builder/state.py +1 -1
  12. universal_mcp/agents/codeact0/__init__.py +2 -1
  13. universal_mcp/agents/codeact0/agent.py +12 -5
  14. universal_mcp/agents/codeact0/langgraph_agent.py +11 -14
  15. universal_mcp/agents/codeact0/llm_tool.py +2 -2
  16. universal_mcp/agents/codeact0/playbook_agent.py +364 -0
  17. universal_mcp/agents/codeact0/prompts.py +113 -39
  18. universal_mcp/agents/codeact0/sandbox.py +43 -32
  19. universal_mcp/agents/codeact0/state.py +29 -3
  20. universal_mcp/agents/codeact0/tools.py +186 -0
  21. universal_mcp/agents/codeact0/utils.py +53 -18
  22. universal_mcp/agents/shared/__main__.py +3 -2
  23. universal_mcp/agents/shared/prompts.py +1 -1
  24. universal_mcp/agents/shared/tool_node.py +17 -12
  25. universal_mcp/agents/utils.py +36 -12
  26. {universal_mcp_agents-0.1.14.dist-info → universal_mcp_agents-0.1.16.dist-info}/METADATA +3 -3
  27. universal_mcp_agents-0.1.16.dist-info/RECORD +50 -0
  28. universal_mcp/agents/codeact0/usecases/1-unsubscribe.yaml +0 -4
  29. universal_mcp/agents/codeact0/usecases/10-reddit2.yaml +0 -10
  30. universal_mcp/agents/codeact0/usecases/11-github.yaml +0 -14
  31. universal_mcp/agents/codeact0/usecases/2-reddit.yaml +0 -27
  32. universal_mcp/agents/codeact0/usecases/2.1-instructions.md +0 -81
  33. universal_mcp/agents/codeact0/usecases/2.2-instructions.md +0 -71
  34. universal_mcp/agents/codeact0/usecases/3-earnings.yaml +0 -4
  35. universal_mcp/agents/codeact0/usecases/4-maps.yaml +0 -41
  36. universal_mcp/agents/codeact0/usecases/5-gmailreply.yaml +0 -8
  37. universal_mcp/agents/codeact0/usecases/6-contract.yaml +0 -6
  38. universal_mcp/agents/codeact0/usecases/7-overnight.yaml +0 -14
  39. universal_mcp/agents/codeact0/usecases/8-sheets_chart.yaml +0 -25
  40. universal_mcp/agents/codeact0/usecases/9-learning.yaml +0 -9
  41. universal_mcp/agents/planner/__init__.py +0 -51
  42. universal_mcp/agents/planner/__main__.py +0 -28
  43. universal_mcp/agents/planner/graph.py +0 -85
  44. universal_mcp/agents/planner/prompts.py +0 -14
  45. universal_mcp/agents/planner/state.py +0 -11
  46. universal_mcp_agents-0.1.14.dist-info/RECORD +0 -66
  47. {universal_mcp_agents-0.1.14.dist-info → universal_mcp_agents-0.1.16.dist-info}/WHEEL +0 -0
@@ -1,33 +1,112 @@
1
- uneditable_prompt = """
2
- You are Wingmen, an AI Assistant created by AgentR. You are a creative, straight-forward and direct principal software engineer.
3
-
4
- Your job is to answer the user's question or perform the task they ask for.
5
- - Answer simple questions (which do not require you to write any code or access any external resources) directly. Note that any operation that involves using ONLY print functions should be answered directly.
6
- - For task requiring operations or access to external resources, you should achieve the task by executing Python code snippets.
7
- - You have access to `execute_ipython_cell` tool that allows you to execute Python code in an IPython notebook cell.
8
- - In writing or natural language processing tasks DO NOT answer directly. Instead use `execute_ipython_cell` tool with the AI functions provided to you for tasks like summarizing, text generation, classification, data extraction from text or unstructured data, etc.
9
- - The code you write will be executed in a sandbox environment, and you can use the output of previous executions in your code.
10
- - Read and understand the output of the previous code snippet and use it to answer the user's request. Note that the code output is NOT visible to the user, so after the task is complete, you have to give the output to the user in a markdown format.
11
- - If needed, feel free to ask for more information from the user (without using the execute_ipython_cell tool) to clarify the task.
12
-
13
- GUIDELINES for writing code:
14
- - Variables defined at the top level of previous code snippets can be referenced in your code.
15
- - External functions which return a dict or list[dict] are ambiguous. Therefore, you MUST explore the structure of the returned data using `smart_print()` statements before using it, printing keys and values. `smart_print` truncates long strings from data, preventing huge output logs.
16
- - When an operation involves running a fixed set of steps on a list of items, run one run correctly and then use a for loop to run the steps on each item in the list.
17
- - In a single code snippet, try to achieve as much as possible.
18
- - You can only import libraries that come pre-installed with Python.
19
- - You must wrap await calls in an async function and call it using `asyncio.run`.
20
- - For displaying final results to the user, you must present your output in markdown format, including image links, so that they are rendered and displayed to the user. The code output is NOT visible to the user.
21
-
22
- """
23
1
  import inspect
24
2
  import re
25
3
  from collections.abc import Sequence
26
- from datetime import datetime
27
4
 
28
5
  from langchain_core.tools import StructuredTool
6
+
29
7
  from universal_mcp.agents.codeact0.utils import schema_to_signature
30
8
 
9
+ uneditable_prompt = """
10
+ You are **Wingmen**, an AI Assistant created by AgentR — a creative, straight-forward, and direct principal software engineer with access to tools.
11
+
12
+ ## Responsibilities
13
+
14
+ - **Answer directly** if the task is simple (e.g. print, math, general knowledge).
15
+ - For any task requiring logic, execution, or data handling, use `execute_ipython_cell`.
16
+ - For writing or NLP tasks (summarizing, generating, extracting), always use AI functions via code — never respond directly.
17
+
18
+ ## Tool vs. Function: Required Separation
19
+
20
+ You must clearly distinguish between tools (called via the tool calling API) and internal functions (used inside code blocks).
21
+
22
+ ### Tools — Must Be Called via Tool Calling API
23
+
24
+ These must be called using **tool calling**, not from inside code blocks:
25
+
26
+ - `execute_ipython_cell` — For running any Python code or logic.
27
+ - `search_functions` — To discover available functions for a task.
28
+ - `load_functions` — To load a specific function by full ID.
29
+
30
+ **Do not attempt to call these inside `python` code.**
31
+ Use tool calling syntax for these operations.
32
+
33
+ ### Functions — Must Be Used Inside Code Blocks
34
+
35
+ All other functions, including LLM functions, must always be used within code executed by `execute_ipython_cell`. These include:
36
+
37
+ - `smart_print()` — For inspecting unknown data structures before looping.
38
+ - `asyncio.run()` — For wrapping and executing asynchronous logic. You must not use await outside an async function. And the async function must be called by `asyncio.run()`.
39
+ - Any functions for applications loaded via `load_functions`.
40
+ - Any logic, data handling, writing, NLP, generation, summarization, or extraction functionality of LLMs.
41
+
42
+ These must be called **inside a Python code block**, and that block must be executed using `execute_ipython_cell`.
43
+
44
+ ## Tool/Function Usage Policy
45
+
46
+ 1. **Always Use Tools/Functions for Required Tasks**
47
+ Any searching, loading, or executing must be done using a tool/function call. Never answer manually if a tool/function is appropriate.
48
+
49
+ 2. **Use Existing Functions First**
50
+ Use existing functions if available. Otherwise, use `search_functions` with a concise query describing the task.
51
+
52
+ 3. **Load Only Relevant Tools**
53
+ When calling `load_functions`, include only relevant function IDs.
54
+ - Prefer connected applications over unconnected ones.
55
+ - If multiple functions match (i.e. if none are connected, or multiple are connected), ask the user to choose.
56
+ - After loading a tool, you do not need to import/declare it again. It can be called directly in further cells.
57
+
58
+ 4. **Follow First Turn Process Strictly**
59
+ On the **first turn**, do only **one** of the following:
60
+ - Handle directly (if trivial)
61
+ - Use a tool/function (`execute_ipython_cell`, `search_functions`, etc.)
62
+
63
+ **Do not extend the conversation on the first message.**
64
+
65
+ ## Coding Rules
66
+
67
+ - Use `smart_print()` to inspect unknown structures, especially those received from function outputs, before looping or branching.
68
+ - Validate logic with a single item before processing lists or large inputs.
69
+ - Try to achieve as much as possible in a single code block.
70
+ - Use only pre-installed Python libraries. Do import them once before using.
71
+ - Outer level functions, variables, classes, and imports declared previously can be used in later cells.
72
+ - For all functions, call using keyword arguments only. DO NOT use any positional arguments.
73
+
74
+ ### **Async Function Usage — Critical**
75
+
76
+ When calling asynchronous functions:
77
+ - You must define or use an **inner async function**.
78
+ - Use `await` only **inside** that async function.
79
+ - Run it using `asyncio.run(<function_name>())` **without** `await` at the outer level.
80
+
81
+ **Wrong - Using `await` outside an async function**
82
+ ```
83
+ result = await some_async_function()
84
+ ```
85
+ **Wrong - Attaching await before asyncio.run**.
86
+ `await asyncio.run(main())`
87
+ These will raise SyntaxError: 'await' outside async function
88
+ The correct method is the following-
89
+ ```
90
+ import asyncio
91
+ async def some_async_function():
92
+ ...
93
+
94
+ async def main():
95
+ result = await some_async_function()
96
+ print(result)
97
+
98
+ asyncio.run(main())
99
+ #or
100
+ result = asyncio.run(some_async_function(arg1 = <arg1>))
101
+ ```
102
+ ## Output Formatting
103
+ - All code results must be returned in **Markdown**.
104
+ - The user cannot see raw output, so format results clearly:
105
+ - Use tables for structured data.
106
+ - Provide links for files or images.
107
+ - Be explicit in formatting to ensure readability.
108
+ """
109
+
31
110
 
32
111
  def make_safe_function_name(name: str) -> str:
33
112
  """Convert a tool name to a valid Python function name."""
@@ -119,24 +198,22 @@ def indent(text, prefix, predicate=None):
119
198
  return "".join(prefixed_lines)
120
199
 
121
200
 
122
-
123
201
  def create_default_prompt(
124
202
  tools: Sequence[StructuredTool],
125
- additional_tools : Sequence[StructuredTool],
203
+ additional_tools: Sequence[StructuredTool],
126
204
  base_prompt: str | None = None,
127
205
  ):
128
- system_prompt = uneditable_prompt.strip() + (
129
- "\n\nIn addition to the Python Standard Library, you can use the following external functions:"
130
- "\n"
206
+ system_prompt = uneditable_prompt.strip() + (
207
+ "\n\nIn addition to the Python Standard Library, you can use the following external functions:\n"
131
208
  )
132
209
  tools_context = {}
133
210
  for tool in tools:
134
- if hasattr(tool, "coroutine") and tool.coroutine is not None:
135
- tool_callable = tool.coroutine
136
- is_async = True
137
- elif hasattr(tool, "func") and tool.func is not None:
211
+ if hasattr(tool, "func") and tool.func is not None:
138
212
  tool_callable = tool.func
139
213
  is_async = False
214
+ elif hasattr(tool, "coroutine") and tool.coroutine is not None:
215
+ tool_callable = tool.coroutine
216
+ is_async = True
140
217
  system_prompt += f'''{"async " if is_async else ""}{schema_to_signature(tool.args, tool.name)}:
141
218
  """{tool.description}"""
142
219
  ...
@@ -145,12 +222,12 @@ def create_default_prompt(
145
222
  tools_context[safe_name] = tool_callable
146
223
 
147
224
  for tool in additional_tools:
148
- if hasattr(tool, "coroutine") and tool.coroutine is not None:
149
- tool_callable = tool.coroutine
150
- is_async = True
151
- elif hasattr(tool, "func") and tool.func is not None:
225
+ if hasattr(tool, "func") and tool.func is not None:
152
226
  tool_callable = tool.func
153
227
  is_async = False
228
+ elif hasattr(tool, "coroutine") and tool.coroutine is not None:
229
+ tool_callable = tool.coroutine
230
+ is_async = True
154
231
  system_prompt += f'''{"async " if is_async else ""}def {tool.name} {str(inspect.signature(tool_callable))}:
155
232
  """{tool.description}"""
156
233
  ...
@@ -162,6 +239,3 @@ def create_default_prompt(
162
239
  system_prompt += f"Your goal is to perform the following task:\n\n{base_prompt}"
163
240
 
164
241
  return system_prompt, tools_context
165
-
166
-
167
-
@@ -14,55 +14,66 @@ from universal_mcp.agents.codeact0.utils import derive_context
14
14
 
15
15
 
16
16
  def eval_unsafe(
17
- code: str, _locals: dict[str, Any], add_context: dict[str, Any]
17
+ code: str, _locals: dict[str, Any], add_context: dict[str, Any], timeout: int = 180
18
18
  ) -> tuple[str, dict[str, Any], dict[str, Any]]:
19
- # print(_locals)
19
+ """
20
+ Execute code safely with a timeout.
21
+ - Returns (output_str, filtered_locals_dict, new_add_context)
22
+ - Errors or timeout are returned as output_str.
23
+ - Previous variables in _locals persist across calls.
24
+ """
25
+
20
26
  EXCLUDE_TYPES = (
21
- types.ModuleType, # modules
27
+ types.ModuleType,
22
28
  type(re.match("", "")),
23
- type(threading.Lock()), # instead of threading.Lock
24
- type(threading.RLock()), # reentrant lock
25
- threading.Event, # events
26
- threading.Condition, # condition vars
27
- threading.Semaphore, # semaphores
28
- queue.Queue, # thread-safe queues
29
- socket.socket, # network sockets
30
- io.IOBase, # file handles (and StringIO/BytesIO)
29
+ type(threading.Lock()),
30
+ type(threading.RLock()),
31
+ threading.Event,
32
+ threading.Condition,
33
+ threading.Semaphore,
34
+ queue.Queue,
35
+ socket.socket,
36
+ io.IOBase,
31
37
  )
32
- try:
33
- with contextlib.redirect_stdout(io.StringIO()) as f:
34
- # Execute the code in the provided locals context
35
- # Using exec to allow dynamic code execution
36
- # This is a simplified version; in production, consider security implications
37
- exec(code, _locals, _locals)
38
- result = f.getvalue()
39
- if not result:
40
- result = "<code ran, no output printed to stdout>"
41
- except Exception as e:
42
- result = f"Error during execution: {repr(e)}"
43
-
44
- # Return all variables in locals except __builtins__ and unpicklable objects (including tools)
38
+
39
+ result_container = {"output": "<no output>"}
40
+
41
+ def target():
42
+ try:
43
+ with contextlib.redirect_stdout(io.StringIO()) as f:
44
+ exec(code, _locals, _locals)
45
+ result_container["output"] = f.getvalue() or "<code ran, no output printed to stdout>"
46
+ except Exception as e:
47
+ result_container["output"] = "Error during execution: " + str(e)
48
+
49
+ thread = threading.Thread(target=target)
50
+ thread.start()
51
+ thread.join(timeout)
52
+
53
+ if thread.is_alive():
54
+ result_container["output"] = f"Code timeout: code execution exceeded {timeout} seconds."
55
+
56
+ # Filter locals for picklable/storable variables
45
57
  all_vars = {}
46
58
  for key, value in _locals.items():
47
59
  if key == "__builtins__":
48
60
  continue
49
-
50
- # Skip coroutines, async generators, and coroutine functions
51
61
  if inspect.iscoroutine(value) or inspect.iscoroutinefunction(value):
52
62
  continue
53
63
  if inspect.isasyncgen(value) or inspect.isasyncgenfunction(value):
54
64
  continue
55
-
56
- # Skip "obviously unpicklable" types
57
65
  if isinstance(value, EXCLUDE_TYPES):
58
66
  continue
59
-
60
- # Keep if it's not a callable OR if it has no __name__ attribute
61
67
  if not callable(value) or not hasattr(value, "__name__"):
62
68
  all_vars[key] = value
63
69
 
64
- new_add_context = derive_context(code, add_context)
65
- return result, all_vars, new_add_context
70
+ # Safely derive context
71
+ try:
72
+ new_add_context = derive_context(code, add_context)
73
+ except Exception:
74
+ new_add_context = add_context
75
+
76
+ return result_container["output"], all_vars, new_add_context
66
77
 
67
78
 
68
79
  @tool(parse_docstring=True)
@@ -1,12 +1,38 @@
1
- from typing import Any
1
+ from typing import Annotated, Any
2
2
 
3
- from langgraph.graph import MessagesState
3
+ from langgraph.prebuilt.chat_agent_executor import AgentState
4
4
 
5
5
 
6
- class CodeActState(MessagesState):
6
+ def _enqueue(left: list, right: list) -> list:
7
+ """Treat left as a FIFO queue, append new items from right (preserve order),
8
+ keep items unique, and cap total size to 20 (drop oldest items)."""
9
+ max_size = 30
10
+ preferred_size = 20
11
+ if len(right) > preferred_size:
12
+ preferred_size = min(max_size, len(right))
13
+ queue = list(left or [])
14
+
15
+ for item in right[:preferred_size] or []:
16
+ if item in queue:
17
+ queue.remove(item)
18
+ queue.append(item)
19
+
20
+ if len(queue) > preferred_size:
21
+ queue = queue[-preferred_size:]
22
+
23
+ return queue
24
+
25
+
26
+ class CodeActState(AgentState):
7
27
  """State for CodeAct agent."""
8
28
 
9
29
  context: dict[str, Any]
10
30
  """Dictionary containing the execution context with available tools and variables."""
11
31
  add_context: dict[str, Any]
12
32
  """Dictionary containing the additional context (functions, classes, imports) to be added to the execution context."""
33
+ playbook_mode: str | None
34
+ """State for the playbook agent."""
35
+ selected_tool_ids: Annotated[list[str], _enqueue]
36
+ """Queue for tools exported from registry"""
37
+ plan: str | None
38
+ """Plan for the playbook agent."""
@@ -0,0 +1,186 @@
1
+ import asyncio
2
+ from collections import defaultdict
3
+ from typing import Any
4
+
5
+ from langchain_core.tools import tool
6
+ from universal_mcp.tools.registry import ToolRegistry
7
+ from universal_mcp.types import ToolFormat
8
+
9
+ MAX_LENGHT=100
10
+
11
+ def enter_playbook_mode():
12
+ """Call this function to enter playbook mode. Playbook mode is when the user wants to store a repeated task as a script with some inputs for the future."""
13
+ return
14
+
15
+ def exit_playbook_mode():
16
+ """Call this function to exit playbook mode. Playbook mode is when the user wants to store a repeated task as a script with some inputs for the future."""
17
+ return
18
+
19
+
20
+
21
+ def create_meta_tools(tool_registry: ToolRegistry) -> dict[str, Any]:
22
+ """Create the meta tools for searching and loading tools"""
23
+
24
+ @tool
25
+ async def search_functions(queries: list[str]) -> str:
26
+ """Search for relevant functions given list of queries.
27
+ Each single query should be atomic (doable with a single function).
28
+ For tasks requiring multiple functions, add separate queries for each subtask"""
29
+ try:
30
+ # Fetch all connections
31
+ connections = await tool_registry.list_connected_apps()
32
+ connected_apps = {connection["app_id"] for connection in connections}
33
+
34
+ app_tools = defaultdict(set)
35
+ MAX_LENGTH = 20
36
+
37
+ # Process all queries concurrently
38
+ search_tasks = []
39
+ for query in queries:
40
+ search_tasks.append(_search_query_tools(query))
41
+
42
+ query_results = await asyncio.gather(*search_tasks)
43
+
44
+ # Aggregate results with limit per app and automatic deduplication
45
+ for tools_list in query_results:
46
+ for tool in tools_list:
47
+ app = tool["id"].split("__")[0]
48
+ tool_id = tool["id"]
49
+
50
+ # Check if within limit and add to set (automatically deduplicates)
51
+ if len(app_tools[app]) < MAX_LENGTH:
52
+ cleaned_desc = tool["description"].split("Context:")[0].strip()
53
+ app_tools[app].add(f"{tool_id}: {cleaned_desc}")
54
+
55
+ # Build result string efficiently
56
+ result_parts = []
57
+ for app, tools in app_tools.items():
58
+ app_status = "connected" if app in connected_apps else "NOT connected"
59
+ result_parts.append(f"Tools from {app} (status: {app_status} by user):")
60
+ # Convert set to sorted list for consistent output
61
+ for tool in sorted(tools):
62
+ result_parts.append(f" - {tool}")
63
+ result_parts.append("") # Empty line between apps
64
+
65
+ result_parts.append("Call load_functions to select the required functions only.")
66
+ return "\n".join(result_parts)
67
+
68
+ except Exception as e:
69
+ return f"Error: {e}"
70
+
71
+ async def _search_query_tools(query: str) -> list[dict]:
72
+ """Helper function to search apps and tools for a single query."""
73
+ # Start both searches concurrently
74
+ tools_search_task = tool_registry.search_tools(query, limit=10)
75
+ apps_search_task = tool_registry.search_apps(query, limit=4)
76
+
77
+ # Wait for both to complete
78
+ tools_from_general_search, apps_list = await asyncio.gather(tools_search_task, apps_search_task)
79
+
80
+ # Create tasks for searching tools from each app
81
+ app_tool_tasks = [tool_registry.search_tools(query, limit=5, app_id=app["id"]) for app in apps_list]
82
+
83
+ # Wait for all app-specific tool searches to complete
84
+ app_tools_results = await asyncio.gather(*app_tool_tasks)
85
+
86
+ # Combine all results
87
+ tools_list = list(tools_from_general_search)
88
+ for app_tools in app_tools_results:
89
+ tools_list.extend(app_tools)
90
+
91
+ return tools_list
92
+
93
+ @tool
94
+ async def load_functions(tool_ids: list[str]) -> str:
95
+ """Load specific functions by their IDs for use in subsequent steps.
96
+
97
+ Args:
98
+ tool_ids: Function ids in the form 'app__function'. Example: 'google_mail__send_email'
99
+
100
+ Returns:
101
+ Confirmation message about loaded functions
102
+ """
103
+ return f"Successfully loaded {len(tool_ids)} functions: {tool_ids}"
104
+
105
+ @tool
106
+ async def web_search(query: str) -> list:
107
+ """Search the web for the given query and return structured search results.
108
+
109
+ Do not use for app-specific searches (for example, reddit or linkedin searches
110
+
111
+ Returns:
112
+ list: A list of up to 10 search result dictionaries, each containing:
113
+ - id (str): Unique identifier, typically the URL
114
+ - title (str): The title/headline of the search result
115
+ - url (str): The web URL of the result
116
+ - publishedDate (str): ISO 8601 formatted date (e.g., "2025-01-01T00:00:00.000Z")
117
+ - author (str): Author name (may be empty string)
118
+ - summary (str): Text summary/snippet of the content
119
+ - image (str): URL to associated image (if available)
120
+
121
+ Example:
122
+ results = await web_search(query="python programming")
123
+ """
124
+ await tool_registry.export_tools(["exa__search_with_filters"], ToolFormat.LANGCHAIN)
125
+ response = await tool_registry.call_tool(
126
+ "exa__search_with_filters", {"query": query, "contents": {"summary": True}}
127
+ )
128
+ return response["results"]
129
+
130
+ return {"search_functions": search_functions, "load_functions": load_functions, "web_search": web_search}
131
+
132
+
133
+ async def get_valid_tools(tool_ids: list[str], registry: ToolRegistry) -> tuple[list[str], list[str]]:
134
+ """For a given list of tool_ids, validates the tools and returns a list of links for the apps that have not been logged in"""
135
+ correct, incorrect = [], []
136
+ connections = await registry.list_connected_apps()
137
+ connected_apps = {connection["app_id"] for connection in connections}
138
+ unconnected = set()
139
+ unconnected_links = []
140
+ app_tool_list: dict[str, set[str]] = {}
141
+
142
+ # Group tool_ids by app for fewer registry calls
143
+ app_to_tools: dict[str, list[tuple[str, str]]] = {}
144
+ for tool_id in tool_ids:
145
+ if "__" not in tool_id:
146
+ incorrect.append(tool_id)
147
+ continue
148
+ app, tool_name = tool_id.split("__", 1)
149
+ app_to_tools.setdefault(app, []).append((tool_id, tool_name))
150
+
151
+ # Fetch all apps concurrently
152
+ async def fetch_tools(app: str):
153
+ try:
154
+ tools_dict = await registry.list_tools(app)
155
+ return app, {tool_unit["name"] for tool_unit in tools_dict}
156
+ except Exception:
157
+ return app, None
158
+
159
+ results = await asyncio.gather(*(fetch_tools(app) for app in app_to_tools))
160
+
161
+ # Build map of available tools per app
162
+ for app, tools in results:
163
+ if tools is not None:
164
+ app_tool_list[app] = tools
165
+
166
+ # Validate tool_ids
167
+ for app, tool_entries in app_to_tools.items():
168
+ available = app_tool_list.get(app)
169
+ if available is None:
170
+ incorrect.extend(tool_id for tool_id, _ in tool_entries)
171
+ continue
172
+ if app not in connected_apps and app not in unconnected:
173
+ unconnected.add(app)
174
+ text = registry.client.get_authorization_url(app)
175
+ start = text.find(":") + 1
176
+ end = text.find(". R", start)
177
+ url = text[start:end].strip()
178
+ markdown_link = f"[{app}]({url})"
179
+ unconnected_links.append(markdown_link)
180
+ for tool_id, tool_name in tool_entries:
181
+ if tool_name in available:
182
+ correct.append(tool_id)
183
+ else:
184
+ incorrect.append(tool_id)
185
+
186
+ return correct, unconnected_links