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.

@@ -8,18 +8,17 @@ from typing import Optional
8
8
 
9
9
  import typer
10
10
  import yaml
11
- from rich.console import Console
12
11
  from rich.table import Table
13
12
  from rich.text import Text
14
13
 
15
14
  from fast_agent.llm.provider_key_manager import API_KEY_HINT_TEXT, ProviderKeyManager
16
15
  from fast_agent.llm.provider_types import Provider
16
+ from fast_agent.ui.console import console
17
17
 
18
18
  app = typer.Typer(
19
19
  help="Check and diagnose FastAgent configuration",
20
20
  no_args_is_help=False, # Allow showing our custom help instead
21
21
  )
22
- console = Console()
23
22
 
24
23
 
25
24
  def find_config_files(start_path: Path) -> dict[str, Optional[Path]]:
@@ -305,6 +304,27 @@ def show_check_summary() -> None:
305
304
  env_table.add_column("Setting", style="white")
306
305
  env_table.add_column("Value")
307
306
 
307
+ # Determine keyring backend early so it can appear in the top section
308
+ # Also detect whether the backend is actually usable (not the fail backend)
309
+ keyring_usable = False
310
+ try:
311
+ import keyring # type: ignore
312
+
313
+ keyring_backend = keyring.get_keyring()
314
+ keyring_name = getattr(keyring_backend, "name", keyring_backend.__class__.__name__)
315
+ try:
316
+ # Detect the "fail" backend explicitly; it's present but unusable
317
+ from keyring.backends.fail import Keyring as FailKeyring # type: ignore
318
+
319
+ keyring_usable = not isinstance(keyring_backend, FailKeyring)
320
+ except Exception:
321
+ # If we can't import the fail backend marker, assume usable
322
+ keyring_usable = True
323
+ except Exception:
324
+ keyring = None # type: ignore
325
+ keyring_name = "unavailable"
326
+ keyring_usable = False
327
+
308
328
  # Python info (highlight version and path in green)
309
329
  env_table.add_row(
310
330
  "Python Version", f"[green]{'.'.join(system_info['python_version'].split('.')[:3])}[/green]"
@@ -336,9 +356,15 @@ def show_check_summary() -> None:
336
356
  )
337
357
  else: # parsed successfully
338
358
  env_table.add_row("Config File", f"[green]Found[/green] ({config_path})")
339
- default_model_value = config_summary.get("default_model", "haiku (system default)")
359
+ default_model_value = config_summary.get("default_model", "gpt-5-mini.low (system default)")
340
360
  env_table.add_row("Default Model", f"[green]{default_model_value}[/green]")
341
361
 
362
+ # Keyring backend (always shown in application-level settings)
363
+ if keyring_usable and keyring_name != "unavailable":
364
+ env_table.add_row("Keyring Backend", f"[green]{keyring_name}[/green]")
365
+ else:
366
+ env_table.add_row("Keyring Backend", "[red]not available[/red]")
367
+
342
368
  console.print(env_table)
343
369
 
344
370
  # Logger Settings panel with two-column layout
@@ -470,10 +496,15 @@ def show_check_summary() -> None:
470
496
  if config_summary.get("status") == "parsed":
471
497
  mcp_servers = config_summary.get("mcp_servers", [])
472
498
  if mcp_servers:
499
+ from fast_agent.config import MCPServerSettings
500
+ from fast_agent.mcp.oauth_client import compute_server_identity
501
+
473
502
  servers_table = Table(show_header=True, box=None)
474
503
  servers_table.add_column("Name", style="white", header_style="bold bright_white")
475
504
  servers_table.add_column("Transport", style="white", header_style="bold bright_white")
476
505
  servers_table.add_column("Command/URL", header_style="bold bright_white")
506
+ servers_table.add_column("OAuth", header_style="bold bright_white")
507
+ servers_table.add_column("Token", header_style="bold bright_white")
477
508
 
478
509
  for server in mcp_servers:
479
510
  name = server["name"]
@@ -489,7 +520,48 @@ def show_check_summary() -> None:
489
520
  if "Not configured" not in command_url:
490
521
  command_url = f"[green]{command_url}[/green]"
