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.
- fast_agent/agents/llm_agent.py +15 -1
- fast_agent/agents/mcp_agent.py +73 -1
- fast_agent/agents/tool_agent.py +10 -0
- fast_agent/agents/workflow/router_agent.py +10 -2
- fast_agent/cli/__main__.py +8 -5
- fast_agent/cli/commands/auth.py +393 -0
- fast_agent/cli/commands/check_config.py +76 -4
- fast_agent/cli/commands/go.py +8 -2
- fast_agent/cli/commands/quickstart.py +3 -1
- fast_agent/cli/commands/server_helpers.py +10 -2
- fast_agent/cli/commands/setup.py +7 -9
- fast_agent/cli/constants.py +1 -1
- fast_agent/cli/main.py +3 -1
- fast_agent/config.py +63 -9
- fast_agent/mcp/mcp_aggregator.py +30 -0
- fast_agent/mcp/mcp_connection_manager.py +41 -4
- fast_agent/mcp/oauth_client.py +509 -0
- fast_agent/resources/setup/.gitignore +6 -0
- fast_agent/resources/setup/agent.py +8 -1
- fast_agent/resources/setup/fastagent.config.yaml +1 -2
- fast_agent/resources/setup/pyproject.toml.tmpl +6 -0
- fast_agent/ui/console_display.py +48 -31
- fast_agent/ui/enhanced_prompt.py +8 -0
- fast_agent/ui/interactive_prompt.py +54 -0
- {fast_agent_mcp-0.3.4.dist-info → fast_agent_mcp-0.3.6.dist-info}/METADATA +39 -2
- {fast_agent_mcp-0.3.4.dist-info → fast_agent_mcp-0.3.6.dist-info}/RECORD +29 -27
- {fast_agent_mcp-0.3.4.dist-info → fast_agent_mcp-0.3.6.dist-info}/WHEEL +0 -0
- {fast_agent_mcp-0.3.4.dist-info → fast_agent_mcp-0.3.6.dist-info}/entry_points.txt +0 -0
- {fast_agent_mcp-0.3.4.dist-info → fast_agent_mcp-0.3.6.dist-info}/licenses/LICENSE +0 -0
fast_agent/agents/llm_agent.py
CHANGED
|
@@ -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
|
-
|
|
157
|
+
highlight_index=highlight_index,
|
|
144
158
|
max_item_length=max_item_length,
|
|
145
159
|
name=display_name,
|
|
146
160
|
model=display_model,
|
fast_agent/agents/mcp_agent.py
CHANGED
|
@@ -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
|
-
|
|
629
|
+
highlight_index=highlight_index,
|
|
558
630
|
max_item_length=12,
|
|
559
631
|
)
|
|
560
632
|
|
fast_agent/agents/tool_agent.py
CHANGED
|
@@ -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=
|
|
306
|
-
|
|
313
|
+
bottom_items=agent_keys,
|
|
314
|
+
highlight_index=highlight_index,
|
|
307
315
|
name=self.name,
|
|
308
316
|
)
|
|
309
317
|
|
fast_agent/cli/__main__.py
CHANGED
|
@@ -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
|
|
17
|
-
|
|
18
|
-
|
|
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)
|