cade-cli 0.3.3__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.
- cade_cli-0.3.3.dist-info/METADATA +151 -0
- cade_cli-0.3.3.dist-info/RECORD +44 -0
- cade_cli-0.3.3.dist-info/WHEEL +4 -0
- cade_cli-0.3.3.dist-info/entry_points.txt +2 -0
- cadecoder/__init__.py +1 -0
- cadecoder/ai/__init__.py +6 -0
- cadecoder/ai/prompts.py +572 -0
- cadecoder/cli/__init__.py +0 -0
- cadecoder/cli/app.py +147 -0
- cadecoder/cli/auth.py +483 -0
- cadecoder/cli/commands/__init__.py +5 -0
- cadecoder/cli/commands/auth.py +143 -0
- cadecoder/cli/commands/chat.py +264 -0
- cadecoder/cli/commands/mcp.py +477 -0
- cadecoder/cli/commands/tools.py +226 -0
- cadecoder/core/__init__.py +12 -0
- cadecoder/core/config.py +380 -0
- cadecoder/core/constants.py +281 -0
- cadecoder/core/errors.py +145 -0
- cadecoder/core/logging.py +148 -0
- cadecoder/core/types.py +235 -0
- cadecoder/core/utils.py +279 -0
- cadecoder/execution/__init__.py +46 -0
- cadecoder/execution/context_window.py +521 -0
- cadecoder/execution/orchestrator.py +562 -0
- cadecoder/execution/parallel.py +287 -0
- cadecoder/providers/__init__.py +60 -0
- cadecoder/providers/base.py +294 -0
- cadecoder/providers/openai.py +251 -0
- cadecoder/storage/__init__.py +0 -0
- cadecoder/storage/threads.py +489 -0
- cadecoder/templates/login_failed.html +21 -0
- cadecoder/templates/login_success.html +21 -0
- cadecoder/templates/styles.css +87 -0
- cadecoder/tools/__init__.py +19 -0
- cadecoder/tools/builtin.py +644 -0
- cadecoder/tools/filesystem.py +315 -0
- cadecoder/tools/git.py +221 -0
- cadecoder/tools/manager.py +1635 -0
- cadecoder/ui/__init__.py +7 -0
- cadecoder/ui/display.py +338 -0
- cadecoder/ui/input.py +145 -0
- cadecoder/ui/session.py +455 -0
- cadecoder/ui/state.py +20 -0
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
"""CLI commands for managing MCP servers."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import http.server
|
|
5
|
+
import threading
|
|
6
|
+
import webbrowser
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
from urllib.parse import parse_qs, urlparse
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
|
|
14
|
+
from cadecoder.tools.manager import (
|
|
15
|
+
MCPAuthType,
|
|
16
|
+
MCPServerConfig,
|
|
17
|
+
MCPServerStore,
|
|
18
|
+
MCPToolManager,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
# Create MCP command group
|
|
22
|
+
mcp_app = typer.Typer(
|
|
23
|
+
name="mcp",
|
|
24
|
+
help="Manage MCP (Model Context Protocol) servers",
|
|
25
|
+
no_args_is_help=True,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
console = Console()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@mcp_app.command("list")
|
|
32
|
+
def list_servers() -> None:
|
|
33
|
+
"""List all configured MCP servers."""
|
|
34
|
+
store = MCPServerStore()
|
|
35
|
+
servers = store.list_all()
|
|
36
|
+
|
|
37
|
+
if not servers:
|
|
38
|
+
console.print("[yellow]No MCP servers configured.[/yellow]")
|
|
39
|
+
console.print("[dim]Use 'cade mcp add <name> <url>' to add a server.[/dim]")
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
table = Table(title="MCP Servers")
|
|
43
|
+
table.add_column("Name", style="cyan")
|
|
44
|
+
table.add_column("URL", style="white")
|
|
45
|
+
table.add_column("Auth", style="yellow")
|
|
46
|
+
table.add_column("Enabled", style="green")
|
|
47
|
+
table.add_column("Tools", style="blue")
|
|
48
|
+
table.add_column("Last Connected", style="dim")
|
|
49
|
+
|
|
50
|
+
for server in servers:
|
|
51
|
+
enabled = "✓" if server.enabled else "✗"
|
|
52
|
+
last_conn = (
|
|
53
|
+
server.last_connected.strftime("%Y-%m-%d %H:%M") if server.last_connected else "-"
|
|
54
|
+
)
|
|
55
|
+
auth_display = server.auth_type.value
|
|
56
|
+
if server.auth_type != MCPAuthType.NONE:
|
|
57
|
+
auth_display += " ✓" if server.auth_value else " ✗"
|
|
58
|
+
|
|
59
|
+
table.add_row(
|
|
60
|
+
server.name,
|
|
61
|
+
server.url,
|
|
62
|
+
auth_display,
|
|
63
|
+
enabled,
|
|
64
|
+
str(server.tool_count) if server.tool_count else "-",
|
|
65
|
+
last_conn,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
console.print(table)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@mcp_app.command("add")
|
|
72
|
+
def add_server(
|
|
73
|
+
name: Annotated[str, typer.Argument(help="Unique name for the server")],
|
|
74
|
+
url: Annotated[str, typer.Argument(help="Server URL (e.g., http://localhost:8080)")],
|
|
75
|
+
auth_type: Annotated[
|
|
76
|
+
str,
|
|
77
|
+
typer.Option(
|
|
78
|
+
"--auth",
|
|
79
|
+
"-a",
|
|
80
|
+
help="Authentication type: none, bearer, or api_key",
|
|
81
|
+
),
|
|
82
|
+
] = "none",
|
|
83
|
+
auth_value: Annotated[
|
|
84
|
+
str | None,
|
|
85
|
+
typer.Option(
|
|
86
|
+
"--token",
|
|
87
|
+
"-t",
|
|
88
|
+
help="Authentication token or API key",
|
|
89
|
+
),
|
|
90
|
+
] = None,
|
|
91
|
+
disabled: Annotated[
|
|
92
|
+
bool,
|
|
93
|
+
typer.Option(
|
|
94
|
+
"--disabled",
|
|
95
|
+
"-d",
|
|
96
|
+
help="Add server in disabled state",
|
|
97
|
+
),
|
|
98
|
+
] = False,
|
|
99
|
+
) -> None:
|
|
100
|
+
"""Add a new MCP server configuration."""
|
|
101
|
+
store = MCPServerStore()
|
|
102
|
+
|
|
103
|
+
# Check if server already exists
|
|
104
|
+
existing = store.get(name)
|
|
105
|
+
if existing:
|
|
106
|
+
console.print(f"[red]Server '{name}' already exists.[/red]")
|
|
107
|
+
console.print("[dim]Use 'cade mcp rm' first or choose a different name.[/dim]")
|
|
108
|
+
raise typer.Exit(1)
|
|
109
|
+
|
|
110
|
+
# Validate auth type
|
|
111
|
+
try:
|
|
112
|
+
auth_enum = MCPAuthType(auth_type.lower())
|
|
113
|
+
except ValueError:
|
|
114
|
+
console.print(f"[red]Invalid auth type: {auth_type}[/red]")
|
|
115
|
+
console.print("[dim]Valid types: none, bearer, api_key[/dim]")
|
|
116
|
+
raise typer.Exit(1)
|
|
117
|
+
|
|
118
|
+
# Warn if auth requires value
|
|
119
|
+
if auth_enum != MCPAuthType.NONE and not auth_value:
|
|
120
|
+
console.print(
|
|
121
|
+
f"[yellow]Warning: Auth type '{auth_type}' specified but no token provided.[/yellow]"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# Create and save config
|
|
125
|
+
config = MCPServerConfig(
|
|
126
|
+
name=name,
|
|
127
|
+
url=url,
|
|
128
|
+
auth_type=auth_enum,
|
|
129
|
+
auth_value=auth_value,
|
|
130
|
+
enabled=not disabled,
|
|
131
|
+
)
|
|
132
|
+
store.add(config)
|
|
133
|
+
|
|
134
|
+
console.print(f"[green]✓[/green] Added MCP server '{name}'")
|
|
135
|
+
console.print(f" URL: {url}")
|
|
136
|
+
console.print(f" Auth: {auth_enum.value}")
|
|
137
|
+
console.print(f" Enabled: {not disabled}")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@mcp_app.command("rm")
|
|
141
|
+
def remove_server(
|
|
142
|
+
name: Annotated[str, typer.Argument(help="Name of the server to remove")],
|
|
143
|
+
force: Annotated[
|
|
144
|
+
bool,
|
|
145
|
+
typer.Option("--force", "-f", help="Skip confirmation"),
|
|
146
|
+
] = False,
|
|
147
|
+
) -> None:
|
|
148
|
+
"""Remove an MCP server configuration."""
|
|
149
|
+
store = MCPServerStore()
|
|
150
|
+
|
|
151
|
+
server = store.get(name)
|
|
152
|
+
if not server:
|
|
153
|
+
console.print(f"[red]Server '{name}' not found.[/red]")
|
|
154
|
+
raise typer.Exit(1)
|
|
155
|
+
|
|
156
|
+
if not force:
|
|
157
|
+
confirm = typer.confirm(f"Remove MCP server '{name}'?")
|
|
158
|
+
if not confirm:
|
|
159
|
+
console.print("[dim]Cancelled.[/dim]")
|
|
160
|
+
raise typer.Exit(0)
|
|
161
|
+
|
|
162
|
+
store.remove(name)
|
|
163
|
+
console.print(f"[green]✓[/green] Removed MCP server '{name}'")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@mcp_app.command("status")
|
|
167
|
+
def check_status() -> None:
|
|
168
|
+
"""Check connectivity status of all MCP servers."""
|
|
169
|
+
store = MCPServerStore()
|
|
170
|
+
servers = store.list_all()
|
|
171
|
+
|
|
172
|
+
if not servers:
|
|
173
|
+
console.print("[yellow]No MCP servers configured.[/yellow]")
|
|
174
|
+
return
|
|
175
|
+
|
|
176
|
+
table = Table(title="MCP Server Status")
|
|
177
|
+
table.add_column("Name", style="cyan")
|
|
178
|
+
table.add_column("URL", style="white")
|
|
179
|
+
table.add_column("Status", style="white")
|
|
180
|
+
table.add_column("Tools", style="blue")
|
|
181
|
+
|
|
182
|
+
async def check_all() -> list[tuple[MCPServerConfig, bool, str, int]]:
|
|
183
|
+
results = []
|
|
184
|
+
for server in servers:
|
|
185
|
+
if not server.enabled:
|
|
186
|
+
results.append((server, False, "Disabled", 0))
|
|
187
|
+
continue
|
|
188
|
+
|
|
189
|
+
manager = MCPToolManager(server)
|
|
190
|
+
try:
|
|
191
|
+
connected, status_msg = await manager.check_status()
|
|
192
|
+
tool_count = 0
|
|
193
|
+
if connected:
|
|
194
|
+
tools = await manager.get_tools()
|
|
195
|
+
tool_count = len(tools)
|
|
196
|
+
results.append((server, connected, status_msg, tool_count))
|
|
197
|
+
except Exception as e:
|
|
198
|
+
results.append((server, False, str(e)[:50], 0))
|
|
199
|
+
finally:
|
|
200
|
+
await manager.close()
|
|
201
|
+
return results
|
|
202
|
+
|
|
203
|
+
with console.status("[cyan]Checking servers...", spinner="dots"):
|
|
204
|
+
results = asyncio.run(check_all())
|
|
205
|
+
|
|
206
|
+
for server, connected, status_msg, tool_count in results:
|
|
207
|
+
status_style = "green" if connected else "red"
|
|
208
|
+
status_icon = "✓" if connected else "✗"
|
|
209
|
+
table.add_row(
|
|
210
|
+
server.name,
|
|
211
|
+
server.url,
|
|
212
|
+
f"[{status_style}]{status_icon} {status_msg}[/{status_style}]",
|
|
213
|
+
str(tool_count) if tool_count else "-",
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
console.print(table)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@mcp_app.command("enable")
|
|
220
|
+
def enable_server(
|
|
221
|
+
name: Annotated[str, typer.Argument(help="Name of the server to enable")],
|
|
222
|
+
) -> None:
|
|
223
|
+
"""Enable an MCP server."""
|
|
224
|
+
store = MCPServerStore()
|
|
225
|
+
server = store.get(name)
|
|
226
|
+
|
|
227
|
+
if not server:
|
|
228
|
+
console.print(f"[red]Server '{name}' not found.[/red]")
|
|
229
|
+
raise typer.Exit(1)
|
|
230
|
+
|
|
231
|
+
server.enabled = True
|
|
232
|
+
store.add(server)
|
|
233
|
+
console.print(f"[green]✓[/green] Enabled MCP server '{name}'")
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@mcp_app.command("disable")
|
|
237
|
+
def disable_server(
|
|
238
|
+
name: Annotated[str, typer.Argument(help="Name of the server to disable")],
|
|
239
|
+
) -> None:
|
|
240
|
+
"""Disable an MCP server."""
|
|
241
|
+
store = MCPServerStore()
|
|
242
|
+
server = store.get(name)
|
|
243
|
+
|
|
244
|
+
if not server:
|
|
245
|
+
console.print(f"[red]Server '{name}' not found.[/red]")
|
|
246
|
+
raise typer.Exit(1)
|
|
247
|
+
|
|
248
|
+
server.enabled = False
|
|
249
|
+
store.add(server)
|
|
250
|
+
console.print(f"[yellow]✓[/yellow] Disabled MCP server '{name}'")
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@mcp_app.command("set-auth")
|
|
254
|
+
def set_auth(
|
|
255
|
+
name: Annotated[str, typer.Argument(help="Name of the server")],
|
|
256
|
+
auth_type: Annotated[
|
|
257
|
+
str,
|
|
258
|
+
typer.Option("--type", "-t", help="Auth type: none, bearer, api_key"),
|
|
259
|
+
] = "bearer",
|
|
260
|
+
token: Annotated[
|
|
261
|
+
str | None,
|
|
262
|
+
typer.Option("--token", help="Auth token or API key"),
|
|
263
|
+
] = None,
|
|
264
|
+
) -> None:
|
|
265
|
+
"""Set authentication for an MCP server."""
|
|
266
|
+
store = MCPServerStore()
|
|
267
|
+
server = store.get(name)
|
|
268
|
+
|
|
269
|
+
if not server:
|
|
270
|
+
console.print(f"[red]Server '{name}' not found.[/red]")
|
|
271
|
+
raise typer.Exit(1)
|
|
272
|
+
|
|
273
|
+
try:
|
|
274
|
+
auth_enum = MCPAuthType(auth_type.lower())
|
|
275
|
+
except ValueError:
|
|
276
|
+
console.print(f"[red]Invalid auth type: {auth_type}[/red]")
|
|
277
|
+
raise typer.Exit(1)
|
|
278
|
+
|
|
279
|
+
server.auth_type = auth_enum
|
|
280
|
+
server.auth_value = token
|
|
281
|
+
store.add(server)
|
|
282
|
+
|
|
283
|
+
console.print(f"[green]✓[/green] Updated auth for '{name}'")
|
|
284
|
+
console.print(f" Type: {auth_enum.value}")
|
|
285
|
+
console.print(f" Token: {'set' if token else 'not set'}")
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
# OAuth callback handler
|
|
289
|
+
_oauth_result: dict[str, str | None] = {"code": None, "error": None}
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
class OAuthCallbackHandler(http.server.BaseHTTPRequestHandler):
|
|
293
|
+
"""Handle OAuth callback from authorization server."""
|
|
294
|
+
|
|
295
|
+
def do_GET(self) -> None:
|
|
296
|
+
"""Handle callback GET request."""
|
|
297
|
+
global _oauth_result
|
|
298
|
+
|
|
299
|
+
parsed = urlparse(self.path)
|
|
300
|
+
params = parse_qs(parsed.query)
|
|
301
|
+
|
|
302
|
+
if "code" in params:
|
|
303
|
+
_oauth_result["code"] = params["code"][0]
|
|
304
|
+
self.send_response(200)
|
|
305
|
+
self.send_header("Content-Type", "text/html")
|
|
306
|
+
self.end_headers()
|
|
307
|
+
self.wfile.write(
|
|
308
|
+
b"<html><body><h1>Authorization successful!</h1>"
|
|
309
|
+
b"<p>You can close this window and return to the terminal.</p>"
|
|
310
|
+
b"</body></html>"
|
|
311
|
+
)
|
|
312
|
+
elif "error" in params:
|
|
313
|
+
_oauth_result["error"] = params.get("error_description", params["error"])[0]
|
|
314
|
+
self.send_response(400)
|
|
315
|
+
self.send_header("Content-Type", "text/html")
|
|
316
|
+
self.end_headers()
|
|
317
|
+
self.wfile.write(
|
|
318
|
+
f"<html><body><h1>Authorization failed</h1>"
|
|
319
|
+
f"<p>{_oauth_result['error']}</p></body></html>".encode()
|
|
320
|
+
)
|
|
321
|
+
else:
|
|
322
|
+
self.send_response(400)
|
|
323
|
+
self.end_headers()
|
|
324
|
+
|
|
325
|
+
def log_message(self, format: str, *args: object) -> None:
|
|
326
|
+
"""Suppress logging."""
|
|
327
|
+
pass
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
@mcp_app.command("authorize")
|
|
331
|
+
def authorize_server(
|
|
332
|
+
name: Annotated[str, typer.Argument(help="Name of the server to authorize")],
|
|
333
|
+
client_id: Annotated[
|
|
334
|
+
str | None,
|
|
335
|
+
typer.Option("--client-id", "-c", help="OAuth client ID"),
|
|
336
|
+
] = None,
|
|
337
|
+
scope: Annotated[
|
|
338
|
+
str | None,
|
|
339
|
+
typer.Option("--scope", "-s", help="OAuth scopes (space-separated)"),
|
|
340
|
+
] = None,
|
|
341
|
+
) -> None:
|
|
342
|
+
"""Initiate OAuth authorization flow for an MCP server.
|
|
343
|
+
|
|
344
|
+
This command will:
|
|
345
|
+
1. Discover the authorization server from the MCP server
|
|
346
|
+
2. Open a browser for you to authorize
|
|
347
|
+
3. Capture the callback and exchange the code for tokens
|
|
348
|
+
4. Store the tokens for future use
|
|
349
|
+
"""
|
|
350
|
+
global _oauth_result
|
|
351
|
+
_oauth_result = {"code": None, "error": None}
|
|
352
|
+
|
|
353
|
+
store = MCPServerStore()
|
|
354
|
+
server = store.get(name)
|
|
355
|
+
|
|
356
|
+
if not server:
|
|
357
|
+
console.print(f"[red]Server '{name}' not found.[/red]")
|
|
358
|
+
raise typer.Exit(1)
|
|
359
|
+
|
|
360
|
+
if client_id:
|
|
361
|
+
server.oauth_client_id = client_id
|
|
362
|
+
if scope:
|
|
363
|
+
server.oauth_scopes = scope.split()
|
|
364
|
+
|
|
365
|
+
async def run_oauth_flow() -> bool:
|
|
366
|
+
manager = MCPToolManager(server, store)
|
|
367
|
+
|
|
368
|
+
try:
|
|
369
|
+
# First, try to initialize to trigger 401 and discover auth server
|
|
370
|
+
console.print("[cyan]Discovering authorization server...[/cyan]")
|
|
371
|
+
|
|
372
|
+
try:
|
|
373
|
+
# Make a test request to get 401
|
|
374
|
+
await manager._send_request(
|
|
375
|
+
"initialize",
|
|
376
|
+
{
|
|
377
|
+
"protocolVersion": "2024-11-05",
|
|
378
|
+
"capabilities": {},
|
|
379
|
+
"clientInfo": {"name": "cade", "version": "1.0.0"},
|
|
380
|
+
},
|
|
381
|
+
)
|
|
382
|
+
console.print("[yellow]Server doesn't require authentication.[/yellow]")
|
|
383
|
+
return True
|
|
384
|
+
except Exception as e:
|
|
385
|
+
if "ToolAuthorizationRequired" not in str(type(e).__name__):
|
|
386
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
387
|
+
return False
|
|
388
|
+
|
|
389
|
+
# Check if we got an auth URL
|
|
390
|
+
if not hasattr(manager, "_pending_code_verifier"):
|
|
391
|
+
console.print("[red]Failed to discover authorization server.[/red]")
|
|
392
|
+
return False
|
|
393
|
+
|
|
394
|
+
# Start local callback server
|
|
395
|
+
server_address = ("127.0.0.1", 9876)
|
|
396
|
+
httpd = http.server.HTTPServer(server_address, OAuthCallbackHandler)
|
|
397
|
+
httpd.timeout = 300 # 5 minute timeout
|
|
398
|
+
|
|
399
|
+
# Open browser
|
|
400
|
+
if manager._auth_server_metadata:
|
|
401
|
+
auth_url, _, _ = await manager._oauth_handler.start_oauth_flow(
|
|
402
|
+
manager._auth_server_metadata,
|
|
403
|
+
scope,
|
|
404
|
+
client_id or server.oauth_client_id,
|
|
405
|
+
)
|
|
406
|
+
console.print("\n[cyan]Opening browser for authorization...[/cyan]")
|
|
407
|
+
console.print(f"[dim]URL: {auth_url[:80]}...[/dim]\n")
|
|
408
|
+
webbrowser.open(auth_url)
|
|
409
|
+
|
|
410
|
+
console.print("[yellow]Waiting for authorization (5 min timeout)...[/yellow]")
|
|
411
|
+
|
|
412
|
+
# Handle one request
|
|
413
|
+
def handle_callback() -> None:
|
|
414
|
+
httpd.handle_request()
|
|
415
|
+
|
|
416
|
+
thread = threading.Thread(target=handle_callback)
|
|
417
|
+
thread.start()
|
|
418
|
+
thread.join(timeout=300)
|
|
419
|
+
|
|
420
|
+
if _oauth_result["error"]:
|
|
421
|
+
console.print(f"[red]Authorization failed: {_oauth_result['error']}[/red]")
|
|
422
|
+
return False
|
|
423
|
+
|
|
424
|
+
if not _oauth_result["code"]:
|
|
425
|
+
console.print("[red]No authorization code received.[/red]")
|
|
426
|
+
return False
|
|
427
|
+
|
|
428
|
+
# Exchange code for tokens
|
|
429
|
+
console.print("[cyan]Exchanging code for tokens...[/cyan]")
|
|
430
|
+
success = await manager.complete_oauth_flow(_oauth_result["code"])
|
|
431
|
+
|
|
432
|
+
if success:
|
|
433
|
+
console.print(f"[green]✓[/green] Successfully authorized '{name}'")
|
|
434
|
+
return True
|
|
435
|
+
else:
|
|
436
|
+
console.print("[red]Failed to exchange code for tokens.[/red]")
|
|
437
|
+
return False
|
|
438
|
+
else:
|
|
439
|
+
console.print("[red]No authorization server metadata available.[/red]")
|
|
440
|
+
return False
|
|
441
|
+
|
|
442
|
+
finally:
|
|
443
|
+
await manager.close()
|
|
444
|
+
|
|
445
|
+
success = asyncio.run(run_oauth_flow())
|
|
446
|
+
if not success:
|
|
447
|
+
raise typer.Exit(1)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
@mcp_app.command("token-info")
|
|
451
|
+
def token_info(
|
|
452
|
+
name: Annotated[str, typer.Argument(help="Name of the server")],
|
|
453
|
+
) -> None:
|
|
454
|
+
"""Show OAuth token information for an MCP server."""
|
|
455
|
+
store = MCPServerStore()
|
|
456
|
+
server = store.get(name)
|
|
457
|
+
|
|
458
|
+
if not server:
|
|
459
|
+
console.print(f"[red]Server '{name}' not found.[/red]")
|
|
460
|
+
raise typer.Exit(1)
|
|
461
|
+
|
|
462
|
+
if server.auth_type != MCPAuthType.OAUTH or not server.oauth_tokens:
|
|
463
|
+
console.print(f"[yellow]Server '{name}' is not using OAuth authentication.[/yellow]")
|
|
464
|
+
return
|
|
465
|
+
|
|
466
|
+
tokens = server.oauth_tokens
|
|
467
|
+
console.print(f"[cyan]OAuth Token Info for '{name}':[/cyan]")
|
|
468
|
+
console.print(f" Token Type: {tokens.token_type}")
|
|
469
|
+
console.print(f" Access Token: {tokens.access_token[:20]}...")
|
|
470
|
+
console.print(f" Refresh Token: {'set' if tokens.refresh_token else 'not set'}")
|
|
471
|
+
if tokens.expires_at:
|
|
472
|
+
if tokens.is_expired():
|
|
473
|
+
console.print(f" [red]Expired: {tokens.expires_at}[/red]")
|
|
474
|
+
else:
|
|
475
|
+
console.print(f" Expires: {tokens.expires_at}")
|
|
476
|
+
if tokens.scope:
|
|
477
|
+
console.print(f" Scopes: {tokens.scope}")
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""CLI commands for viewing and managing tools."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.panel import Panel
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
|
|
12
|
+
from cadecoder.core.constants import DEFAULT_AI_MODEL
|
|
13
|
+
from cadecoder.execution.orchestrator import create_orchestrator
|
|
14
|
+
|
|
15
|
+
# Create tool command group
|
|
16
|
+
tool_app = typer.Typer(
|
|
17
|
+
name="tool",
|
|
18
|
+
help="View and manage available tools",
|
|
19
|
+
no_args_is_help=False,
|
|
20
|
+
invoke_without_command=True,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
console = Console()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@tool_app.callback()
|
|
27
|
+
def tool_callback(ctx: typer.Context) -> None:
|
|
28
|
+
"""View available tools. Lists all tools by default."""
|
|
29
|
+
if ctx.invoked_subcommand is None:
|
|
30
|
+
# No subcommand provided, run list_tools
|
|
31
|
+
list_tools(source=None, search=None)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@tool_app.command("list")
|
|
35
|
+
def list_tools(
|
|
36
|
+
source: Annotated[
|
|
37
|
+
str | None,
|
|
38
|
+
typer.Option(
|
|
39
|
+
"--source",
|
|
40
|
+
"-s",
|
|
41
|
+
help="Filter by source: local, remote, mcp",
|
|
42
|
+
),
|
|
43
|
+
] = None,
|
|
44
|
+
search: Annotated[
|
|
45
|
+
str | None,
|
|
46
|
+
typer.Option(
|
|
47
|
+
"--search",
|
|
48
|
+
"-q",
|
|
49
|
+
help="Search tools by name",
|
|
50
|
+
),
|
|
51
|
+
] = None,
|
|
52
|
+
) -> None:
|
|
53
|
+
"""List all available tools."""
|
|
54
|
+
|
|
55
|
+
async def get_tools() -> tuple[list[dict], dict[str, str]]:
|
|
56
|
+
orchestrator = create_orchestrator(default_model=DEFAULT_AI_MODEL)
|
|
57
|
+
tools = await orchestrator.tool_manager.get_tools()
|
|
58
|
+
|
|
59
|
+
# Get source mapping
|
|
60
|
+
source_map = {}
|
|
61
|
+
if hasattr(orchestrator.tool_manager, "get_all_tool_info"):
|
|
62
|
+
for info in orchestrator.tool_manager.get_all_tool_info():
|
|
63
|
+
source_map[info["name"]] = info.get("source", "unknown")
|
|
64
|
+
|
|
65
|
+
return tools, source_map
|
|
66
|
+
|
|
67
|
+
with console.status("[cyan]Loading tools...", spinner="dots"):
|
|
68
|
+
tools, source_map = asyncio.run(get_tools())
|
|
69
|
+
|
|
70
|
+
# Filter by source
|
|
71
|
+
if source:
|
|
72
|
+
source_lower = source.lower()
|
|
73
|
+
tools = [
|
|
74
|
+
t
|
|
75
|
+
for t in tools
|
|
76
|
+
if source_map.get(t.get("function", {}).get("name", ""), "").lower() == source_lower
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
# Filter by search
|
|
80
|
+
if search:
|
|
81
|
+
search_lower = search.lower()
|
|
82
|
+
tools = [
|
|
83
|
+
t
|
|
84
|
+
for t in tools
|
|
85
|
+
if search_lower in t.get("function", {}).get("name", "").lower()
|
|
86
|
+
or search_lower in t.get("function", {}).get("description", "").lower()
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
if not tools:
|
|
90
|
+
console.print("[yellow]No tools found.[/yellow]")
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
# Single table with all tools
|
|
94
|
+
table = Table(title="Available Tools", show_lines=False)
|
|
95
|
+
table.add_column("Name", style="cyan", no_wrap=True)
|
|
96
|
+
table.add_column("Source", style="dim", no_wrap=True, width=8)
|
|
97
|
+
table.add_column("Description", style="white", max_width=55)
|
|
98
|
+
|
|
99
|
+
# Sort all tools by name
|
|
100
|
+
for tool in sorted(tools, key=lambda t: t.get("function", {}).get("name", "")):
|
|
101
|
+
func = tool.get("function", {})
|
|
102
|
+
name = func.get("name", "unknown")
|
|
103
|
+
desc = func.get("description", "")
|
|
104
|
+
|
|
105
|
+
# Get source
|
|
106
|
+
tool_source = source_map.get(name, "unknown")
|
|
107
|
+
source_label = {
|
|
108
|
+
"local": "local",
|
|
109
|
+
"remote": "arcade",
|
|
110
|
+
"mcp": "mcp",
|
|
111
|
+
}.get(tool_source, tool_source)
|
|
112
|
+
|
|
113
|
+
# Clean up description - remove source prefix
|
|
114
|
+
if desc.startswith("["):
|
|
115
|
+
bracket_end = desc.find("]")
|
|
116
|
+
if bracket_end != -1:
|
|
117
|
+
desc = desc[bracket_end + 1 :].strip()
|
|
118
|
+
|
|
119
|
+
# Cut at first newline
|
|
120
|
+
if "\n" in desc:
|
|
121
|
+
desc = desc.split("\n")[0].strip()
|
|
122
|
+
|
|
123
|
+
# Truncate long descriptions
|
|
124
|
+
if len(desc) > 55:
|
|
125
|
+
desc = desc[:52] + "..."
|
|
126
|
+
|
|
127
|
+
table.add_row(name, source_label, desc)
|
|
128
|
+
|
|
129
|
+
console.print(table)
|
|
130
|
+
console.print(f"\n[dim]Total: {len(tools)} tools[/dim]")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@tool_app.command("info")
|
|
134
|
+
def tool_info(
|
|
135
|
+
name: Annotated[str, typer.Argument(help="Name of the tool")],
|
|
136
|
+
) -> None:
|
|
137
|
+
"""Show detailed information about a tool."""
|
|
138
|
+
|
|
139
|
+
async def get_tool_details() -> tuple[dict | None, str | None]:
|
|
140
|
+
orchestrator = create_orchestrator(default_model=DEFAULT_AI_MODEL)
|
|
141
|
+
tools = await orchestrator.tool_manager.get_tools()
|
|
142
|
+
|
|
143
|
+
# Find the tool
|
|
144
|
+
tool = None
|
|
145
|
+
for t in tools:
|
|
146
|
+
if t.get("function", {}).get("name", "") == name:
|
|
147
|
+
tool = t
|
|
148
|
+
break
|
|
149
|
+
|
|
150
|
+
# Get source
|
|
151
|
+
source = None
|
|
152
|
+
if hasattr(orchestrator.tool_manager, "get_tool_source"):
|
|
153
|
+
source = orchestrator.tool_manager.get_tool_source(name)
|
|
154
|
+
|
|
155
|
+
return tool, source
|
|
156
|
+
|
|
157
|
+
with console.status("[cyan]Loading tool info...", spinner="dots"):
|
|
158
|
+
tool, source = asyncio.run(get_tool_details())
|
|
159
|
+
|
|
160
|
+
if not tool:
|
|
161
|
+
console.print(f"[red]Tool '{name}' not found.[/red]")
|
|
162
|
+
raise typer.Exit(1)
|
|
163
|
+
|
|
164
|
+
func = tool.get("function", {})
|
|
165
|
+
tool_name = func.get("name", "unknown")
|
|
166
|
+
description = func.get("description", "No description")
|
|
167
|
+
parameters = func.get("parameters", {})
|
|
168
|
+
|
|
169
|
+
# Build info display
|
|
170
|
+
content = Text()
|
|
171
|
+
|
|
172
|
+
# Name and source
|
|
173
|
+
content.append(f"{tool_name}\n", style="bold cyan")
|
|
174
|
+
if source:
|
|
175
|
+
source_display = {
|
|
176
|
+
"local": "Local",
|
|
177
|
+
"remote": "Arcade Cloud",
|
|
178
|
+
"mcp": "MCP Server",
|
|
179
|
+
}.get(source, source.title())
|
|
180
|
+
content.append(f"Source: {source_display}\n\n", style="dim")
|
|
181
|
+
|
|
182
|
+
# Description
|
|
183
|
+
# Clean up description prefix
|
|
184
|
+
if description.startswith("["):
|
|
185
|
+
bracket_end = description.find("]")
|
|
186
|
+
if bracket_end != -1:
|
|
187
|
+
description = description[bracket_end + 1 :].strip()
|
|
188
|
+
|
|
189
|
+
content.append("Description:\n", style="bold")
|
|
190
|
+
content.append(f"{description}\n\n", style="white")
|
|
191
|
+
|
|
192
|
+
# Parameters
|
|
193
|
+
props = parameters.get("properties", {})
|
|
194
|
+
required = set(parameters.get("required", []))
|
|
195
|
+
|
|
196
|
+
if props:
|
|
197
|
+
content.append("Parameters:\n", style="bold")
|
|
198
|
+
for param_name, param_info in props.items():
|
|
199
|
+
param_type = param_info.get("type", "any")
|
|
200
|
+
param_desc = param_info.get("description", "")
|
|
201
|
+
is_required = param_name in required
|
|
202
|
+
|
|
203
|
+
req_marker = " [required]" if is_required else ""
|
|
204
|
+
content.append(f" • {param_name}", style="cyan")
|
|
205
|
+
content.append(f" ({param_type}){req_marker}\n", style="dim")
|
|
206
|
+
if param_desc:
|
|
207
|
+
content.append(f" {param_desc}\n", style="white")
|
|
208
|
+
else:
|
|
209
|
+
content.append("Parameters: None\n", style="dim")
|
|
210
|
+
|
|
211
|
+
panel = Panel(
|
|
212
|
+
content,
|
|
213
|
+
title="Tool Info",
|
|
214
|
+
border_style="blue",
|
|
215
|
+
padding=(1, 2),
|
|
216
|
+
)
|
|
217
|
+
console.print(panel)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@tool_app.command("search")
|
|
221
|
+
def search_tools(
|
|
222
|
+
query: Annotated[str, typer.Argument(help="Search query")],
|
|
223
|
+
) -> None:
|
|
224
|
+
"""Search for tools by name or description."""
|
|
225
|
+
# Delegate to list with search parameter
|
|
226
|
+
list_tools(source=None, search=query)
|