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 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
@@ -0,0 +1,9 @@
1
+ import sys
2
+ from .cli import main
3
+
4
+ if __name__ == "__main__":
5
+ try:
6
+ main()
7
+ except KeyboardInterrupt:
8
+ print("\n\nInterrupted by user. Exiting...")
9
+ sys.exit(0)
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)