fast-agent-mcp 0.2.23__py3-none-any.whl → 0.2.25__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fast-agent-mcp
3
- Version: 0.2.23
3
+ Version: 0.2.25
4
4
  Summary: Define, Prompt and Test MCP enabled Agents and Workflows
5
5
  Author-email: Shaun Smith <fastagent@llmindset.co.uk>
6
6
  License: Apache License
@@ -212,13 +212,15 @@ Requires-Python: >=3.10
212
212
  Requires-Dist: a2a-types>=0.1.0
213
213
  Requires-Dist: aiohttp>=3.11.13
214
214
  Requires-Dist: anthropic>=0.49.0
215
+ Requires-Dist: azure-identity>=1.14.0
215
216
  Requires-Dist: fastapi>=0.115.6
216
217
  Requires-Dist: mcp>=1.8.0
217
218
  Requires-Dist: openai>=1.63.2
218
219
  Requires-Dist: opentelemetry-distro>=0.50b0
219
220
  Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.29.0
220
- Requires-Dist: opentelemetry-instrumentation-anthropic>=0.39.3
221
- Requires-Dist: opentelemetry-instrumentation-openai>=0.39.3
221
+ Requires-Dist: opentelemetry-instrumentation-anthropic>=0.39.3; python_version >= '3.10' and python_version < '4.0'
222
+ Requires-Dist: opentelemetry-instrumentation-mcp>=0.40.3; python_version >= '3.10' and python_version < '4.0'
223
+ Requires-Dist: opentelemetry-instrumentation-openai>=0.39.3; python_version >= '3.10' and python_version < '4.0'
222
224
  Requires-Dist: prompt-toolkit>=3.0.50
223
225
  Requires-Dist: pydantic-settings>=2.7.0
224
226
  Requires-Dist: pydantic>=2.10.4
@@ -226,6 +228,8 @@ Requires-Dist: pyyaml>=6.0.2
226
228
  Requires-Dist: rich>=13.9.4
227
229
  Requires-Dist: tensorzero>=2025.4.7
228
230
  Requires-Dist: typer>=0.15.1
231
+ Provides-Extra: azure
232
+ Requires-Dist: azure-identity>=1.14.0; extra == 'azure'
229
233
  Provides-Extra: dev
230
234
  Requires-Dist: anthropic>=0.42.0; extra == 'dev'
231
235
  Requires-Dist: pre-commit>=4.0.1; extra == 'dev'
@@ -1,10 +1,10 @@
1
1
  mcp_agent/__init__.py,sha256=18T0AG0W9sJhTY38O9GFFOzliDhxx9p87CvRyti9zbw,1620
2
2
  mcp_agent/app.py,sha256=WRsiUdwy_9IAnaGRDwuLm7pzgQpt2wgsg10vBOpfcwM,5539
3
- mcp_agent/config.py,sha256=L_wUWTdqFXaRTBA5tL_j2l_9dufWE_MHHPut5e89lBk,12773
3
+ mcp_agent/config.py,sha256=0a6P_nvr6fhBLMjk1XsfQuS_wVO1dmQ1sJR5RQdf5FA,13321
4
4
  mcp_agent/console.py,sha256=Gjf2QLFumwG1Lav__c07X_kZxxEUSkzV-1_-YbAwcwo,813
5
- mcp_agent/context.py,sha256=Kb3s_0MolHx7AeTs1NVcY3ly-xFBd35o8LT7Srpx9is,7334
5
+ mcp_agent/context.py,sha256=5pnw78LgezCLeO5Os5dgmLDadwXqw_B4Ojib48XP1s4,7431
6
6
  mcp_agent/context_dependent.py,sha256=QXfhw3RaQCKfscEEBRGuZ3sdMWqkgShz2jJ1ivGGX1I,1455
7
- mcp_agent/event_progress.py,sha256=b1VKlQQF2AgPMb6XHjlJAVoPdx8GuxRTUk2g-4lBNm0,2749
7
+ mcp_agent/event_progress.py,sha256=040lrCCclcOuryi07YGSej25kTQF5_JMXY12Yj-3u1U,2773
8
8
  mcp_agent/mcp_server_registry.py,sha256=QTzu0elBWzqXks6u5nI5n8uN5CX8CpyV6ybxnyt5LZM,11531
9
9
  mcp_agent/progress_display.py,sha256=GeJU9VUt6qKsFVymG688hCMVCsAygG9ifiiEb5IcbN4,361
10
10
  mcp_agent/agents/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -22,7 +22,7 @@ mcp_agent/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
22
22
  mcp_agent/cli/__main__.py,sha256=AVZ7tQFhU_sDOGuUGJq8ujgKtcxsYJBJwHbVaaiRDlI,166
23
23
  mcp_agent/cli/main.py,sha256=XjrgXMBaPKkVqAFo8T9LJz6Tp1-ivrKDOuNYWke99YA,3090
24
24
  mcp_agent/cli/terminal.py,sha256=GRwD-RGW7saIz2IOWZn5vD6JjiArscELBThm1GTFkuI,1065
25
- mcp_agent/cli/commands/check_config.py,sha256=9Ryxo_fLInm3YKdYv46yLrAJgnQtMisGreu6Kkriw2g,16677
25
+ mcp_agent/cli/commands/check_config.py,sha256=KJbXUFx5Qih3lb_r-Fcx_uAjgHhgD7qqPewQtIDofKM,18321
26
26
  mcp_agent/cli/commands/go.py,sha256=LIsOJQuTdfCUcNm7JT-NQDU8cI-GCnYwYjN2VOWxvqs,8658
27
27
  mcp_agent/cli/commands/quickstart.py,sha256=SM3CHMzDgvTxIpKjFuX9BrS_N1vRoXNBDaO90aWx1Rk,14586
28
28
  mcp_agent/cli/commands/setup.py,sha256=eOEd4TL-b0DaDeSJMGOfNOsTEItoZ67W88eTP4aP-bo,6482
@@ -53,19 +53,20 @@ mcp_agent/llm/augmented_llm.py,sha256=CqtSGo_QrHE73tz_DHMd0wdt2F41gwuUu5Bue51FNm
53
53
  mcp_agent/llm/augmented_llm_passthrough.py,sha256=zHcctNpwg4EFJvD1x9Eg443SVX-uyzFphLikwF_yVE0,6288
