fast-agent-mcp 0.3.4__py3-none-any.whl → 0.3.6__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 fast-agent-mcp might be problematic. Click here for more details.

@@ -137,10 +137,24 @@ class LlmAgent(LlmDecorator):
137
137
  display_name = name if name is not None else self.name
138
138
  display_model = model if model is not None else (self.llm.model_name if self._llm else None)
139
139
 
140
+ # Convert highlight_items to highlight_index
141
+ highlight_index = None
142
+ if highlight_items and bottom_items:
143
+ if isinstance(highlight_items, str):
144
+ try:
145
+ highlight_index = bottom_items.index(highlight_items)
146
+ except ValueError:
147
+ pass
148
+ elif isinstance(highlight_items, list) and len(highlight_items) > 0:
149
+ try:
150
+ highlight_index = bottom_items.index(highlight_items[0])
151
+ except ValueError:
152
+ pass
153
+
140
154
  await self.display.show_assistant_message(
141
155
  message_text,
142
156
  bottom_items=bottom_items,
143
- highlight_items=highlight_items,
157
+ highlight_index=highlight_index,
144
158
  max_item_length=max_item_length,
145
159
  name=display_name,
146
160
  model=display_model,
@@ -156,6 +156,9 @@ class McpAgent(ABC, ToolAgent):
156
156
  """
157
157
  await self.__aenter__()
158
158
 
159
+ # Apply template substitution to the instruction with server instructions
160
+ await self._apply_instruction_templates()
161
+
159
162
  async def shutdown(self) -> None:
160
163
  """
161
164
  Shutdown the agent and close all MCP server connections.
@@ -174,6 +177,67 @@ class McpAgent(ABC, ToolAgent):
174
177
  self._initialized = value
175
178
  self._aggregator.initialized = value
176
179
 
180
+ async def _apply_instruction_templates(self) -> None:
181
+ """
182
+ Apply template substitution to the instruction, including server instructions.
183
+ This is called during initialization after servers are connected.
184
+ """
185
+ if not self.instruction:
186
+ return
187
+
188
+ # Gather server instructions if the template includes {{serverInstructions}}
189
+ if "{{serverInstructions}}" in self.instruction:
190
+ try:
191
+ instructions_data = await self._aggregator.get_server_instructions()
192
+ server_instructions = self._format_server_instructions(instructions_data)
193
+ except Exception as e:
194
+ self.logger.warning(f"Failed to get server instructions: {e}")
195
+ server_instructions = ""
196
+
197
+ # Replace the template variable
198
+ self.instruction = self.instruction.replace("{{serverInstructions}}", server_instructions)
199
+
200
+
201
+ # Update default request params to match
202
+ if self._default_request_params:
203
+ self._default_request_params.systemPrompt = self.instruction
204
+
205
+ self.logger.debug(f"Applied instruction templates for agent {self._name}")
206
+
207
+ def _format_server_instructions(self, instructions_data: Dict[str, tuple[str | None, List[str]]]) -> str:
208
+ """
209
+ Format server instructions with XML tags and tool lists.
210
+
211
+ Args:
212
+ instructions_data: Dict mapping server name to (instructions, tool_names)
213
+
214
+ Returns:
215
+ Formatted string with server instructions
216
+ """
217
+ if not instructions_data:
218
+ return ""
219
+
220
+ formatted_parts = []
221
+ for server_name, (instructions, tool_names) in instructions_data.items():
222
+ # Skip servers with no instructions
223
+ if instructions is None:
224
+ continue
225
+
226
+ # Format tool names with server prefix
227
+ prefixed_tools = [f"{server_name}-{tool}" for tool in tool_names]
228
+ tools_list = ", ".join(prefixed_tools) if prefixed_tools else "No tools available"
229
+
230
+ formatted_parts.append(
231
+ f"<mcp-server name=\"{server_name}\">\n"
232
+ f"<tools>{tools_list}</tools>\n"
233
+ f"<instructions>\n{instructions}\n</instructions>\n"
234
+ f"</mcp-server>"
235
+ )
236
+
237
+ if formatted_parts:
238
+ return "\n\n".join(formatted_parts)
239
+ return ""
240
+
177
241
  async def __call__(
178
242
  self,
179
243
  message: Union[
@@ -549,12 +613,20 @@ class McpAgent(ABC, ToolAgent):
549
613
  namespaced_tool = self._aggregator._namespaced_tool_map.get(tool_name)
550
614
  display_tool_name = namespaced_tool.tool.name if namespaced_tool else tool_name
551
615
 
616
+ # Find the index of the current tool in available_tools for highlighting
617
+ highlight_index = None
618
+ try:
619
+ highlight_index = available_tools.index(display_tool_name)
620
+ except ValueError:
621
+ # Tool not found in list, no highlighting
622
+ pass
623
+
552
624
  self.display.show_tool_call(
553
625
  name=self._name,
554
626
  tool_args=tool_args,
555
627
  bottom_items=available_tools,
556
628
  tool_name=display_tool_name,
557
- highlight_items=tool_name,
629
+ highlight_index=highlight_index,
558
630
  max_item_length=12,
559
631
  )
560
632
 
@@ -128,11 +128,21 @@ class ToolAgent(LlmAgent):
128
128
  for correlation_id, tool_request in request.tool_calls.items():
129
129
  tool_name = tool_request.params.name
130
130
  tool_args = tool_request.params.arguments or {}
131
+
132
+ # Find the index of the current tool in available_tools for highlighting
133
+ highlight_index = None
134
+ try:
135
+ highlight_index = available_tools.index(tool_name)
136
+ except ValueError:
137
+ # Tool not found in list, no highlighting
138
+ pass
139
+
131
140
  self.display.show_tool_call(
132
141
  name=self.name,
133
142
  tool_args=tool_args,
134
143
  bottom_items=available_tools,
135
144
  tool_name=tool_name,
145
+ highlight_index=highlight_index,
136
146
  max_item_length=12,
137
147
  )
138
148
 
@@ -300,10 +300,18 @@ class RouterAgent(LlmAgent):
300
300
  if response.reasoning:
301
301
  routing_message += f" ({response.reasoning})"
302
302
 
303
+ # Convert highlight_items to highlight_index
304
+ agent_keys = list(self.agent_map.keys())
305
+ highlight_index = None
306
+ try:
307
+ highlight_index = agent_keys.index(response.agent)
308
+ except ValueError:
309
+ pass
310
+
303
311
  await self.display.show_assistant_message(
304
312
  routing_message,
305
- bottom_items=list(self.agent_map.keys()),
306
- highlight_items=[response.agent],
313
+ bottom_items=agent_keys,
314
+ highlight_index=highlight_index,
307
315
  name=self.name,
308
316
  )
309
317
 
@@ -13,14 +13,17 @@ def main():
13
13
  # Check if first arg is not already a subcommand
14
14
  first_arg = sys.argv[1]
15
15
 
16
- if first_arg not in KNOWN_SUBCOMMANDS and any(
17
- arg in sys.argv or any(arg.startswith(opt + "=") for opt in GO_SPECIFIC_OPTIONS)
18
- for arg in sys.argv
19
- ):
16
+ # Only auto-route if any known go-specific options are present
17
+ has_go_options = any(
18
+ (arg in GO_SPECIFIC_OPTIONS) or any(arg.startswith(opt + "=") for opt in GO_SPECIFIC_OPTIONS)
19
+ for arg in sys.argv[1:]
20
+ )
21
+
22
+ if first_arg not in KNOWN_SUBCOMMANDS and has_go_options:
20
23
  # Find where to insert 'go' - before the first go-specific option
21
24
  insert_pos = 1
22
25
  for i, arg in enumerate(sys.argv[1:], 1):
23
- if arg in GO_SPECIFIC_OPTIONS or any(
26
+ if (arg in GO_SPECIFIC_OPTIONS) or any(
24
27
  arg.startswith(opt + "=") for opt in GO_SPECIFIC_OPTIONS
25
28
  ):
26
29
  insert_pos = i
@@ -0,0 +1,393 @@
1
+ """Authentication management commands for fast-agent.
2
+
3
+ Shows keyring backend, per-server OAuth token status, and provides a way to clear tokens.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Dict, List, Optional
9
+
10
+ import typer
11
+ from rich.table import Table
12
+
13
+ from fast_agent.config import Settings, get_settings
14
+ from fast_agent.mcp.oauth_client import (
15
+ _derive_base_server_url,
16
+ clear_keyring_token,
17
+ compute_server_identity,
18
+ list_keyring_tokens,
19
+ )
20
+ from fast_agent.ui.console import console
21
+
22
+ app = typer.Typer(help="Manage OAuth authentication state for MCP servers")
23
+
24
+
25
+ def _get_keyring_status() -> tuple[str, bool]:
26
+ """Return (backend_name, usable) where usable=False for the fail backend or missing keyring."""
27
+ try:
28
+ import keyring
29
+
30
+ kr = keyring.get_keyring()
31
+ name = getattr(kr, "name", kr.__class__.__name__)
32
+ try:
33
+ from keyring.backends.fail import Keyring as FailKeyring # type: ignore
34
+
35
+ return name, not isinstance(kr, FailKeyring)
36
+ except Exception:
37
+ # If fail backend marker cannot be imported, assume usable
38
+ return name, True
39
+ except Exception:
40
+ return "unavailable", False
41
+
42
+
43
+ def _get_keyring_backend_name() -> str:
44
+ # Backwards-compat helper; prefer _get_keyring_status in new code
45
+ name, _ = _get_keyring_status()
46
+ return name
47
+
48
+
49
+ def _keyring_get_password(service: str, username: str) -> str | None:
50
+ try:
51
+ import keyring
52
+
53
+ return keyring.get_password(service, username)
54
+ except Exception:
55
+ return None
56
+
57
+
58
+ def _keyring_delete_password(service: str, username: str) -> bool:
59
+ try:
60
+ import keyring
61
+
62
+ keyring.delete_password(service, username)
63
+ return True
64
+ except Exception:
65
+ return False
66
+
67
+
68
+ def _server_rows_from_settings(settings: Settings):
69
+ rows = []
70
+ mcp = getattr(settings, "mcp", None)
71
+ servers = getattr(mcp, "servers", {}) if mcp else {}
72
+ for name, cfg in servers.items():
73
+ transport = getattr(cfg, "transport", "")
74
+ if transport == "stdio":
75
+ # STDIO servers do not use OAuth; skip in auth views
76
+ continue
77
+ url = getattr(cfg, "url", None)
78
+ auth = getattr(cfg, "auth", None)
79
+ oauth_enabled = getattr(auth, "oauth", True) if auth is not None else True
80
+ persist = getattr(auth, "persist", "keyring") if auth is not None else "keyring"
81
+ identity = compute_server_identity(cfg)
82
+ # token presence only meaningful if persist is keyring and transport is http/sse
83
+ has_token = False
84
+ if persist == "keyring" and transport in ("http", "sse") and oauth_enabled:
85
+ has_token = (
86
+ _keyring_get_password("fast-agent-mcp", f"oauth:tokens:{identity}") is not None
87
+ )
88
+ rows.append(
89
+ {
90
+ "name": name,
91
+ "transport": transport,
92
+ "url": url or "",
93
+ "persist": persist,
94
+ "oauth": oauth_enabled and transport in ("http", "sse"),
95
+ "has_token": has_token,
96
+ "identity": identity,
97
+ }
98
+ )
99
+ return rows
100
+
101
+
102
+ def _servers_by_identity(settings: Settings) -> Dict[str, List[str]]:
103
+ """Group configured server names by derived identity (base URL)."""
104
+ mapping: Dict[str, List[str]] = {}
105
+ mcp = getattr(settings, "mcp", None)
106
+ servers = getattr(mcp, "servers", {}) if mcp else {}
107
+ for name, cfg in servers.items():
108
+ try:
109
+ identity = compute_server_identity(cfg)
110
+ except Exception:
111
+ identity = name
112
+ mapping.setdefault(identity, []).append(name)
113
+ return mapping
114
+
115
+
116
+ @app.command()
117
+ def status(
118
+ target: Optional[str] = typer.Argument(None, help="Identity (base URL) or server name"),
119
+ config_path: Optional[str] = typer.Option(None, "--config-path", "-c"),
120
+ ) -> None:
121
+ """Show keyring backend and token status for configured MCP servers."""
122
+ settings = get_settings(config_path)
123
+ backend, backend_usable = _get_keyring_status()
124
+
125
+ # Single-target view if target provided
126
+ if target:
127
+ settings = get_settings(config_path)
128
+ identity = _derive_base_server_url(target) if "://" in target else None
129
+ if not identity:
130
+ servers = getattr(getattr(settings, "mcp", None), "servers", {}) or {}
131
+ cfg = servers.get(target)
132
+ if not cfg:
133
+ typer.echo(f"Server '{target}' not found in config; treating as identity")
134
+ identity = target
135
+ else:
136
+ identity = compute_server_identity(cfg)
137
+
138
+ # Direct presence check
139
+ present = False
140
+ if backend_usable:
141
+ try:
142
+ import keyring
143
+
144
+ present = (
145
+ keyring.get_password("fast-agent-mcp", f"oauth:tokens:{identity}") is not None
146
+ )
147
+ except Exception:
148
+ present = False
149
+
150
+ table = Table(show_header=True, box=None)
151
+ table.add_column("Identity", header_style="bold")
152
+ table.add_column("Token", header_style="bold")
153
+ table.add_column("Servers", header_style="bold")
154
+ by_id = _servers_by_identity(settings)
155
+ servers_for_id = ", ".join(by_id.get(identity, [])) or "[dim]None[/dim]"
156
+ token_disp = "[bold green]✓[/bold green]" if present else "[dim]✗[/dim]"
157
+ table.add_row(identity, token_disp, servers_for_id)
158
+
159
+ if backend_usable and backend != "unavailable":
160
+ console.print(f"Keyring backend: [green]{backend}[/green]")
161
+ else:
162
+ console.print("Keyring backend: [red]not available[/red]")
163
+ console.print(table)
164
+ console.print(
165
+ "\n[dim]Run 'fast-agent auth clear --identity "
166
+ f"{identity}[/dim][dim]' to remove this token, or 'fast-agent auth clear --all' to remove all.[/dim]"
167
+ )
168
+ return
169
+
170
+ # Full status view
171
+ if backend_usable and backend != "unavailable":
172
+ console.print(f"Keyring backend: [green]{backend}[/green]")
173
+ else:
174
+ console.print("Keyring backend: [red]not available[/red]")
175
+
176
+ tokens = list_keyring_tokens()
177
+ token_table = Table(show_header=True, box=None)
178
+ token_table.add_column("Stored Tokens (Identity)", header_style="bold")
179
+ token_table.add_column("Present", header_style="bold")
180
+ if tokens:
181
+ for ident in tokens:
182
+ token_table.add_row(ident, "[bold green]✓[/bold green]")
183
+ else:
184
+ token_table.add_row("[dim]None[/dim]", "[dim]✗[/dim]")
185
+
186
+ console.print(token_table)
187
+
188
+ rows = _server_rows_from_settings(settings)
189
+ if rows:
190
+ map_table = Table(show_header=True, box=None)
191
+ map_table.add_column("Server", header_style="bold")
192
+ map_table.add_column("Transport", header_style="bold")
193
+ map_table.add_column("OAuth", header_style="bold")
194
+ map_table.add_column("Persist", header_style="bold")
195
+ map_table.add_column("Token", header_style="bold")
196
+ map_table.add_column("Identity", header_style="bold")
197
+ for row in rows:
198
+ oauth_status = "[green]on[/green]" if row["oauth"] else "[dim]off[/dim]"
199
+ persist = row["persist"]
200
+ persist_disp = (
201
+ f"[green]{persist}[/green]"
202
+ if persist == "keyring"
203
+ else f"[yellow]{persist}[/yellow]"
204
+ )
205
+ # Direct presence check for each identity so status works even without index
206
+ has_token = False
207
+ token_disp = "[dim]✗[/dim]"
208
+ if persist == "keyring" and row["oauth"]:
209
+ if backend_usable:
210
+ try:
211
+ import keyring
212
+
213
+ has_token = (
214
+ keyring.get_password(
215
+ "fast-agent-mcp", f"oauth:tokens:{row['identity']}"
216
+ )
217
+ is not None
218
+ )
219
+ except Exception:
220
+ has_token = False
221
+ token_disp = "[bold green]✓[/bold green]" if has_token else "[dim]✗[/dim]"
222
+ else:
223
+ token_disp = "[red]not available[/red]"
224
+ elif persist == "memory" and row["oauth"]:
225
+ token_disp = "[yellow]memory[/yellow]"
226
+ map_table.add_row(
227
+ row["name"],
228
+ row["transport"].upper(),
229
+ oauth_status,
230
+ persist_disp,
231
+ token_disp,
232
+ row["identity"],
233
+ )
234
+ console.print(map_table)
235
+
236
+ console.print(
237
+ "\n[dim]Run 'fast-agent auth clear --identity <identity>' to remove a token, or 'fast-agent auth clear --all' to remove all.[/dim]"
238
+ )
239
+
240
+
241
+ @app.command()
242
+ def clear(
243
+ server: Optional[str] = typer.Argument(None, help="Server name to clear (from config)"),
244
+ identity: Optional[str] = typer.Option(
245
+ None, "--identity", help="Token identity (base URL) to clear"
246
+ ),
247
+ all: bool = typer.Option(False, "--all", help="Clear tokens for all identities in keyring"),
248
+ config_path: Optional[str] = typer.Option(None, "--config-path", "-c"),
249
+ ) -> None:
250
+ """Clear stored OAuth tokens from the keyring."""
251
+ targets_identities: list[str] = []
252
+ if all:
253
+ targets_identities = list_keyring_tokens()
254
+ elif identity:
255
+ targets_identities = [identity]
256
+ elif server:
257
+ settings = get_settings(config_path)
258
+ rows = _server_rows_from_settings(settings)
259
+ match = next((r for r in rows if r["name"] == server), None)
260
+ if not match:
261
+ typer.echo(f"Server '{server}' not found in config")
262
+ raise typer.Exit(1)
263
+ targets_identities = [match["identity"]]
264
+ else:
265
+ typer.echo("Provide --identity, a server name, or use --all")
266
+ raise typer.Exit(1)
267
+
268
+ # Confirm destructive action
269
+ if not typer.confirm("Remove tokens for the selected server(s) from keyring?", default=False):
270
+ raise typer.Exit()
271
+
272
+ removed_any = False
273
+ for ident in targets_identities:
274
+ if clear_keyring_token(ident):
275
+ removed_any = True
276
+ if removed_any:
277
+ typer.echo("Tokens removed.")
278
+ else:
279
+ typer.echo("No tokens found or nothing removed.")
280
+
281
+
282
+ @app.callback(invoke_without_command=True)
283
+ def main(
284
+ ctx: typer.Context, config_path: Optional[str] = typer.Option(None, "--config-path", "-c")
285
+ ) -> None:
286
+ """Default to showing status if no subcommand is provided."""
287
+ if ctx.invoked_subcommand is None:
288
+ try:
289
+ status(target=None, config_path=config_path)
290
+ except Exception as e:
291
+ typer.echo(f"Error showing auth status: {e}")
292
+
293
+
294
+ @app.command()
295
+ def login(
296
+ target: str = typer.Argument(..., help="Server name (from config) or identity (base URL)"),
297
+ transport: Optional[str] = typer.Option(
298
+ None, "--transport", help="Transport for identity mode: http or sse"
299
+ ),
300
+ config_path: Optional[str] = typer.Option(None, "--config-path", "-c"),
301
+ ) -> None:
302
+ """Start OAuth flow and store tokens for a server.
303
+
304
+ Accepts either a configured server name or an identity (base URL).
305
+ For identity mode, default transport is 'http' (uses <identity>/mcp).
306
+ """
307
+ # Resolve to a minimal MCPServerSettings
308
+ from fast_agent.config import MCPServerAuthSettings, MCPServerSettings
309
+ from fast_agent.mcp.oauth_client import build_oauth_provider
310
+
311
+ cfg = None
312
+ resolved_transport = None
313
+
314
+ if "://" in target:
315
+ # Identity mode
316
+ base = _derive_base_server_url(target)
317
+ if not base:
318
+ typer.echo("Invalid identity URL")
319
+ raise typer.Exit(1)
320
+ resolved_transport = (transport or "http").lower()
321
+ if resolved_transport not in ("http", "sse"):
322
+ typer.echo("--transport must be 'http' or 'sse'")
323
+ raise typer.Exit(1)
324
+ endpoint = base + ("/mcp" if resolved_transport == "http" else "/sse")
325
+ cfg = MCPServerSettings(
326
+ name=base,
327
+ transport=resolved_transport,
328
+ url=endpoint,
329
+ auth=MCPServerAuthSettings(),
330
+ )
331
+ else:
332
+ # Server name mode
333
+ settings = get_settings(config_path)
334
+ servers = getattr(getattr(settings, "mcp", None), "servers", {}) or {}
335
+ cfg = servers.get(target)
336
+ if not cfg:
337
+ typer.echo(f"Server '{target}' not found in config")
338
+ raise typer.Exit(1)
339
+ resolved_transport = getattr(cfg, "transport", "")
340
+ if resolved_transport == "stdio":
341
+ typer.echo("STDIO servers do not support OAuth")
342
+ raise typer.Exit(1)
343
+
344
+ # Build OAuth provider
345
+ provider = build_oauth_provider(cfg)
346
+ if provider is None:
347
+ typer.echo("OAuth is disabled or misconfigured for this server/identity")
348
+ raise typer.Exit(1)
349
+
350
+ async def _run_login():
351
+ try:
352
+ # Use appropriate transport; connect and initialize a minimal session
353
+ if resolved_transport == "http":
354
+ from mcp.client.session import ClientSession
355
+ from mcp.client.streamable_http import streamablehttp_client
356
+
357
+ async with streamablehttp_client(
358
+ cfg.url or "",
359
+ getattr(cfg, "headers", None),
360
+ auth=provider,
361
+ ) as (read_stream, write_stream, _get_session_id):
362
+ async with ClientSession(read_stream, write_stream) as session:
363
+ await session.initialize()
364
+ return True
365
+ elif resolved_transport == "sse":
366
+ from mcp.client.session import ClientSession
367
+ from mcp.client.sse import sse_client
368
+
369
+ async with sse_client(
370
+ cfg.url or "",
371
+ getattr(cfg, "headers", None),
372
+ auth=provider,
373
+ ) as (read_stream, write_stream):
374
+ async with ClientSession(read_stream, write_stream) as session:
375
+ await session.initialize()
376
+ return True
377
+ else:
378
+ return False
379
+ except Exception as e:
380
+ # Surface concise error; detailed logging is in the library
381
+ typer.echo(f"Login failed: {e}")
382
+ return False
383
+
384
+ import asyncio
385
+
386
+ ok = asyncio.run(_run_login())
387
+ if ok:
388
+ from fast_agent.mcp.oauth_client import compute_server_identity
389
+
390
+ ident = compute_server_identity(cfg)
391
+ typer.echo(f"Authenticated. Tokens stored for identity: {ident}")
392
+ else:
393
+ raise typer.Exit(1)