491
522
 
492
- servers_table.add_row(name, transport, command_url)
523
+ # OAuth status and token presence
524
+ # Default for unsupported transports (e.g., STDIO): show "-" rather than "off"
525
+ oauth_status = "[dim]-[/dim]"
526
+ token_status = "[dim]n/a[/dim]"
527
+ # Attempt to reconstruct minimal server settings for identity check
528
+ try:
529
+ cfg = MCPServerSettings(
530
+ name=name,
531
+ transport="sse"
532
+ if transport == "SSE"
533
+ else ("stdio" if transport == "STDIO" else "http"),
534
+ url=(server.get("url") or None),
535
+ auth=server.get("auth") if isinstance(server.get("auth"), dict) else None,
536
+ )
537
+ except Exception:
538
+ cfg = None
539
+
540
+ if cfg and cfg.transport in ("http", "sse"):
541
+ # Determine if OAuth is enabled for this server
542
+ oauth_enabled = True
543
+ if cfg.auth is not None and hasattr(cfg.auth, "oauth"):
544
+ oauth_enabled = bool(getattr(cfg.auth, "oauth"))
545
+ oauth_status = "[green]on[/green]" if oauth_enabled else "[dim]off[/dim]"
546
+
547
+ # Only check token presence when using keyring persist
548
+ persist = "keyring"
549
+ if cfg.auth is not None and hasattr(cfg.auth, "persist"):
550
+ persist = getattr(cfg.auth, "persist") or "keyring"
551
+ if keyring and keyring_usable and persist == "keyring" and oauth_enabled:
552
+ identity = compute_server_identity(cfg)
553
+ tkey = f"oauth:tokens:{identity}"
554
+ try:
555
+ has = keyring.get_password("fast-agent-mcp", tkey) is not None
556
+ except Exception:
557
+ has = False
558
+ token_status = "[bold green]✓[/bold green]" if has else "[dim]✗[/dim]"
559
+ elif persist == "keyring" and not keyring_usable and oauth_enabled:
560
+ token_status = "[red]not available[/red]"
561
+ elif persist == "memory" and oauth_enabled:
562
+ token_status = "[yellow]memory[/yellow]"
563
+
564
+ servers_table.add_row(name, transport, command_url, oauth_status, token_status)
493
565
 
494
566
  _print_section_header("MCP Servers", color="blue")
495
567
  console.print(servers_table)
@@ -18,10 +18,16 @@ app = typer.Typer(
18
18
  context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
19
19
  )
20
20
 
21
+ default_instruction = """You are a helpful AI Agent.
22
+
23
+ {{serverInstructions}}
24
+
25
+ The current date is {{currentDate}}."""
26
+
21
27
 