54
54
  mcp_agent/llm/augmented_llm_playback.py,sha256=6L_RWIK__R67oZK7u3Xt3hWy1T2LnHXIO-efqgP3tPw,4177
55
55
  mcp_agent/llm/memory.py,sha256=HQ_c1QemOUjrkY6Z2omE6BG5fXga7y4jN7KCMOuGjPs,3345
56
- mcp_agent/llm/model_factory.py,sha256=h3NJSa0yPa9iiLojEqBhIm9wgEBB46ZBibe44MnskHM,8089
56
+ mcp_agent/llm/model_factory.py,sha256=vKR4wI0cDElMc4JSE-WuLXTarAZsi9-5I7B9wUbI6c4,8218
57
57
  mcp_agent/llm/prompt_utils.py,sha256=yWQHykoK13QRF7evHUKxVF0SpVLN-Bsft0Yixzvn0g0,4825
58
58
  mcp_agent/llm/provider_key_manager.py,sha256=-K_FuibN6hdSnweT32lB8mKTfCARnbja6zYYs0ErTKg,2802
59
- mcp_agent/llm/provider_types.py,sha256=oWwXTlyr6hIzU_QLJ5T-UwxZGo5e4Pjwtahz2cr1PHg,364
59
+ mcp_agent/llm/provider_types.py,sha256=m7vAQA0MSn4iVCoHQYwZ8pK8nW4iVLxp_Ul1JpnXMpY,408
60
60
  mcp_agent/llm/sampling_converter.py,sha256=C7wPBlmT0eD90XWabC22zkxsrVHKCrjwIwg6cG628cI,2926
61
61
  mcp_agent/llm/sampling_format_converter.py,sha256=xGz4odHpOcP7--eFaJaFtUR8eR9jxZS7MnLH6J7n0EU,1263
62
62
  mcp_agent/llm/providers/__init__.py,sha256=heVxtmuqFJOnjjxHz4bWSqTAxXoN1E8twC_gQ_yJpHk,265
63
63
  mcp_agent/llm/providers/anthropic_utils.py,sha256=vYDN5G5jKMhD2CQg8veJYab7tvvzYkDMq8M1g_hUAQg,3275
64
64
  mcp_agent/llm/providers/augmented_llm_anthropic.py,sha256=gK_IvllVBNJUUrSfpgFpdhM-d4liCt0MLq7d2lXS7RI,15510
65
+ mcp_agent/llm/providers/augmented_llm_azure.py,sha256=VPrD6lNrEw6EdYUTa9MDvHDNIPjJU5CG5xnKCM3JYdA,5878
65
66
  mcp_agent/llm/providers/augmented_llm_deepseek.py,sha256=NiZK5nv91ZS2VgVFXpbsFNFYLsLcppcbo_RstlRMd7I,1145
66
67
  mcp_agent/llm/providers/augmented_llm_generic.py,sha256=5Uq8ZBhcFuQTt7koP_5ykolREh2iWu8zKhNbh3pM9lQ,1210
67
68
  mcp_agent/llm/providers/augmented_llm_google.py,sha256=N0a2fphVtkvNYxKQpEX6J4tlO1C_mRw4sw3LBXnrOeI,1130
68
- mcp_agent/llm/providers/augmented_llm_openai.py,sha256=0C7BOB7i3xo0HsMCTagRSQ8Hsywb-31mot26OfohzCU,14478
69
+ mcp_agent/llm/providers/augmented_llm_openai.py,sha256=5CFHKayjm-aeCBpohIK3WelAEuX7_LDGZIKnWR_rq-s,14577
69
70
  mcp_agent/llm/providers/augmented_llm_openrouter.py,sha256=V_TlVKm92GHBxYIo6gpvH_6cAaIdppS25Tz6x5T7LW0,2341
70
71
  mcp_agent/llm/providers/augmented_llm_tensorzero.py,sha256=Mol_Wzj_ZtccW-LMw0oFwWUt1m1yfofloay9QYNP23c,20729
71
72
  mcp_agent/llm/providers/multipart_converter_anthropic.py,sha256=t5lHYGfFUacJldnrVtMNW-8gEMoto8Y7hJkDrnyZR-Y,16650
@@ -83,11 +84,12 @@ mcp_agent/logging/logger.py,sha256=l02OGX_c5FOyH0rspd4ZvnkJcbb0FahhUhlh2KI8mqE,1
83
84
  mcp_agent/logging/rich_progress.py,sha256=oY9fjb4Tyw6887v8sgO6EGIK4lnmIoR3NNxhA_-Ln_M,4893
84
85
  mcp_agent/logging/transport.py,sha256=m8YsLLu5T8eof_ndpLQs4gHOzqqEL98xsVwBwDsBfxI,17335
85
86
  mcp_agent/mcp/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
87
+ mcp_agent/mcp/common.py,sha256=DiWLH9rxWvCgkKRsHQehY9mDhQl9gki1-q7LVUflDvI,425
86
88
  mcp_agent/mcp/gen_client.py,sha256=fAVwFVCgSamw4PwoWOV4wrK9TABx1S_zZv8BctRyF2k,3030
87
89
  mcp_agent/mcp/interfaces.py,sha256=PAou8znAl2HgtvfCpLQOZFbKra9F72OcVRfBJbboNX8,6965
88
90
  mcp_agent/mcp/logger_textio.py,sha256=vljC1BtNTCxBAda9ExqNB-FwVNUZIuJT3h1nWmCjMws,3172
89
- mcp_agent/mcp/mcp_agent_client_session.py,sha256=Ng7epBXq8BEA_3m1GX5LqwafgNUAMSzBugwN6N0VUWQ,4364
90
- mcp_agent/mcp/mcp_aggregator.py,sha256=lVSt0yp0CnaYjcHCWmluwBeFgl8JXHYEZk0MzXgrQzA,40110
91
+ mcp_agent/mcp/mcp_agent_client_session.py,sha256=4597ww1ihSKh-zKc9xMF3ODqosVPU_A4xVmUbk1DvcE,6002
92
+ mcp_agent/mcp/mcp_aggregator.py,sha256=KAtQTg6CBpTiHoMg6NKcMSiJ7Cvl-BgS0Lff784qwrs,46063
91
93
  mcp_agent/mcp/mcp_connection_manager.py,sha256=jlqaAdS4zc1UfVBHQU0TkTbVr0-rOkbN9bkrLPrZVLk,17159
