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/display.py ADDED
@@ -0,0 +1,260 @@
1
+ import json
2
+ from typing import List, Any, Optional
3
+ from rich.console import Console, Group
4
+ from rich.table import Table
5
+ from rich.panel import Panel
6
+ from rich.text import Text
7
+ from rich.syntax import Syntax
8
+ from rich.control import Control
9
+ from rich.segment import ControlType
10
+
11
+ # Global console instance with constrained width for better readability
12
+ console = Console(width=100)
13
+
14
+
15
+ def display_logo():
16
+ """Display the Forbin ASCII logo."""
17
+ logo = """
18
+ [bold cyan]
19
+ ███████╗ ██████╗ ██████╗ ██████╗ ██╗███╗ ██╗
20
+ ██╔════╝██╔═══██╗██╔══██╗██╔══██╗██║████╗ ██║
21
+ █████╗ ██║ ██║██████╔╝██████╔╝██║██╔██╗ ██║
22
+ ██╔══╝ ██║ ██║██╔══██╗██╔══██╗██║██║╚██╗██║
23
+ ██║ ╚██████╔╝██║ ██║██████╔╝██║██║ ╚████║
24
+ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═════╝ ╚═╝╚═╝ ╚═══╝[/bold cyan]
25
+ [dim] MCP Remote Tool Tester v1.0.0[/dim]
26
+ [italic dim] "This is the voice of world control..."[/italic dim]
27
+ """
28
+ console.print(logo)
29
+
30
+
31
+ def display_config_panel(server_url: Optional[str], health_url: Optional[str] = None):
32
+ """Display configuration information in a panel."""
33
+
34
+ config_table = Table.grid(padding=(0, 2))
35
+ config_table.add_column(style="bold cyan", justify="right")
36
+ config_table.add_column(style="white")
37
+
38
+ server_url_display = server_url or "[dim]Not configured[/dim]"
39
+ config_table.add_row("Server URL:", server_url_display)
40
+ if health_url:
41
+ config_table.add_row("Health URL:", health_url)
42
+ else:
43
+ config_table.add_row("Health URL:", "[dim]Not configured[/dim]")
44
+
45
+ console.print()
46
+ console.print(
47
+ Panel(
48
+ config_table,
49
+ title="[bold]Configuration[/bold]",
50
+ title_align="left",
51
+ border_style="cyan",
52
+ padding=(1, 2),
53
+ )
54
+ )
55
+ console.print()
56
+
57
+
58
+ def display_step(
59
+ step_num: int, total_steps: int, title: str, status: str = "in_progress", update: bool = False
60
+ ):
61
+ """Display a step indicator with status.
62
+
63
+ Args:
64
+ step_num: Current step number
65
+ total_steps: Total number of steps
66
+ title: Step title
67
+ status: One of 'in_progress', 'success', 'skip'
68
+ update: If True, updates the previous line instead of creating a new one
69
+ """
70
+ icons = {"in_progress": ">", "success": "+", "skip": "-"}
71
+
72
+ colors = {"in_progress": "yellow", "success": "green", "skip": "dim"}
73
+
74
+ icon = icons.get(status, "*")
75
+ color = colors.get(status, "white")
76
+
77
+ step_text = f"[{color}]{icon} Step {step_num}/{total_steps}:[/{color}] [bold {color}]{title}[/bold {color}]"
78
+
79
+ if update:
80
+ # Move cursor up one line and clear it, then print the updated status
81
+ console.control(Control((ControlType.CURSOR_UP, 1), (ControlType.ERASE_IN_LINE, 2)))
82
+ console.print(step_text)
83
+ else:
84
+ console.print(step_text)
85
+
86
+
87
+ def display_tools(tools: List[Any]):
88
+ """Display a compact list of available tools."""
89
+ if not tools:
90
+ console.print(
91
+ Panel(
92
+ "No tools available on this server.", title="Available Tools", border_style="yellow"
93
+ )
94
+ )
95
+ return
96
+
97
+ console.print()
98
+ console.print("[bold underline]Available Tools[/bold underline]")
99
+ console.print()
100
+
101
+ for i, tool in enumerate(tools, 1):
102
+ description = tool.description.strip() if tool.description else "No description"
103
+ # Truncate long descriptions for compact display
104
+ if len(description) > 60:
105
+ description = description[:57] + "..."
106
+ console.print(
107
+ f" [bold cyan]{i:2}[/bold cyan]. [white]{tool.name}[/white] [dim]- {description}[/dim]"
108
+ )
109
+
110
+ console.print()
111
+
112
+
113
+ def _highlight_json_in_text(text: str):
114
+ """Highlight JSON content in text with syntax colors.
115
+
116
+ Detects JSON objects/arrays and applies basic syntax highlighting.
117
+ Returns a Text object with styled content.
118
+ """
119
+ import re
120
+
121
+ # Simple check if text looks like it contains JSON
122
+ if not any(char in text for char in ["{", "[", '":']):
123
+ return text
124
+
125
+ # Try to detect and highlight JSON-like patterns
126
+ result = Text()
127
+
128
+ # Pattern to match JSON-like content (simple approach)
129
+ # This will highlight common JSON patterns with colors
130
+ current_pos = 0
131
+
132
+ # Find JSON strings (simple pattern for "key": "value")
133
+ string_pattern = r'"([^"\\]*(\\.[^"\\]*)*)"'
134
+
135
+ for match in re.finditer(string_pattern, text):
136
+ # Add text before match
137
+ if match.start() > current_pos:
138
+ result.append(text[current_pos : match.start()])
139
+
140
+ # Add the matched string with color
141
+ matched_text = match.group(0)
142
+
143
+ # Check if this looks like a key (followed by :)
144
+ next_char_pos = match.end()
145
+ if next_char_pos < len(text) and text[next_char_pos : next_char_pos + 1].strip().startswith(
146
+ ":"
147
+ ):
148
+ result.append(matched_text, style="bold cyan") # JSON key
149
+ else:
150
+ result.append(matched_text, style="green") # JSON value
151
+
152
+ current_pos = match.end()
153
+
154
+ # Add remaining text
155
+ if current_pos < len(text):
156
+ remaining = text[current_pos:]
157
+ # Highlight other JSON syntax
158
+ remaining = remaining.replace("{", "{\u200b") # Add zero-width space for splitting
159
+ remaining = remaining.replace("}", "}\u200b")
160
+ remaining = remaining.replace("[", "[\u200b")
161
+ remaining = remaining.replace("]", "]\u200b")
162
+ remaining = remaining.replace(":", ":\u200b")
163
+
164
+ for part in remaining.split("\u200b"):
165
+ if part in ["{", "}", "[", "]"]:
166
+ result.append(part, style="bold yellow")
167
+ elif part == ":":
168
+ result.append(part, style="dim")
169
+ else:
170
+ result.append(part)
171
+
172
+ return result if len(result) > 0 else text
173
+
174
+
175
+ def display_tool_header(tool: Any):
176
+ """Display a simple header for the tool view."""
177
+ console.print()
178
+ console.rule(f"[bold cyan]{tool.name}[/bold cyan]")
179
+ console.print()
180
+
181
+
182
+ def display_tool_menu():
183
+ """Display the tool view menu options."""
184
+ console.print("[bold underline]Options:[/bold underline]")
185
+ console.print(" [bold cyan]d[/bold cyan] - View details")
186
+ console.print(" [bold cyan]r[/bold cyan] - Run tool")
187
+ console.print(" [bold cyan]b[/bold cyan] - Back to tool list")
188
+ console.print(" [bold cyan]q[/bold cyan] - Quit")
189
+ console.print()
190
+
191
+
192
+ def _parse_description_with_code_blocks(description: str) -> List[Any]:
193
+ """Parse description and extract code blocks for syntax highlighting."""
194
+ import re
195
+
196
+ content: List[Any] = []
197
+
198
+ # Pattern to match ```json ... ``` or ``` ... ``` code blocks
199
+ code_block_pattern = r"```(\w*)\n(.*?)```"
200
+
201
+ last_end = 0
202
+ for match in re.finditer(code_block_pattern, description, re.DOTALL):
203
+ # Add text before the code block
204
+ before_text = description[last_end : match.start()].strip()
205
+ if before_text:
206
+ content.append(Text(before_text))
207
+ content.append(Text(""))
208
+
209
+ # Get language and code
210
+ lang = match.group(1) or "json"
211
+ code = match.group(2).strip()
212
+
213
+ # Add syntax-highlighted code block
214
+ content.append(Syntax(code, lang, theme="monokai", line_numbers=False))
215
+ content.append(Text(""))
216
+
217
+ last_end = match.end()
218
+
219
+ # Add any remaining text after the last code block
220
+ remaining = description[last_end:].strip()
221
+ if remaining:
222
+ content.append(Text(remaining))
223
+
224
+ return content
225
+
226
+
227
+ def display_tool_schema(tool: Any):
228
+ """Display detailed schema for a specific tool with syntax-highlighted JSON."""
229
+
230
+ content: List[Any] = []
231
+
232
+ # Description with parsed code blocks
233
+ if tool.description:
234
+ parsed_content = _parse_description_with_code_blocks(tool.description)
235
+ content.extend(parsed_content)
236
+ if parsed_content:
237
+ content.append(Text(""))
238
+
239
+ # Input Schema as syntax-highlighted JSON
240
+ if tool.inputSchema:
241
+ content.append(Text("Input Schema:", style="bold underline"))
242
+ content.append(Text(""))
243
+ json_str = json.dumps(tool.inputSchema, indent=2)
244
+ content.append(Syntax(json_str, "json", theme="monokai", line_numbers=False))
245
+ else:
246
+ content.append(Text("No input parameters required.", style="dim"))
247
+
248
+ # Combine all content
249
+ panel_content = Group(*content)
250
+
251
+ console.print()
252
+ console.print(
253
+ Panel(
254
+ panel_content,
255
+ title=f"[bold]{tool.name}[/bold] - Details",
256
+ border_style="blue",
257
+ expand=False,
258
+ )
259
+ )
260
+ console.print()
forbin/tools.py ADDED
@@ -0,0 +1,193 @@
1
+ import asyncio
2
+ import json
3
+ from typing import Any, Dict, List, TYPE_CHECKING
4
+ from rich.prompt import Prompt
5
+ from rich.panel import Panel
6
+ from rich.syntax import Syntax
7
+
8
+ from .display import console
9
+
10
+ if TYPE_CHECKING:
11
+ from .client import MCPSession
12
+
13
+
14
+ async def list_tools(mcp_session: "MCPSession") -> List[Any]:
15
+ """
16
+ List all available tools from the MCP server.
17
+
18
+ Args:
19
+ mcp_session: Connected MCPSession
20
+
21
+ Returns:
22
+ List of tool objects
23
+ """
24
+ with console.status(" [dim]Retrieving tool manifest...[/dim]", spinner="dots"):
25
+ tools = await asyncio.wait_for(mcp_session.list_tools(), timeout=15.0)
26
+
27
+ return tools
28
+
29
+
30
+ def parse_parameter_value(value_str: str, param_type: str) -> Any:
31
+ """Parse a string input into the appropriate type."""
32
+ if not value_str.strip():
33
+ return None
34
+
35
+ if param_type == "boolean":
36
+ return value_str.lower() in ("true", "t", "yes", "y", "1")
37
+ elif param_type == "integer":
38
+ try:
39
+ return int(value_str)
40
+ except ValueError:
41
+ # Re-raise to be caught by caller
42
+ raise
43
+ elif param_type == "number":
44
+ try:
45
+ return float(value_str)
46
+ except ValueError:
47
+ raise
48
+ elif param_type in ("object", "array"):
49
+ try:
50
+ return json.loads(value_str)
51
+ except json.JSONDecodeError:
52
+ raise
53
+ else: # string
54
+ return value_str
55
+
56
+
57
+ def get_tool_parameters(tool: Any) -> Dict[str, Any]:
58
+ """Interactively collect parameters for a tool."""
59
+ params: dict[str, Any] = {}
60
+
61
+ if not tool.inputSchema or not isinstance(tool.inputSchema, dict):
62
+ return params
63
+
64
+ properties = tool.inputSchema.get("properties", {})
65
+ required = tool.inputSchema.get("required", [])
66
+
67
+ if not properties:
68
+ return params
69
+
70
+ console.print()
71
+ console.rule("[bold cyan]ENTER PARAMETERS[/bold cyan]")
72
+ console.print("Enter parameter values (press [bold]Enter[/bold] to skip optional parameters)\n")
73
+
74
+ for param_name, param_info in properties.items():
75
+ param_type = param_info.get("type", "string")
76
+ param_desc = param_info.get("description", "")
77
+ is_required = param_name in required
78
+
79
+ # Show parameter info
80
+ req_str = "[red](required)[/red]" if is_required else "[green](optional)[/green]"
81
+ console.print(f"[bold cyan]{param_name}[/bold cyan] ({param_type}) {req_str}")
82
+ if param_desc:
83
+ console.print(f" [dim]{param_desc}[/dim]")
84
+
85
+ # Show enum values if present
86
+ if "enum" in param_info:
87
+ console.print(f" Allowed values: {', '.join(str(v) for v in param_info['enum'])}")
88
+
89
+ # Get value
90
+ while True:
91
+ try:
92
+ # We use generic Prompt and handle manual validation to support complex types and skipping
93
+ value_str = Prompt.ask(" ->", default="", show_default=False)
94
+
95
+ if not value_str:
96
+ if is_required:
97
+ console.print(
98
+ " [red]This parameter is required. Please enter a value.[/red]"
99
+ )
100
+ continue
101
+ else:
102
+ break
103
+
104
+ # Parse the value
105
+ value = parse_parameter_value(value_str, param_type)
106
+ params[param_name] = value
107
+ break
108
+
109
+ except (ValueError, json.JSONDecodeError) as e:
110
+ console.print(f" [red]Invalid value for type {param_type}:[/red] {e}")
111
+ console.print(" Please try again.")
112
+
113
+ console.print()
114
+
115
+ return params
116
+
117
+
118
+ async def call_tool(mcp_session: "MCPSession", tool: Any, params: Dict[str, Any]):
119
+ """Call a tool with the given parameters."""
120
+ console.print()
121
+ console.rule("[bold magenta]CALLING TOOL[/bold magenta]")
122
+ console.print(f"Tool: [bold]{tool.name}[/bold]")
123
+ console.print()
124
+
125
+ # Show parameters nicely
126
+ if params:
127
+ json_str = json.dumps(params, indent=2)
128
+ console.print(
129
+ Panel(
130
+ Syntax(json_str, "json", theme="monokai", line_numbers=False),
131
+ title="[bold]Parameters[/bold]",
132
+ title_align="left",
133
+ border_style="cyan",
134
+ )
135
+ )
136
+ else:
137
+ console.print("[dim]No parameters[/dim]")
138
+
139
+ console.print("\n[bold]Executing...[/bold]")
140
+
141
+ try:
142
+ with console.status("Waiting for response...", spinner="dots"):
143
+ result = await mcp_session.call_tool(tool.name, params)
144
+
145
+ console.print("\n[bold green]Tool execution completed![/bold green]\n")
146
+ console.rule("[bold green]RESULT[/bold green]")
147
+ console.print()
148
+
149
+ # Extract and display result
150
+ if result.content:
151
+ for item in result.content:
152
+ text = getattr(item, "text", None)
153
+ if text:
154
+ # Try to detect if it looks like JSON for syntax highlighting
155
+ text_stripped = text.strip()
156
+ if text_stripped.startswith(("{", "[")) and text_stripped.endswith(("}", "]")):
157
+ try:
158
+ # Validate and pretty-print JSON
159
+ parsed = json.loads(text_stripped)
160
+ formatted = json.dumps(parsed, indent=2)
161
+ console.print(
162
+ Panel(
163
+ Syntax(formatted, "json", theme="monokai", line_numbers=False),
164
+ border_style="green",
165
+ title="[bold]Response[/bold]",
166
+ title_align="left",
167
+ )
168
+ )
169
+ continue
170
+ except json.JSONDecodeError:
171
+ pass
172
+
173
+ # For non-JSON text responses
174
+ console.print(
175
+ Panel(
176
+ text.strip(),
177
+ border_style="green",
178
+ title="[bold]Response[/bold]",
179
+ title_align="left",
180
+ )
181
+ )
182
+ else:
183
+ console.print(str(item))
184
+ else:
185
+ console.print("[dim]No content returned[/dim]")
186
+
187
+ console.print()
188
+ console.rule()
189
+ console.print()
190
+
191
+ except Exception as e:
192
+ console.print(f"[bold red]Tool execution failed:[/bold red] {type(e).__name__}")
193
+ console.print(f" Error: {e}\n")
forbin/utils.py ADDED
@@ -0,0 +1,111 @@
1
+ import sys
2
+ import asyncio
3
+ import select
4
+ from . import config
5
+
6
+
7
+ # Suppress stderr warnings from MCP library (like "Session termination failed: 400")
8
+ class FilteredStderr:
9
+ def __init__(self, original_stderr):
10
+ self.original_stderr = original_stderr
11
+ self.suppress_patterns = [
12
+ "Error in post_writer",
13
+ "Session termination failed",
14
+ "httpx.HTTPStatusError",
15
+ "streamable_http.py",
16
+ "Traceback (most recent call last)",
17
+ "File ", # Suppress file paths in tracebacks
18
+ "raise ",
19
+ "await ",
20
+ "BrokenResourceError",
21
+ "ClosedResourceError",
22
+ "raise_for_status",
23
+ "handle_request_async",
24
+ "_handle_post_request",
25
+ ]
26
+ self.buffer = ""
27
+ self.suppressing = False
28
+ self.suppress_depth = 0
29
+
30
+ def write(self, text):
31
+ # If verbose mode is ON, don't suppress anything
32
+ if config.VERBOSE:
33
+ self.original_stderr.write(text)
34
+ return
35
+
36
+ # Check if this line starts a suppressible error block
37
+ if any(pattern in text for pattern in self.suppress_patterns):
38
+ self.suppressing = True
39
+ self.suppress_depth = 10 # Suppress next 10 lines
40
+ return
41
+
42
+ # If we're suppressing, decrement counter
43
+ if self.suppressing:
44
+ if text.strip() == "":
45
+ # Blank line ends suppression
46
+ self.suppressing = False
47
+ self.suppress_depth = 0
48
+ else:
49
+ # Any line during suppression decrements counter
50
+ self.suppress_depth -= 1
51
+ if self.suppress_depth <= 0:
52
+ self.suppressing = False
53
+ return
54
+
55
+ return
56
+
57
+ # If not suppressing, write to original stderr
58
+ if not self.suppressing:
59
+ self.original_stderr.write(text)
60
+
61
+ def flush(self):
62
+ self.original_stderr.flush()
63
+
64
+
65
+ def setup_logging():
66
+ """Replace stderr with filtered version."""
67
+ sys.stderr = FilteredStderr(sys.stderr)
68
+
69
+
70
+ async def listen_for_toggle():
71
+ """
72
+ Background task to listen for 'v' key to toggle verbose logging.
73
+ Uses non-blocking stdin read.
74
+ """
75
+ # Only try to import termios/tty on Unix-like systems
76
+ try:
77
+ import termios
78
+ import tty
79
+ except ImportError:
80
+ return
81
+
82
+ fd = sys.stdin.fileno()
83
+ # Check if we are in a terminal
84
+ if not sys.stdin.isatty():
85
+ return
86
+
87
+ old_settings = termios.tcgetattr(fd)
88
+ try:
89
+ tty.setcbreak(fd)
90
+ while True:
91
+ # Non-blocking check for input
92
+ if select.select([sys.stdin], [], [], 0.1)[0]:
93
+ char = sys.stdin.read(1).lower()
94
+ if char == "v":
95
+ config.VERBOSE = not config.VERBOSE
96
+ from .display import console
97
+
98
+ status = (
99
+ "[bold green]ON[/bold green]"
100
+ if config.VERBOSE
101
+ else "[bold red]OFF[/bold red]"
102
+ )
103
+ # Clear current line and print toggle status
104
+ console.print(f"\n[bold cyan]Verbose logging toggled {status}[/bold cyan]")
105
+
106
+ await asyncio.sleep(0.1)
107
+ except Exception:
108
+ # Silently fail if something goes wrong with the terminal settings
109
+ pass
110
+ finally:
111
+ termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)