22
28
  async def _run_agent(
23
29
  name: str = "fast-agent cli",
24
- instruction: str = "You are a helpful AI Agent.",
30
+ instruction: str = default_instruction,
25
31
  config_path: Optional[str] = None,
26
32
  server_list: Optional[List[str]] = None,
27
33
  model: Optional[str] = None,
@@ -352,7 +358,7 @@ def go(
352
358
  stdio_commands.append(stdio)
353
359
 
354
360
  # Resolve instruction from file/URL or use default
355
- resolved_instruction = "You are a helpful AI Agent." # Default
361
+ resolved_instruction = default_instruction # Default
356
362
  agent_name = "agent"
357
363
 
358
364
  if instruction:
@@ -8,11 +8,13 @@ from rich.console import Console
8
8
  from rich.panel import Panel
9
9
  from rich.table import Table
10
10
 
11
+ from fast_agent.ui.console import console as shared_console
12
+
11
13
  app = typer.Typer(
12
14
  help="Create fast-agent quickstarts",
13
15
  no_args_is_help=False, # Allow showing our custom help instead
14
16
  )
15
- console = Console()
17
+ console = shared_console
16
18
 
17
19
  EXAMPLE_TYPES = {
18
20
  "workflow": {
@@ -89,7 +89,7 @@ async def add_servers_to_config(fast_app: Any, servers: Dict[str, Dict[str, Any]
89
89
  ):
90
90
  fast_app.app.context.config.mcp.servers = {}
91
91
 
92
- # Add each server to the config
92
+ # Add each server to the config (and keep the runtime registry in sync)
93
93
  for server_name, server_config in servers.items():
94
94
  # Build server settings based on transport type
95
95
  server_settings = {"transport": server_config["transport"]}
@@ -103,4 +103,12 @@ async def add_servers_to_config(fast_app: Any, servers: Dict[str, Dict[str, Any]
103
103
  if "headers" in server_config:
104
104
  server_settings["headers"] = server_config["headers"]
105
105
 
106
- fast_app.app.context.config.mcp.servers[server_name] = MCPServerSettings(**server_settings)
106
+ mcp_server = MCPServerSettings(**server_settings)
107
+ # Update config model
108
+ fast_app.app.context.config.mcp.servers[server_name] = mcp_server
109
+ # Ensure ServerRegistry sees dynamic additions even when no config file exists
110
+ if (
111
+ hasattr(fast_app.app.context, "server_registry")
112
+ and fast_app.app.context.server_registry is not None
113
+ ):
114
+ fast_app.app.context.server_registry.registry[server_name] = mcp_server
@@ -1,11 +1,12 @@
1
1
  from pathlib import Path
2
2
 
3
3
  import typer
4
- from rich.console import Console
5
4
  from rich.prompt import Confirm
6
5
 
6
+ from fast_agent.ui.console import console as shared_console
7
+
7
8
  app = typer.Typer()
8
- console = Console()
9
+ console = shared_console
9
10
 
10
11
 
11
12
  def load_template_text(filename: str) -> str:
@@ -25,9 +26,7 @@ def load_template_text(filename: str) -> str:
25
26
  res_name = "pyproject.toml.tmpl"
26
27
  else:
27
28
  res_name = filename
28
- resource_path = (
29
- files("fast_agent").joinpath("resources").joinpath("setup").joinpath(res_name)
30
- )
29
+ resource_path = files("fast_agent").joinpath("resources").joinpath("setup").joinpath(res_name)
31
30
  if resource_path.is_file():
32
31
  return resource_path.read_text()
33
32
 
@@ -136,9 +135,8 @@ def init(
136
135
  # Always use latest fast-agent-mcp (no version pin)
137
136
  fast_agent_dep = '"fast-agent-mcp"'
138
137
 
139
- return (
140
- template_text.replace("{{python_requires}}", py_req)
141
- .replace("{{fast_agent_dep}}", fast_agent_dep)
138
+ return template_text.replace("{{python_requires}}", py_req).replace(
139
+ "{{fast_agent_dep}}", fast_agent_dep
142
140
  )
143
141
 
144
142
  pyproject_template = load_template_text("pyproject.toml")
@@ -168,7 +166,7 @@ def init(
168
166
  "2. Keep fastagent.secrets.yaml secure and never commit it to version control"
169
167
  )
170
168
  console.print(
171
- "3. Update fastagent.config.yaml to set a default model (currently system default is 'haiku')"
169
+ "3. Update fastagent.config.yaml to set a default model (currently system default is 'gpt-5-mini.low')"
172
170
  )
173
171
  console.print("\nTo get started, run:")
174
172
  console.print(" uv run agent.py")
@@ -22,4 +22,4 @@ GO_SPECIFIC_OPTIONS = {
22
22
  }
23
23
 
24
24
  # Known subcommands that should not trigger auto-routing
25
- KNOWN_SUBCOMMANDS = {"go", "setup", "check", "bootstrap", "quickstart", "--help", "-h", "--version"}
25
+ KNOWN_SUBCOMMANDS = {"go", "setup", "check", "auth", "bootstrap", "quickstart", "--help", "-h", "--version"}
fast_agent/cli/main.py CHANGED
@@ -3,7 +3,7 @@
3
3
  import typer
4
4
  from rich.table import Table
5
5
 
6
- from fast_agent.cli.commands import check_config, go, quickstart, setup
6
+ from fast_agent.cli.commands import auth, check_config, go, quickstart, setup
7
7
  from fast_agent.cli.terminal import Application
8
8
  from fast_agent.ui.console import console as shared_console
9
9
 
@@ -16,6 +16,7 @@ app = typer.Typer(
16
16
  app.add_typer(go.app, name="go", help="Run an interactive agent directly from the command line")
17
17
  app.add_typer(setup.app, name="setup", help="Set up a new agent project")
18
18
  app.add_typer(check_config.app, name="check", help="Show or diagnose fast-agent configuration")
19
+ app.add_typer(auth.app, name="auth", help="Manage OAuth authentication for MCP servers")
19
20
  app.add_typer(quickstart.app, name="bootstrap", help="Create example applications")
20
21
  app.add_typer(quickstart.app, name="quickstart", help="Create example applications")
21
22
 
@@ -62,6 +63,7 @@ def show_welcome() -> None:
62
63
 
63
64
  table.add_row("[bold]go[/bold]", "Start an interactive session")
64
65
  table.add_row("check", "Show current configuration")
66
+ table.add_row("auth", "Manage OAuth tokens and keyring")
65
67
  table.add_row("setup", "Create agent template and configuration")
66
68
  table.add_row("quickstart", "Create example applications (workflow, researcher, etc.)")
67
69
 
fast_agent/config.py CHANGED
@@ -9,20 +9,35 @@ from pathlib import Path
9
9
  from typing import Any, Dict, List, Literal, Optional, Tuple
10
10
 
11
11
  from mcp import Implementation
12
- from pydantic import BaseModel, ConfigDict, field_validator
12
+ from pydantic import BaseModel, ConfigDict, field_validator, model_validator
13
13
  from pydantic_settings import BaseSettings, SettingsConfigDict
14
14
 
15
15
 
16
16
  class MCPServerAuthSettings(BaseModel):
17
- """Represents authentication configuration for a server."""
17
+ """Represents authentication configuration for a server.
18
18
 
19
- api_key: str | None = None
19
+ Minimal OAuth v2.1 support with sensible defaults.
20
+ """
21
+
22
+ # Enable OAuth for SSE/HTTP transports. If None is provided for the auth block,
23
+ # the system will assume OAuth is enabled by default.
24
+ oauth: bool = True
25
+
26
+ # Local callback server configuration
27
+ redirect_port: int = 3030
28
+ redirect_path: str = "/callback"
29
+
30
+ # Optional scope override. If set to a list, values are space-joined.
31
+ scope: str | list[str] | None = None
32
+
33
+ # Token persistence: use OS keychain via 'keyring' by default; fallback to 'memory'.
34
+ persist: Literal["keyring", "memory"] = "keyring"
20
35
 
21
36
  model_config = ConfigDict(extra="allow", arbitrary_types_allowed=True)
22
37
 
23
38
 
24
39
  class MCPSamplingSettings(BaseModel):
25
- model: str = "haiku"
40
+ model: str = "gpt-5-mini.low"
26
41
 
27
42
  model_config = ConfigDict(extra="allow", arbitrary_types_allowed=True)
28
43
 
@@ -107,8 +122,47 @@ class MCPServerSettings(BaseModel):
107
122
  cwd: str | None = None
108
123
  """Working directory for the executed server command."""
109
124
 
125
+ include_instructions: bool = True
126
+ """Whether to include this server's instructions in the system prompt (default: True)."""
127
+
110
128
  implementation: Implementation | None = None
111
129
 
130
+ @model_validator(mode="before")
131
+ @classmethod
132
+ def validate_transport_inference(cls, values):
133
+ """Automatically infer transport type based on url/command presence."""
134
+ import warnings
135
+
136
+ if isinstance(values, dict):
137
+ # Check if transport was explicitly provided in the input
138
+ transport_explicit = "transport" in values
139
+ url = values.get("url")
140
+ command = values.get("command")
141
+
142
+ # Only infer if transport was not explicitly set
143
+ if not transport_explicit:
144
+ # Check if we have both url and command specified
145
+ has_url = url is not None and str(url).strip()
146
+ has_command = command is not None and str(command).strip()
147
+
148
+ if has_url and has_command:
149
+ warnings.warn(
150
+ f"MCP Server config has both 'url' ({url}) and 'command' ({command}) specified. "
151
+ "Preferring HTTP transport and ignoring command.",
152
+ UserWarning,
153
+ stacklevel=4,
154
+ )
155
+ values["transport"] = "http"
156
+ values["command"] = None # Clear command to avoid confusion
157
+ elif has_url and not has_command:
158
+ values["transport"] = "http"
159
+ elif has_command and not has_url:
160
+ # Keep default "stdio" for command-based servers
161
+ values["transport"] = "stdio"
162
+ # If neither url nor command is specified, keep default "stdio"
163
+
164
+ return values
165
+
112
166
 
113
167
  class MCPSettings(BaseModel):
114
168
  """Configuration for all MCP servers."""
@@ -260,8 +314,8 @@ class TensorZeroSettings(BaseModel):
260
314
  Settings for using TensorZero via its OpenAI-compatible API.
261
315
  """
262
316
 
263
- base_url: Optional[str] = None
264
- api_key: Optional[str] = None
317
+ base_url: str | None = None
318
+ api_key: str | None = None
265
319
  model_config = ConfigDict(extra="allow", arbitrary_types_allowed=True)
266
320
 
267
321
 
@@ -287,7 +341,7 @@ class HuggingFaceSettings(BaseModel):
287
341
  Settings for HuggingFace authentication (used for MCP connections).
288
342
  """
289
343
 
290
- api_key: Optional[str] = None
344
+ api_key: str | None = None
291
345
  model_config = ConfigDict(extra="allow", arbitrary_types_allowed=True)
292
346
 
293
347
 
@@ -408,7 +462,7 @@ class Settings(BaseSettings):
408
462
  execution_engine: Literal["asyncio"] = "asyncio"
409
463
  """Execution engine for the fast-agent application"""
410
464
 
411
- default_model: str | None = "haiku"
465
+ default_model: str | None = "gpt-5-mini.low"
412
466
  """
413
467
  Default model for agents. Format is provider.model_name.<reasoning_effort>, for example openai.o3-mini.low
414
468
  Aliases are provided for common models e.g. sonnet, haiku, gpt-4.1, o3-mini etc.
@@ -459,7 +513,7 @@ class Settings(BaseSettings):
459
513
  groq: GroqSettings | None = None
460
514
  """Settings for using the Groq provider in the fast-agent application"""
461
515
 
462
- logger: LoggerSettings | None = LoggerSettings()
516
+ logger: LoggerSettings = LoggerSettings()
463
517
  """Logger settings for the fast-agent application"""
464
518
 
465
519
  # MCP UI integration mode for handling ui:// embedded resources from MCP tool results
@@ -461,6 +461,36 @@ class MCPAggregator(ContextDependent):
461
461
  for server_name in self.server_names:
462
462
  await self._refresh_server_tools(server_name)
463
463
 
464
+ async def get_server_instructions(self) -> Dict[str, tuple[str, List[str]]]:
465
+ """
466
+ Get instructions from all connected servers along with their tool names.
467
+
468
+ Returns:
469
+ Dict mapping server name to tuple of (instructions, list of tool names)
470
+ """
471
+ instructions = {}
472
+
473
+ if self.connection_persistence and hasattr(self, '_persistent_connection_manager'):
474
+ # Get instructions from persistent connections
475
+ for server_name in self.server_names:
476
+ try:
477
+ server_conn = await self._persistent_connection_manager.get_server(
478
+ server_name, client_session_factory=self._create_session_factory(server_name)
479
+ )
480
+ # Always include server, even if no instructions
481
+ # Get tool names for this server
482
+ tool_names = [
483
+ namespaced_tool.tool.name
484
+ for namespaced_tool_name, namespaced_tool in self._namespaced_tool_map.items()
485
+ if namespaced_tool.server_name == server_name
486
+ ]
487
+ # Include server even if instructions is None
488
+ instructions[server_name] = (server_conn.server_instructions, tool_names)
489
+ except Exception as e:
490
+ logger.debug(f"Failed to get instructions from server {server_name}: {e}")
491
+
492
+ return instructions
493
+
464
494
  async def _execute_on_server(
465
495
  self,
466
496
  server_name: str,
@@ -33,6 +33,7 @@ from fast_agent.core.logging.logger import get_logger
33
33
  from fast_agent.event_progress import ProgressAction
34
34
  from fast_agent.mcp.logger_textio import get_stderr_handler
35
35
  from fast_agent.mcp.mcp_agent_client_session import MCPAgentClientSession
36
+ from fast_agent.mcp.oauth_client import build_oauth_provider
36
37
 
37
38
  if TYPE_CHECKING:
38
39
  from fast_agent.context import Context
@@ -104,6 +105,9 @@ class ServerConnection:
104
105
  self._error_occurred = False
105
106
  self._error_message = None
106
107
 
108
+ # Server instructions from initialization
109
+ self.server_instructions: str | None = None
110
+
107
111
  def is_healthy(self) -> bool:
108
112
  """Check if the server connection is healthy and ready to use."""
109
113
  return self.session is not None and not self._error_occurred
@@ -130,10 +134,20 @@ class ServerConnection:
130
134
  Initializes the server connection and session.
131
135
  Must be called within an async context.
132
136
  """
133
-
137
+ assert self.session, "Session must be created before initialization"
134
138
  result = await self.session.initialize()
135
139
 
136
140
  self.server_capabilities = result.capabilities
141
+
142
+ # Store instructions if provided by the server and enabled in config
143
+ if self.server_config.include_instructions:
144
+ self.server_instructions = getattr(result, 'instructions', None)
145
+ if self.server_instructions:
146
+ logger.debug(f"{self.server_name}: Received server instructions", data={"instructions": self.server_instructions})
147
+ else:
148
+ self.server_instructions = None
149
+ logger.debug(f"{self.server_name}: Server instructions disabled by configuration")
150
+
137
151
  # If there's an init hook, run it
138
152
 
139
153
  # Now the session is ready for use
@@ -341,6 +355,10 @@ class MCPConnectionManager(ContextDependent):
341
355
 
342
356
  def transport_context_factory():
343
357
  if config.transport == "stdio":
358
+ if not config.command:
359
+ raise ValueError(
360
+ f"Server '{server_name}' uses stdio transport but no command is specified"
361
+ )
344
362
  server_params = StdioServerParameters(
345
363
  command=config.command,
346
364
  args=config.args if config.args is not None else [],
@@ -353,18 +371,37 @@ class MCPConnectionManager(ContextDependent):
353
371
  logger.debug(f"{server_name}: Creating stdio client with custom error handler")
354
372
  return _add_none_to_context(stdio_client(server_params, errlog=error_handler))
355
373
  elif config.transport == "sse":
374
+ if not config.url:
375
+ raise ValueError(
376
+ f"Server '{server_name}' uses sse transport but no url is specified"
377
+ )
356
378
  # Suppress MCP library error spam
357
379
  self._suppress_mcp_sse_errors()
358
-
380
+ oauth_auth = build_oauth_provider(config)
381
+ # If using OAuth, strip any pre-existing Authorization headers to avoid conflicts
382
+ headers = dict(config.headers or {})
383
+ if oauth_auth is not None:
384
+ headers.pop("Authorization", None)
385
+ headers.pop("X-HF-Authorization", None)
359
386
  return _add_none_to_context(
360
387
  sse_client(
361
388
  config.url,
362
- config.headers,
389
+ headers,
363
390
  sse_read_timeout=config.read_transport_sse_timeout_seconds,
391
+ auth=oauth_auth,
364
392
  )
365
393
  )
366
394
  elif config.transport == "http":
367
- return streamablehttp_client(config.url, config.headers)
395
+ if not config.url:
396
+ raise ValueError(
397
+ f"Server '{server_name}' uses http transport but no url is specified"
398
+ )
399
+ oauth_auth = build_oauth_provider(config)
400
+ headers = dict(config.headers or {})
401
+ if oauth_auth is not None:
402
+ headers.pop("Authorization", None)
403
+ headers.pop("X-HF-Authorization", None)
404
+ return streamablehttp_client(config.url, headers, auth=oauth_auth)
368
405
  else:
369
406
  raise ValueError(f"Unsupported transport: {config.transport}")
370
407