forbin-mcp 0.1.0__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.
- forbin/__init__.py +36 -0
- forbin/__main__.py +9 -0
- forbin/cli.py +288 -0
- forbin/client.py +232 -0
- forbin/config.py +28 -0
- forbin/display.py +260 -0
- forbin/tools.py +193 -0
- forbin/utils.py +111 -0
- forbin_mcp-0.1.0.dist-info/METADATA +392 -0
- forbin_mcp-0.1.0.dist-info/RECORD +13 -0
- forbin_mcp-0.1.0.dist-info/WHEEL +4 -0
- forbin_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- forbin_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
forbin/__init__.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from .cli import main as main, interactive_session as interactive_session
|
|
2
|
+
from .client import (
|
|
3
|
+
connect_to_mcp_server as connect_to_mcp_server,
|
|
4
|
+
connect_and_list_tools as connect_and_list_tools,
|
|
5
|
+
wake_up_server as wake_up_server,
|
|
6
|
+
MCPSession as MCPSession,
|
|
7
|
+
)
|
|
8
|
+
from .tools import (
|
|
9
|
+
list_tools as list_tools,
|
|
10
|
+
call_tool as call_tool,
|
|
11
|
+
get_tool_parameters as get_tool_parameters,
|
|
12
|
+
)
|
|
13
|
+
from .display import (
|
|
14
|
+
display_tools as display_tools,
|
|
15
|
+
display_tool_header as display_tool_header,
|
|
16
|
+
display_tool_menu as display_tool_menu,
|
|
17
|
+
display_tool_schema as display_tool_schema,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__version__ = "0.1.0"
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"main",
|
|
24
|
+
"interactive_session",
|
|
25
|
+
"connect_to_mcp_server",
|
|
26
|
+
"connect_and_list_tools",
|
|
27
|
+
"wake_up_server",
|
|
28
|
+
"MCPSession",
|
|
29
|
+
"list_tools",
|
|
30
|
+
"call_tool",
|
|
31
|
+
"get_tool_parameters",
|
|
32
|
+
"display_tools",
|
|
33
|
+
"display_tool_header",
|
|
34
|
+
"display_tool_menu",
|
|
35
|
+
"display_tool_schema",
|
|
36
|
+
]
|
forbin/__main__.py
ADDED
forbin/cli.py
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
from rich.prompt import Prompt
|
|
5
|
+
|
|
6
|
+
from . import config
|
|
7
|
+
from .config import validate_config
|
|
8
|
+
from .utils import setup_logging, listen_for_toggle
|
|
9
|
+
from .client import connect_and_list_tools, wake_up_server
|
|
10
|
+
from .tools import get_tool_parameters, call_tool
|
|
11
|
+
from .display import (
|
|
12
|
+
display_tools,
|
|
13
|
+
display_tool_header,
|
|
14
|
+
display_tool_menu,
|
|
15
|
+
display_tool_schema,
|
|
16
|
+
display_logo,
|
|
17
|
+
display_config_panel,
|
|
18
|
+
display_step,
|
|
19
|
+
console,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def test_connectivity():
|
|
24
|
+
"""Test connectivity to the MCP server."""
|
|
25
|
+
# Start background listener for 'v' key toggle
|
|
26
|
+
listener_task = asyncio.create_task(listen_for_toggle())
|
|
27
|
+
mcp_session = None
|
|
28
|
+
try:
|
|
29
|
+
display_logo()
|
|
30
|
+
display_config_panel(config.MCP_SERVER_URL, config.MCP_HEALTH_URL)
|
|
31
|
+
|
|
32
|
+
# Determine total steps
|
|
33
|
+
total_steps = 2 if config.MCP_HEALTH_URL else 1
|
|
34
|
+
current_step = 1
|
|
35
|
+
|
|
36
|
+
# Step 1: Wake up server if health URL is configured
|
|
37
|
+
if config.MCP_HEALTH_URL:
|
|
38
|
+
display_step(current_step, total_steps, "WAKING UP SERVER", "in_progress")
|
|
39
|
+
is_awake = await wake_up_server(config.MCP_HEALTH_URL, max_attempts=6, wait_seconds=5)
|
|
40
|
+
|
|
41
|
+
if not is_awake:
|
|
42
|
+
console.print("[bold red] Failed to wake up server[/bold red]\n")
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
display_step(current_step, total_steps, "WAKING UP SERVER", "success", update=True)
|
|
46
|
+
|
|
47
|
+
# Wait for MCP server to initialize (shorter wait like working example)
|
|
48
|
+
with console.status(
|
|
49
|
+
" [dim]Waiting for server initialization (5s)...[/dim]", spinner="dots"
|
|
50
|
+
):
|
|
51
|
+
await asyncio.sleep(5)
|
|
52
|
+
|
|
53
|
+
console.print()
|
|
54
|
+
current_step += 1
|
|
55
|
+
|
|
56
|
+
# Step 2: Connect to MCP server AND list tools in one operation
|
|
57
|
+
# (This avoids session expiry between connect and list_tools)
|
|
58
|
+
display_step(current_step, total_steps, "CONNECTING AND LISTING TOOLS", "in_progress")
|
|
59
|
+
mcp_session, tools = await connect_and_list_tools(max_attempts=3, wait_seconds=5)
|
|
60
|
+
|
|
61
|
+
if not mcp_session:
|
|
62
|
+
console.print("[bold red] Failed to connect to MCP server[/bold red]\n")
|
|
63
|
+
console.print("[yellow]This may indicate:[/yellow]")
|
|
64
|
+
console.print(" - The MCP server is not properly configured")
|
|
65
|
+
console.print(" - The server endpoint URL is incorrect")
|
|
66
|
+
console.print(" - The server is returning errors for MCP requests")
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
display_step(
|
|
70
|
+
current_step, total_steps, "CONNECTING AND LISTING TOOLS", "success", update=True
|
|
71
|
+
)
|
|
72
|
+
console.print()
|
|
73
|
+
console.print(
|
|
74
|
+
f"[bold green]Test complete![/bold green] Server has [bold cyan]{len(tools)}[/bold cyan] tools available"
|
|
75
|
+
)
|
|
76
|
+
console.print()
|
|
77
|
+
|
|
78
|
+
finally:
|
|
79
|
+
# Cancel the listener task when exiting
|
|
80
|
+
listener_task.cancel()
|
|
81
|
+
try:
|
|
82
|
+
await listener_task
|
|
83
|
+
except asyncio.CancelledError:
|
|
84
|
+
pass
|
|
85
|
+
# Clean up MCP session
|
|
86
|
+
if mcp_session:
|
|
87
|
+
await mcp_session.cleanup()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
async def interactive_session():
|
|
91
|
+
"""Run an interactive session to explore and test MCP tools."""
|
|
92
|
+
validate_config()
|
|
93
|
+
setup_logging()
|
|
94
|
+
|
|
95
|
+
# Start background listener for 'v' key toggle during setup
|
|
96
|
+
listener_task = asyncio.create_task(listen_for_toggle())
|
|
97
|
+
mcp_session = None
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
# Display logo and configuration
|
|
101
|
+
display_logo()
|
|
102
|
+
display_config_panel(config.MCP_SERVER_URL, config.MCP_HEALTH_URL)
|
|
103
|
+
|
|
104
|
+
# Determine total steps
|
|
105
|
+
total_steps = 2 if config.MCP_HEALTH_URL else 1
|
|
106
|
+
current_step = 1
|
|
107
|
+
|
|
108
|
+
# Step 1: Wake up server if health URL is configured
|
|
109
|
+
if config.MCP_HEALTH_URL:
|
|
110
|
+
display_step(current_step, total_steps, "WAKING UP SERVER", "in_progress")
|
|
111
|
+
is_awake = await wake_up_server(config.MCP_HEALTH_URL, max_attempts=6, wait_seconds=5)
|
|
112
|
+
|
|
113
|
+
if not is_awake:
|
|
114
|
+
console.print(
|
|
115
|
+
"[bold red] Failed to wake up server after all attempts[/bold red]\n"
|
|
116
|
+
)
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
display_step(current_step, total_steps, "WAKING UP SERVER", "success", update=True)
|
|
120
|
+
|
|
121
|
+
# Wait for MCP server to initialize (shorter wait like working example)
|
|
122
|
+
with console.status(
|
|
123
|
+
" [dim]Waiting for server initialization (5s)...[/dim]", spinner="dots"
|
|
124
|
+
):
|
|
125
|
+
await asyncio.sleep(5)
|
|
126
|
+
|
|
127
|
+
console.print()
|
|
128
|
+
current_step += 1
|
|
129
|
+
|
|
130
|
+
# Step 2: Connect to MCP server AND list tools in one operation
|
|
131
|
+
# (This avoids session expiry between connect and list_tools)
|
|
132
|
+
display_step(current_step, total_steps, "CONNECTING AND LISTING TOOLS", "in_progress")
|
|
133
|
+
mcp_session, tools = await connect_and_list_tools(max_attempts=3, wait_seconds=5)
|
|
134
|
+
|
|
135
|
+
if not mcp_session:
|
|
136
|
+
console.print("[bold red] Failed to connect to MCP server[/bold red]\n")
|
|
137
|
+
console.print("[yellow]This may indicate:[/yellow]")
|
|
138
|
+
console.print(" - The MCP server is not properly configured")
|
|
139
|
+
console.print(" - The server endpoint URL is incorrect")
|
|
140
|
+
console.print(" - The server is returning errors for MCP requests")
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
display_step(
|
|
144
|
+
current_step, total_steps, "CONNECTING AND LISTING TOOLS", "success", update=True
|
|
145
|
+
)
|
|
146
|
+
console.print()
|
|
147
|
+
|
|
148
|
+
if not tools:
|
|
149
|
+
console.print("[yellow]No tools available on this server.[/yellow]")
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
# Stop background listener before entering interactive loop
|
|
153
|
+
# The interactive loop handles 'v' key itself
|
|
154
|
+
listener_task.cancel()
|
|
155
|
+
try:
|
|
156
|
+
await listener_task
|
|
157
|
+
except asyncio.CancelledError:
|
|
158
|
+
pass
|
|
159
|
+
|
|
160
|
+
# Main interaction loop - Tool List View
|
|
161
|
+
running = True
|
|
162
|
+
while running:
|
|
163
|
+
display_tools(tools)
|
|
164
|
+
|
|
165
|
+
console.print("[bold underline]Commands:[/bold underline]")
|
|
166
|
+
console.print(" [bold cyan]number[/bold cyan] - Select a tool")
|
|
167
|
+
console.print(
|
|
168
|
+
" [bold cyan]v[/bold cyan] - Toggle verbose logging (current: {})".format(
|
|
169
|
+
"[green]ON[/green]" if config.VERBOSE else "[red]OFF[/red]"
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
console.print(" [bold cyan]q[/bold cyan] - Quit")
|
|
173
|
+
console.print()
|
|
174
|
+
|
|
175
|
+
choice = Prompt.ask("Select tool").strip().lower()
|
|
176
|
+
|
|
177
|
+
if choice in ("quit", "q", "exit"):
|
|
178
|
+
console.print("\n[bold yellow]Exiting...[/bold yellow]")
|
|
179
|
+
break
|
|
180
|
+
|
|
181
|
+
if choice == "v":
|
|
182
|
+
config.VERBOSE = not config.VERBOSE
|
|
183
|
+
status = (
|
|
184
|
+
"[bold green]ON[/bold green]" if config.VERBOSE else "[bold red]OFF[/bold red]"
|
|
185
|
+
)
|
|
186
|
+
console.print(f"\n[bold cyan]Verbose logging toggled {status}[/bold cyan]\n")
|
|
187
|
+
continue
|
|
188
|
+
|
|
189
|
+
# Try to parse as tool number
|
|
190
|
+
try:
|
|
191
|
+
tool_num = int(choice)
|
|
192
|
+
if 1 <= tool_num <= len(tools):
|
|
193
|
+
selected_tool = tools[tool_num - 1]
|
|
194
|
+
|
|
195
|
+
# Enter Tool View loop
|
|
196
|
+
while True:
|
|
197
|
+
display_tool_header(selected_tool)
|
|
198
|
+
display_tool_menu()
|
|
199
|
+
|
|
200
|
+
tool_choice = Prompt.ask("Choose option").strip().lower()
|
|
201
|
+
|
|
202
|
+
if tool_choice in ("d", "details", "1"):
|
|
203
|
+
# View details
|
|
204
|
+
display_tool_schema(selected_tool)
|
|
205
|
+
|
|
206
|
+
elif tool_choice in ("r", "run", "2"):
|
|
207
|
+
# Run tool
|
|
208
|
+
params = get_tool_parameters(selected_tool)
|
|
209
|
+
await call_tool(mcp_session, selected_tool, params)
|
|
210
|
+
|
|
211
|
+
elif tool_choice in ("b", "back", "3"):
|
|
212
|
+
# Back to tool list
|
|
213
|
+
break
|
|
214
|
+
|
|
215
|
+
elif tool_choice in ("q", "quit", "exit"):
|
|
216
|
+
# Quit entirely
|
|
217
|
+
console.print("\n[bold yellow]Exiting...[/bold yellow]")
|
|
218
|
+
running = False
|
|
219
|
+
break
|
|
220
|
+
|
|
221
|
+
elif tool_choice == "v":
|
|
222
|
+
config.VERBOSE = not config.VERBOSE
|
|
223
|
+
status = (
|
|
224
|
+
"[bold green]ON[/bold green]"
|
|
225
|
+
if config.VERBOSE
|
|
226
|
+
else "[bold red]OFF[/bold red]"
|
|
227
|
+
)
|
|
228
|
+
console.print(
|
|
229
|
+
f"\n[bold cyan]Verbose logging toggled {status}[/bold cyan]\n"
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
else:
|
|
233
|
+
console.print(
|
|
234
|
+
"[red]Invalid option. Use 'd' for details, 'r' to run, 'b' to go back, or 'q' to quit.[/red]\n"
|
|
235
|
+
)
|
|
236
|
+
else:
|
|
237
|
+
console.print(
|
|
238
|
+
f"[red]Invalid tool number. Choose between 1 and {len(tools)}[/red]\n"
|
|
239
|
+
)
|
|
240
|
+
except ValueError:
|
|
241
|
+
console.print("[red]Invalid choice. Enter a tool number or 'q' to quit.[/red]\n")
|
|
242
|
+
|
|
243
|
+
finally:
|
|
244
|
+
# Ensure listener is cancelled if we exit early
|
|
245
|
+
if not listener_task.done():
|
|
246
|
+
listener_task.cancel()
|
|
247
|
+
try:
|
|
248
|
+
await listener_task
|
|
249
|
+
except asyncio.CancelledError:
|
|
250
|
+
pass
|
|
251
|
+
|
|
252
|
+
# Clean up MCP session
|
|
253
|
+
if mcp_session:
|
|
254
|
+
await mcp_session.cleanup()
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
async def async_main():
|
|
258
|
+
"""Async main entry point."""
|
|
259
|
+
setup_logging()
|
|
260
|
+
|
|
261
|
+
try:
|
|
262
|
+
# Check for command line arguments
|
|
263
|
+
if len(sys.argv) > 1:
|
|
264
|
+
if sys.argv[1] in ("--test", "-t"):
|
|
265
|
+
await test_connectivity()
|
|
266
|
+
return
|
|
267
|
+
elif sys.argv[1] in ("--help", "-h"):
|
|
268
|
+
display_logo()
|
|
269
|
+
console.print("\n[bold]Usage:[/bold]")
|
|
270
|
+
console.print(" forbin Run interactive session")
|
|
271
|
+
console.print(" forbin --test Test connectivity only")
|
|
272
|
+
console.print(" forbin --help Show this help message")
|
|
273
|
+
console.print("\n[bold]Configuration:[/bold]")
|
|
274
|
+
console.print(" Set MCP_SERVER_URL, MCP_TOKEN, and optionally MCP_HEALTH_URL")
|
|
275
|
+
console.print(" in a .env file (see .env.example)")
|
|
276
|
+
console.print("\n[bold]Interactive Shortcuts:[/bold]")
|
|
277
|
+
console.print(" [bold cyan]'v'[/bold cyan] - Toggle verbose logging at any time")
|
|
278
|
+
return
|
|
279
|
+
|
|
280
|
+
# Run interactive session by default
|
|
281
|
+
await interactive_session()
|
|
282
|
+
except asyncio.CancelledError:
|
|
283
|
+
pass
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def main():
|
|
287
|
+
"""Synchronous entry point for CLI."""
|
|
288
|
+
asyncio.run(async_main())
|
forbin/client.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from typing import Optional
|
|
3
|
+
import httpx
|
|
4
|
+
from fastmcp.client import Client
|
|
5
|
+
from fastmcp.client.auth import BearerAuth
|
|
6
|
+
|
|
7
|
+
from . import config
|
|
8
|
+
from .display import console
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class MCPSession:
|
|
12
|
+
"""Wrapper to hold both the client and session for proper lifecycle management."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, client: Client, session):
|
|
15
|
+
self.client = client
|
|
16
|
+
self.session = session
|
|
17
|
+
|
|
18
|
+
async def list_tools(self):
|
|
19
|
+
"""List available tools from the MCP server."""
|
|
20
|
+
return await self.session.list_tools()
|
|
21
|
+
|
|
22
|
+
async def call_tool(self, name: str, arguments: dict):
|
|
23
|
+
"""Call a tool with the given arguments."""
|
|
24
|
+
return await self.session.call_tool(name, arguments)
|
|
25
|
+
|
|
26
|
+
async def cleanup(self):
|
|
27
|
+
"""Close the MCP session."""
|
|
28
|
+
if self.client:
|
|
29
|
+
try:
|
|
30
|
+
await self.client.__aexit__(None, None, None)
|
|
31
|
+
except Exception:
|
|
32
|
+
# Suppress session termination errors (these are harmless cleanup warnings)
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def wake_up_server(health_url: str, max_attempts: int = 6, wait_seconds: float = 5) -> bool:
|
|
37
|
+
"""
|
|
38
|
+
Wake up a suspended server by calling the health endpoint.
|
|
39
|
+
Useful for Fly.io and other platforms that suspend inactive services.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
health_url: The health endpoint URL
|
|
43
|
+
max_attempts: Maximum number of health check attempts
|
|
44
|
+
wait_seconds: Seconds to wait between attempts
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
True if server is awake, False otherwise
|
|
48
|
+
"""
|
|
49
|
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
50
|
+
with console.status(" [dim]Polling health endpoint...[/dim]", spinner="dots") as status:
|
|
51
|
+
for attempt in range(1, max_attempts + 1):
|
|
52
|
+
try:
|
|
53
|
+
status.update(f" [dim]Attempt {attempt}/{max_attempts}...[/dim]")
|
|
54
|
+
response = await client.get(health_url)
|
|
55
|
+
|
|
56
|
+
if response.status_code == 200:
|
|
57
|
+
return True
|
|
58
|
+
else:
|
|
59
|
+
if attempt == max_attempts:
|
|
60
|
+
console.print(
|
|
61
|
+
f" [yellow]Server responded with status {response.status_code}[/yellow]"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
except (httpx.ConnectError, httpx.TimeoutException) as e:
|
|
65
|
+
if config.VERBOSE or attempt == max_attempts:
|
|
66
|
+
error_msg = f" [yellow]Connection failed: {type(e).__name__}[/yellow]"
|
|
67
|
+
if config.VERBOSE:
|
|
68
|
+
error_msg += f" [dim]({str(e)})[/dim]"
|
|
69
|
+
console.print(error_msg)
|
|
70
|
+
except Exception as e:
|
|
71
|
+
if config.VERBOSE or attempt == max_attempts:
|
|
72
|
+
console.print(f" [red]Unexpected error: {e}[/red]")
|
|
73
|
+
|
|
74
|
+
if attempt < max_attempts:
|
|
75
|
+
await asyncio.sleep(wait_seconds)
|
|
76
|
+
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def connect_to_mcp_server(
|
|
81
|
+
max_attempts: int = 3, wait_seconds: float = 5
|
|
82
|
+
) -> Optional[MCPSession]:
|
|
83
|
+
"""
|
|
84
|
+
Connect to the MCP server with retry logic.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
max_attempts: Maximum connection attempts
|
|
88
|
+
wait_seconds: Seconds to wait between attempts
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
MCPSession instance or None if failed
|
|
92
|
+
"""
|
|
93
|
+
server_url = config.MCP_SERVER_URL or ""
|
|
94
|
+
token = config.MCP_TOKEN or ""
|
|
95
|
+
|
|
96
|
+
with console.status(" [dim]Establishing connection...[/dim]", spinner="dots") as status:
|
|
97
|
+
for attempt in range(1, max_attempts + 1):
|
|
98
|
+
client = None
|
|
99
|
+
try:
|
|
100
|
+
status.update(f" [dim]Attempt {attempt}/{max_attempts}...[/dim]")
|
|
101
|
+
|
|
102
|
+
client = Client(
|
|
103
|
+
server_url,
|
|
104
|
+
auth=BearerAuth(token=token),
|
|
105
|
+
init_timeout=30.0, # Extended timeout for cold starts
|
|
106
|
+
timeout=600.0, # Wait up to 10 minutes for tool operations
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# Enter the client context and capture the session
|
|
110
|
+
session = await client.__aenter__()
|
|
111
|
+
return MCPSession(client, session)
|
|
112
|
+
|
|
113
|
+
except asyncio.TimeoutError:
|
|
114
|
+
if config.VERBOSE or attempt == max_attempts:
|
|
115
|
+
console.print(" [red]Timeout (server not responding)[/red]")
|
|
116
|
+
# Clean up partial connection
|
|
117
|
+
if client:
|
|
118
|
+
try:
|
|
119
|
+
await client.__aexit__(None, None, None)
|
|
120
|
+
except Exception:
|
|
121
|
+
pass
|
|
122
|
+
if attempt < max_attempts:
|
|
123
|
+
await asyncio.sleep(wait_seconds)
|
|
124
|
+
except Exception as e:
|
|
125
|
+
error_name = type(e).__name__
|
|
126
|
+
if config.VERBOSE or attempt == max_attempts:
|
|
127
|
+
if "BrokenResourceError" in error_name or "ClosedResourceError" in error_name:
|
|
128
|
+
console.print(" [yellow]Connection error (server not ready)[/yellow]")
|
|
129
|
+
else:
|
|
130
|
+
console.print(f" [red]{error_name}: {e}[/red]")
|
|
131
|
+
|
|
132
|
+
if config.VERBOSE and not (
|
|
133
|
+
"BrokenResourceError" in error_name or "ClosedResourceError" in error_name
|
|
134
|
+
):
|
|
135
|
+
import traceback
|
|
136
|
+
|
|
137
|
+
console.print(f"[dim]{traceback.format_exc()}[/dim]")
|
|
138
|
+
|
|
139
|
+
# Clean up partial connection
|
|
140
|
+
if client:
|
|
141
|
+
try:
|
|
142
|
+
await client.__aexit__(None, None, None)
|
|
143
|
+
except Exception:
|
|
144
|
+
pass
|
|
145
|
+
|
|
146
|
+
if attempt < max_attempts:
|
|
147
|
+
await asyncio.sleep(wait_seconds)
|
|
148
|
+
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
async def connect_and_list_tools(
|
|
153
|
+
max_attempts: int = 3, wait_seconds: float = 5
|
|
154
|
+
) -> tuple[Optional[MCPSession], list]:
|
|
155
|
+
"""
|
|
156
|
+
Connect to MCP server AND list tools in a single retry loop.
|
|
157
|
+
|
|
158
|
+
This combines connection and tool listing to avoid session expiry
|
|
159
|
+
between the two operations.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
max_attempts: Maximum connection attempts
|
|
163
|
+
wait_seconds: Seconds to wait between attempts
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
Tuple of (MCPSession instance or None, list of tools)
|
|
167
|
+
"""
|
|
168
|
+
server_url = config.MCP_SERVER_URL or ""
|
|
169
|
+
token = config.MCP_TOKEN or ""
|
|
170
|
+
|
|
171
|
+
with console.status(" [dim]Establishing connection...[/dim]", spinner="dots") as status:
|
|
172
|
+
for attempt in range(1, max_attempts + 1):
|
|
173
|
+
client = None
|
|
174
|
+
try:
|
|
175
|
+
status.update(f" [dim]Attempt {attempt}/{max_attempts}...[/dim]")
|
|
176
|
+
|
|
177
|
+
client = Client(
|
|
178
|
+
server_url,
|
|
179
|
+
auth=BearerAuth(token=token),
|
|
180
|
+
init_timeout=30.0, # Extended timeout for cold starts
|
|
181
|
+
timeout=600.0, # Wait up to 10 minutes for tool operations
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
# Enter the client context and capture the session
|
|
185
|
+
session = await client.__aenter__()
|
|
186
|
+
mcp_session = MCPSession(client, session)
|
|
187
|
+
|
|
188
|
+
# Immediately list tools while session is fresh
|
|
189
|
+
status.update(
|
|
190
|
+
f" [dim]Retrieving tools (attempt {attempt}/{max_attempts})...[/dim]"
|
|
191
|
+
)
|
|
192
|
+
tools = await asyncio.wait_for(mcp_session.list_tools(), timeout=15.0)
|
|
193
|
+
|
|
194
|
+
return mcp_session, tools
|
|
195
|
+
|
|
196
|
+
except asyncio.TimeoutError:
|
|
197
|
+
if config.VERBOSE or attempt == max_attempts:
|
|
198
|
+
console.print(" [red]Timeout (server not responding)[/red]")
|
|
199
|
+
# Clean up partial connection
|
|
200
|
+
if client:
|
|
201
|
+
try:
|
|
202
|
+
await client.__aexit__(None, None, None)
|
|
203
|
+
except Exception:
|
|
204
|
+
pass
|
|
205
|
+
if attempt < max_attempts:
|
|
206
|
+
await asyncio.sleep(wait_seconds)
|
|
207
|
+
except Exception as e:
|
|
208
|
+
error_name = type(e).__name__
|
|
209
|
+
if config.VERBOSE or attempt == max_attempts:
|
|
210
|
+
if "BrokenResourceError" in error_name or "ClosedResourceError" in error_name:
|
|
211
|
+
console.print(" [yellow]Connection error (server not ready)[/yellow]")
|
|
212
|
+
else:
|
|
213
|
+
console.print(f" [red]{error_name}: {e}[/red]")
|
|
214
|
+
|
|
215
|
+
if config.VERBOSE and not (
|
|
216
|
+
"BrokenResourceError" in error_name or "ClosedResourceError" in error_name
|
|
217
|
+
):
|
|
218
|
+
import traceback
|
|
219
|
+
|
|
220
|
+
console.print(f"[dim]{traceback.format_exc()}[/dim]")
|
|
221
|
+
|
|
222
|
+
# Clean up partial connection
|
|
223
|
+
if client:
|
|
224
|
+
try:
|
|
225
|
+
await client.__aexit__(None, None, None)
|
|
226
|
+
except Exception:
|
|
227
|
+
pass
|
|
228
|
+
|
|
229
|
+
if attempt < max_attempts:
|
|
230
|
+
await asyncio.sleep(wait_seconds)
|
|
231
|
+
|
|
232
|
+
return None, []
|
forbin/config.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
from dotenv import load_dotenv
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
# Load environment variables from .env file
|
|
8
|
+
load_dotenv()
|
|
9
|
+
|
|
10
|
+
# Get configuration from environment variables
|
|
11
|
+
MCP_SERVER_URL: Optional[str] = os.getenv("MCP_SERVER_URL")
|
|
12
|
+
MCP_HEALTH_URL: Optional[str] = os.getenv("MCP_HEALTH_URL")
|
|
13
|
+
MCP_TOKEN: Optional[str] = os.getenv("MCP_TOKEN")
|
|
14
|
+
|
|
15
|
+
# Runtime flags
|
|
16
|
+
VERBOSE: bool = False
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def validate_config():
|
|
20
|
+
"""Validate required environment variables."""
|
|
21
|
+
if not MCP_SERVER_URL:
|
|
22
|
+
print("Error: MCP_SERVER_URL environment variable is required")
|
|
23
|
+
print("Please create a .env file (see .env.example for template)")
|
|
24
|
+
sys.exit(1)
|
|
25
|
+
if not MCP_TOKEN:
|
|
26
|
+
print("Error: MCP_TOKEN environment variable is required")
|
|
27
|
+
print("Please create a .env file (see .env.example for template)")
|
|
28
|
+
sys.exit(1)
|