92
94
  mcp_agent/mcp/mime_utils.py,sha256=difepNR_gpb4MpMLkBRAoyhDk-AjXUHTiqKvT_VwS1o,1805
93
95
  mcp_agent/mcp/prompt_message_multipart.py,sha256=BDwRdNwyWHb2q2bccDb2iR2VlORqVvkvoG3xYzcMpCE,4403
@@ -145,9 +147,9 @@ mcp_agent/resources/examples/workflows/orchestrator.py,sha256=rOGilFTliWWnZ3Jx5w
145
147
  mcp_agent/resources/examples/workflows/parallel.py,sha256=DQ5vY5-h8Qa5QHcYjsWXhZ_FYrYoloVWOdgeXV9p2gI,1890
146
148
  mcp_agent/resources/examples/workflows/router.py,sha256=E4x_-c3l4YW9w1i4ARcDtkdeqIdbWEGfsMzwLYpdbVc,1677
147
149
  mcp_agent/resources/examples/workflows/short_story.txt,sha256=X3y_1AyhLFN2AKzCKvucJtDgAFIJfnlbsbGZO5bBWu0,1187
148
- mcp_agent/ui/console_display.py,sha256=EUeMJ7yqtxJ0-hAjXNZFJFTlmfyKlENdfzTlw0e5ETg,9949
149
- fast_agent_mcp-0.2.23.dist-info/METADATA,sha256=Vtk96ocWT3Xk_Y8f3ZLUILhh2aybr8OebCPn5jZeZOY,30156
150
- fast_agent_mcp-0.2.23.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
151
- fast_agent_mcp-0.2.23.dist-info/entry_points.txt,sha256=bRniFM5zk3Kix5z7scX0gf9VnmGQ2Cz_Q1Gh7Ir4W00,186
152
- fast_agent_mcp-0.2.23.dist-info/licenses/LICENSE,sha256=cN3FxDURL9XuzE5mhK9L2paZo82LTfjwCYVT7e3j0e4,10939
153
- fast_agent_mcp-0.2.23.dist-info/RECORD,,
150
+ mcp_agent/ui/console_display.py,sha256=UKqax5V2TC0hkZZORmmd6UqUk0DGX7A25E3h1k9f42k,10982
151
+ fast_agent_mcp-0.2.25.dist-info/METADATA,sha256=V-sL9gUnTgSUvsV2plQm3Spj4nJAnKYjermxRjIPCxc,30488
152
+ fast_agent_mcp-0.2.25.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
153
+ fast_agent_mcp-0.2.25.dist-info/entry_points.txt,sha256=bRniFM5zk3Kix5z7scX0gf9VnmGQ2Cz_Q1Gh7Ir4W00,186
154
+ fast_agent_mcp-0.2.25.dist-info/licenses/LICENSE,sha256=cN3FxDURL9XuzE5mhK9L2paZo82LTfjwCYVT7e3j0e4,10939
155
+ fast_agent_mcp-0.2.25.dist-info/RECORD,,
@@ -92,42 +92,73 @@ def get_secrets_summary(secrets_path: Optional[Path]) -> dict:
92
92
  return result
93
93
 
94
94
 
95
- def check_api_keys(secrets_summary: dict) -> dict:
96
- """Check if API keys are configured in secrets file or environment."""
95
+ def check_api_keys(secrets_summary: dict, config_summary: dict) -> dict:
96
+ """Check if API keys are configured in secrets file or environment, including Azure DefaultAzureCredential.
97
+ Now also checks Azure config in main config file for retrocompatibility.
98
+ """
97
99
  import os
98
100
 
99
- # Initialize results dict using Provider enum values
100
101
  results = {
101
- provider.value: {"env": None, "config": None}
102
+ provider.value: {"env": "", "config": ""}
102
103
  for provider in Provider
103
104
  if provider != Provider.FAST_AGENT
104
- } # Include GENERIC but exclude FAST_AGENT
105
+ }
105
106
 
106
107
  # Get secrets if available
107
108
  secrets = secrets_summary.get("secrets", {})
108
109
  secrets_status = secrets_summary.get("status", "not_found")
110
+ # Get config if available
111
+ config = config_summary if config_summary.get("status") == "parsed" else {}
112
+ config_azure = {}
113
+ if config and "azure" in config.get("config", {}):
114
+ config_azure = config["config"]["azure"]
109
115
 
110
- # Check both environment variables and config file for each provider
111
116
  for provider_value in results:
112
- # Check environment variables using ProviderKeyManager
113
- env_key_name = ProviderKeyManager.get_env_key_name(provider_value)
114
- env_key_value = os.environ.get(env_key_name)
115
- if env_key_value:
116
- # Store the last 5 characters if key is long enough
117
- if len(env_key_value) > 5:
118
- results[provider_value]["env"] = f"...{env_key_value[-5:]}"
119
- else:
120
- results[provider_value]["env"] = "...***"
121
-
122
- # Check secrets file if it was parsed successfully
123
- if secrets_status == "parsed":
124
- config_key = ProviderKeyManager.get_config_file_key(provider_value, secrets)
125
- if config_key and config_key != API_KEY_HINT_TEXT:
126
- # Store the last 5 characters if key is long enough
127
- if len(config_key) > 5:
128
- results[provider_value]["config"] = f"...{config_key[-5:]}"
117
+ # Special handling for Azure: support api_key and DefaultAzureCredential
118
+ if provider_value == "azure":
119
+ # Prefer secrets if present, else fallback to config
120
+ azure_cfg = {}
121
+ if secrets_status == "parsed" and "azure" in secrets:
122
+ azure_cfg = secrets.get("azure", {})
123
+ elif config_azure:
124
+ azure_cfg = config_azure
125
+
126
+ use_default_cred = azure_cfg.get("use_default_azure_credential", False)
127
+ base_url = azure_cfg.get("base_url")
128
+ api_key = azure_cfg.get("api_key")
129
+ # DefaultAzureCredential mode
130
+ if use_default_cred and base_url:
131
+ results[provider_value]["config"] = "DefaultAzureCredential"
132
+ # API key mode (retrocompatible)
133
+ if api_key and api_key != API_KEY_HINT_TEXT:
134
+ if len(api_key) > 5:
135
+ if results[provider_value]["config"]:
136
+ results[provider_value]["config"] += " + api_key"
137
+ else:
138
+ results[provider_value]["config"] = f"...{api_key[-5:]}"
129
139
  else:
130
- results[provider_value]["config"] = "...***"
140
+ if results[provider_value]["config"]:
141
+ results[provider_value]["config"] += " + api_key"
142
+ else:
143
+ results[provider_value]["config"] = "...***"
144
+ else:
145
+ # Check environment variables using ProviderKeyManager
146
+ env_key_name = ProviderKeyManager.get_env_key_name(provider_value)
147
+ env_key_value = os.environ.get(env_key_name)
148
+ if env_key_value:
149
+ if len(env_key_value) > 5:
150
+ results[provider_value]["env"] = f"...{env_key_value[-5:]}"
151
+ else:
152
+ results[provider_value]["env"] = "...***"
153
+
154
+ # Check secrets file if it was parsed successfully
155
+ if secrets_status == "parsed":
156
+ config_key = ProviderKeyManager.get_config_file_key(provider_value, secrets)
157
+ if config_key and config_key != API_KEY_HINT_TEXT:
158
+ if len(config_key) > 5:
159
+ results[provider_value]["config"] = f"...{config_key[-5:]}"
160
+ else:
161
+ results[provider_value]["config"] = "...***"
131
162
 
132
163
  return results
133
164
 
@@ -235,7 +266,7 @@ def show_check_summary() -> None:
235
266
  system_info = get_system_info()
236
267
  config_summary = get_config_summary(config_files["config"])
237
268
  secrets_summary = get_secrets_summary(config_files["secrets"])
238
- api_keys = check_api_keys(secrets_summary)
269
+ api_keys = check_api_keys(secrets_summary, config_summary)
239
270
  fastagent_version = get_fastagent_version()
240
271
 
241
272
  # System info panel
@@ -341,8 +372,10 @@ def show_check_summary() -> None:
341
372
 
342
373
  keys_table.add_row(provider.capitalize(), env_status, config_status, active)
343
374
 
344
- console.print(Panel(keys_table, title="API Keys", border_style="blue"))
345
-
375
+ # Print the API Keys panel (fix: this was missing)
376
+ keys_panel = Panel(keys_table, title="API Keys", border_style="blue", subtitle_align="left")
377
+ console.print(keys_panel)
378
+
346
379
  # MCP Servers panel (shown after API Keys)
347
380
  if config_summary.get("status") == "parsed":
348
381
  mcp_servers = config_summary.get("mcp_servers", [])
@@ -447,4 +480,4 @@ def show(
447
480
  def main(ctx: typer.Context) -> None:
448
481
  """Check and diagnose FastAgent configuration."""
449
482
  if ctx.invoked_subcommand is None:
450
- show_check_summary()
483
+ show_check_summary()
mcp_agent/config.py CHANGED
@@ -179,6 +179,20 @@ class OpenRouterSettings(BaseModel):
179
179
  model_config = ConfigDict(extra="allow", arbitrary_types_allowed=True)
180
180
 
181
181
 
182
+ class AzureSettings(BaseModel):
183
+ """
184
+ Settings for using Azure OpenAI Service in the fast-agent application.
185
+ """
186
+
187
+ api_key: str | None = None
188
+ resource_name: str | None = None
189
+ azure_deployment: str | None = None
190
+ api_version: str | None = None
191
+ base_url: str | None = None # Optional, can be constructed from resource_name
192
+
193
+ model_config = ConfigDict(extra="allow", arbitrary_types_allowed=True)
194
+
195
+
182
196
  class OpenTelemetrySettings(BaseModel):
183
197
  """
184
198
  OTEL settings for the fast-agent application.
@@ -302,6 +316,9 @@ class Settings(BaseSettings):
302
316
  tensorzero: Optional[TensorZeroSettings] = None
303
317
  """Settings for using TensorZero inference gateway"""
304
318
 
319
+ azure: AzureSettings | None = None
320
+ """Settings for using Azure OpenAI Service in the fast-agent application"""
321
+
305
322
  logger: LoggerSettings | None = LoggerSettings()
306
323
  """Logger settings for the fast-agent application"""
307
324
 
mcp_agent/context.py CHANGED
@@ -11,6 +11,7 @@ from mcp import ServerSession
11
11
  from opentelemetry import trace
12
12
  from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
13
13
  from opentelemetry.instrumentation.anthropic import AnthropicInstrumentor
14
+ from opentelemetry.instrumentation.mcp import McpInstrumentor
14
15
  from opentelemetry.instrumentation.openai import OpenAIInstrumentor
15
16
  from opentelemetry.propagate import set_global_textmap
16
17
  from opentelemetry.sdk.resources import Resource
@@ -111,6 +112,7 @@ async def configure_otel(config: "Settings") -> None:
111
112
  trace.set_tracer_provider(tracer_provider)
112
113
  AnthropicInstrumentor().instrument()
113
114
  OpenAIInstrumentor().instrument()
115
+ McpInstrumentor().instrument()
114
116
 
115
117
 
116
118
  async def configure_logger(config: "Settings") -> None:
@@ -19,6 +19,7 @@ class ProgressAction(str, Enum):
19
19
  PLANNING = "Planning"
20
20
  READY = "Ready"
21
21
  CALLING_TOOL = "Calling Tool"
22
+ UPDATED = "Updated"
22
23
  FINISHED = "Finished"
23
24
  SHUTDOWN = "Shutdown"
24
25
  AGGREGATOR_INITIALIZED = "Running"
@@ -10,6 +10,7 @@ from mcp_agent.llm.augmented_llm_passthrough import PassthroughLLM
10
10
  from mcp_agent.llm.augmented_llm_playback import PlaybackLLM
11
11
  from mcp_agent.llm.provider_types import Provider
12
12
  from mcp_agent.llm.providers.augmented_llm_anthropic import AnthropicAugmentedLLM
13
+ from mcp_agent.llm.providers.augmented_llm_azure import AzureOpenAIAugmentedLLM
13
14
  from mcp_agent.llm.providers.augmented_llm_deepseek import DeepSeekAugmentedLLM
14
15
  from mcp_agent.llm.providers.augmented_llm_generic import GenericAugmentedLLM
15
16
  from mcp_agent.llm.providers.augmented_llm_google import GoogleAugmentedLLM
@@ -113,6 +114,7 @@ class ModelFactory:
113
114
  Provider.GOOGLE: GoogleAugmentedLLM, # type: ignore
114
115
  Provider.OPENROUTER: OpenRouterAugmentedLLM,
115
116
  Provider.TENSORZERO: TensorZeroAugmentedLLM,
117
+ Provider.AZURE: AzureOpenAIAugmentedLLM,
116
118
  }
117
119
 
118
120
  # Mapping of special model names to their specific LLM classes
@@ -16,3 +16,4 @@ class Provider(Enum):
16
16
  GENERIC = "generic"
17
17
  OPENROUTER = "openrouter"
18
18
  TENSORZERO = "tensorzero" # For TensorZero Gateway
19
+ AZURE = "azure" # Azure OpenAI Service
@@ -0,0 +1,137 @@
1
+ from openai import AuthenticationError, AzureOpenAI, OpenAI
2
+
3
+ from mcp_agent.core.exceptions import ProviderKeyError
4
+ from mcp_agent.llm.provider_types import Provider
5
+ from mcp_agent.llm.providers.augmented_llm_openai import OpenAIAugmentedLLM
6
+
7
+ try:
8
+ from azure.identity import DefaultAzureCredential
9
+ except ImportError:
10
+ DefaultAzureCredential = None
11
+
12
+
13
+ def _extract_resource_name(url: str) -> str | None:
14
+ from urllib.parse import urlparse
15
+
16
+ host = urlparse(url).hostname or ""
17
+ suffix = ".openai.azure.com"
18
+ return host.replace(suffix, "") if host.endswith(suffix) else None
19
+
20
+
21
+ DEFAULT_AZURE_API_VERSION = "2023-05-15"
22
+
23
+
24
+ class AzureOpenAIAugmentedLLM(OpenAIAugmentedLLM):
25
+ """
26
+ Azure OpenAI implementation extending OpenAIAugmentedLLM.
27
+ Handles both API Key and DefaultAzureCredential authentication.
28
+ """
29
+
30
+ def __init__(self, provider: Provider = Provider.AZURE, *args, **kwargs):
31
+ # Set provider to AZURE, pass through to base
32
+ super().__init__(provider=provider, *args, **kwargs)
33
+
34
+ # Context/config extraction
35
+ context = getattr(self, "context", None)
36
+ config = getattr(context, "config", None) if context else None
37
+ azure_cfg = getattr(config, "azure", None) if config else None
38
+
39
+ if azure_cfg is None:
40
+ raise ProviderKeyError(
41
+ "Missing Azure configuration",
42
+ "Azure provider requires configuration section 'azure' in your config file.",
43
+ )
44
+
45
+ self.use_default_cred = getattr(azure_cfg, "use_default_azure_credential", False)
46
+ default_request_params = getattr(self, "default_request_params", None)
47
+ self.deployment_name = getattr(default_request_params, "model", None) or getattr(
48
+ azure_cfg, "azure_deployment", None
49
+ )
50
+ self.api_version = getattr(azure_cfg, "api_version", None) or DEFAULT_AZURE_API_VERSION
51
+
52
+ if self.use_default_cred:
53
+ self.base_url = getattr(azure_cfg, "base_url", None)
54
+ if not self.base_url:
55
+ raise ProviderKeyError(
56
+ "Missing Azure endpoint",
57
+ "When using 'use_default_azure_credential', 'base_url' is required in azure config.",
58
+ )
59
+ if DefaultAzureCredential is None:
60
+ raise ProviderKeyError(
61
+ "azure-identity not installed",
62
+ "You must install 'azure-identity' to use DefaultAzureCredential authentication.",
63
+ )
64
+ self.credential = DefaultAzureCredential()
65
+
66
+ def get_azure_token():
67
+ token = self.credential.get_token("https://cognitiveservices.azure.com/.default")
68
+ return token.token
69
+
70
+ self.get_azure_token = get_azure_token
71
+ else:
72
+ self.api_key = getattr(azure_cfg, "api_key", None)
73
+ self.resource_name = getattr(azure_cfg, "resource_name", None)
74
+ self.base_url = getattr(azure_cfg, "base_url", None) or (
75
+ f"https://{self.resource_name}.openai.azure.com/" if self.resource_name else None
76
+ )
77
+ if not self.api_key:
78
+ raise ProviderKeyError(
79
+ "Missing Azure OpenAI credentials",
80
+ "Field 'api_key' is required in azure config.",
81
+ )
82
+ if not (self.resource_name or self.base_url):
83
+ raise ProviderKeyError(
84
+ "Missing Azure endpoint",
85
+ "Provide either 'resource_name' or 'base_url' under azure config.",
86
+ )
87
+ if not self.deployment_name:
88
+ raise ProviderKeyError(
89
+ "Missing deployment name",
90
+ "Set 'azure_deployment' in config or pass model=<deployment>.",
91
+ )
92
+ # If resource_name was missing, try to extract it from base_url
93
+ if not self.resource_name and self.base_url:
94
+ self.resource_name = _extract_resource_name(self.base_url)
95
+
96
+ def _openai_client(self) -> OpenAI:
97
+ """
98
+ Returns an AzureOpenAI client, handling both API Key and DefaultAzureCredential.
99
+ """
100
+ try:
101
+ if self.use_default_cred:
102
+ if self.base_url is None:
103
+ raise ProviderKeyError(
104
+ "Missing Azure endpoint",
105
+ "azure_endpoint (base_url) is None at client creation time.",
106
+ )
107
+ return AzureOpenAI(
108
+ azure_ad_token_provider=self.get_azure_token,
109
+ azure_endpoint=self.base_url,
110
+ api_version=self.api_version,
111
+ azure_deployment=self.deployment_name,
112
+ )
113
+ else:
114
+ if self.base_url is None:
115
+ raise ProviderKeyError(
116
+ "Missing Azure endpoint",
117
+ "azure_endpoint (base_url) is None at client creation time.",
118
+ )
119
+ return AzureOpenAI(
120
+ api_key=self.api_key,
121
+ azure_endpoint=self.base_url,
122
+ api_version=self.api_version,
123
+ azure_deployment=self.deployment_name,
124
+ )
125
+ except AuthenticationError as e:
126
+ if self.use_default_cred:
127
+ raise ProviderKeyError(
128
+ "Invalid Azure AD credentials",
129
+ "The configured Azure AD credentials were rejected.\n"
130
+ "Please check your Azure identity setup.",
131
+ ) from e
132
+ else:
133
+ raise ProviderKeyError(
134
+ "Invalid Azure OpenAI API key",
135
+ "The configured Azure OpenAI API key was rejected.\n"
136
+ "Please check that your API key is valid and not expired.",
137
+ ) from e
@@ -78,7 +78,7 @@ class OpenAIAugmentedLLM(AugmentedLLM[ChatCompletionMessageParam, ChatCompletion
78
78
  self._reasoning_effort = self.context.config.openai.reasoning_effort
79
79
 
80
80
  # Determine if we're using a reasoning model
81
- # TODO -- move this to model capabiltities, add o4.
81
+ # TODO -- move this to model capabilities, add o4.
82
82
  chosen_model = self.default_request_params.model if self.default_request_params else None
83
83
  self._reasoning = chosen_model and (
84
84
  chosen_model.startswith("o3") or chosen_model.startswith("o1")
@@ -274,7 +274,9 @@ class OpenAIAugmentedLLM(AugmentedLLM[ChatCompletionMessageParam, ChatCompletion
274
274
  # Calculate new conversation messages (excluding prompts)
275
275
  new_messages = messages[len(prompt_messages) :]
276
276
 
277
- # Update conversation history
277
+ if system_prompt:
278
+ new_messages = new_messages[1:]
279
+
278
280
  self.history.set(new_messages)
279
281
 
280
282
  self._log_chat_finished(model=self.default_request_params.model)
@@ -323,7 +325,7 @@ class OpenAIAugmentedLLM(AugmentedLLM[ChatCompletionMessageParam, ChatCompletion
323
325
  return result
324
326
 
325
327
  def _prepare_api_request(
326
- self, messages, tools, request_params: RequestParams
328
+ self, messages, tools: List[ChatCompletionToolParam] | None, request_params: RequestParams
327
329
  ) -> dict[str, str]:
328
330
  # Create base arguments dictionary
329
331
 
@@ -343,9 +345,8 @@ class OpenAIAugmentedLLM(AugmentedLLM[ChatCompletionMessageParam, ChatCompletion
343
345
  )
344
346
  else:
345
347
  base_args["max_tokens"] = request_params.maxTokens
346
-
347
- if tools:
348
- base_args["parallel_tool_calls"] = request_params.parallel_tool_calls
348
+ if tools:
349
+ base_args["parallel_tool_calls"] = request_params.parallel_tool_calls
349
350
 
350
351
  arguments: Dict[str, str] = self.prepare_provider_arguments(
351
352
  base_args, request_params, self.OPENAI_EXCLUDE_FIELDS.union(self.BASE_EXCLUDE_FIELDS)
@@ -354,7 +355,7 @@ class OpenAIAugmentedLLM(AugmentedLLM[ChatCompletionMessageParam, ChatCompletion
354
355
 
355
356
  def adjust_schema(self, inputSchema: Dict) -> Dict:
356
357
  # return inputSchema
357
- if not Provider.OPENAI == self.provider:
358
+ if self.provider not in [Provider.OPENAI, Provider.AZURE]:
358
359
  return inputSchema
359
360
 
360
361
  if "properties" in inputSchema:
@@ -0,0 +1,16 @@
1
+ """
2
+ Common constants and utilities shared between modules to avoid circular imports.
3
+ """
4
+
5
+ # Constants
6
+ SEP = "-"
7
+
8
+
9
+ def create_namespaced_name(server_name: str, resource_name: str) -> str:
10
+ """Create a namespaced resource name from server and resource names"""
11
+ return f"{server_name}{SEP}{resource_name}"
12
+
13
+
14
+ def is_namespaced_name(name: str) -> bool:
15
+ """Check if a name is already namespaced"""
16
+ return SEP in name
@@ -6,21 +6,16 @@ It adds logging and supports sampling requests.
6
6
  from datetime import timedelta
7
7
  from typing import TYPE_CHECKING, Optional
8
8
 
9
- from mcp import ClientSession
9
+ from mcp import ClientSession, ServerNotification
10
10
  from mcp.shared.session import (
11
- ReceiveNotificationT,
12
11
  ReceiveResultT,
13
12
  RequestId,
14
13
  SendNotificationT,
15
14
  SendRequestT,
16
15
  SendResultT,
17
16
  )
18
- from mcp.types import (
19
- ErrorData,
20
- ListRootsResult,
21
- Root,
22
- )
23
- from pydantic import AnyUrl
17
+ from mcp.types import ErrorData, ListRootsResult, Root, ToolListChangedNotification
18
+ from pydantic import FileUrl
24
19
 
25
20
  from mcp_agent.context_dependent import ContextDependent
26
21
  from mcp_agent.logging.logger import get_logger
@@ -45,7 +40,7 @@ async def list_roots(ctx: ClientSession) -> ListRootsResult:
45
40
  ):
46
41
  roots = [
47
42
  Root(
48
- uri=AnyUrl(
43
+ uri=FileUrl(
49
44
  root.server_uri_alias or root.uri,
50
45
  ),
51
46
  name=root.name,
@@ -67,6 +62,11 @@ class MCPAgentClientSession(ClientSession, ContextDependent):
67
62
  """
68
63
 
69
64
  def __init__(self, *args, **kwargs) -> None:
65
+ # Extract server_name if provided in kwargs
66
+ self.session_server_name = kwargs.pop("server_name", None)
67
+ # Extract the notification callbacks if provided
68
+ self._tool_list_changed_callback = kwargs.pop("tool_list_changed_callback", None)
69
+
70
70
  super().__init__(*args, **kwargs, list_roots_callback=list_roots, sampling_callback=sample)
71
71
  self.server_config: Optional[MCPServerSettings] = None
72
72
 
@@ -104,7 +104,7 @@ class MCPAgentClientSession(ClientSession, ContextDependent):
104
104
  )
105
105
  return await super()._send_response(request_id, response)
106
106
 
107
- async def _received_notification(self, notification: ReceiveNotificationT) -> None:
107
+ async def _received_notification(self, notification: ServerNotification) -> None:
108
108
  """
109
109
  Can be overridden by subclasses to handle a notification without needing
110
110
  to listen on the message stream.
@@ -113,7 +113,37 @@ class MCPAgentClientSession(ClientSession, ContextDependent):
113
113
  "_received_notification: notification=",
114
114
  data=notification.model_dump(),
115
115
  )
116
- return await super()._received_notification(notification)
116
+
117
+ # Call parent notification handler first
118
+ await super()._received_notification(notification)
119
+
120
+ # Then process our specific notification types
121
+ match notification.root:
122
+ case ToolListChangedNotification():
123
+ # Simple notification handling - just call the callback if it exists
124
+ if self._tool_list_changed_callback and self.session_server_name:
125
+ logger.info(
126
+ f"Tool list changed for server '{self.session_server_name}', triggering callback"
127
+ )
128
+ # Use asyncio.create_task to prevent blocking the notification handler
129
+ import asyncio
130
+ asyncio.create_task(self._handle_tool_list_change_callback(self.session_server_name))
131
+ else:
132
+ logger.debug(
133
+ f"Tool list changed for server '{self.session_server_name}' but no callback registered"
134
+ )
135
+
136
+ return None
137
+
138
+ async def _handle_tool_list_change_callback(self, server_name: str) -> None:
139
+ """
140
+ Helper method to handle tool list change callback in a separate task
141
+ to prevent blocking the notification handler
142
+ """
143
+ try:
144
+ await self._tool_list_changed_callback(server_name)
145
+ except Exception as e:
146
+ logger.error(f"Error in tool list changed callback: {e}")
117
147
 
118
148
  async def send_progress_notification(
119
149
  self, progress_token: str | int, progress: float, total: float | None = None
@@ -25,6 +25,7 @@ from pydantic import AnyUrl, BaseModel, ConfigDict
25
25
  from mcp_agent.context_dependent import ContextDependent
26
26
  from mcp_agent.event_progress import ProgressAction
27
27
  from mcp_agent.logging.logger import get_logger
28
+ from mcp_agent.mcp.common import SEP, create_namespaced_name, is_namespaced_name
28
29
  from mcp_agent.mcp.gen_client import gen_client
29
30
  from mcp_agent.mcp.mcp_agent_client_session import MCPAgentClientSession
30
31
  from mcp_agent.mcp.mcp_connection_manager import MCPConnectionManager
@@ -35,23 +36,11 @@ if TYPE_CHECKING:
35
36
 
36
37
  logger = get_logger(__name__) # This will be replaced per-instance when agent_name is available
37
38
 
38
- SEP = "-"
39
-
40
39
  # Define type variables for the generalized method
41
40
  T = TypeVar("T")
42
41
  R = TypeVar("R")
43
42
 
44
43
 
45
- def create_namespaced_name(server_name: str, resource_name: str) -> str:
46
- """Create a namespaced resource name from server and resource names"""
47
- return f"{server_name}{SEP}{resource_name}"
48
-
49
-
50
- def is_namespaced_name(name: str) -> bool:
51
- """Check if a name is already namespaced"""
52
- return SEP in name
53
-
54
-
55
44
  class NamespacedTool(BaseModel):
56
45
  """
57
46
  A tool that is namespaced by server name.
@@ -94,6 +83,11 @@ class MCPAggregator(ContextDependent):
94
83
  self._persistent_connection_manager = self.context._connection_manager
95
84
 
96
85
  await self.load_servers()
86
+ # Import the display component here to avoid circular imports
87
+ from mcp_agent.ui.console_display import ConsoleDisplay
88
+
89
+ # Initialize the display component
90
+ self.display = ConsoleDisplay(config=self.context.config)
97
91
 
98
92
  return self
99
93
 
@@ -138,6 +132,9 @@ class MCPAggregator(ContextDependent):
138
132
  self._prompt_cache: Dict[str, List[Prompt]] = {}
139
133
  self._prompt_cache_lock = Lock()
140
134
 
135
+ # Lock for refreshing tools from a server
136
+ self._refresh_lock = Lock()
137
+
141
138
  async def close(self) -> None:
142
139
  """
143
140
  Close all persistent connections when the aggregator is deleted.
@@ -217,8 +214,18 @@ class MCPAggregator(ContextDependent):
217
214
  },
218
215
  )
219
216
 
217
+ # Create a wrapper to capture the parameters for the client session
218
+ def session_factory(read_stream, write_stream, read_timeout):
219
+ return MCPAgentClientSession(
220
+ read_stream,
221
+ write_stream,
222
+ read_timeout,
223
+ server_name=server_name,
224
+ tool_list_changed_callback=self._handle_tool_list_changed,
225
+ )
226
+
220
227
  await self._persistent_connection_manager.get_server(
221
- server_name, client_session_factory=MCPAgentClientSession
228
+ server_name, client_session_factory=session_factory
222
229
  )
223
230
 
224
231
  logger.info(
@@ -261,8 +268,20 @@ class MCPAggregator(ContextDependent):
261
268
  tools = await fetch_tools(server_connection.session)
262
269
  prompts = await fetch_prompts(server_connection.session, server_name)
263
270
  else:
271
+ # Create a factory function for the client session
272
+ def create_session(read_stream, write_stream, read_timeout):
273
+ return MCPAgentClientSession(
274
+ read_stream,
275
+ write_stream,
276
+ read_timeout,
277
+ server_name=server_name,
278
+ tool_list_changed_callback=self._handle_tool_list_changed,
279
+ )
280
+
264
281
  async with gen_client(
265
- server_name, server_registry=self.context.server_registry
282
+ server_name,
283
+ server_registry=self.context.server_registry,
284
+ client_session_factory=create_session,
266
285
  ) as client:
267
286
  tools = await fetch_tools(client)
268
287
  prompts = await fetch_prompts(client, server_name)
@@ -384,6 +403,15 @@ class MCPAggregator(ContextDependent):
384
403
  ]
385
404
  )
386
405
 
406
+ async def refresh_all_tools(self) -> None:
407
+ """
408
+ Refresh the tools for all servers.
409
+ This is useful when you know tools have changed but haven't received notifications.
410
+ """
411
+ logger.info("Refreshing tools for all servers")
412
+ for server_name in self.server_names:
413
+ await self._refresh_server_tools(server_name)
414
+
387
415
  async def _execute_on_server(
388
416
  self,
389
417
  server_name: str,
@@ -864,6 +892,103 @@ class MCPAggregator(ContextDependent):
864
892
  logger.debug(f"Available prompts across servers: {results}")
865
893
  return results
866
894
 
895
+ async def _handle_tool_list_changed(self, server_name: str) -> None:
896
+ """
897
+ Callback handler for ToolListChangedNotification.
898
+ This will refresh the tools for the specified server.
899
+
900
+ Args:
901
+ server_name: The name of the server whose tools have changed
902
+ """
903
+ logger.info(f"Tool list changed for server '{server_name}', refreshing tools")
904
+
905
+ # Refresh the tools for this server
906
+ await self._refresh_server_tools(server_name)
907
+
908
+ async def _refresh_server_tools(self, server_name: str) -> None:
909
+ """
910
+ Refresh the tools for a specific server.
911
+
912
+ Args:
913
+ server_name: The name of the server to refresh tools for
914
+ """
915
+ if not await self.validate_server(server_name):
916
+ logger.error(f"Cannot refresh tools for unknown server '{server_name}'")
917
+ return
918
+
919
+ await self.display.show_tool_update(aggregator=self, updated_server=server_name)
920
+
921
+ async with self._refresh_lock:
922
+ try:
923
+ # Fetch new tools from the server
924
+ if self.connection_persistence:
925
+ # Create a factory function that will include our parameters
926
+ def create_session(read_stream, write_stream, read_timeout):
927
+ return MCPAgentClientSession(
928
+ read_stream,
929
+ write_stream,
930
+ read_timeout,
931
+ server_name=server_name,
932
+ tool_list_changed_callback=self._handle_tool_list_changed,
933
+ )
934
+
935
+ server_connection = await self._persistent_connection_manager.get_server(
936
+ server_name, client_session_factory=create_session
937
+ )
938
+ tools_result = await server_connection.session.list_tools()
939
+ new_tools = tools_result.tools or []
940
+ else:
941
+ # Create a factory function for the client session
942
+ def create_session(read_stream, write_stream, read_timeout):
943
+ return MCPAgentClientSession(
944
+ read_stream,
945
+ write_stream,
946
+ read_timeout,
947
+ server_name=server_name,
948
+ tool_list_changed_callback=self._handle_tool_list_changed,
949
+ )
950
+
951
+ async with gen_client(
952
+ server_name,
953
+ server_registry=self.context.server_registry,
954
+ client_session_factory=create_session,
955
+ ) as client:
956
+ tools_result = await client.list_tools()
957
+ new_tools = tools_result.tools or []
958
+
959
+ # Update tool maps
960
+ async with self._tool_map_lock:
961
+ # Remove old tools for this server
962
+ old_tools = self._server_to_tool_map.get(server_name, [])
963
+ for old_tool in old_tools:
964
+ if old_tool.namespaced_tool_name in self._namespaced_tool_map:
965
+ del self._namespaced_tool_map[old_tool.namespaced_tool_name]
966
+
967
+ # Add new tools
968
+ self._server_to_tool_map[server_name] = []
969
+ for tool in new_tools:
970
+ namespaced_tool_name = create_namespaced_name(server_name, tool.name)
971
+ namespaced_tool = NamespacedTool(
972
+ tool=tool,
973
+ server_name=server_name,
974
+ namespaced_tool_name=namespaced_tool_name,
975
+ )
976
+
977
+ self._namespaced_tool_map[namespaced_tool_name] = namespaced_tool
978
+ self._server_to_tool_map[server_name].append(namespaced_tool)
979
+
980
+ logger.info(
981
+ f"Successfully refreshed tools for server '{server_name}'",
982
+ data={
983
+ "progress_action": ProgressAction.UPDATED,
984
+ "server_name": server_name,
985
+ "agent_name": self.agent_name,
986
+ "tool_count": len(new_tools),
987
+ },
988
+ )
989
+ except Exception as e:
990
+ logger.error(f"Failed to refresh tools for server '{server_name}': {e}")
991
+
867
992
  async def get_resource(
868
993
  self, resource_uri: str, server_name: str | None = None
869
994
  ) -> ReadResourceResult:
@@ -5,7 +5,8 @@ from rich.panel import Panel
5
5
  from rich.text import Text
6
6
 
7
7
  from mcp_agent import console
8
- from mcp_agent.mcp.mcp_aggregator import SEP
8
+ from mcp_agent.mcp.common import SEP
9
+ from mcp_agent.mcp.mcp_aggregator import MCPAggregator
9
10
 
10
11
  # Constants
11
12
  HUMAN_INPUT_TOOL_NAME = "__human_input__"
@@ -96,6 +97,32 @@ class ConsoleDisplay:
96
97
  console.console.print(panel, markup=self._markup)
97
98
  console.console.print("\n")
98
99
 
100
+ async def show_tool_update(self, aggregator: MCPAggregator | None, updated_server: str) -> None:
101
+ """Show a tool update for a server"""
102
+ if not self.config or not self.config.logger.show_tools:
103
+ return
104
+
105
+ display_server_list = Text()
106
+
107
+ if aggregator:
108
+ for server_name in await aggregator.list_servers():
109
+ style = "green" if updated_server == server_name else "dim white"
110
+ display_server_list.append(f"[{server_name}] ", style)
111
+
112
+ panel = Panel(
113
+ f"[dim green]Updating tools for server {updated_server}[/]",
114
+ title="[TOOL UPDATE]",
115
+ title_align="left",
116
+ style="green",
117
+ border_style="bold white",
118
+ padding=(1, 2),
119
+ subtitle=display_server_list,
120
+ subtitle_align="left",
121
+ )
122
+ console.console.print("\n")
123
+ console.console.print(panel, markup=self._markup)
124
+ console.console.print("\n")
125
+
99
126
  def _format_tool_list(self, available_tools, selected_tool_name):
100
127
  """Format the list of available tools, highlighting the selected one."""
101
128
  display_tool_list = Text()