├── .env.example ├── .gitignore ├── .python-version ├── Makefile ├── README.md ├── TODO.md ├── examples ├── basic_llm_call.py ├── mcp_cli_diagnostics.py ├── mcp_round_trip.py ├── mcp_round_trip_with_toolmanager.py ├── ollama_llm_call.py ├── sample_tools │ ├── calculator_tool.py │ ├── search_tool.py │ └── weather_tool.py ├── test_perplexity.py ├── test_safe_servers.py ├── test_servers.py ├── test_streaming.py ├── timeout_investigation.py └── tool_round_trip.py ├── license.md ├── pyproject.toml ├── sample_messages ├── initialize_request.json └── initialize_response.json ├── scripts └── mcp-cli ├── server_config.json ├── src └── mcp_cli │ ├── __init__.py │ ├── chat │ ├── __init__.py │ ├── __main__.py │ ├── chat_context.py │ ├── chat_handler.py │ ├── command_completer.py │ ├── commands │ │ ├── __init__.py │ │ ├── conversation.py │ │ ├── conversation_history.py │ │ ├── exit.py │ │ ├── help.py │ │ ├── help_text.py │ │ ├── interrupt.py │ │ ├── model.py │ │ ├── ping.py │ │ ├── prompts.py │ │ ├── provider.py │ │ ├── resources.py │ │ ├── servers.py │ │ ├── tool_history.py │ │ ├── tools.py │ │ └── verbose.py │ ├── conversation.py │ ├── streaming_handler.py │ ├── system_prompt.py │ ├── tool_processor.py │ └── ui_manager.py │ ├── cli │ ├── __init__.py │ ├── commands │ │ ├── __init__.py │ │ ├── base.py │ │ ├── chat.py │ │ ├── clear.py │ │ ├── cmd.py │ │ ├── exit.py │ │ ├── help.py │ │ ├── interactive.py │ │ ├── ping.py │ │ ├── prompts.py │ │ ├── provider.py │ │ ├── resources.py │ │ ├── servers.py │ │ ├── tools.py │ │ └── tools_call.py │ └── registry.py │ ├── cli_options.py │ ├── commands │ ├── __init__.py │ ├── clear.py │ ├── exit.py │ ├── help.py │ ├── model.py │ ├── ping.py │ ├── prompts.py │ ├── provider.py │ ├── resources.py │ ├── servers.py │ ├── tools.py │ └── tools_call.py │ ├── config.py │ ├── interactive │ ├── __init__.py │ ├── commands │ │ ├── __init__.py │ │ ├── base.py │ │ ├── clear.py │ │ ├── exit.py │ │ ├── help.py │ │ ├── model.py │ │ ├── ping.py │ │ ├── prompts.py │ │ ├── provider.py │ │ ├── resources.py │ │ ├── servers.py │ │ └── tools.py │ ├── registry.py │ └── shell.py │ ├── llm │ ├── __init__.py │ ├── system_prompt_generator.py │ └── tools_handler.py │ ├── logging_config.py │ ├── main.py │ ├── model_manager.py │ ├── run_command.py │ ├── tools │ ├── __init__.py │ ├── adapter.py │ ├── formatting.py │ ├── manager.py │ └── models.py │ ├── ui │ ├── __init__.py │ ├── colors.py │ └── ui_helpers.py │ └── utils │ ├── __init__.py │ ├── async_utils.py │ ├── llm_probe.py │ └── rich_helpers.py ├── test.db ├── test_config.json ├── test_mcp_cli.py ├── tests ├── __init__.py └── mcp_cli │ ├── __init__.py │ ├── chat │ ├── __init__.py │ ├── test_chat_context.py │ ├── test_chat_handler.py │ ├── test_tool_processor.py │ └── test_ui_manager.py │ ├── cli │ ├── test_cli_chat.py │ ├── test_cli_interactive.py │ ├── test_cli_registry.py │ └── test_cmd.py │ ├── commands │ ├── __init__.py │ ├── test_clear.py │ ├── test_exit.py │ ├── test_help.py │ ├── test_ping.py │ ├── test_prompts.py │ ├── test_resources.py │ ├── test_servers.py │ ├── test_tools.py │ └── test_tools_call.py │ ├── interactive │ ├── test_interactive_registry.py │ └── test_interactive_shell.py │ ├── llm │ ├── __init__.py │ └── test_system_prompt_generator.py │ ├── test_cli_options.py │ ├── test_config.py │ ├── test_run_command.py │ └── tools │ ├── test_adapter.py │ ├── test_formatting.py │ ├── test_models.py │ └── test_tool_manager.py └── uv.lock /.env.example: -------------------------------------------------------------------------------- 1 | OPENAI_API_KEY=mykey -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | .env 3 | .venv 4 | .pytest_cache 5 | dist/ 6 | .pdm-build 7 | *.egg-info 8 | .DS_Store 9 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.12 2 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Makefile mixing uv for install/test and PYTHON3 for build/publish 2 | PROJECT_NAME := mcp-cli 3 | DEFAULT_GOAL := help 4 | 5 | # Let users override the Python interpreter for build/publish (e.g. make build PYTHON3=python3.12) 6 | PYTHON3 ?= python3 7 | 8 | .PHONY: help 9 | help: 10 | @echo "Makefile for $(PROJECT_NAME) - mixing uv for install/test, $(PYTHON3) for build/publish" 11 | @echo 12 | @echo "Targets:" 13 | @echo " install Install package in uv environment (editable mode)" 14 | @echo " test Run tests with uv" 15 | @echo " clean Remove build artifacts" 16 | @echo 17 | 18 | # ------------------------------------------------------------------------ 19 | # 1) Install in the uv environment 20 | # ------------------------------------------------------------------------ 21 | .PHONY: install 22 | install: 23 | @echo "Installing package in uv environment (editable mode)..." 24 | uv run python -m pip install --no-cache-dir -e . 25 | @echo "Install complete." 26 | 27 | # ------------------------------------------------------------------------ 28 | # 2) Build with system python3 (or PYTHON3 override) 29 | # ------------------------------------------------------------------------ 30 | # .PHONY: build 31 | # build: 32 | # @echo "Building sdist and wheel with \`$(PYTHON3)\`..." 33 | # $(PYTHON3) -m build 34 | # @echo "Build complete. Artifacts in ./dist" 35 | 36 | # ------------------------------------------------------------------------ 37 | # 3) Test with uv-run 38 | # ------------------------------------------------------------------------ 39 | .PHONY: test 40 | test: 41 | @echo "Running tests with uv run pytest..." 42 | uv run pytest 43 | 44 | # ------------------------------------------------------------------------ 45 | # 4) Clean build artifacts 46 | # ------------------------------------------------------------------------ 47 | .PHONY: clean 48 | clean: 49 | @echo "Cleaning build artifacts..." 50 | rm -rf build dist *.egg-info .pytest_cache 51 | find . -name '__pycache__' -exec rm -rf {} + 52 | find . -name '*.pyc' -exec rm -rf {} + 53 | @echo "Clean complete." 54 | 55 | # ------------------------------------------------------------------------ 56 | # 5) Publish to PyPI using \`$(PYTHON3)\` 57 | # ------------------------------------------------------------------------ 58 | # .PHONY: publish 59 | # publish: clean build 60 | # @echo "Publishing to PyPI with \`$(PYTHON3)\` and twine..." 61 | # $(PYTHON3) -m twine check dist/* 62 | # $(PYTHON3) -m twine upload dist/* 63 | # @echo "Publish to PyPI complete." 64 | 65 | # ------------------------------------------------------------------------ 66 | # 6) Publish to TestPyPI using \`$(PYTHON3)\` 67 | # ------------------------------------------------------------------------ 68 | # .PHONY: publish-test 69 | # publish-test: clean build 70 | # @echo "Publishing to TestPyPI with \`$(PYTHON3)\` and twine..." 71 | # $(PYTHON3) -m twine check dist/* 72 | # $(PYTHON3) -m twine upload --repository testpypi dist/* 73 | # @echo "Publish to TestPyPI complete." -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # TODO 2 | - model handling 3 | - anthropic 4 | - openai 5 | - structured outputs 6 | - litwellm 7 | - streaming 8 | - configurable provider with sqlite for providers and model adding in tools 9 | - bootstrap mcp server -------------------------------------------------------------------------------- /examples/basic_llm_call.py: -------------------------------------------------------------------------------- 1 | # examples/basic_llm_call.py 2 | """Minimal diagnostic: send one prompt through the MCP-CLI LLM layer. 3 | 4 | Run from the repo root, e.g. 5 | 6 | uv run examples/basic_llm_call.py \ 7 | --provider openai \ 8 | --model gpt-4o-mini \ 9 | --prompt "Hello from the diagnostic script!" 10 | 11 | Requires `OPENAI_API_KEY` for OpenAI; load a local `.env` automatically. 12 | """ 13 | from __future__ import annotations 14 | import argparse 15 | import asyncio 16 | import os 17 | import sys 18 | from typing import Any, Dict, List 19 | from dotenv import load_dotenv 20 | 21 | # imports 22 | from chuk_llm.llm.llm_client import get_llm_client 23 | from mcp_cli.llm.system_prompt_generator import SystemPromptGenerator 24 | 25 | # load environment variables 26 | load_dotenv() 27 | 28 | 29 | async def run_llm_diagnostic(provider: str, model: str, prompt: str) -> None: 30 | """Send *prompt* to *provider/model* and print the assistant reply.""" 31 | if provider.lower() == "openai" and not os.getenv("OPENAI_API_KEY"): 32 | sys.exit("[ERROR] OPENAI_API_KEY environment variable is not set") 33 | 34 | # get the client 35 | client = get_llm_client(provider=provider, model=model) 36 | 37 | # get the system prompt 38 | system_prompt = SystemPromptGenerator().generate_prompt({}) 39 | messages: List[Dict[str, Any]] = [ 40 | {"role": "system", "content": system_prompt}, 41 | {"role": "user", "content": prompt}, 42 | ] 43 | 44 | # do a completion 45 | completion = await client.create_completion(messages=messages) 46 | 47 | print("\n=== LLM Response ===\n") 48 | if isinstance(completion, dict): 49 | print(completion.get("response", completion)) 50 | else: # highly unlikely now, but be safe 51 | print(completion) 52 | 53 | 54 | # ────────────────────────────── CLI wrapper ────────────────────────────── 55 | 56 | def main() -> None: 57 | parser = argparse.ArgumentParser(description="Basic LLM diagnostic script") 58 | parser.add_argument("--provider", default="openai", help="LLM provider") 59 | parser.add_argument("--model", default="gpt-4o-mini", help="Model name") 60 | parser.add_argument("--prompt", default="Hello, world!", help="Prompt text") 61 | args = parser.parse_args() 62 | 63 | try: 64 | asyncio.run(run_llm_diagnostic(args.provider, args.model, args.prompt)) 65 | except KeyboardInterrupt: 66 | print("\n[Cancelled]") 67 | 68 | 69 | if __name__ == "__main__": # pragma: no cover 70 | main() -------------------------------------------------------------------------------- /examples/ollama_llm_call.py: -------------------------------------------------------------------------------- 1 | # examples/ollama_llm_call.py 2 | """Minimal diagnostic: send one prompt through the MCP‑CLI client layer 3 | using **Ollama** as the backend. 4 | 5 | Run from repo root, e.g. 6 | 7 | uv run examples/ollama_llm_call.py \ 8 | --model qwen2.5-coder \ 9 | --prompt "Hello from Ollama!" 10 | 11 | No environment variables are required, but the local *ollama* server 12 | must be running and the chosen model must be pulled. 13 | """ 14 | from __future__ import annotations 15 | import argparse 16 | import asyncio 17 | import sys 18 | from typing import Any, Dict, List 19 | 20 | # mcp cli imports 21 | from chuk_llm.llm.llm_client import get_llm_client 22 | from mcp_cli.llm.system_prompt_generator import SystemPromptGenerator 23 | 24 | 25 | async def run_ollama_diagnostic(model: str, prompt: str) -> None: 26 | """Send *prompt* to the local Ollama server and print the reply.""" 27 | try: 28 | client = get_llm_client(provider="ollama", model=model) 29 | except Exception as exc: 30 | sys.exit(f"[ERROR] Could not create Ollama client: {exc}") 31 | 32 | system_prompt = SystemPromptGenerator().generate_prompt({}) 33 | messages: List[Dict[str, Any]] = [ 34 | {"role": "system", "content": system_prompt}, 35 | {"role": "user", "content": prompt}, 36 | ] 37 | 38 | try: 39 | completion = await client.create_completion(messages=messages) 40 | except Exception as exc: 41 | sys.exit(f"[ERROR] Ollama API error: {exc}") 42 | 43 | print("\n=== Ollama Response ===\n") 44 | if isinstance(completion, dict): 45 | print(completion.get("response", completion)) 46 | else: 47 | # Shouldn't happen, but just in case 48 | print(completion) 49 | 50 | 51 | # ────────────────────────────── CLI wrapper ────────────────────────────── 52 | 53 | def main() -> None: 54 | parser = argparse.ArgumentParser(description="Ollama LLM diagnostic script") 55 | parser.add_argument("--model", default="qwen2.5-coder", help="Model name") 56 | parser.add_argument("--prompt", default="Hello, Ollama!", help="Prompt text") 57 | args = parser.parse_args() 58 | 59 | try: 60 | asyncio.run(run_ollama_diagnostic(args.model, args.prompt)) 61 | except KeyboardInterrupt: 62 | print("\n[Cancelled]") 63 | 64 | 65 | if __name__ == "__main__": # pragma: no cover 66 | main() 67 | -------------------------------------------------------------------------------- /examples/sample_tools/calculator_tool.py: -------------------------------------------------------------------------------- 1 | # examples/sample_tools/calculator_tool.py 2 | from typing import Dict 3 | 4 | # imports 5 | from chuk_tool_processor.models.validated_tool import ValidatedTool 6 | from chuk_tool_processor.registry.decorators import register_tool 7 | 8 | 9 | @register_tool(name="calculator") 10 | class CalculatorTool(ValidatedTool): 11 | """Perform mathematical calculations.""" 12 | 13 | class Arguments(ValidatedTool.Arguments): 14 | operation: str 15 | x: float 16 | y: float 17 | 18 | class Result(ValidatedTool.Result): 19 | result: float 20 | operation: str 21 | 22 | def _execute(self, operation: str, x: float, y: float) -> Dict: 23 | """Perform calculations.""" 24 | if operation == "add": 25 | result = x + y 26 | elif operation == "subtract": 27 | result = x - y 28 | elif operation == "multiply": 29 | result = x * y 30 | elif operation == "divide": 31 | if y == 0: 32 | raise ValueError("Division by zero") 33 | result = x / y 34 | else: 35 | raise ValueError(f"Unknown operation: {operation}") 36 | 37 | return {"result": result, "operation": operation} 38 | -------------------------------------------------------------------------------- /examples/sample_tools/search_tool.py: -------------------------------------------------------------------------------- 1 | # examples/sample_tools/search_tool.py 2 | import time 3 | from typing import Dict, List 4 | 5 | # imports 6 | from chuk_tool_processor.registry.decorators import register_tool 7 | from chuk_tool_processor.models.validated_tool import ValidatedTool 8 | 9 | 10 | @register_tool(name="search") 11 | class SearchTool(ValidatedTool): 12 | """Search the web for information.""" 13 | 14 | class Arguments(ValidatedTool.Arguments): 15 | query: str 16 | num_results: int = 3 17 | 18 | class Result(ValidatedTool.Result): 19 | results: List[Dict[str, str]] 20 | 21 | def _execute(self, query: str, num_results: int) -> Dict: 22 | """Simulate web search.""" 23 | time.sleep(0.8) # pretend latency 24 | return { 25 | "results": [ 26 | { 27 | "title": f"Result {i+1} for {query}", 28 | "url": f"https://example.com/result{i+1}", 29 | "snippet": f"This is a search result about {query}.", 30 | } 31 | for i in range(num_results) 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /examples/sample_tools/weather_tool.py: -------------------------------------------------------------------------------- 1 | # examples/sample_tools/weather_tool.py 2 | import time 3 | 4 | # imports 5 | from chuk_tool_processor.registry.decorators import register_tool 6 | from chuk_tool_processor.models.validated_tool import ValidatedTool 7 | 8 | 9 | @register_tool(name="weather") 10 | class WeatherTool(ValidatedTool): 11 | """Get current weather information for a location.""" 12 | 13 | class Arguments(ValidatedTool.Arguments): 14 | location: str 15 | units: str = "metric" 16 | 17 | class Result(ValidatedTool.Result): 18 | temperature: float 19 | conditions: str 20 | humidity: float 21 | location: str 22 | 23 | def _execute(self, location: str, units: str) -> dict: # sync implementation 24 | """Simulate a weather‑API call (demo only).""" 25 | time.sleep(0.5) # pretend network latency 26 | return { 27 | "temperature": 22.5, 28 | "conditions": "Partly Cloudy", 29 | "humidity": 65.0, 30 | "location": location, 31 | } 32 | -------------------------------------------------------------------------------- /examples/test_streaming.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # test_streaming.py - Test chuk-llm streaming directly 3 | 4 | import asyncio 5 | import os 6 | from chuk_llm.llm.llm_client import get_llm_client 7 | 8 | async def test_streaming(): 9 | """Test if chuk-llm streaming works directly""" 10 | 11 | print("Testing chuk-llm streaming...") 12 | 13 | # Get the same client that MCP CLI would use 14 | client = get_llm_client("openai", model="gpt-4o-mini") 15 | 16 | print(f"Client type: {type(client)}") 17 | print(f"Client methods: {[m for m in dir(client) if not m.startswith('_')]}") 18 | 19 | # Test if create_completion has stream parameter 20 | import inspect 21 | try: 22 | sig = inspect.signature(client.create_completion) 23 | print(f"create_completion signature: {sig}") 24 | params = list(sig.parameters.keys()) 25 | print(f"Parameters: {params}") 26 | has_stream = 'stream' in params 27 | print(f"Has 'stream' parameter: {has_stream}") 28 | except Exception as e: 29 | print(f"Could not inspect signature: {e}") 30 | 31 | messages = [ 32 | {"role": "user", "content": "Write a short haiku about programming."} 33 | ] 34 | 35 | print("\n--- Testing regular completion ---") 36 | try: 37 | response = await client.create_completion(messages) 38 | print(f"Regular response: {response}") 39 | except Exception as e: 40 | print(f"Regular completion failed: {e}") 41 | 42 | print("\n--- Testing streaming completion ---") 43 | try: 44 | print("Attempting streaming with stream=True...") 45 | full_response = "" 46 | chunk_count = 0 47 | 48 | async for chunk in client.create_completion(messages, stream=True): 49 | chunk_count += 1 50 | print(f"Chunk {chunk_count}: {chunk}") 51 | 52 | if chunk.get("response"): 53 | content = chunk["response"] 54 | print(content, end="", flush=True) 55 | full_response += content 56 | 57 | print(f"\nStreaming completed. Total chunks: {chunk_count}") 58 | print(f"Full response: {full_response}") 59 | 60 | except TypeError as e: 61 | print(f"Streaming failed - method doesn't accept stream parameter: {e}") 62 | except Exception as e: 63 | print(f"Streaming failed with error: {e}") 64 | 65 | if __name__ == "__main__": 66 | asyncio.run(test_streaming()) -------------------------------------------------------------------------------- /license.md: -------------------------------------------------------------------------------- 1 | # Released under MIT License 2 | Copyright (c) Chris Hay. 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | 6 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "mcp-cli" 7 | version = "0.1.0" 8 | description = "A cli for the Model Context Provider" 9 | requires-python = ">=3.11" 10 | readme = "README.md" 11 | authors = [ 12 | { name = "Chris Hay", email = "chrishayuk@younknowwhere.com" } 13 | ] 14 | keywords = ["llm", "openai", "claude", "mcp", "cli"] 15 | license = {text = "MIT"} 16 | dependencies = [ 17 | "anthropic>=0.51.0", 18 | "asyncio>=3.4.3", 19 | "chuk-llm>=0.1.8", 20 | "chuk-mcp>=0.1.7", 21 | "chuk-tool-processor>=0.4", 22 | "google-genai>=1.15.0", 23 | "groq>=0.24.0", 24 | "ollama>=0.4.2", 25 | "openai>=1.55.3", 26 | "prompt-toolkit>=3.0.50", 27 | "python-dotenv>=1.0.1", 28 | "rich>=13.9.4", 29 | "typer>=0.15.2", 30 | ] 31 | 32 | [project.scripts] 33 | mcp-cli = "mcp_cli.main:app" 34 | mcp-llm = "mcp_cli.llm.__main__:main" 35 | 36 | [project.optional-dependencies] 37 | wasm = [] 38 | dev = [ 39 | "numpy>=2.2.3", 40 | "pytest-asyncio>=0.25.3", 41 | "asyncio>=3.4.3" 42 | ] 43 | 44 | [tool.setuptools] 45 | package-dir = { "" = "src" } 46 | packages = ["mcp_cli"] 47 | 48 | [dependency-groups] 49 | dev = [ 50 | "colorama>=0.4.6", 51 | "pydantic>=2.10.2", 52 | "pytest-asyncio>=0.25.3", 53 | ] 54 | -------------------------------------------------------------------------------- /sample_messages/initialize_request.json: -------------------------------------------------------------------------------- 1 | { 2 | "jsonrpc": "2.0", 3 | "id": "init-1", 4 | "result": { 5 | "protocolVersion": "2024-11-05", 6 | "capabilities": { 7 | "experimental": {}, 8 | "prompts": { 9 | "listChanged": false 10 | }, 11 | "resources": { 12 | "subscribe": false, 13 | "listChanged": false 14 | }, 15 | "tools": { 16 | "listChanged": false 17 | } 18 | }, 19 | "serverInfo": { 20 | "name": "sqlite", 21 | "version": "0.1.0" 22 | } 23 | } 24 | } -------------------------------------------------------------------------------- /sample_messages/initialize_response.json: -------------------------------------------------------------------------------- 1 | { 2 | "jsonrpc": "2.0", 3 | "id": "init-1", 4 | "method": "None", 5 | "params": "None", 6 | "result": { 7 | "protocolVersion": "2024-11-05", 8 | "capabilities": { 9 | "experimental": {}, 10 | "prompts": { 11 | "listChanged": false 12 | }, 13 | "resources": { 14 | "subscribe": false, 15 | "listChanged": false 16 | }, 17 | "tools": { 18 | "listChanged": false 19 | } 20 | }, 21 | "serverInfo": { 22 | "name": "sqlite", 23 | "version": "0.1.0" 24 | } 25 | }, 26 | "error": "None" 27 | } -------------------------------------------------------------------------------- /scripts/mcp-cli: -------------------------------------------------------------------------------- 1 | #!/user/bin/env python3 2 | 3 | 4 | -------------------------------------------------------------------------------- /server_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "mcpServers": { 3 | "sqlite": { 4 | "command": "uvx", 5 | "args": ["mcp-server-sqlite", "--db-path", "test.db"] 6 | }, 7 | "perplexity":{ 8 | "command": "uv", 9 | "args": ["--directory", "/Users/christopherhay/chris-source/chuk-mcp-servers/chuk-mcp-perplexity", "run", "src/chuk_mcp_perplexity/main.py"] 10 | 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/mcp_cli/__init__.py: -------------------------------------------------------------------------------- 1 | # mcp_cli/__init__.py 2 | """ 3 | MCP-CLI package root. 4 | 5 | Early-loads environment variables from a .env file so that provider 6 | adapters (OpenAI, Groq, Anthropic, …) can read API keys via `os.getenv` 7 | without the caller having to export them in the shell. 8 | 9 | If python-dotenv isn’t installed, we just continue silently. 10 | 11 | Nothing else should be imported from here to keep side-effects minimal. 12 | """ 13 | from __future__ import annotations 14 | 15 | import logging 16 | 17 | try: 18 | from dotenv import load_dotenv 19 | loaded = load_dotenv() # returns True if a .env file was found 20 | if loaded: 21 | logging.getLogger(__name__).debug(".env loaded successfully") 22 | except ModuleNotFoundError: 23 | # python-dotenv not installed — .env support disabled 24 | logging.getLogger(__name__).debug("python-dotenv not installed; skipping .env load") 25 | -------------------------------------------------------------------------------- /src/mcp_cli/chat/__init__.py: -------------------------------------------------------------------------------- 1 | # mcp_cli/chat/__init__.py 2 | from mcp_cli.chat.chat_handler import handle_chat_mode 3 | 4 | __all__ = ['handle_chat_mode'] -------------------------------------------------------------------------------- /src/mcp_cli/chat/command_completer.py: -------------------------------------------------------------------------------- 1 | # mcp_cli/chat/command_completer.py 2 | from prompt_toolkit.completion import Completer, Completion 3 | class ChatCommandCompleter(Completer): 4 | """Completer for chat/interactive slash-commands.""" 5 | 6 | def __init__(self, context): 7 | self.context = context 8 | 9 | # ↓↓↓ import happens *after* all modules are fully initialised 10 | def get_completions(self, document, complete_event): 11 | from mcp_cli.chat.commands import get_command_completions # ← moved 12 | 13 | txt = document.text.lstrip() 14 | if not txt.startswith("/"): 15 | return 16 | 17 | word_before = document.get_word_before_cursor() 18 | for cand in get_command_completions(txt): 19 | if txt == cand: 20 | continue # already typed 21 | if " " not in cand: # plain command 22 | yield Completion( 23 | cand, 24 | start_position=-len(txt), 25 | style="fg:goldenrod", 26 | ) 27 | else: # completing an argument 28 | yield Completion( 29 | cand.split()[-1], 30 | start_position=-len(word_before), 31 | style="fg:goldenrod", 32 | ) 33 | -------------------------------------------------------------------------------- /src/mcp_cli/chat/commands/exit.py: -------------------------------------------------------------------------------- 1 | # mcp_cli/chat/commands/exit.py 2 | """ 3 | Chat-mode “/exit” and “/quit” commands for MCP-CLI 4 | ================================================== 5 | 6 | Both commands perform a single task: **politely end the current chat 7 | session**. 8 | 9 | * They set ``context["exit_requested"] = True`` – the main chat loop checks 10 | this flag and breaks. 11 | * A red confirmation panel is printed so the user knows the request was 12 | acknowledged. 13 | * No other session state is mutated, making the handler safe to hot-reload. 14 | 15 | The module uses :pyfunc:`mcp_cli.utils.rich_helpers.get_console`, which 16 | automatically falls back to plain text when ANSI colours are unavailable 17 | (e.g. legacy Windows consoles or when piping output to a file). 18 | """ 19 | 20 | from __future__ import annotations 21 | 22 | from typing import Any, Dict, List 23 | 24 | # Cross-platform Rich console helper 25 | from mcp_cli.utils.rich_helpers import get_console 26 | from rich.panel import Panel 27 | 28 | # Chat-command registry 29 | from mcp_cli.chat.commands import register_command 30 | 31 | 32 | # ════════════════════════════════════════════════════════════════════════════ 33 | # Core handlers 34 | # ════════════════════════════════════════════════════════════════════════════ 35 | async def cmd_exit(_parts: List[str], ctx: Dict[str, Any]) -> bool: # noqa: D401 36 | """ 37 | Terminate the chat session. 38 | 39 | Usage 40 | ----- 41 | /exit 42 | """ 43 | console = get_console() 44 | ctx["exit_requested"] = True 45 | console.print(Panel("Exiting chat mode.", style="bold red")) 46 | return True 47 | 48 | 49 | async def cmd_quit(parts: List[str], ctx: Dict[str, Any]) -> bool: # noqa: D401 50 | """ 51 | Terminate the chat session. 52 | 53 | Usage 54 | ----- 55 | /quit 56 | """ 57 | return await cmd_exit(parts, ctx) 58 | 59 | 60 | # ════════════════════════════════════════════════════════════════════════════ 61 | # Registration 62 | # ════════════════════════════════════════════════════════════════════════════ 63 | register_command("/exit", cmd_exit) 64 | register_command("/quit", cmd_quit) 65 | -------------------------------------------------------------------------------- /src/mcp_cli/chat/commands/help_text.py: -------------------------------------------------------------------------------- 1 | # mcp_cli/chat/commands/help_text.py 2 | """ 3 | Help text module for various command groups. 4 | """ 5 | 6 | TOOL_COMMANDS_HELP = """ 7 | ## Tool Commands 8 | 9 | MCP provides several commands for working with tools: 10 | 11 | - `/tools`: List all available tools across connected servers 12 | - `/tools --all`: Show detailed information including parameters 13 | - `/tools --raw`: Show raw tool definitions (for debugging) 14 | 15 | - `/toolhistory` or `/th`: Show history of tool calls in the current session 16 | - `/th -n 5`: Show only the last 5 tool calls 17 | - `/th --json`: Show tool calls in JSON format 18 | 19 | - `/verbose` or `/v`: Toggle between verbose and compact tool display modes 20 | - Verbose mode shows full details of each tool call 21 | - Compact mode shows a condensed, animated view 22 | 23 | - `/interrupt`, `/stop`, or `/cancel`: Interrupt running tool execution 24 | 25 | In compact mode (default), tool calls are shown in a condensed format. 26 | Use `/toolhistory` to see all tools that have been called in the session. 27 | """ 28 | 29 | CONVERSATION_COMMANDS_HELP = """ 30 | ## Conversation Commands 31 | 32 | MCP also provides commands to view and manage the conversation history: 33 | 34 | - `/conversation` or `/ch`: Display the conversation history for the current session 35 | - `/conversation --json`: Show the conversation history in raw JSON format 36 | 37 | These commands allow you to review all the messages exchanged during the session, making it easier to track the flow of your conversation. 38 | """ 39 | 40 | # You can concatenate these texts or export them separately as needed. 41 | ALL_HELP_TEXT = TOOL_COMMANDS_HELP + "\n" + CONVERSATION_COMMANDS_HELP -------------------------------------------------------------------------------- /src/mcp_cli/chat/commands/model.py: -------------------------------------------------------------------------------- 1 | # mcp_cli/chat/commands/model.py 2 | """ 3 | Chat-mode `/model` command for MCP-CLI 4 | ====================================== 5 | 6 | Allows users to *inspect* or *change* the current LLM model straight from the 7 | chat session. 8 | 9 | Shortcuts 10 | --------- 11 | * `/model` – show current provider & model 12 | * `/model list` – list models for the active provider 13 | * `/model ` – switch to ** (probe-tests first) 14 | 15 | The heavy-lifting is delegated to 16 | :meth:`mcp_cli.commands.model.model_action_async`, which pings the target model 17 | to ensure it responds before committing the switch. 18 | """ 19 | 20 | from __future__ import annotations 21 | from typing import Any, Dict, List 22 | 23 | # Cross-platform Rich console (handles colour fallback on Windows / pipes) 24 | from mcp_cli.utils.rich_helpers import get_console 25 | 26 | from mcp_cli.commands.model import model_action_async 27 | from mcp_cli.chat.commands import register_command 28 | 29 | 30 | # ════════════════════════════════════════════════════════════════════════════ 31 | # /model entry-point 32 | # ════════════════════════════════════════════════════════════════════════════ 33 | async def cmd_model(parts: List[str], ctx: Dict[str, Any]) -> bool: # noqa: D401 34 | """ 35 | View or change the active LLM model. 36 | 37 | * `/model` – show current provider & model 38 | * `/model list` – list available models for the active provider 39 | * `/model ` – attempt to switch to **** (probe first) 40 | 41 | The command passes its arguments verbatim to the shared helper and prints 42 | any errors in a user-friendly way. 43 | """ 44 | console = get_console() 45 | 46 | try: 47 | await model_action_async(parts[1:], context=ctx) 48 | except Exception as exc: # pragma: no cover – unexpected 49 | console.print(f"[red]Model command failed:[/red] {exc}") 50 | return True 51 | 52 | 53 | # ──────────────────────────────────────────────────────────────────────────── 54 | # registration 55 | # ──────────────────────────────────────────────────────────────────────────── 56 | register_command("/model", cmd_model) 57 | -------------------------------------------------------------------------------- /src/mcp_cli/chat/commands/ping.py: -------------------------------------------------------------------------------- 1 | # mcp_cli/chat/commands/ping.py 2 | """ 3 | Chat-mode `/ping` command for MCP-CLI 4 | ==================================== 5 | 6 | This module wires the chat-command **/ping** to the shared 7 | :meth:`mcp_cli.commands.ping.ping_action_async` helper so that end-users 8 | can measure the round-trip latency to each MCP server from inside an 9 | interactive chat session. 10 | 11 | Key Features 12 | ------------ 13 | * **Cross-platform console** - uses :pyfunc:`mcp_cli.utils.rich_helpers.get_console` 14 | which transparently falls back to plain text when ANSI colours are not 15 | supported (e.g. Windows `cmd.exe`, PowerShell without VT, or when the 16 | output is being piped to a file). 17 | * **Zero state** - the handler is a thin façade; it never mutates the 18 | context and can be hot-reloaded safely. 19 | * **Filter support** - any additional tokens after */ping* are treated as 20 | case-insensitive filters that match either the *index* **or** the 21 | *display-name* of a server (e.g. ``/ping 0 db analytics``). 22 | 23 | Usage Examples 24 | -------------- 25 | >>> /ping # ping every connected server 26 | >>> /ping 0 api # ping only server 0 and the one named "api" 27 | 28 | The response is rendered as a Rich table with three columns: 29 | * **Server** - the user-friendly name or index 30 | * **Status** - ✓ on success, ✗ on timeout or error 31 | * **Latency** - round-trip time in milliseconds 32 | """ 33 | from __future__ import annotations 34 | 35 | from typing import Any, Dict, List 36 | 37 | # Rich console helper (handles Windows quirks, ANSI passthrough, etc.) 38 | from mcp_cli.utils.rich_helpers import get_console 39 | 40 | # Shared implementation 41 | from mcp_cli.commands.ping import ping_action_async 42 | from mcp_cli.tools.manager import ToolManager 43 | from mcp_cli.chat.commands import register_command 44 | 45 | 46 | async def ping_command(parts: List[str], ctx: Dict[str, Any]) -> bool: # noqa: D401 47 | """Measure round-trip latency to one or more MCP servers.""" 48 | console = get_console() 49 | 50 | tm: ToolManager | None = ctx.get("tool_manager") 51 | if tm is None: 52 | console.print("[red]Error:[/red] ToolManager not available.") 53 | return True # command *was* handled (nothing else to do) 54 | 55 | # Everything after "/ping" is considered a filter (index or name) 56 | targets = parts[1:] 57 | return await ping_action_async(tm, targets=targets) 58 | 59 | 60 | # --------------------------------------------------------------------------- 61 | # Registration (no extra alias - keep namespace clean) 62 | # --------------------------------------------------------------------------- 63 | register_command("/ping", ping_command) 64 | 65 | -------------------------------------------------------------------------------- /src/mcp_cli/chat/commands/prompts.py: -------------------------------------------------------------------------------- 1 | # mcp_cli/chat/commands/prompts.py 2 | """ 3 | Chat-mode `/prompts` command for MCP-CLI 4 | ======================================== 5 | 6 | This module connects the **/prompts** slash-command to the shared 7 | :meth:`mcp_cli.commands.prompts.prompts_action_cmd` coroutine so users can 8 | list every prompt template stored on the connected MCP servers straight from 9 | the interactive chat session. 10 | 11 | Features 12 | -------- 13 | * **Cross-platform Rich console** - relies on 14 | :pyfunc:`mcp_cli.utils.rich_helpers.get_console`, which transparently 15 | falls back to plain text when ANSI colours aren't available (e.g. Windows 16 | *cmd.exe*, PowerShell without VT, or when piping to a file). 17 | * **Read-only & stateless** - the handler simply renders a Rich table and 18 | never mutates the chat context, so it's safe to hot-reload. 19 | * **One-liner behaviour** - a single `await` to the shared helper keeps the 20 | code footprint minimal. 21 | 22 | Example 23 | ------- 24 | >>> /prompts 25 | ┏━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ 26 | ┃ Name ┃ Description ┃ 27 | ┡━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ 28 | │ greet │ Friendly greeting prompt │ 29 | │ sql_query │ Extract SQL table info │ 30 | └───────────┴───────────────────────────────────────────────┘ 31 | """ 32 | 33 | from __future__ import annotations 34 | 35 | from typing import Any, Dict, List 36 | 37 | # Cross-platform Rich console helper (handles Windows quirks, piping, etc.) 38 | from mcp_cli.utils.rich_helpers import get_console 39 | 40 | # Shared implementation 41 | from mcp_cli.commands.prompts import prompts_action_cmd 42 | from mcp_cli.tools.manager import ToolManager 43 | from mcp_cli.chat.commands import register_command 44 | 45 | 46 | async def cmd_prompts(_parts: List[str], ctx: Dict[str, Any]) -> bool: # noqa: D401 47 | """List stored prompt templates from all connected servers.""" 48 | console = get_console() 49 | 50 | tm: ToolManager | None = ctx.get("tool_manager") 51 | if tm is None: 52 | console.print("[red]Error:[/red] ToolManager not available.") 53 | return True # command handled (nothing further to do) 54 | 55 | # Delegate to the shared async helper 56 | await prompts_action_cmd(tm) 57 | return True 58 | 59 | 60 | # ──────────────────────────────────────────────────────────────── 61 | # Registration (no extra alias – keep namespace clean) 62 | # ──────────────────────────────────────────────────────────────── 63 | register_command("/prompts", cmd_prompts) 64 | -------------------------------------------------------------------------------- /src/mcp_cli/chat/commands/provider.py: -------------------------------------------------------------------------------- 1 | # mcp_cli/chat/commands/provider.py 2 | """ 3 | Chat-mode `/provider` command for MCP-CLI 4 | ======================================== 5 | 6 | Gives you full control over **LLM providers** without leaving the chat 7 | session. 8 | 9 | At a glance 10 | ----------- 11 | * `/provider` - show current provider & model 12 | * `/provider list` - list available providers 13 | * `/provider config` - dump full provider configs 14 | * `/provider diagnostic` - ping each provider with a tiny prompt 15 | * `/provider set ` - change one config value (e.g. API key) 16 | * `/provider [model]` - switch provider (and optional model) 17 | 18 | All heavy lifting is delegated to 19 | :meth:`mcp_cli.commands.provider.provider_action_async`, which performs 20 | safety probes before committing any switch. 21 | 22 | Features 23 | -------- 24 | * **Cross-platform Rich console** - via 25 | :pyfunc:`mcp_cli.utils.rich_helpers.get_console`. 26 | * **Graceful error surfacing** - unexpected exceptions are caught and printed 27 | as red error messages instead of exploding the event-loop. 28 | """ 29 | 30 | from __future__ import annotations 31 | from typing import Any, Dict, List 32 | 33 | # Cross-platform Rich console helper 34 | from mcp_cli.utils.rich_helpers import get_console 35 | 36 | # Shared implementation 37 | from mcp_cli.commands.provider import provider_action_async 38 | from mcp_cli.chat.commands import register_command 39 | 40 | 41 | # ════════════════════════════════════════════════════════════════════════════ 42 | # /provider entry-point 43 | # ════════════════════════════════════════════════════════════════════════════ 44 | async def cmd_provider(parts: List[str], ctx: Dict[str, Any]) -> bool: # noqa: D401 45 | """Handle the `/provider` slash-command inside chat.""" 46 | console = get_console() 47 | 48 | try: 49 | # Forward everything after the command itself to the shared helper 50 | await provider_action_async(parts[1:], context=ctx) 51 | except Exception as exc: # pragma: no cover – unexpected edge cases 52 | console.print(f"[red]Provider command failed:[/red] {exc}") 53 | 54 | return True 55 | 56 | 57 | # ──────────────────────────────────────────────────────────────────────────── 58 | # registration 59 | # ──────────────────────────────────────────────────────────────────────────── 60 | register_command("/provider", cmd_provider) 61 | -------------------------------------------------------------------------------- /src/mcp_cli/chat/commands/resources.py: -------------------------------------------------------------------------------- 1 | # mcp_cli/chat/commands/resources.py 2 | """ 3 | Chat-mode “/resources” command for MCP-CLI 4 | ========================================= 5 | 6 | The **/resources** slash-command shows every *resource* currently recorded 7 | by the connected MCP server(s) - things like uploaded files, database 8 | snapshots, or any other artefact that a tool has stored. 9 | 10 | Why it exists 11 | ------------- 12 | * Quickly verify that an upload/tool-execution succeeded. 13 | * Check MIME-type and size before attempting to download or process a 14 | resource. 15 | * Discover orphaned artefacts you may want to clean up. 16 | 17 | Implementation highlights 18 | ------------------------- 19 | * Delegates all heavy lifting to 20 | :pyfunc:`mcp_cli.commands.resources.resources_action_async`, ensuring one 21 | source of truth for table formatting across CLI, interactive shell and 22 | chat modes. 23 | * Uses :pyfunc:`mcp_cli.utils.rich_helpers.get_console` so colours degrade 24 | gracefully on Windows consoles or when output is piped. 25 | * Read-only - does **not** mutate the chat context, therefore safe to 26 | hot-reload. 27 | 28 | Example 29 | ------- 30 | >>> /resources 31 | ┏━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━┳━━━━━━━━━━━━━━┓ 32 | ┃ Server ┃ URI ┃ Size ┃ MIME-type ┃ 33 | ┡━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━╇━━━━━━━━━━━━━━┩ 34 | │ sqlite │ /tmp/report_2025-05-26T00-01-03.csv │ 4 KB │ text/csv │ 35 | │ sqlite │ /tmp/raw_dump_2025-05-25T23-59-10.parquet │ 12 MB │ application… │ 36 | └──────────┴────────────────────────────────────────────┴───────┴──────────────┘ 37 | """ 38 | 39 | from __future__ import annotations 40 | 41 | from typing import Any, Dict, List 42 | 43 | # Cross-platform Rich console helper 44 | from mcp_cli.utils.rich_helpers import get_console 45 | 46 | # Shared async helper 47 | from mcp_cli.commands.resources import resources_action_async 48 | from mcp_cli.tools.manager import ToolManager 49 | from mcp_cli.chat.commands import register_command 50 | 51 | 52 | # ════════════════════════════════════════════════════════════════════════════ 53 | # Command handler 54 | # ════════════════════════════════════════════════════════════════════════════ 55 | async def cmd_resources(_parts: List[str], ctx: Dict[str, Any]) -> bool: # noqa: D401 56 | """ 57 | List all recorded resources across connected servers. 58 | 59 | Usage 60 | ----- 61 | /resources - show resources 62 | """ 63 | console = get_console() 64 | 65 | tm: ToolManager | None = ctx.get("tool_manager") 66 | if tm is None: 67 | console.print("[red]Error:[/red] ToolManager not available.") 68 | return True # command handled 69 | 70 | # Delegate to the canonical async implementation 71 | await resources_action_async(tm) 72 | return True 73 | 74 | 75 | # ════════════════════════════════════════════════════════════════════════════ 76 | # Registration 77 | # ════════════════════════════════════════════════════════════════════════════ 78 | register_command("/resources", cmd_resources) 79 | -------------------------------------------------------------------------------- /src/mcp_cli/chat/commands/servers.py: -------------------------------------------------------------------------------- 1 | # mcp_cli/chat/commands/servers.py 2 | """ 3 | List all MCP servers currently connected to this session. 4 | 5 | The **/servers** slash-command (alias **/srv**) shows every server that the 6 | active ``ToolManager`` is aware of, together with its friendly name, tool 7 | count and health status. 8 | 9 | Features 10 | -------- 11 | * **Cross-platform output** – uses 12 | :pyfunc:`mcp_cli.utils.rich_helpers.get_console` for automatic fallback to 13 | plain text on Windows consoles or when piping output to a file. 14 | * **Read-only** – purely informational; the command never mutates the chat 15 | context and is safe to hot-reload. 16 | * **One-liner implementation** – delegates the heavy lifting to the shared 17 | :pyfunc:`mcp_cli.commands.servers.servers_action_async` helper, ensuring 18 | a single source of truth between chat and CLI modes. 19 | 20 | Examples 21 | -------- 22 | /servers → tabular list of every connected server 23 | /srv → same, using the shorter alias 24 | """ 25 | from __future__ import annotations 26 | 27 | from typing import Any, Dict, List 28 | 29 | # Cross-platform Rich console helper 30 | from mcp_cli.utils.rich_helpers import get_console 31 | 32 | # Shared async helper 33 | from mcp_cli.commands.servers import servers_action_async 34 | from mcp_cli.tools.manager import ToolManager 35 | from mcp_cli.chat.commands import register_command 36 | 37 | 38 | # ════════════════════════════════════════════════════════════════════════════ 39 | # Command handler 40 | # ════════════════════════════════════════════════════════════════════════════ 41 | async def servers_command(_parts: List[str], ctx: Dict[str, Any]) -> bool: # noqa: D401 42 | """List all MCP servers currently connected to this session.""" 43 | console = get_console() 44 | 45 | tm: ToolManager | None = ctx.get("tool_manager") 46 | if tm is None: 47 | console.print("[red]Error:[/red] ToolManager not available.") 48 | return True # command handled 49 | 50 | await servers_action_async(tm) 51 | return True 52 | 53 | 54 | # ════════════════════════════════════════════════════════════════════════════ 55 | # Registration 56 | # ════════════════════════════════════════════════════════════════════════════ 57 | register_command("/servers", servers_command) 58 | -------------------------------------------------------------------------------- /src/mcp_cli/chat/commands/tools.py: -------------------------------------------------------------------------------- 1 | # mcp_cli/chat/commands/tools.py 2 | from __future__ import annotations 3 | 4 | from typing import Any, Dict, List 5 | 6 | # Cross-platform Rich console helper 7 | from mcp_cli.utils.rich_helpers import get_console 8 | 9 | # Shared helpers 10 | from mcp_cli.commands.tools import tools_action_async 11 | from mcp_cli.commands.tools_call import tools_call_action 12 | from mcp_cli.tools.manager import ToolManager 13 | from mcp_cli.chat.commands import register_command 14 | 15 | 16 | # ════════════════════════════════════════════════════════════════════════════ 17 | # Command handler 18 | # ════════════════════════════════════════════════════════════════════════════ 19 | async def tools_command(parts: List[str], ctx: Dict[str, Any]) -> bool: # noqa: D401 20 | """ 21 | List available tools (or call one interactively). 22 | 23 | This chat-command shows every server-side tool exposed by the connected 24 | MCP servers and can also launch a mini-wizard that walks you through 25 | executing a tool with JSON arguments. 26 | 27 | Usage 28 | ----- 29 | /tools – list tools 30 | /tools --all – include parameter schemas 31 | /tools --raw – dump raw JSON definitions 32 | /tools call – interactive “call tool” helper 33 | /t – short alias 34 | """ 35 | console = get_console() 36 | 37 | tm: ToolManager | None = ctx.get("tool_manager") 38 | if tm is None: 39 | console.print("[red]Error:[/red] ToolManager not available.") 40 | return True # command handled 41 | 42 | args = parts[1:] # drop the command itself 43 | 44 | # ── Interactive call helper ──────────────────────────────────────────── 45 | if args and args[0].lower() == "call": 46 | await tools_call_action(tm) 47 | return True 48 | 49 | # ── Tool listing ─────────────────────────────────────────────────────── 50 | show_details = "--all" in args 51 | show_raw = "--raw" in args 52 | 53 | await tools_action_async( 54 | tm, 55 | show_details=show_details, 56 | show_raw=show_raw, 57 | ) 58 | return True 59 | 60 | 61 | # ════════════════════════════════════════════════════════════════════════════ 62 | # Registration 63 | # ════════════════════════════════════════════════════════════════════════════ 64 | register_command("/tools", tools_command) 65 | 66 | -------------------------------------------------------------------------------- /src/mcp_cli/chat/commands/verbose.py: -------------------------------------------------------------------------------- 1 | # mcp_cli/chat/commands/verbose.py 2 | """ 3 | Chat-mode "/verbose" command for MCP-CLI 4 | ======================================= 5 | 6 | This module implements the **/verbose** (alias **/v**) slash-command that 7 | toggles between verbose and compact display modes for tool execution and 8 | streaming responses. 9 | 10 | Display Modes 11 | ------------- 12 | * **Verbose mode** - shows full details of each tool call with JSON arguments 13 | * **Compact mode** - shows condensed, animated progress view (default) 14 | 15 | The mode affects: 16 | - Tool execution display 17 | - Streaming response formatting 18 | - Progress indicators 19 | 20 | Features 21 | -------- 22 | * **Persistent setting** - mode is remembered for the session 23 | * **Real-time switching** - can toggle during tool execution 24 | * **Status display** - shows current mode when toggled 25 | * **Context integration** - works with both UI manager and streaming handler 26 | 27 | Examples 28 | -------- 29 | >>> /verbose # toggle between verbose/compact 30 | >>> /v # short alias 31 | """ 32 | 33 | from __future__ import annotations 34 | 35 | from typing import Any, Dict, List 36 | 37 | # Cross-platform Rich console helper 38 | from mcp_cli.utils.rich_helpers import get_console 39 | 40 | # Chat-command registry 41 | from mcp_cli.chat.commands import register_command 42 | 43 | 44 | # ════════════════════════════════════════════════════════════════════════════ 45 | # Command handler 46 | # ════════════════════════════════════════════════════════════════════════════ 47 | async def verbose_command(_parts: List[str], ctx: Dict[str, Any]) -> bool: # noqa: D401 48 | """ 49 | Toggle between verbose and compact display modes. 50 | 51 | Usage 52 | ----- 53 | /verbose - toggle display mode 54 | /v - short alias 55 | 56 | Modes 57 | ----- 58 | * **Verbose**: Shows full tool call details with JSON arguments 59 | * **Compact**: Shows condensed progress with animations (default) 60 | """ 61 | console = get_console() 62 | 63 | # Get UI manager from context 64 | ui_manager = ctx.get("ui_manager") 65 | if not ui_manager: 66 | # Fallback: look for context object that might have UI manager 67 | context_obj = ctx.get("context") 68 | if context_obj and hasattr(context_obj, "ui_manager"): 69 | ui_manager = context_obj.ui_manager 70 | 71 | if not ui_manager: 72 | console.print("[red]Error:[/red] UI manager not available.") 73 | return True 74 | 75 | # Toggle verbose mode 76 | current_mode = getattr(ui_manager, "verbose_mode", False) 77 | new_mode = not current_mode 78 | ui_manager.verbose_mode = new_mode 79 | 80 | # Update any streaming handlers if they exist 81 | streaming_handler = getattr(ui_manager, "streaming_handler", None) 82 | if streaming_handler and hasattr(streaming_handler, "verbose_mode"): 83 | streaming_handler.verbose_mode = new_mode 84 | 85 | # Show status 86 | mode_name = "verbose" if new_mode else "compact" 87 | mode_desc = ( 88 | "full tool details and expanded responses" if new_mode 89 | else "condensed progress and animations" 90 | ) 91 | 92 | console.print(f"[green]Display mode:[/green] {mode_name}") 93 | console.print(f"[dim]Shows {mode_desc}[/dim]") 94 | 95 | # If tools are currently running, show how the change affects them 96 | if getattr(ui_manager, "tools_running", False): 97 | if new_mode: 98 | console.print("[cyan]Future tool calls will show full details.[/cyan]") 99 | else: 100 | console.print("[cyan]Switched to compact tool progress display.[/cyan]") 101 | 102 | return True 103 | 104 | 105 | # ════════════════════════════════════════════════════════════════════════════ 106 | # Registration 107 | # ════════════════════════════════════════════════════════════════════════════ 108 | register_command("/verbose", verbose_command) 109 | register_command("/v", verbose_command) -------------------------------------------------------------------------------- /src/mcp_cli/chat/system_prompt.py: -------------------------------------------------------------------------------- 1 | # mcp_cli/chat/system_prompt.py 2 | 3 | # llm imports 4 | from mcp_cli.llm.system_prompt_generator import SystemPromptGenerator 5 | 6 | def generate_system_prompt(tools): 7 | """Generate a concise system prompt for the assistant.""" 8 | prompt_generator = SystemPromptGenerator() 9 | tools_json = {"tools": tools} 10 | 11 | system_prompt = prompt_generator.generate_prompt(tools_json) 12 | system_prompt += """ 13 | 14 | **GENERAL GUIDELINES:** 15 | 16 | 1. Step-by-step reasoning: 17 | - Analyze tasks systematically. 18 | - Break down complex problems into smaller, manageable parts. 19 | - Verify assumptions at each step to avoid errors. 20 | - Reflect on results to improve subsequent actions. 21 | 22 | 2. Effective tool usage: 23 | - Explore: 24 | - Identify available information and verify its structure. 25 | - Check assumptions and understand data relationships. 26 | - Iterate: 27 | - Start with simple queries or actions. 28 | - Build upon successes, adjusting based on observations. 29 | - Handle errors: 30 | - Carefully analyze error messages. 31 | - Use errors as a guide to refine your approach. 32 | - Document what went wrong and suggest fixes. 33 | 34 | 3. Clear communication: 35 | - Explain your reasoning and decisions at each step. 36 | - Share discoveries transparently with the user. 37 | - Outline next steps or ask clarifying questions as needed. 38 | 39 | EXAMPLES OF BEST PRACTICES: 40 | 41 | - Working with databases: 42 | - Check schema before writing queries. 43 | - Verify the existence of columns or tables. 44 | - Start with basic queries and refine based on results. 45 | 46 | - Processing data: 47 | - Validate data formats and handle edge cases. 48 | - Ensure integrity and correctness of results. 49 | 50 | - Accessing resources: 51 | - Confirm resource availability and permissions. 52 | - Handle missing or incomplete data gracefully. 53 | 54 | REMEMBER: 55 | - Be thorough and systematic. 56 | - Each tool call should have a clear and well-explained purpose. 57 | - Make reasonable assumptions if ambiguous. 58 | - Minimize unnecessary user interactions by providing actionable insights. 59 | 60 | EXAMPLES OF ASSUMPTIONS: 61 | - Default sorting (e.g., descending order) if not specified. 62 | - Assume basic user intentions, such as fetching top results by a common metric. 63 | """ 64 | return system_prompt -------------------------------------------------------------------------------- /src/mcp_cli/cli/__init__.py: -------------------------------------------------------------------------------- 1 | # mcp_cli/cli/__init__.py 2 | """ 3 | mcp_cli.cli 4 | 5 | Holds the CLI-facing registry and subcommands under cli/commands. 6 | """ -------------------------------------------------------------------------------- /src/mcp_cli/cli/commands/__init__.py: -------------------------------------------------------------------------------- 1 | # mcp_cli/cli/commands/__init__.py 2 | def register_all_commands() -> None: 3 | """ 4 | Instantiate and register every CLI command into the registry. 5 | """ 6 | # Delay import to avoid circular dependencies 7 | from mcp_cli.cli.registry import CommandRegistry 8 | 9 | # Core "mode" commands 10 | from mcp_cli.cli.commands.interactive import InteractiveCommand 11 | from mcp_cli.cli.commands.chat import ChatCommand 12 | from mcp_cli.cli.commands.cmd import CmdCommand 13 | from mcp_cli.cli.commands.ping import PingCommand 14 | from mcp_cli.cli.commands.provider import ProviderCommand 15 | 16 | # Sub-app commands 17 | from mcp_cli.cli.commands.tools import ToolsListCommand 18 | from mcp_cli.cli.commands.tools_call import ToolsCallCommand 19 | from mcp_cli.cli.commands.prompts import PromptsListCommand 20 | from mcp_cli.cli.commands.resources import ResourcesListCommand 21 | from mcp_cli.cli.commands.servers import ServersListCommand # ← NEW 22 | 23 | # Register everything in the central registry 24 | CommandRegistry.register(InteractiveCommand()) 25 | CommandRegistry.register(ChatCommand()) 26 | CommandRegistry.register(CmdCommand()) 27 | CommandRegistry.register(PingCommand()) 28 | CommandRegistry.register(ProviderCommand()) 29 | 30 | CommandRegistry.register(ToolsListCommand()) 31 | CommandRegistry.register(ToolsCallCommand()) 32 | CommandRegistry.register(PromptsListCommand()) 33 | CommandRegistry.register(ResourcesListCommand()) 34 | CommandRegistry.register(ServersListCommand()) # ← NEW 35 | -------------------------------------------------------------------------------- /src/mcp_cli/cli/commands/base.py: -------------------------------------------------------------------------------- 1 | # mcp_cli/cli/commands/base.py 2 | """Base command classes for MCP CLI. 3 | This module defines the abstract base classes used by all CLI command implementations. 4 | """ 5 | from __future__ import annotations 6 | 7 | import asyncio 8 | import inspect 9 | import logging 10 | from abc import ABC, abstractmethod 11 | from typing import Any, Callable, Dict, List, Optional, TypeVar, Union, Awaitable 12 | 13 | import typer 14 | 15 | from mcp_cli.tools.manager import ToolManager 16 | from mcp_cli.cli_options import process_options 17 | 18 | # Type hints for command functions 19 | CommandReturn = TypeVar('CommandReturn') 20 | AsyncCommandFunc = Callable[..., Awaitable[CommandReturn]] 21 | CommandFunc = Union[AsyncCommandFunc, Callable[..., CommandReturn]] 22 | 23 | logger = logging.getLogger(__name__) 24 | 25 | 26 | class BaseCommand(ABC): 27 | """Abstract base class for all MCP CLI commands.""" 28 | 29 | name: str 30 | help: str 31 | 32 | def __init__(self, name: str, help_text: str = ""): 33 | self.name = name 34 | self.help = help_text or "Run the command." 35 | 36 | @abstractmethod 37 | async def execute(self, tool_manager: ToolManager, **params) -> Any: 38 | """Execute the command with the given tool manager and parameters.""" 39 | pass 40 | 41 | def register(self, app: typer.Typer, run_command_func: Callable) -> None: 42 | """Register this command with the Typer app.""" 43 | # Default implementation - override in subclasses if needed 44 | # Pass help text explicitly to the command decorator 45 | @app.command(self.name, help=self.help) 46 | def _command_wrapper( 47 | config_file: str = typer.Option("server_config.json", help="Configuration file path"), 48 | server: Optional[str] = typer.Option(None, help="Server to connect to"), 49 | provider: str = typer.Option("openai", help="LLM provider name"), 50 | model: Optional[str] = typer.Option(None, help="Model name"), 51 | disable_filesystem: bool = typer.Option(False, help="Disable filesystem access"), 52 | ) -> None: # ← REMOVED **kwargs - this was causing the error 53 | """Command wrapper with preserved help text.""" 54 | servers, _, server_names = process_options( 55 | server, disable_filesystem, provider, model, config_file 56 | ) 57 | 58 | extra_params = { 59 | "provider": provider, 60 | "model": model, 61 | "server_names": server_names, 62 | } 63 | 64 | run_command_func( 65 | self.wrapped_execute, 66 | config_file, 67 | servers, 68 | extra_params=extra_params 69 | ) 70 | 71 | # Explicitly set the wrapper's docstring to match the help text 72 | _command_wrapper.__doc__ = self.help 73 | 74 | async def wrapped_execute(self, tool_manager: ToolManager, **kwargs) -> Any: 75 | """Standard wrapper for execute to ensure consistent behavior.""" 76 | logger.debug(f"Executing command: {self.name} with params: {kwargs}") 77 | return await self.execute(tool_manager, **kwargs) 78 | 79 | 80 | class FunctionCommand(BaseCommand): 81 | """Command implementation that wraps a function.""" 82 | 83 | def __init__( 84 | self, 85 | name: str, 86 | func: CommandFunc, 87 | help_text: str = "" 88 | ): 89 | super().__init__(name, help_text or (func.__doc__ or "")) 90 | self.func = func 91 | self._is_async = asyncio.iscoroutinefunction(func) 92 | 93 | async def execute(self, tool_manager: ToolManager, **params) -> Any: 94 | """Execute the wrapped function.""" 95 | # Extract only parameters that the function accepts 96 | sig = inspect.signature(self.func) 97 | valid_params = {} 98 | 99 | for param_name, param in sig.parameters.items(): 100 | if param_name == "tool_manager": 101 | valid_params[param_name] = tool_manager 102 | elif param_name in params: 103 | valid_params[param_name] = params[param_name] 104 | 105 | # Call the function (async or sync) 106 | if self._is_async: 107 | return await self.func(**valid_params) 108 | else: 109 | return self.func(**valid_params) -------------------------------------------------------------------------------- /src/mcp_cli/cli/commands/clear.py: -------------------------------------------------------------------------------- 1 | # mcp_cli/cli/commands/clear.py 2 | """ 3 | CLI (“typer”) entry-point for clear. 4 | """ 5 | import typer 6 | from mcp_cli.commands.clear import clear_action 7 | 8 | # app 9 | app = typer.Typer(help="Clear the terminal screen") 10 | 11 | @app.command("run") 12 | def clear_run() -> None: 13 | """ 14 | Clear the terminal screen. 15 | """ 16 | clear_action() 17 | -------------------------------------------------------------------------------- /src/mcp_cli/cli/commands/exit.py: -------------------------------------------------------------------------------- 1 | # mcp_cli/cli/commands/exit.py 2 | import typer 3 | from typing import Any 4 | import logging 5 | 6 | # mcp cli imports 7 | from mcp_cli.commands.exit import exit_action 8 | from mcp_cli.cli.commands.base import BaseCommand 9 | 10 | # logger 11 | logger = logging.getLogger(__name__) 12 | 13 | # ─── Typer sub‐app ─────────────────────────────────────────────────────────── 14 | app = typer.Typer(help="Exit the interactive mode") 15 | 16 | @app.command("run") 17 | def exit_run() -> None: 18 | """ 19 | Exit the interactive mode. 20 | """ 21 | # Perform the shared exit behavior 22 | exit_action() 23 | # Then terminate Typer 24 | raise typer.Exit(code=0) 25 | 26 | 27 | # ─── In‐process command for CommandRegistry ───────────────────────────────── 28 | class ExitCommand(BaseCommand): 29 | """`exit` command for non-interactive invocation.""" 30 | 31 | def __init__(self): 32 | super().__init__( 33 | name="exit", 34 | help_text="Exit the interactive mode.", 35 | aliases=["quit", "q"] 36 | ) 37 | 38 | async def execute(self, tool_manager: Any, **params: Any) -> bool: 39 | """ 40 | Delegates to the shared `exit_action`, then returns True to signal exit. 41 | """ 42 | logger.debug("Executing ExitCommand") 43 | exit_action() 44 | return True 45 | -------------------------------------------------------------------------------- /src/mcp_cli/cli/commands/help.py: -------------------------------------------------------------------------------- 1 | # src/mcp_cli/cli/commands/help.py 2 | 3 | import typer 4 | from typing import Optional, Any 5 | import logging 6 | 7 | from rich.console import Console 8 | 9 | # shared implementation 10 | from mcp_cli.commands.help import help_action 11 | 12 | # BaseCommand for in-process registry 13 | from mcp_cli.cli.commands.base import BaseCommand 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | # ─── Typer sub-app ──────────────────────────────────────────────────────────── 18 | app = typer.Typer(help="Display interactive-command help") 19 | 20 | @app.command("run") 21 | def help_run( 22 | command: Optional[str] = typer.Argument( 23 | None, help="Name of the command to show detailed help for" 24 | ) 25 | ) -> None: 26 | """ 27 | Show help for all commands, or detailed help for one command. 28 | """ 29 | console = Console() 30 | help_action(console, command) 31 | # Typer will auto-exit after returning from this function. 32 | 33 | 34 | # ─── In-process command for CommandRegistry ───────────────────────────────── 35 | class HelpCommand(BaseCommand): 36 | """`help` command for non-interactive invocation.""" 37 | 38 | def __init__(self): 39 | super().__init__( 40 | name="help", 41 | help_text="Display available commands or help for a specific command." 42 | ) 43 | 44 | async def execute(self, tool_manager: Any, **params: Any) -> None: 45 | """ 46 | Delegates to the shared `help_action`. 47 | Expects optional: 48 | • command: str 49 | """ 50 | command = params.get("command") 51 | console = Console() 52 | logger.debug(f"Executing HelpCommand for: {command!r}") 53 | help_action(console, command) 54 | -------------------------------------------------------------------------------- /src/mcp_cli/cli/commands/interactive.py: -------------------------------------------------------------------------------- 1 | # src/mcp_cli/cli/commands/interactive.py 2 | from __future__ import annotations 3 | import asyncio 4 | import logging 5 | from typing import Any, Optional, Dict 6 | import typer 7 | 8 | # mcp cli imports 9 | from mcp_cli.tools.manager import get_tool_manager 10 | from mcp_cli.cli.commands.base import BaseCommand 11 | 12 | # logger 13 | logger = logging.getLogger(__name__) 14 | 15 | # ─── Typer sub‐app ─────────────────────────────────────────────────────────── 16 | app = typer.Typer(help="Start interactive command mode") 17 | 18 | @app.command("run") 19 | def interactive_run( 20 | provider: str = typer.Option("openai", help="LLM provider name"), 21 | model: str = typer.Option("gpt-4o-mini", help="Model identifier"), 22 | server: Optional[str] = typer.Option(None, help="Comma-separated list of servers"), 23 | disable_filesystem: bool = typer.Option(False, help="Disable local filesystem tools"), 24 | ) -> None: 25 | """ 26 | Launch the interactive MCP CLI shell. 27 | """ 28 | tm = get_tool_manager() 29 | if tm is None: 30 | typer.echo("[red]Error:[/] no ToolManager initialized", err=True) 31 | raise typer.Exit(code=1) 32 | 33 | # Build server_names dict if provided 34 | server_names = None 35 | if server: 36 | # e.g. "0=sqlite,1=foo" 37 | server_names = dict(pair.split("=", 1) for pair in server.split(",")) 38 | 39 | logger.debug( 40 | "Invoking interactive shell", 41 | extra={"provider": provider, "model": model, "server_names": server_names}, 42 | ) 43 | 44 | # Defer import to avoid circular import at module load 45 | from mcp_cli.interactive.shell import interactive_mode 46 | 47 | # Run the async interactive loop 48 | success = asyncio.run( 49 | interactive_mode( 50 | tool_manager=tm, 51 | provider=provider, 52 | model=model, 53 | server_names=server_names, 54 | ) 55 | ) 56 | 57 | # Exit with 0 if shell returned True 58 | raise typer.Exit(code=0 if success else 1) 59 | 60 | 61 | # ─── In‐process command for CommandRegistry ───────────────────────────────── 62 | class InteractiveCommand(BaseCommand): 63 | """CLI command to launch interactive mode.""" 64 | 65 | def __init__(self): 66 | super().__init__( 67 | name="interactive", 68 | help_text="Start interactive command mode." 69 | ) 70 | 71 | async def execute( 72 | self, 73 | tool_manager: Any, 74 | **params: Any 75 | ) -> Any: 76 | """ 77 | Execute the interactive shell. 78 | 79 | Expects: 80 | • provider: str 81 | • model: str 82 | • server_names: Optional[Dict[int, str]] 83 | """ 84 | # Defer import to avoid circularity 85 | from mcp_cli.interactive.shell import interactive_mode 86 | 87 | provider = params.get("provider", "openai") 88 | model = params.get("model", "gpt-4o-mini") 89 | server_names = params.get("server_names") 90 | 91 | logger.debug( 92 | "Starting interactive mode via in-process command", 93 | extra={"provider": provider, "model": model, "server_names": server_names} 94 | ) 95 | 96 | return await interactive_mode( 97 | tool_manager=tool_manager, 98 | provider=provider, 99 | model=model, 100 | server_names=server_names 101 | ) 102 | -------------------------------------------------------------------------------- /src/mcp_cli/cli/commands/ping.py: -------------------------------------------------------------------------------- 1 | # mcp_cli/cli/commands/ping.py 2 | """ 3 | Ping MCP servers (CLI + registry command). 4 | """ 5 | from __future__ import annotations 6 | 7 | import logging 8 | from typing import Any, Dict, List, Optional 9 | 10 | import typer 11 | 12 | from mcp_cli.commands.ping import ( 13 | ping_action, # sync wrapper (run_blocking) 14 | ping_action_async, # real async implementation 15 | ) 16 | from mcp_cli.tools.manager import get_tool_manager 17 | from mcp_cli.cli.commands.base import BaseCommand 18 | 19 | logger = logging.getLogger(__name__) 20 | 21 | # ── Typer sub-app (plain CLI invocation) ──────────────────────────────────── 22 | app = typer.Typer(help="Ping connected MCP servers") 23 | 24 | @app.command("run") 25 | def ping_run( 26 | name: List[str] = typer.Option( 27 | None, "--name", "-n", 28 | help="Override server display names (index=name)", 29 | ), 30 | targets: List[str] = typer.Argument( 31 | [], metavar="[TARGET]...", help="Filter by server index or name" 32 | ), 33 | ) -> None: 34 | """ 35 | Blocking CLI entry-point. Examples: 36 | 37 | mcp-cli ping run # ping all servers 38 | mcp-cli ping run 0 2 # ping servers 0 and 2 39 | mcp-cli ping run -n 0=db db # rename server 0→db and ping “db” 40 | """ 41 | tm = get_tool_manager() 42 | if tm is None: 43 | typer.echo("Error: no ToolManager initialised", err=True) 44 | raise typer.Exit(code=1) 45 | 46 | mapping: Optional[Dict[int, str]] = None 47 | if name: 48 | mapping = {} 49 | for token in name: 50 | if "=" in token: 51 | idx, lbl = token.split("=", 1) 52 | try: 53 | mapping[int(idx)] = lbl 54 | except ValueError: 55 | pass 56 | 57 | ok = ping_action(tm, server_names=mapping, targets=targets) 58 | raise typer.Exit(code=0 if ok else 1) 59 | 60 | 61 | # ── Registry / interactive version ────────────────────────────────────────── 62 | class PingCommand(BaseCommand): 63 | """Global `ping` command (usable from chat / interactive shell).""" 64 | 65 | def __init__(self) -> None: 66 | super().__init__("ping", "Ping connected MCP servers.") 67 | 68 | async def execute(self, tool_manager: Any, **params: Any) -> bool: # noqa: D401 69 | mapping = params.get("server_names") 70 | targets = params.get("targets", []) or [] 71 | logger.debug("PingCommand: mapping=%s targets=%s", mapping, targets) 72 | return await ping_action_async( 73 | tool_manager, server_names=mapping, targets=targets 74 | ) 75 | -------------------------------------------------------------------------------- /src/mcp_cli/cli/commands/prompts.py: -------------------------------------------------------------------------------- 1 | # src/mcp_cli/cli/prompts.py 2 | """ 3 | CLI binding for “prompts list”. 4 | """ 5 | from __future__ import annotations 6 | 7 | import logging 8 | from typing import Any, List 9 | 10 | import typer 11 | 12 | from mcp_cli.commands.prompts import prompts_action, prompts_action_async 13 | from mcp_cli.tools.manager import get_tool_manager 14 | from mcp_cli.cli.commands.base import BaseCommand 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | # ─── Typer sub-app (plain CLI) ─────────────────────────────────────────────── 19 | app = typer.Typer(help="List prompts recorded on connected MCP servers") 20 | 21 | 22 | @app.command("run") 23 | def prompts_run() -> None: 24 | """ 25 | List all prompts (blocking CLI mode). 26 | """ 27 | tm = get_tool_manager() 28 | if tm is None: 29 | typer.echo("Error: no ToolManager initialised", err=True) 30 | raise typer.Exit(code=1) 31 | 32 | prompts_action(tm) 33 | raise typer.Exit(code=0) 34 | 35 | 36 | # ─── Registry / interactive variant ───────────────────────────────────────── 37 | class PromptsListCommand(BaseCommand): 38 | """`prompts list` command usable from interactive shell or scripts.""" 39 | 40 | def __init__(self) -> None: 41 | super().__init__( 42 | name="prompts list", 43 | help_text="List all prompts recorded on connected servers.", 44 | ) 45 | 46 | async def execute(self, tool_manager: Any, **_: Any) -> None: 47 | logger.debug("Executing PromptsListCommand") 48 | await prompts_action_async(tool_manager) 49 | 50 | # ------------------------------------------------------------------ 51 | # Typer registration for the CommandRegistry wrapper 52 | # ------------------------------------------------------------------ 53 | def register(self, app: typer.Typer, run_command_func) -> None: 54 | """ 55 | Add a `prompts list` sub-command to the given Typer app. 56 | 57 | A variadic *kwargs* argument is optional so users are not forced 58 | to provide key=value pairs. 59 | """ 60 | 61 | @app.command("list") 62 | def _prompts_list( 63 | # global CLI options 64 | config_file: str = "server_config.json", 65 | server: str | None = None, 66 | provider: str = "openai", 67 | model: str | None = None, 68 | disable_filesystem: bool = False, 69 | # optional key=value extras 70 | kwargs: List[str] = typer.Argument( 71 | [], metavar="KWARGS", help="Extra key=value arguments" 72 | ), 73 | ) -> None: # noqa: D401 (Typer callback) 74 | from mcp_cli.cli_options import process_options 75 | 76 | servers, _, server_names = process_options( 77 | server, disable_filesystem, provider, model, config_file 78 | ) 79 | 80 | # Forward to the shared async execute method through the 81 | # registry’s run_command helper. 82 | run_command_func( 83 | self.wrapped_execute, 84 | config_file, 85 | servers, 86 | extra_params={"server_names": server_names}, 87 | ) 88 | -------------------------------------------------------------------------------- /src/mcp_cli/cli/commands/provider.py: -------------------------------------------------------------------------------- 1 | # src/mcp_cli/cli/commands/provider.py 2 | """ 3 | CLI binding for "provider" management commands. 4 | 5 | All heavy-lifting is delegated to the shared helper: 6 | mcp_cli.commands.provider.provider_action_async 7 | """ 8 | from __future__ import annotations 9 | 10 | import logging 11 | from typing import Any 12 | 13 | import typer 14 | from rich.console import Console 15 | 16 | from mcp_cli.commands.provider import provider_action_async 17 | from mcp_cli.model_manager import ModelManager # ← CHANGED 18 | from mcp_cli.cli.commands.base import BaseCommand 19 | from mcp_cli.tools.manager import get_tool_manager 20 | from mcp_cli.utils.async_utils import run_blocking 21 | 22 | log = logging.getLogger(__name__) 23 | 24 | # ─── Typer sub-app ─────────────────────────────────────────────────────────── 25 | app = typer.Typer(help="Manage LLM provider configuration") 26 | 27 | 28 | def _call_shared_helper(argv: list[str]) -> None: 29 | """Parse argv (after 'provider') and run shared async helper.""" 30 | # Build a transient context dict (the shared helper expects it) 31 | ctx: dict[str, Any] = { 32 | "model_manager": ModelManager(), # ← CHANGED 33 | # The CLI path has no session client – we omit "client" 34 | } 35 | run_blocking(provider_action_async(argv, context=ctx)) 36 | 37 | 38 | @app.command("show", help="Show current provider & model") 39 | def provider_show() -> None: 40 | _call_shared_helper([]) 41 | 42 | 43 | @app.command("list", help="List configured providers") 44 | def provider_list() -> None: 45 | _call_shared_helper(["list"]) 46 | 47 | 48 | @app.command("config", help="Display full provider config") 49 | def provider_config() -> None: 50 | _call_shared_helper(["config"]) 51 | 52 | 53 | @app.command("set", help="Set one configuration value") 54 | def provider_set( 55 | provider_name: str = typer.Argument(...), 56 | key: str = typer.Argument(...), 57 | value: str = typer.Argument(...), 58 | ) -> None: 59 | _call_shared_helper(["set", provider_name, key, value]) 60 | 61 | 62 | # ─── In-process command for CommandRegistry ────────────────────────────────── 63 | class ProviderCommand(BaseCommand): 64 | """`provider` command usable from interactive or scripting contexts.""" 65 | 66 | def __init__(self) -> None: 67 | super().__init__( 68 | name="provider", 69 | help_text="Manage LLM providers (show/list/config/set/switch).", 70 | ) 71 | 72 | async def execute(self, tool_manager: Any, **params: Any) -> None: # noqa: D401 73 | """ 74 | Forward params to *provider_action_async*. 75 | 76 | Expected keys in **params: 77 | • subcommand (str) – list | config | set | show 78 | • provider_name, key, value (for 'set') 79 | • model / provider (optional overrides) 80 | """ 81 | argv: list[str] = [] 82 | 83 | sub = params.get("subcommand", "show") 84 | argv.append(sub) 85 | 86 | if sub == "set": 87 | for arg in ("provider_name", "key", "value"): 88 | val = params.get(arg) 89 | if val is None: 90 | Console().print(f"[red]Missing {arg} for 'set'[/red]") 91 | return 92 | argv.append(str(val)) 93 | elif sub not in {"show", "list", "config"}: 94 | # treat it as "switch provider [model]" 95 | argv = [sub] # sub is actually provider name 96 | maybe_model = params.get("model") 97 | if maybe_model: 98 | argv.append(maybe_model) 99 | 100 | context: dict[str, Any] = { 101 | "model_manager": ModelManager(), # ← CHANGED 102 | } 103 | await provider_action_async(argv, context=context) -------------------------------------------------------------------------------- /src/mcp_cli/cli/commands/resources.py: -------------------------------------------------------------------------------- 1 | # src/mcp_cli/cli/commands/resources.py 2 | """ 3 | CLI binding for “resources list”. 4 | """ 5 | from __future__ import annotations 6 | 7 | import logging 8 | from typing import Any 9 | 10 | import typer 11 | 12 | # shared helpers 13 | from mcp_cli.commands.resources import resources_action, resources_action_async 14 | from mcp_cli.tools.manager import get_tool_manager 15 | from mcp_cli.cli.commands.base import BaseCommand 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | # ─── Typer sub-app ─────────────────────────────────────────────────────────── 20 | app = typer.Typer(help="List resources recorded on connected MCP servers") 21 | 22 | 23 | @app.command("run") 24 | def resources_run() -> None: 25 | """ 26 | Show all recorded resources (blocking CLI mode). 27 | """ 28 | tm = get_tool_manager() 29 | if tm is None: 30 | typer.echo("Error: no ToolManager initialised", err=True) 31 | raise typer.Exit(code=1) 32 | 33 | resources_action(tm) # synchronous wrapper 34 | raise typer.Exit(code=0) 35 | 36 | 37 | # ─── In-process command for CommandRegistry ───────────────────────────────── 38 | class ResourcesListCommand(BaseCommand): 39 | """`resources list` command usable from interactive shell or scripts.""" 40 | 41 | def __init__(self) -> None: 42 | super().__init__( 43 | name="resources list", 44 | help_text="List all resources recorded on connected servers.", 45 | ) 46 | 47 | async def execute(self, tool_manager: Any, **_: Any) -> None: 48 | """ 49 | Delegates to the shared *async* helper. 50 | """ 51 | logger.debug("Executing ResourcesListCommand") 52 | await resources_action_async(tool_manager) 53 | -------------------------------------------------------------------------------- /src/mcp_cli/cli/commands/servers.py: -------------------------------------------------------------------------------- 1 | # src/mcp_cli/cli/commands/servers.py 2 | """ 3 | CLI binding for “servers list”. 4 | """ 5 | from __future__ import annotations 6 | 7 | import logging 8 | from typing import Any 9 | 10 | import typer 11 | 12 | # shared helpers 13 | from mcp_cli.commands.servers import servers_action, servers_action_async 14 | from mcp_cli.tools.manager import get_tool_manager 15 | from mcp_cli.cli.commands.base import BaseCommand 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | # ─── Typer sub-app ─────────────────────────────────────────────────────────── 20 | app = typer.Typer(help="List connected MCP servers") 21 | 22 | 23 | @app.command("run") 24 | def servers_run() -> None: 25 | """ 26 | Show connected servers with status & tool counts (blocking CLI mode). 27 | """ 28 | tm = get_tool_manager() 29 | if tm is None: 30 | typer.echo("Error: no ToolManager initialised", err=True) 31 | raise typer.Exit(code=1) 32 | 33 | servers_action(tm) 34 | raise typer.Exit(code=0) 35 | 36 | 37 | # ─── In-process command for CommandRegistry ───────────────────────────────── 38 | class ServersListCommand(BaseCommand): 39 | """`servers list` command usable from interactive shell or scripts.""" 40 | 41 | def __init__(self) -> None: 42 | super().__init__( 43 | name="servers list", 44 | help_text="Show all connected servers with their status and tool counts.", 45 | ) 46 | 47 | async def execute(self, tool_manager: Any, **_: Any) -> None: 48 | """ 49 | Delegates to the shared *async* helper. 50 | """ 51 | logger.debug("Executing ServersListCommand") 52 | await servers_action_async(tool_manager) 53 | -------------------------------------------------------------------------------- /src/mcp_cli/cli/commands/tools.py: -------------------------------------------------------------------------------- 1 | # src/mcp_cli/cli/commands/tools.py 2 | """ 3 | CLI binding for the “tools list” command. 4 | 5 | * `tools run` (Typer entry-point) - sync, suitable for plain CLI. 6 | * `ToolsListCommand` - async, used by interactive shell 7 | or CommandRegistry invocations. 8 | """ 9 | from __future__ import annotations 10 | 11 | from typing import Any 12 | 13 | import typer 14 | 15 | # Shared helpers 16 | from mcp_cli.commands.tools import tools_action, tools_action_async 17 | from mcp_cli.tools.manager import get_tool_manager 18 | from mcp_cli.cli.commands.base import BaseCommand 19 | 20 | # ─── Typer sub-app ─────────────────────────────────────────────────────────── 21 | app = typer.Typer(help="List available tools") 22 | 23 | 24 | @app.command("run") 25 | def tools_run( 26 | all: bool = typer.Option(False, "--all", help="Show detailed tool information"), 27 | raw: bool = typer.Option(False, "--raw", help="Show raw JSON definitions"), 28 | ) -> None: 29 | """ 30 | List unique tools across all connected servers (blocking CLI mode). 31 | """ 32 | tm = get_tool_manager() 33 | if tm is None: 34 | typer.echo("[red]Error:[/] no ToolManager initialized", err=True) 35 | raise typer.Exit(code=1) 36 | 37 | tools_action(tm, show_details=all, show_raw=raw) 38 | raise typer.Exit(code=0) 39 | 40 | 41 | # ─── In-process command for CommandRegistry ───────────────────────────────── 42 | class ToolsListCommand(BaseCommand): 43 | """`tools list` command (async) for non-interactive invocation.""" 44 | 45 | def __init__(self) -> None: 46 | super().__init__( 47 | name="tools list", 48 | help_text="List unique tools across all connected servers.", 49 | ) 50 | 51 | async def execute(self, tool_manager: Any, **params: Any) -> None: 52 | """ 53 | Delegates to the shared async helper. 54 | 55 | Parameters accepted via **params: 56 | • all → bool (show_details) 57 | • raw → bool (show_raw) 58 | """ 59 | show_details = params.get("all", False) 60 | show_raw = params.get("raw", False) 61 | await tools_action_async( 62 | tool_manager, 63 | show_details=show_details, 64 | show_raw=show_raw, 65 | ) 66 | -------------------------------------------------------------------------------- /src/mcp_cli/cli/commands/tools_call.py: -------------------------------------------------------------------------------- 1 | # mcp_cli/cli/commands/tools_call.py 2 | from __future__ import annotations 3 | import asyncio 4 | import typer 5 | from typing import Any 6 | import logging 7 | 8 | # shared action 9 | from mcp_cli.commands.tools_call import tools_call_action 10 | from mcp_cli.tools.manager import get_tool_manager 11 | 12 | # BaseCommand for registry 13 | from mcp_cli.cli.commands.base import BaseCommand 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | # ─── Typer sub‐app ─────────────────────────────────────────────────────────── 18 | app = typer.Typer(help="Call a specific tool with arguments") 19 | 20 | @app.command("run") 21 | def tools_call_run() -> None: 22 | """ 23 | Launch the interactive tool‐call interface. 24 | """ 25 | tm = get_tool_manager() 26 | if tm is None: 27 | typer.echo("[red]Error:[/] no ToolManager initialized", err=True) 28 | raise typer.Exit(code=1) 29 | 30 | # Run the async action 31 | asyncio.run(tools_call_action(tm)) 32 | 33 | # Exit successfully 34 | raise typer.Exit(code=0) 35 | 36 | 37 | # ─── In‐process command for CommandRegistry ───────────────────────────────── 38 | class ToolsCallCommand(BaseCommand): 39 | """`tools call` command for non‐interactive invocation.""" 40 | 41 | def __init__(self): 42 | super().__init__( 43 | name="tools call", 44 | help_text="Call a specific tool with arguments." 45 | ) 46 | 47 | async def execute(self, tool_manager: Any, **params: Any) -> None: 48 | """ 49 | Delegates to the shared `tools_call_action`. 50 | """ 51 | logger.debug("Executing ToolsCallCommand") 52 | await tools_call_action(tool_manager) 53 | -------------------------------------------------------------------------------- /src/mcp_cli/cli_options.py: -------------------------------------------------------------------------------- 1 | # mcp_cli/cli_options.py 2 | """ 3 | Shared option-processing helpers for MCP-CLI commands. 4 | """ 5 | from __future__ import annotations 6 | 7 | import json 8 | import logging 9 | import os 10 | from pathlib import Path 11 | from typing import Dict, List, Optional, Tuple 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | def load_config(config_file: str) -> Optional[dict]: 17 | """Read config file and return dict or None.""" 18 | try: 19 | if Path(config_file).is_file(): 20 | with open(config_file, "r", encoding="utf-8") as fh: 21 | return json.load(fh) 22 | logger.warning("Config file '%s' not found.", config_file) 23 | except (json.JSONDecodeError, OSError) as exc: 24 | logger.error("Error loading config file '%s': %s", config_file, exc) 25 | return None 26 | 27 | 28 | def extract_server_names(cfg: Optional[dict], specified: List[str] = None) -> Dict[int, str]: 29 | """Extract server names from config, optionally filtered by specified list.""" 30 | if not cfg or "mcpServers" not in cfg: 31 | return {} 32 | 33 | servers = cfg["mcpServers"] 34 | 35 | if specified: 36 | return {i: name for i, name in enumerate(specified) if name in servers} 37 | else: 38 | return {i: name for i, name in enumerate(servers.keys())} 39 | 40 | 41 | def process_options( 42 | server: Optional[str], 43 | disable_filesystem: bool, 44 | provider: str, 45 | model: Optional[str], 46 | config_file: str = "server_config.json", 47 | ) -> Tuple[List[str], List[str], Dict[int, str]]: 48 | """ 49 | Process CLI options and return (servers_list, user_specified, server_names). 50 | 51 | Sets environment variables for downstream components. 52 | """ 53 | # Parse servers 54 | user_specified = [] 55 | if server: 56 | user_specified = [s.strip() for s in server.split(",")] 57 | 58 | # Set environment variables (components will use ModelManager for actual values) 59 | os.environ["LLM_PROVIDER"] = provider 60 | if model: 61 | os.environ["LLM_MODEL"] = model 62 | 63 | if not disable_filesystem: 64 | os.environ["SOURCE_FILESYSTEMS"] = json.dumps([os.getcwd()]) 65 | 66 | # Load server config 67 | cfg = load_config(config_file) 68 | servers_list = user_specified or (list(cfg["mcpServers"].keys()) if cfg and "mcpServers" in cfg else []) 69 | server_names = extract_server_names(cfg, user_specified) 70 | 71 | logger.debug("Processed options: provider=%s model=%s servers=%s", provider, model, servers_list) 72 | return servers_list, user_specified, server_names -------------------------------------------------------------------------------- /src/mcp_cli/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrishayuk/mcp-cli/b94b3141a075fa83cbb2c2ba921bbf98dfa2e07e/src/mcp_cli/commands/__init__.py -------------------------------------------------------------------------------- /src/mcp_cli/commands/clear.py: -------------------------------------------------------------------------------- 1 | # mcp_cli/commands/clear.py 2 | """ 3 | Clear the user's terminal window 4 | ================================ 5 | 6 | This helper is shared by both: 7 | 8 | * **Chat-mode** - the `/clear` and `/cls` slash-commands. 9 | * **Non-interactive CLI** - the `mcp-cli clear run` Typer sub-command. 10 | 11 | It simply calls :pyfunc:`mcp_cli.ui.ui_helpers.clear_screen` and, if 12 | *verbose* is enabled, prints a tiny confirmation so scripts can detect that 13 | the operation completed. 14 | """ 15 | from __future__ import annotations 16 | 17 | # mcp cli 18 | from mcp_cli.ui.ui_helpers import clear_screen 19 | from mcp_cli.utils.rich_helpers import get_console 20 | 21 | 22 | def clear_action(*, verbose: bool = False) -> None: # noqa: D401 23 | """ 24 | Clear whatever terminal the user is running in. 25 | 26 | Parameters 27 | ---------- 28 | verbose: 29 | When **True** a dim “Screen cleared.” message is written afterwards 30 | (useful for log files or when the command is scripted). 31 | """ 32 | clear_screen() 33 | 34 | if verbose: 35 | console = get_console() 36 | console.print("[dim]Screen cleared.[/dim]") 37 | -------------------------------------------------------------------------------- /src/mcp_cli/commands/exit.py: -------------------------------------------------------------------------------- 1 | # mcp_cli/commands/exit.py 2 | """ 3 | Terminate the current MCP-CLI session 4 | ===================================== 5 | 6 | Used by both chat-mode (/exit | /quit) **and** the non-interactive CLI's 7 | `exit` sub-command. It restores the TTY first, then either returns to the 8 | caller (interactive) or exits the process (one-shot mode). 9 | """ 10 | from __future__ import annotations 11 | import sys 12 | 13 | # mcp cli 14 | from mcp_cli.ui.ui_helpers import restore_terminal 15 | from mcp_cli.utils.rich_helpers import get_console 16 | 17 | 18 | def exit_action(interactive: bool = True) -> bool: # noqa: D401 19 | """ 20 | Cleanly close the current MCP-CLI session. 21 | 22 | Parameters 23 | ---------- 24 | interactive 25 | • **True** → just tell the outer loop to break and *return* 26 | • **False** → restore the TTY **then** call :pyfunc:`sys.exit(0)` 27 | 28 | Returns 29 | ------- 30 | bool 31 | Always ``True`` so interactive callers can treat it as a 32 | “please-stop” flag. (When *interactive* is ``False`` the function 33 | never returns because the process terminates.) 34 | """ 35 | console = get_console() # unified Rich/plain console 36 | console.print("[yellow]Exiting… Goodbye![/yellow]") 37 | 38 | restore_terminal() 39 | 40 | if not interactive: 41 | sys.exit(0) 42 | 43 | return True 44 | 45 | -------------------------------------------------------------------------------- /src/mcp_cli/commands/help.py: -------------------------------------------------------------------------------- 1 | # mcp_cli/commands/help.py 2 | """ 3 | Human-friendly *help* output for both chat-mode and command-line 4 | ================================================================ 5 | 6 | This helper renders either: 7 | 8 | * **A single command's details** when a *command_name* is given. 9 | * **A summary table of *all* commands** otherwise. 10 | 11 | It first tries the *interactive* registry (chat-mode); if that is not 12 | importable we fall back to the CLI registry instead. 13 | 14 | Highlights 15 | ---------- 16 | * **Cross-platform console** - via 17 | :pyfunc:`mcp_cli.utils.rich_helpers.get_console` so colours work on 18 | Windows terminals and disappear automatically when output is redirected. 19 | * **Doc-string parsing** - the first non-empty line that *doesn't* start 20 | with “usage” becomes the one-liner in the command table. 21 | * **Alias column** - shows “–” when a command has no aliases, keeping the 22 | table tidy. 23 | """ 24 | from __future__ import annotations 25 | from typing import Dict, Optional 26 | from rich.markdown import Markdown 27 | from rich.panel import Panel 28 | from rich.table import Table 29 | 30 | # mcp cli 31 | from mcp_cli.utils.rich_helpers import get_console 32 | 33 | # Prefer interactive registry, gracefully fall back to CLI registry 34 | try: 35 | from mcp_cli.interactive.registry import InteractiveCommandRegistry as _Reg 36 | except ImportError: # not in interactive mode 37 | from mcp_cli.cli.registry import CommandRegistry as _Reg # type: ignore 38 | 39 | 40 | def _get_commands() -> Dict[str, object]: 41 | """Return *name → Command* mapping from whichever registry is available.""" 42 | return _Reg.get_all_commands() if hasattr(_Reg, "get_all_commands") else {} 43 | 44 | 45 | # ───────────────────────────────────────────────────────────────────────────── 46 | # Public API 47 | # ───────────────────────────────────────────────────────────────────────────── 48 | def help_action( 49 | command_name: Optional[str] = None, 50 | *, 51 | console=None, 52 | ) -> None: 53 | """ 54 | Render help for one command or for the whole command set. 55 | 56 | Parameters 57 | ---------- 58 | command_name: 59 | Show detailed help for *this* command only. If *None* (default) 60 | a table of every command is produced. 61 | console: 62 | Optional :class:`rich.console.Console` instance. If omitted a 63 | cross-platform console is created automatically via 64 | :pyfunc:`mcp_cli.utils.rich_helpers.get_console`. 65 | """ 66 | console = console or get_console() 67 | commands = _get_commands() 68 | 69 | # ── detailed view ──────────────────────────────────────────────────── 70 | if command_name: 71 | cmd = commands.get(command_name) 72 | if cmd is None: 73 | console.print(f"[red]Unknown command:[/red] {command_name}") 74 | return 75 | 76 | md = Markdown( 77 | f"## `{cmd.name}`\n\n{cmd.help or '*No description provided.*'}" 78 | ) 79 | console.print(Panel(md, title="Command Help", border_style="cyan")) 80 | 81 | if getattr(cmd, "aliases", None): 82 | console.print( 83 | f"[dim]Aliases:[/dim] {', '.join(cmd.aliases)}", justify="right" 84 | ) 85 | return 86 | 87 | # ── summary table ──────────────────────────────────────────────────── 88 | tbl = Table(title="Available Commands") 89 | tbl.add_column("Command", style="green", no_wrap=True) 90 | tbl.add_column("Aliases", style="cyan", no_wrap=True) 91 | tbl.add_column("Description") 92 | 93 | for name, cmd in sorted(commands.items()): 94 | # First non-blank line that isn't a "usage" header 95 | lines = [ 96 | ln.strip() 97 | for ln in (cmd.help or "").splitlines() 98 | if ln.strip() and not ln.strip().lower().startswith("usage") 99 | ] 100 | desc = lines[0] if lines else "No description" 101 | aliases = ", ".join(cmd.aliases) if getattr(cmd, "aliases", None) else "–" 102 | tbl.add_row(name, aliases, desc) 103 | 104 | console.print(tbl) 105 | console.print( 106 | "[dim]Type 'help <command>' for detailed information on a specific " 107 | "command.[/dim]" 108 | ) 109 | -------------------------------------------------------------------------------- /src/mcp_cli/commands/model.py: -------------------------------------------------------------------------------- 1 | # src/mcp_cli/commands/model.py 2 | """ 3 | Model-management command for MCP-CLI. 4 | 5 | Inside chat / interactive mode 6 | ------------------------------ 7 | /model → show current model & provider 8 | /model list → list models for the active provider 9 | /model → probe & switch model (auto-rollback on failure) 10 | """ 11 | from __future__ import annotations 12 | from typing import Any, Dict, List 13 | from rich.table import Table 14 | 15 | # mcp cli 16 | from mcp_cli.model_manager import ModelManager 17 | from mcp_cli.utils.rich_helpers import get_console 18 | from mcp_cli.utils.async_utils import run_blocking 19 | from mcp_cli.utils.llm_probe import LLMProbe 20 | 21 | 22 | # ════════════════════════════════════════════════════════════════════════ 23 | # Async implementation (core logic) 24 | # ════════════════════════════════════════════════════════════════════════ 25 | async def model_action_async(args: List[str], *, context: Dict[str, Any]) -> None: 26 | console = get_console() 27 | 28 | # Re-use (or lazily create) a ModelManager kept in context 29 | model_manager: ModelManager = context.get("model_manager") or ModelManager() 30 | context.setdefault("model_manager", model_manager) 31 | 32 | provider = model_manager.get_active_provider() 33 | current_model = model_manager.get_active_model() 34 | 35 | # ── no arguments → just display current state ─────────────────────── 36 | if not args: 37 | _print_status(console, current_model, provider) 38 | return 39 | 40 | # ── "/model list" helper ──────────────────────────────────────────── 41 | if args[0].lower() == "list": 42 | _print_model_list(console, model_manager, provider) 43 | return 44 | 45 | # ── attempt model switch ──────────────────────────────────────────── 46 | new_model = args[0] 47 | console.print(f"[dim]Probing model '{new_model}'…[/dim]") 48 | 49 | async with LLMProbe(model_manager, suppress_logging=True) as probe: 50 | result = await probe.test_model(new_model) 51 | 52 | if not result.success: 53 | msg = ( 54 | f"provider error: {result.error_message}" 55 | if result.error_message 56 | else "unknown error" 57 | ) 58 | console.print(f"[red]Model switch failed:[/red] {msg}") 59 | console.print(f"[yellow]Keeping current model:[/yellow] {current_model}") 60 | return 61 | 62 | # Success – commit the change 63 | model_manager.set_active_model(new_model) 64 | context["model"] = new_model 65 | context["client"] = result.client 66 | context["model_manager"] = model_manager 67 | console.print(f"[green]Switched to model:[/green] {new_model}") 68 | 69 | 70 | # ════════════════════════════════════════════════════════════════════════ 71 | # Sync wrapper for non-async code-paths 72 | # ════════════════════════════════════════════════════════════════════════ 73 | def model_action(args: List[str], *, context: Dict[str, Any]) -> None: 74 | """Thin synchronous facade around *model_action_async*.""" 75 | run_blocking(model_action_async(args, context=context)) 76 | 77 | 78 | # ════════════════════════════════════════════════════════════════════════ 79 | # Helper functions 80 | # ════════════════════════════════════════════════════════════════════════ 81 | def _print_status(console, model: str, provider: str) -> None: 82 | console.print(f"[cyan]Current model:[/cyan] {model}") 83 | console.print(f"[cyan]Provider :[/cyan] {provider}") 84 | console.print("[dim]/model to switch | /model list to list[/dim]") 85 | 86 | 87 | def _print_model_list(console, model_manager: ModelManager, provider: str) -> None: 88 | table = Table(title=f"Models for provider '{provider}'") 89 | table.add_column("Type", style="cyan", width=10) 90 | table.add_column("Model", style="green") 91 | 92 | table.add_row("default", model_manager.get_default_model(provider)) 93 | console.print(table) 94 | -------------------------------------------------------------------------------- /src/mcp_cli/commands/prompts.py: -------------------------------------------------------------------------------- 1 | # src/mcp_cli/commands/prompts.py 2 | """ 3 | List stored *prompt* templates on every connected MCP server 4 | ============================================================ 5 | 6 | Public entry-points 7 | ------------------- 8 | * **prompts_action_async(tm)** - canonical coroutine (used by chat */prompts*). 9 | * **prompts_action(tm)** - small synchronous wrapper for plain CLI usage. 10 | * **prompts_action_cmd(tm)** - thin alias kept for backward-compatibility. 11 | 12 | All variants ultimately render the same Rich table: 13 | 14 | ┏━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ 15 | ┃ Server ┃ Name ┃ Description ┃ 16 | ┡━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ 17 | │ local │ greet │ Friendly greeting prompt │ 18 | │ api │ sql_query │ Extract columns & types from table │ 19 | └────────┴────────────┴─────────────────────────────────────┘ 20 | """ 21 | from __future__ import annotations 22 | import inspect 23 | from typing import Any, Dict, List 24 | from rich.table import Table 25 | 26 | # mcp cli 27 | from mcp_cli.tools.manager import ToolManager 28 | from mcp_cli.utils.async_utils import run_blocking 29 | from mcp_cli.utils.rich_helpers import get_console 30 | 31 | 32 | # ════════════════════════════════════════════════════════════════════════ 33 | # async (primary) implementation 34 | # ════════════════════════════════════════════════════════════════════════ 35 | async def prompts_action_async(tm: ToolManager) -> List[Dict[str, Any]]: 36 | """ 37 | Fetch **all** prompt templates from every connected server and 38 | display them in a nicely formatted Rich table. 39 | 40 | Returns 41 | ------- 42 | list[dict] 43 | The raw prompt dictionaries exactly as returned by `ToolManager`. 44 | """ 45 | console = get_console() 46 | 47 | try: 48 | maybe = tm.list_prompts() 49 | except Exception as exc: # pragma: no cover – network / server errors 50 | console.print(f"[red]Error:[/red] {exc}") 51 | return [] 52 | 53 | # `tm.list_prompts()` can be sync or async - handle both gracefully 54 | prompts = await maybe if inspect.isawaitable(maybe) else maybe 55 | if not prompts: # None or empty list 56 | console.print("[dim]No prompts recorded.[/dim]") 57 | return [] 58 | 59 | # ── render table ──────────────────────────────────────────────────── 60 | table = Table(title="Prompts", header_style="bold magenta") 61 | table.add_column("Server", style="cyan", no_wrap=True) 62 | table.add_column("Name", style="yellow", no_wrap=True) 63 | table.add_column("Description", overflow="fold") 64 | 65 | for item in prompts: 66 | table.add_row( 67 | item.get("server", "-"), 68 | item.get("name", "-"), 69 | item.get("description", ""), 70 | ) 71 | 72 | console.print(table) 73 | return prompts 74 | 75 | 76 | # ════════════════════════════════════════════════════════════════════════ 77 | # sync wrapper – used by legacy CLI commands 78 | # ════════════════════════════════════════════════════════════════════════ 79 | def prompts_action(tm: ToolManager) -> List[Dict[str, Any]]: 80 | """ 81 | Blocking helper around :pyfunc:`prompts_action_async`. 82 | 83 | It calls :pyfunc:`mcp_cli.utils.async_utils.run_blocking`, raising a 84 | ``RuntimeError`` if invoked from *inside* a running event-loop. 85 | """ 86 | return run_blocking(prompts_action_async(tm)) 87 | 88 | 89 | # ════════════════════════════════════════════════════════════════════════ 90 | # alias for chat/interactive mode 91 | # ════════════════════════════════════════════════════════════════════════ 92 | async def prompts_action_cmd(tm: ToolManager) -> List[Dict[str, Any]]: 93 | """ 94 | Alias kept for the interactive */prompts* command. 95 | 96 | Chat-mode already runs inside an event-loop, so callers should simply 97 | `await` this coroutine instead of the synchronous wrapper. 98 | """ 99 | return await prompts_action_async(tm) 100 | 101 | 102 | __all__ = [ 103 | "prompts_action_async", 104 | "prompts_action", 105 | "prompts_action_cmd", 106 | ] 107 | -------------------------------------------------------------------------------- /src/mcp_cli/commands/resources.py: -------------------------------------------------------------------------------- 1 | # src/mcp_cli/commands/resources.py 2 | """ 3 | List binary *resources* (files, blobs, artefacts) known to every connected 4 | MCP server. 5 | 6 | There are three public call-sites: 7 | 8 | * **resources_action_async(tm)** - canonical coroutine for chat / TUI. 9 | * **resources_action(tm)** - tiny sync wrapper for legacy CLI paths. 10 | * **_human_size(n)** - helper to pretty-print bytes. 11 | 12 | Compared with the old module: 13 | 14 | * All output now flows through :pyfunc:`mcp_cli.utils.rich_helpers.get_console` 15 | so colours & widths behave on Windows, inside pipes, and in CI logs. 16 | * Doc-string starts with a one-line summary, so `/help` shows a nice 17 | description instead of “No description”. 18 | """ 19 | from __future__ import annotations 20 | import inspect 21 | from typing import Any, Dict, List 22 | from rich.table import Table 23 | 24 | # mcp cli 25 | from mcp_cli.tools.manager import ToolManager 26 | from mcp_cli.utils.async_utils import run_blocking 27 | from mcp_cli.utils.rich_helpers import get_console 28 | 29 | 30 | # ════════════════════════════════════════════════════════════════════════ 31 | # helpers 32 | # ════════════════════════════════════════════════════════════════════════ 33 | def _human_size(size: int | None) -> str: 34 | """Convert *size* in bytes to a human-readable string (KB/MB/GB).""" 35 | if size is None or size < 0: 36 | return "-" 37 | for unit in ("B", "KB", "MB", "GB"): 38 | if size < 1024: 39 | return f"{size:.0f} {unit}" 40 | size /= 1024 41 | return f"{size:.1f} TB" 42 | 43 | 44 | # ════════════════════════════════════════════════════════════════════════ 45 | # async (primary) implementation 46 | # ════════════════════════════════════════════════════════════════════════ 47 | async def resources_action_async(tm: ToolManager) -> List[Dict[str, Any]]: 48 | """ 49 | Fetch resources from *tm* and render a Rich table. 50 | 51 | Returns the raw list to allow callers to re-use the data programmatically. 52 | """ 53 | console = get_console() 54 | 55 | # Most MCP servers expose list_resources() as an awaitable, but some 56 | # adapters might return a plain list – handle both. 57 | try: 58 | maybe = tm.list_resources() 59 | resources = await maybe if inspect.isawaitable(maybe) else maybe # type: ignore[arg-type] 60 | except Exception as exc: # noqa: BLE001 61 | console.print(f"[red]Error:[/red] {exc}") 62 | return [] 63 | 64 | resources = resources or [] 65 | if not resources: 66 | console.print("[dim]No resources recorded.[/dim]") 67 | return resources 68 | 69 | table = Table(title="Resources", header_style="bold magenta") 70 | table.add_column("Server", style="cyan") 71 | table.add_column("URI", style="yellow") 72 | table.add_column("Size", justify="right") 73 | table.add_column("MIME-type") 74 | 75 | for item in resources: 76 | table.add_row( 77 | item.get("server", "-"), 78 | item.get("uri", "-"), 79 | _human_size(item.get("size")), 80 | item.get("mimeType", "-"), 81 | ) 82 | 83 | console.print(table) 84 | return resources 85 | 86 | 87 | # ════════════════════════════════════════════════════════════════════════ 88 | # sync wrapper – used by non-interactive CLI paths 89 | # ════════════════════════════════════════════════════════════════════════ 90 | def resources_action(tm: ToolManager) -> List[Dict[str, Any]]: 91 | """ 92 | Blocking wrapper around :pyfunc:`resources_action_async`. 93 | 94 | Raises *RuntimeError* if called from inside an active event-loop. 95 | """ 96 | return run_blocking(resources_action_async(tm)) 97 | 98 | 99 | __all__ = [ 100 | "resources_action_async", 101 | "resources_action", 102 | ] 103 | -------------------------------------------------------------------------------- /src/mcp_cli/commands/servers.py: -------------------------------------------------------------------------------- 1 | # src/mcp_cli/commands/servers.py 2 | """ 3 | Show a table of *connected MCP servers* and how many tools each exposes. 4 | 5 | Three public entry-points 6 | ------------------------- 7 | * **servers_action_async(tm)** - primary coroutine for chat / TUI. 8 | * **servers_action(tm)** - thin sync wrapper for legacy CLI paths. 9 | * The module-level doc-string itself gives a short description that the 10 | `/help` command will pick up. 11 | """ 12 | from __future__ import annotations 13 | from typing import List 14 | from rich.table import Table 15 | 16 | # mcp cli 17 | from mcp_cli.tools.manager import ToolManager 18 | from mcp_cli.utils.async_utils import run_blocking 19 | from mcp_cli.utils.rich_helpers import get_console 20 | 21 | 22 | # ════════════════════════════════════════════════════════════════════════ 23 | # async (canonical) implementation 24 | # ════════════════════════════════════════════════════════════════════════ 25 | async def servers_action_async(tm: ToolManager) -> List: # noqa: D401 26 | """ 27 | Retrieve server metadata from *tm* and render a Rich table. 28 | 29 | Returns the raw list so callers may re-use the data programmatically. 30 | """ 31 | console = get_console() 32 | server_info = await tm.get_server_info() 33 | 34 | if not server_info: 35 | console.print("[yellow]No servers connected.[/yellow]") 36 | return server_info 37 | 38 | table = Table(title="Connected Servers", header_style="bold magenta") 39 | table.add_column("ID", style="cyan") 40 | table.add_column("Name", style="green") 41 | table.add_column("Tools", style="cyan", justify="right") 42 | table.add_column("Status") 43 | 44 | for srv in server_info: 45 | table.add_row( 46 | str(srv.id), 47 | srv.name, 48 | str(srv.tool_count), 49 | srv.status, 50 | ) 51 | 52 | console.print(table) 53 | return server_info 54 | 55 | 56 | # ════════════════════════════════════════════════════════════════════════ 57 | # sync helper – kept for non-async CLI paths 58 | # ════════════════════════════════════════════════════════════════════════ 59 | def servers_action(tm: ToolManager) -> List: 60 | """Blocking wrapper around :pyfunc:`servers_action_async`.""" 61 | return run_blocking(servers_action_async(tm)) 62 | 63 | 64 | __all__ = ["servers_action_async", "servers_action"] 65 | -------------------------------------------------------------------------------- /src/mcp_cli/commands/tools.py: -------------------------------------------------------------------------------- 1 | # src/mcp_cli/commands/tools.py 2 | """ 3 | Show **all tools** exposed by every connected MCP server, either as a 4 | pretty Rich table or raw JSON. 5 | 6 | How to use 7 | ---------- 8 | * Chat / interactive : `/tools`, `/tools --all`, `/tools --raw` 9 | * CLI script : `mcp-cli tools [--all|--raw]` 10 | 11 | Both the chat & CLI layers call :pyfunc:`tools_action_async`; the 12 | blocking helper :pyfunc:`tools_action` exists only for legacy sync code. 13 | """ 14 | from __future__ import annotations 15 | 16 | import json 17 | import logging 18 | from typing import Any, Dict, List 19 | 20 | from rich.syntax import Syntax 21 | from rich.table import Table 22 | 23 | # MCP-CLI helpers 24 | from mcp_cli.tools.formatting import create_tools_table 25 | from mcp_cli.tools.manager import ToolManager 26 | from mcp_cli.utils.async_utils import run_blocking 27 | from mcp_cli.utils.rich_helpers import get_console 28 | 29 | logger = logging.getLogger(__name__) 30 | 31 | # ──────────────────────────────────────────────────────────────────────────────── 32 | # async (canonical) implementation 33 | # ──────────────────────────────────────────────────────────────────────────────── 34 | async def tools_action_async( # noqa: D401 35 | tm: ToolManager, 36 | *, 37 | show_details: bool = False, 38 | show_raw: bool = False, 39 | ) -> List[Dict[str, Any]]: 40 | """ 41 | Fetch the **deduplicated** tool list from *all* servers and print it. 42 | 43 | Parameters 44 | ---------- 45 | tm 46 | A fully-initialised :class:`~mcp_cli.tools.manager.ToolManager`. 47 | show_details 48 | When *True*, include parameter schemas in the table. 49 | show_raw 50 | When *True*, dump raw JSON definitions instead of a table. 51 | 52 | Returns 53 | ------- 54 | list 55 | The list of tool-metadata dictionaries (always JSON-serialisable). 56 | """ 57 | console = get_console() 58 | console.print("[cyan]\nFetching tool catalogue from all servers…[/cyan]") 59 | 60 | all_tools = await tm.get_unique_tools() 61 | if not all_tools: 62 | console.print("[yellow]No tools available from any server.[/yellow]") 63 | logger.debug("ToolManager returned an empty tools list") 64 | return [] 65 | 66 | # ── raw JSON mode ─────────────────────────────────────────────────── 67 | if show_raw: 68 | payload = [ 69 | { 70 | "name": t.name, 71 | "namespace": t.namespace, 72 | "description": t.description, 73 | "parameters": t.parameters, 74 | "is_async": getattr(t, "is_async", False), 75 | "tags": getattr(t, "tags", []), 76 | "aliases": getattr(t, "aliases", []), 77 | } 78 | for t in all_tools 79 | ] 80 | console.print( 81 | Syntax(json.dumps(payload, indent=2, ensure_ascii=False), 82 | "json", line_numbers=True) 83 | ) 84 | return payload 85 | 86 | # ── Rich table mode ───────────────────────────────────────────────── 87 | table: Table = create_tools_table(all_tools, show_details=show_details) 88 | console.print(table) 89 | console.print(f"[green]Total tools available: {len(all_tools)}[/green]") 90 | 91 | # Return a safe JSON structure (no .to_dict() needed) 92 | return [ 93 | { 94 | "name": t.name, 95 | "namespace": t.namespace, 96 | "description": t.description, 97 | "parameters": t.parameters, 98 | "is_async": getattr(t, "is_async", False), 99 | "tags": getattr(t, "tags", []), 100 | "aliases": getattr(t, "aliases", []), 101 | } 102 | for t in all_tools 103 | ] 104 | 105 | # ──────────────────────────────────────────────────────────────────────────────── 106 | # sync wrapper – for legacy CLI paths 107 | # ──────────────────────────────────────────────────────────────────────────────── 108 | def tools_action( 109 | tm: ToolManager, 110 | *, 111 | show_details: bool = False, 112 | show_raw: bool = False, 113 | ) -> List[Dict[str, Any]]: 114 | """ 115 | Blocking wrapper around :pyfunc:`tools_action_async`. 116 | 117 | Raises 118 | ------ 119 | RuntimeError 120 | If called from inside a running event-loop. 121 | """ 122 | return run_blocking( 123 | tools_action_async(tm, show_details=show_details, show_raw=show_raw) 124 | ) 125 | 126 | __all__ = ["tools_action_async", "tools_action"] 127 | -------------------------------------------------------------------------------- /src/mcp_cli/commands/tools_call.py: -------------------------------------------------------------------------------- 1 | # src/mcp_cli/commands/tools_call.py 2 | """ 3 | Open an *interactive “call a tool” wizard* that lets you pick a tool and 4 | pass JSON arguments right from the terminal. 5 | 6 | Highlights 7 | ---------- 8 | * Uses :pyfunc:`mcp_cli.utils.rich_helpers.get_console` so colours & width 9 | work on Windows terminals, plain pipes, CI logs, etc. 10 | * Leaves **zero state** behind - safe to hot-reload while a chat/TUI is 11 | running. 12 | * Re-uses :pyfunc:`mcp_cli.tools.formatting.display_tool_call_result` 13 | for pretty result rendering, so the output looks the same everywhere. 14 | """ 15 | from __future__ import annotations 16 | import asyncio 17 | import json 18 | import logging 19 | from typing import Any, Dict 20 | 21 | # mcp cli 22 | from mcp_cli.utils.rich_helpers import get_console 23 | from mcp_cli.tools.manager import ToolManager 24 | from mcp_cli.tools.models import ToolCallResult 25 | from mcp_cli.tools.formatting import display_tool_call_result 26 | 27 | # logger 28 | logger = logging.getLogger(__name__) 29 | 30 | 31 | # ════════════════════════════════════════════════════════════════════════ 32 | # Main entry-point (async coroutine) 33 | # ════════════════════════════════════════════════════════════════════════ 34 | async def tools_call_action(tm: ToolManager) -> None: # noqa: D401 35 | """ 36 | Launch the mini-wizard, execute the chosen tool, show the result. 37 | 38 | This function is designed for *interactive* use only – it blocks on 39 | `input()` twice (tool selection & JSON args). 40 | """ 41 | console = get_console() 42 | cprint = console.print 43 | 44 | cprint("[cyan]\nTool Call Interface[/cyan]") 45 | 46 | # Fetch distinct tools (no duplicates across servers) 47 | all_tools = await tm.get_unique_tools() 48 | if not all_tools: 49 | cprint("[yellow]No tools available from any server.[/yellow]") 50 | return 51 | 52 | # ── list tools ──────────────────────────────────────────────────── 53 | cprint("[green]Available tools:[/green]") 54 | for idx, tool in enumerate(all_tools, 1): 55 | desc = tool.description or "No description" 56 | cprint(f" {idx}. {tool.name} (from {tool.namespace}) – {desc}") 57 | 58 | # ── user selection ──────────────────────────────────────────────── 59 | sel_raw = await asyncio.to_thread(input, "\nEnter tool number to call: ") 60 | try: 61 | sel = int(sel_raw) - 1 62 | tool = all_tools[sel] 63 | except (ValueError, IndexError): 64 | cprint("[red]Invalid selection.[/red]") 65 | return 66 | 67 | cprint(f"\n[green]Selected:[/green] {tool.name} from {tool.namespace}") 68 | if tool.description: 69 | cprint(f"[cyan]Description:[/cyan] {tool.description}") 70 | 71 | # ── argument collection ─────────────────────────────────────────── 72 | params_schema: Dict[str, Any] = tool.parameters or {} 73 | args: Dict[str, Any] = {} 74 | 75 | if params_schema.get("properties"): 76 | cprint("\n[yellow]Enter arguments as JSON (leave blank for none):[/yellow]") 77 | args_raw = await asyncio.to_thread(input, "> ") 78 | if args_raw.strip(): 79 | try: 80 | args = json.loads(args_raw) 81 | except json.JSONDecodeError: 82 | cprint("[red]Invalid JSON – aborting.[/red]") 83 | return 84 | else: 85 | cprint("[dim]Tool takes no arguments.[/dim]") 86 | 87 | # ── execution ───────────────────────────────────────────────────── 88 | fq_name = f"{tool.namespace}.{tool.name}" 89 | cprint(f"\n[cyan]Calling '{fq_name}'…[/cyan]") 90 | 91 | try: 92 | result: ToolCallResult = await tm.execute_tool(fq_name, args) 93 | display_tool_call_result(result, console) 94 | except Exception as exc: # noqa: BLE001 95 | logger.exception("Error executing tool") 96 | cprint(f"[red]Error: {exc}[/red]") 97 | 98 | 99 | __all__ = ["tools_call_action"] 100 | -------------------------------------------------------------------------------- /src/mcp_cli/config.py: -------------------------------------------------------------------------------- 1 | # mcp_cli/config.py 2 | import json 3 | import logging 4 | 5 | # mcp_client imports 6 | from chuk_mcp.mcp_client.transport.stdio.stdio_server_parameters import StdioServerParameters 7 | 8 | async def load_config(config_path: str, server_name: str) -> StdioServerParameters: 9 | """Load the server configuration from a JSON file.""" 10 | try: 11 | # debug 12 | logging.debug(f"Loading config from {config_path}") 13 | 14 | # Read the configuration file 15 | with open(config_path, "r") as config_file: 16 | config = json.load(config_file) 17 | 18 | # Retrieve the server configuration 19 | server_config = config.get("mcpServers", {}).get(server_name) 20 | if not server_config: 21 | error_msg = f"Server '{server_name}' not found in configuration file." 22 | logging.error(error_msg) 23 | raise ValueError(error_msg) 24 | 25 | # Construct the server parameters 26 | result = StdioServerParameters( 27 | command=server_config["command"], 28 | args=server_config.get("args", []), 29 | env=server_config.get("env"), 30 | ) 31 | 32 | # debug 33 | logging.debug( 34 | f"Loaded config: command='{result.command}', args={result.args}, env={result.env}" 35 | ) 36 | 37 | # return result 38 | return result 39 | 40 | except FileNotFoundError: 41 | # error 42 | error_msg = f"Configuration file not found: {config_path}" 43 | logging.error(error_msg) 44 | raise FileNotFoundError(error_msg) 45 | except json.JSONDecodeError as e: 46 | # json error 47 | error_msg = f"Invalid JSON in configuration file: {e.msg}" 48 | logging.error(error_msg) 49 | raise json.JSONDecodeError(error_msg, e.doc, e.pos) 50 | except ValueError as e: 51 | # error 52 | logging.error(str(e)) 53 | raise 54 | -------------------------------------------------------------------------------- /src/mcp_cli/interactive/__init__.py: -------------------------------------------------------------------------------- 1 | # mcp_cli/interactive/__init__.py -------------------------------------------------------------------------------- /src/mcp_cli/interactive/commands/__init__.py: -------------------------------------------------------------------------------- 1 | # mcp_cli/interactive/commands/__init__.py 2 | """Interactive commands package.""" 3 | from .help import HelpCommand 4 | from .exit import ExitCommand 5 | from .clear import ClearCommand 6 | from .servers import ServersCommand 7 | from .tools import ToolsCommand 8 | from .resources import ResourcesCommand 9 | from .prompts import PromptsCommand 10 | from .ping import PingCommand 11 | from .model import ModelCommand 12 | from .provider import ProviderCommand 13 | 14 | # Export for convenience 15 | __all__ = [ 16 | "HelpCommand", 17 | "ExitCommand", 18 | "ClearCommand", 19 | "ServersCommand", 20 | "ToolsCommand", 21 | "ResourcesCommand", 22 | "PromptsCommand", 23 | "PingCommand", 24 | "ModelCommand", 25 | "ProviderCommand" # Add this export 26 | ] 27 | 28 | def register_all_commands() -> None: 29 | """ 30 | Register every interactive command in the central registry. 31 | """ 32 | from mcp_cli.interactive.registry import InteractiveCommandRegistry 33 | from mcp_cli.interactive.commands.help import HelpCommand 34 | from mcp_cli.interactive.commands.exit import ExitCommand 35 | from mcp_cli.interactive.commands.clear import ClearCommand 36 | from mcp_cli.interactive.commands.servers import ServersCommand 37 | from mcp_cli.interactive.commands.tools import ToolsCommand 38 | from mcp_cli.interactive.commands.resources import ResourcesCommand 39 | from mcp_cli.interactive.commands.prompts import PromptsCommand 40 | from mcp_cli.interactive.commands.ping import PingCommand 41 | from mcp_cli.interactive.commands.model import ModelCommand 42 | from mcp_cli.interactive.commands.provider import ProviderCommand 43 | 44 | reg = InteractiveCommandRegistry 45 | reg.register(HelpCommand()) 46 | reg.register(ExitCommand()) 47 | reg.register(ClearCommand()) 48 | reg.register(ServersCommand()) 49 | reg.register(ToolsCommand()) 50 | reg.register(ResourcesCommand()) 51 | reg.register(PromptsCommand()) 52 | reg.register(PingCommand()) 53 | reg.register(ModelCommand()) 54 | reg.register(ProviderCommand()) -------------------------------------------------------------------------------- /src/mcp_cli/interactive/commands/base.py: -------------------------------------------------------------------------------- 1 | # mcp_cli/interactive/commands/base.py 2 | """Base class for interactive commands.""" 3 | 4 | from __future__ import annotations 5 | 6 | import logging 7 | from abc import ABC, abstractmethod 8 | from typing import Any, Dict, List, Optional, Callable, Awaitable 9 | 10 | from mcp_cli.tools.manager import ToolManager 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | class InteractiveCommand(ABC): 16 | """Base class for interactive mode commands.""" 17 | 18 | name: str 19 | help: str 20 | aliases: List[str] 21 | 22 | def __init__(self, name: str, help_text: str = "", aliases: List[str] = None): 23 | self.name = name 24 | self.help = help_text 25 | self.aliases = aliases or [] 26 | 27 | @abstractmethod 28 | async def execute(self, args: List[str], tool_manager: ToolManager, **kwargs) -> Any: 29 | """Execute the command with the given arguments.""" 30 | pass -------------------------------------------------------------------------------- /src/mcp_cli/interactive/commands/clear.py: -------------------------------------------------------------------------------- 1 | # mcp_cli/interactive/commands/clear.py 2 | """ 3 | Interactive **clear / cls** command for MCP-CLI 4 | =============================================== 5 | 6 | This module wires the interactive commands **`clear`** and **`cls`** 7 | to the shared :pyfunc:`mcp_cli.commands.clear.clear_action` utility so 8 | users can wipe the screen without touching conversation history. 9 | 10 | Why have two names? 11 | ------------------- 12 | * ``clear`` - familiar to Unix/Linux and many PowerShell users. 13 | * ``cls`` - classic Windows `cmd.exe` shortcut. 14 | 15 | Both aliases call exactly the same function. 16 | 17 | Behaviour 18 | --------- 19 | * **Visual reset only** - the terminal window is cleared, but all in-memory 20 | state (conversation, loaded tools, etc.) remains untouched. 21 | * **Cross-platform** - `clear_action()` already detects whether ANSI escape 22 | codes are supported and falls back gracefully (e.g. on vanilla 23 | Windows 10 `cmd.exe` or when output is being piped to a file). 24 | * **No arguments** - any extra tokens after the command are ignored. 25 | 26 | Examples 27 | -------- 28 | >>> clear 29 | >>> cls 30 | """ 31 | 32 | from __future__ import annotations 33 | 34 | from typing import Any, List 35 | 36 | from .base import InteractiveCommand 37 | from mcp_cli.commands.clear import clear_action 38 | 39 | 40 | class ClearCommand(InteractiveCommand): 41 | """Erase the visible terminal contents (aliases: *cls*).""" 42 | 43 | def __init__(self) -> None: 44 | super().__init__( 45 | name="clear", 46 | help_text="Clear the terminal screen without affecting session state.", 47 | aliases=["cls"], 48 | ) 49 | 50 | # ------------------------------------------------------------------ 51 | async def execute( # noqa: D401 – imperative verb is fine 52 | self, 53 | args: List[str], 54 | tool_manager: Any = None, # unused but kept for signature parity 55 | **_: Any, 56 | ) -> None: 57 | """ 58 | Ignore *args* and delegate to :pyfunc:`mcp_cli.commands.clear.clear_action`. 59 | """ 60 | _ = args # explicitly discard 61 | clear_action() 62 | -------------------------------------------------------------------------------- /src/mcp_cli/interactive/commands/exit.py: -------------------------------------------------------------------------------- 1 | # mcp_cli/interactive/commands/exit.py 2 | """ 3 | Interactive **/exit** command for MCP-CLI 4 | ========================================= 5 | 6 | This tiny wrapper wires the interactive _`exit`_ / _`quit`_ / _`q`_ 7 | commands to the shared :pyfunc:`mcp_cli.commands.exit.exit_action` 8 | helper so users can leave the chat shell cleanly. 9 | 10 | Key points 11 | ---------- 12 | * **Graceful shutdown** – delegates all teardown (terminal restore, 13 | asyncio cleanup, etc.) to *exit_action*. 14 | * **Multiple aliases** – `exit`, `quit`, and `q` all resolve to the same 15 | behaviour so muscle-memory from other shells still works. 16 | * **Stateless** – no mutation of the chat context; the helper returns 17 | **True** which the interactive loop interprets as “stop”. 18 | * **Cross-platform** – prints via *exit_action*, which already uses the 19 | Rich console helper that falls back to plain text on Windows / 20 | non-ANSI environments. 21 | 22 | Examples 23 | -------- 24 | >>> exit 25 | >>> quit 26 | >>> q 27 | """ 28 | 29 | from __future__ import annotations 30 | 31 | from typing import Any, List 32 | 33 | from .base import InteractiveCommand 34 | from mcp_cli.commands.exit import exit_action 35 | 36 | 37 | class ExitCommand(InteractiveCommand): 38 | """Terminate the interactive chat session (aliases: *quit*, *q*).""" 39 | 40 | def __init__(self) -> None: 41 | super().__init__( 42 | name="exit", 43 | help_text="Quit the interactive shell (aliases: quit, q).", 44 | aliases=["quit", "q"], 45 | ) 46 | 47 | # ------------------------------------------------------------------ 48 | async def execute( # noqa: D401 – imperative verb is fine here 49 | self, 50 | args: List[str], 51 | tool_manager: Any = None, # unused 52 | **_: Any, 53 | ) -> bool: 54 | """ 55 | Invoke :pyfunc:`mcp_cli.commands.exit.exit_action`. 56 | 57 | *Any* trailing arguments are ignored – the command is always 58 | executed immediately. 59 | """ 60 | _ = args # noqa: F841 – explicitly ignore 61 | return exit_action() # prints goodbye + returns True 62 | -------------------------------------------------------------------------------- /src/mcp_cli/interactive/commands/help.py: -------------------------------------------------------------------------------- 1 | # mcp_cli/interactive/commands/help.py 2 | """ 3 | Interactive **help** command - show a list of all commands or drill-down 4 | into a single command's documentation. 5 | 6 | Usage examples 7 | -------------- 8 | help → table of every command with a one-liner description 9 | help tools → detailed help for the *tools* command 10 | h provider → same, using short alias 11 | ? → same as plain *help* 12 | 13 | Why this file exists 14 | -------------------- 15 | The CLI layer already has `mcp_cli.commands.help.help_action`. 16 | This interactive wrapper merely: 17 | 18 | 1. Grabs a cross-platform Rich console via 19 | :pyfunc:`mcp_cli.utils.rich_helpers.get_console` so colours work on 20 | Windows + piped output. 21 | 2. Passes the optional `` argument straight through to 22 | :func:`help_action`. 23 | """ 24 | 25 | from __future__ import annotations 26 | 27 | from typing import Any, List, Optional 28 | 29 | from mcp_cli.utils.rich_helpers import get_console 30 | from mcp_cli.commands.help import help_action 31 | from .base import InteractiveCommand 32 | 33 | 34 | class HelpCommand(InteractiveCommand): 35 | """Display all commands or detailed help for one command.""" 36 | 37 | def __init__(self) -> None: 38 | super().__init__( 39 | name="help", 40 | aliases=["h", "?"], # keep compatibility with old aliases 41 | help_text="Show global help or detailed help for a specific command.", 42 | ) 43 | 44 | # ------------------------------------------------------------------ 45 | async def execute( # noqa: D401 46 | self, 47 | args: List[str], 48 | tool_manager: Any = None, # unused but kept for interface parity 49 | **_: Any, 50 | ) -> None: 51 | """ 52 | Relay to :func:`mcp_cli.commands.help.help_action`. 53 | 54 | *args* is everything after the command word. 55 | """ 56 | console = get_console() 57 | 58 | # First positional token (if any) is treated as command name. 59 | # Strip a leading “/” so users can type either form. 60 | cmd_name: Optional[str] = args[0].lstrip("/") if args else None 61 | 62 | # help_action is synchronous 63 | help_action(cmd_name, console=console) 64 | -------------------------------------------------------------------------------- /src/mcp_cli/interactive/commands/model.py: -------------------------------------------------------------------------------- 1 | # mcp_cli/interactive/commands/model.py 2 | """ 3 | Interactive **model** command - view or change the active LLM model. 4 | 5 | Usage 6 | ----- 7 | model → show current provider / model 8 | model list → list models for the active provider 9 | model → switch to (probe first) 10 | model → switch provider (and optional model) 11 | m … → short alias 12 | """ 13 | from __future__ import annotations 14 | 15 | import logging 16 | from typing import Any, Dict, List 17 | 18 | from mcp_cli.utils.rich_helpers import get_console 19 | from mcp_cli.commands.model import model_action_async 20 | from .base import InteractiveCommand 21 | 22 | log = logging.getLogger(__name__) 23 | 24 | 25 | class ModelCommand(InteractiveCommand): 26 | """Inspect or change the current LLM model inside the interactive shell.""" 27 | 28 | def __init__(self) -> None: 29 | super().__init__( 30 | name="model", 31 | aliases=["m"], 32 | help_text="Show / switch the active LLM model (see `/help model`).", 33 | ) 34 | 35 | # ------------------------------------------------------------------ 36 | async def execute( # noqa: D401 37 | self, 38 | args: List[str], 39 | tool_manager: Any = None, # unused, kept for signature parity 40 | **ctx: Dict[str, Any], 41 | ) -> None: 42 | """ 43 | Delegate to :func:`mcp_cli.commands.model.model_action_async`. 44 | 45 | *args* is everything after the command word. 46 | """ 47 | console = get_console() 48 | 49 | # Basic sanity-check: the shared helper expects a ModelManager 50 | if "model_manager" not in ctx: 51 | log.debug("No model_manager in context – model command may misbehave.") 52 | console.print( 53 | "[yellow]Warning:[/yellow] internal ModelManager missing; " 54 | "results may be incomplete." 55 | ) 56 | 57 | await model_action_async(args, context=ctx) 58 | -------------------------------------------------------------------------------- /src/mcp_cli/interactive/commands/ping.py: -------------------------------------------------------------------------------- 1 | # mcp_cli/interactive/commands/ping.py 2 | """ 3 | Interactive **ping** command - measure round-trip latency to each 4 | connected MCP server. 5 | 6 | Usage 7 | ----- 8 | ping → ping every server 9 | ping 0 api → ping only server #0 and the one named “api” 10 | pg … → short alias 11 | """ 12 | from __future__ import annotations 13 | 14 | import logging 15 | from typing import Any, Dict, List 16 | 17 | from mcp_cli.utils.rich_helpers import get_console # ← NEW 18 | from mcp_cli.commands.ping import ping_action_async # shared async helper 19 | from mcp_cli.tools.manager import ToolManager 20 | from .base import InteractiveCommand 21 | 22 | log = logging.getLogger(__name__) 23 | 24 | 25 | class PingCommand(InteractiveCommand): 26 | """Measure server latency (interactive shell).""" 27 | 28 | def __init__(self) -> None: 29 | super().__init__( 30 | name="ping", 31 | aliases=["pg"], # handy two-letter shortcut 32 | help_text="Ping each MCP server (optionally filter by index or name).", 33 | ) 34 | 35 | # ------------------------------------------------------------------ 36 | async def execute( # noqa: D401 37 | self, 38 | args: List[str], 39 | tool_manager: ToolManager | None = None, 40 | **ctx: Dict[str, Any], 41 | ) -> None: 42 | """ 43 | Delegate to :func:`mcp_cli.commands.ping.ping_action_async`. 44 | 45 | *args* contains any filters supplied after the command word. 46 | """ 47 | console = get_console() 48 | 49 | if tool_manager is None: 50 | log.debug("PingCommand executed without ToolManager – aborting.") 51 | console.print("[red]Error:[/red] ToolManager not available.") 52 | return 53 | 54 | server_names = ctx.get("server_names") # may be None 55 | targets = args # filters (index / partial name) 56 | 57 | await ping_action_async( 58 | tool_manager, 59 | server_names=server_names, 60 | targets=targets, 61 | ) 62 | -------------------------------------------------------------------------------- /src/mcp_cli/interactive/commands/prompts.py: -------------------------------------------------------------------------------- 1 | # mcp_cli/interactive/commands/prompts.py 2 | """ 3 | Interactive **prompts** command - list prompt templates stored on every 4 | connected MCP server. 5 | 6 | Usage inside the shell 7 | ---------------------- 8 | prompts → show a table of prompts 9 | pr → short alias 10 | """ 11 | from __future__ import annotations 12 | 13 | import logging 14 | from typing import Any, Dict, List 15 | 16 | from mcp_cli.utils.rich_helpers import get_console # ← NEW 17 | from mcp_cli.commands.prompts import prompts_action_cmd # shared async helper 18 | from mcp_cli.tools.manager import ToolManager 19 | from .base import InteractiveCommand 20 | 21 | log = logging.getLogger(__name__) 22 | 23 | 24 | class PromptsCommand(InteractiveCommand): 25 | """Display stored prompt templates found on all servers.""" 26 | 27 | def __init__(self) -> None: 28 | super().__init__( 29 | name="prompts", 30 | aliases=["pr"], # avoid clash with /provider ("p") 31 | help_text="List prompt templates available on connected MCP servers.", 32 | ) 33 | 34 | # ------------------------------------------------------------------ 35 | async def execute( # noqa: D401 (simple delegation) 36 | self, 37 | args: List[str], 38 | tool_manager: ToolManager | None = None, 39 | **ctx: Dict[str, Any], 40 | ) -> None: 41 | """ 42 | Delegate to :func:`mcp_cli.commands.prompts.prompts_action_cmd`. 43 | """ 44 | console = get_console() 45 | 46 | if tool_manager is None: 47 | log.debug("PromptsCommand executed without ToolManager – aborting.") 48 | console.print("[red]Error:[/red] ToolManager not available.") 49 | return 50 | 51 | # No sub-arguments are supported right now: 52 | _ = args # kept for future flags 53 | 54 | await prompts_action_cmd(tool_manager) 55 | -------------------------------------------------------------------------------- /src/mcp_cli/interactive/commands/provider.py: -------------------------------------------------------------------------------- 1 | # mcp_cli/interactive/commands/provider.py 2 | """ 3 | Interactive **provider** command - inspect or switch the active LLM provider 4 | (and optionally the default model) from inside the shell. 5 | """ 6 | from __future__ import annotations 7 | 8 | import logging 9 | from typing import Any, Dict, List 10 | 11 | from mcp_cli.utils.rich_helpers import get_console # ← NEW 12 | from mcp_cli.commands.provider import provider_action_async # shared logic 13 | from .base import InteractiveCommand 14 | 15 | log = logging.getLogger(__name__) 16 | 17 | 18 | class ProviderCommand(InteractiveCommand): 19 | """Show / switch providers, tweak config, run diagnostics.""" 20 | 21 | def __init__(self) -> None: 22 | super().__init__( 23 | name="provider", 24 | aliases=["p"], 25 | help_text=( 26 | "Manage LLM providers.\n\n" 27 | " provider Show current provider/model\n" 28 | " provider list List available providers\n" 29 | " provider config Show provider configuration\n" 30 | " provider diagnostic [prov] Probe provider(s) health\n" 31 | " provider set Update one config key\n" 32 | " provider [model] Switch provider (and model)\n" 33 | ), 34 | ) 35 | 36 | # ------------------------------------------------------------------ 37 | async def execute( # noqa: D401 (simple entry-point) 38 | self, 39 | args: List[str], 40 | tool_manager: Any = None, # kept for API parity (unused) 41 | **ctx: Dict[str, Any], 42 | ) -> None: 43 | """ 44 | Delegate to :func:`provider_action_async`. 45 | 46 | *args* arrive without the leading command word, exactly as the 47 | shared helper expects. 48 | """ 49 | console = get_console() 50 | 51 | # The provider command does not require ToolManager, but log if absent 52 | if tool_manager is None: 53 | log.debug("ProviderCommand executed without ToolManager – OK for now.") 54 | 55 | try: 56 | await provider_action_async(args, context=ctx) 57 | except Exception as exc: # noqa: BLE001 58 | console.print(f"[red]Provider command failed:[/red] {exc}") 59 | log.exception("ProviderCommand error") 60 | -------------------------------------------------------------------------------- /src/mcp_cli/interactive/commands/resources.py: -------------------------------------------------------------------------------- 1 | # mcp_cli/interactive/commands/resources.py 2 | """ 3 | Interactive **resources** command - list every resource discovered by the 4 | connected MCP servers (URI, size, MIME-type, etc.). 5 | """ 6 | from __future__ import annotations 7 | 8 | import logging 9 | from typing import Any, List 10 | 11 | from mcp_cli.utils.rich_helpers import get_console # ← NEW 12 | from mcp_cli.commands.resources import resources_action_async # shared async helper 13 | from mcp_cli.tools.manager import ToolManager 14 | from .base import InteractiveCommand 15 | 16 | log = logging.getLogger(__name__) 17 | 18 | 19 | class ResourcesCommand(InteractiveCommand): 20 | """Display resources harvested by all connected servers.""" 21 | 22 | def __init__(self) -> None: 23 | super().__init__( 24 | name="resources", 25 | aliases=["res"], 26 | help_text="List resources (URI, size, MIME-type) on connected servers.", 27 | ) 28 | 29 | # ------------------------------------------------------------------ 30 | async def execute( # noqa: D401 (simple entry-point) 31 | self, 32 | args: List[str], 33 | tool_manager: ToolManager | None = None, 34 | **_: Any, 35 | ) -> None: 36 | console = get_console() 37 | 38 | if tool_manager is None: 39 | console.print("[red]Error:[/red] ToolManager not available.") 40 | log.debug("ResourcesCommand triggered without a ToolManager instance.") 41 | return 42 | 43 | # currently no extra flags – reserved for future 44 | await resources_action_async(tool_manager) 45 | -------------------------------------------------------------------------------- /src/mcp_cli/interactive/commands/servers.py: -------------------------------------------------------------------------------- 1 | # mcp_cli/interactive/commands/servers.py 2 | """ 3 | Interactive **servers** command - display every connected MCP server with 4 | its status and tool count. 5 | """ 6 | from __future__ import annotations 7 | 8 | import logging 9 | from typing import Any, List 10 | 11 | from mcp_cli.utils.rich_helpers import get_console # ← NEW 12 | from mcp_cli.commands.servers import servers_action_async # shared helper 13 | from mcp_cli.tools.manager import ToolManager 14 | from .base import InteractiveCommand 15 | 16 | log = logging.getLogger(__name__) 17 | 18 | 19 | class ServersCommand(InteractiveCommand): 20 | """Show connected servers and their basic stats.""" 21 | 22 | def __init__(self) -> None: 23 | super().__init__( 24 | name="servers", 25 | aliases=["srv"], 26 | help_text="List connected MCP servers with status and tool count.", 27 | ) 28 | 29 | # ──────────────────────────────────────────────────────────────── 30 | async def execute( # noqa: D401 31 | self, 32 | args: List[str], 33 | tool_manager: ToolManager | None = None, 34 | **_: Any, 35 | ) -> None: 36 | console = get_console() 37 | 38 | if tool_manager is None: 39 | console.print("[red]Error:[/red] ToolManager not available.") 40 | log.debug("ServersCommand executed without a ToolManager instance.") 41 | return 42 | 43 | # No extra arguments are currently supported but kept for future use 44 | await servers_action_async(tool_manager) 45 | -------------------------------------------------------------------------------- /src/mcp_cli/interactive/commands/tools.py: -------------------------------------------------------------------------------- 1 | # mcp_cli/interactive/commands/tools.py 2 | """ 3 | Interactive **tools** command - list all tools or launch the “call-a-tool” 4 | helper inside the *interactive shell* (not the chat TUI). 5 | """ 6 | from __future__ import annotations 7 | 8 | import logging 9 | from typing import Any, List 10 | 11 | from mcp_cli.utils.rich_helpers import get_console # ← NEW 12 | from mcp_cli.commands.tools import tools_action_async # shared async helper 13 | from mcp_cli.commands.tools_call import tools_call_action 14 | from mcp_cli.tools.manager import ToolManager 15 | from .base import InteractiveCommand 16 | 17 | log = logging.getLogger(__name__) 18 | 19 | 20 | class ToolsCommand(InteractiveCommand): 21 | """Show available tools or invoke one interactively.""" 22 | 23 | def __init__(self) -> None: 24 | super().__init__( 25 | name="tools", 26 | aliases=["t"], 27 | help_text=( 28 | "List available tools or run one interactively.\n\n" 29 | "Usage:\n" 30 | " tools – list tools\n" 31 | " tools --all – include parameter details\n" 32 | " tools --raw – dump raw JSON\n" 33 | " tools call – open interactive call helper" 34 | ), 35 | ) 36 | 37 | # ──────────────────────────────────────────────────────────────── 38 | async def execute( # noqa: D401 39 | self, 40 | args: List[str], 41 | tool_manager: ToolManager | None = None, 42 | **_: Any, 43 | ) -> None: 44 | console = get_console() 45 | 46 | # Ensure ToolManager exists 47 | if tool_manager is None: 48 | console.print("[red]Error:[/red] ToolManager not available.") 49 | log.debug("ToolsCommand executed without a ToolManager instance.") 50 | return 51 | 52 | # "tools call" → interactive call helper 53 | if args and args[0].lower() == "call": 54 | await tools_call_action(tool_manager) 55 | return 56 | 57 | # Otherwise list tools 58 | show_details = "--all" in args 59 | show_raw = "--raw" in args 60 | await tools_action_async( 61 | tool_manager, 62 | show_details=show_details, 63 | show_raw=show_raw, 64 | ) 65 | -------------------------------------------------------------------------------- /src/mcp_cli/interactive/registry.py: -------------------------------------------------------------------------------- 1 | # src/mcp_cli/interactive/registry.py 2 | """Registry for interactive commands.""" 3 | from __future__ import annotations 4 | import logging 5 | from typing import Dict, Optional 6 | 7 | # commands 8 | from mcp_cli.interactive.commands.base import InteractiveCommand 9 | 10 | # logger 11 | logger = logging.getLogger(__name__) 12 | 13 | class InteractiveCommandRegistry: 14 | """Registry for interactive commands.""" 15 | 16 | _commands: Dict[str, InteractiveCommand] = {} 17 | _aliases: Dict[str, str] = {} 18 | 19 | @classmethod 20 | def register(cls, command: InteractiveCommand) -> None: 21 | """Register a command under its name and any aliases.""" 22 | cls._commands[command.name] = command 23 | for alias in command.aliases: 24 | cls._aliases[alias] = command.name 25 | 26 | @classmethod 27 | def get_command(cls, name: str) -> Optional[InteractiveCommand]: 28 | """Retrieve a command by name or alias.""" 29 | # resolve alias 30 | if name in cls._aliases: 31 | name = cls._aliases[name] 32 | return cls._commands.get(name) 33 | 34 | @classmethod 35 | def get_all_commands(cls) -> Dict[str, InteractiveCommand]: 36 | """Return the mapping of all registered commands.""" 37 | if not isinstance(cls._commands, dict): 38 | print("[DEBUG] InteractiveCommandRegistry._commands polluted! Type:", type(cls._commands)) 39 | cls._commands = {} 40 | return cls._commands 41 | 42 | 43 | def register_all_commands() -> None: 44 | """ 45 | Register every interactive command in the central registry. 46 | """ 47 | from mcp_cli.interactive.registry import InteractiveCommandRegistry 48 | from mcp_cli.interactive.commands.help import HelpCommand 49 | from mcp_cli.interactive.commands.exit import ExitCommand 50 | from mcp_cli.interactive.commands.clear import ClearCommand 51 | from mcp_cli.interactive.commands.servers import ServersCommand 52 | from mcp_cli.interactive.commands.tools import ToolsCommand 53 | from mcp_cli.interactive.commands.resources import ResourcesCommand 54 | from mcp_cli.interactive.commands.prompts import PromptsCommand 55 | from mcp_cli.interactive.commands.ping import PingCommand 56 | 57 | reg = InteractiveCommandRegistry 58 | reg.register(HelpCommand()) 59 | reg.register(ExitCommand()) 60 | reg.register(ClearCommand()) 61 | reg.register(ServersCommand()) 62 | reg.register(ToolsCommand()) 63 | reg.register(ResourcesCommand()) 64 | reg.register(PromptsCommand()) 65 | reg.register(PingCommand()) 66 | -------------------------------------------------------------------------------- /src/mcp_cli/interactive/shell.py: -------------------------------------------------------------------------------- 1 | # src/mcp_cli/interactive/shell.py 2 | """Interactive shell implementation for MCP CLI with slash-menu autocompletion.""" 3 | from __future__ import annotations 4 | import asyncio 5 | import logging 6 | import shlex 7 | from typing import Any, Dict, List, Optional 8 | 9 | from rich import print 10 | from rich.console import Console 11 | from rich.markdown import Markdown 12 | from rich.panel import Panel 13 | 14 | # Use prompt_toolkit for advanced prompt and autocompletion 15 | from prompt_toolkit import PromptSession 16 | from prompt_toolkit.completion import Completer, Completion 17 | 18 | # mcp cli 19 | from mcp_cli.tools.manager import ToolManager 20 | 21 | # commands 22 | from mcp_cli.interactive.commands import register_all_commands 23 | from mcp_cli.interactive.registry import InteractiveCommandRegistry 24 | 25 | # logger 26 | logger = logging.getLogger(__name__) 27 | 28 | 29 | class SlashCompleter(Completer): 30 | """Provides completions for slash commands based on registered commands.""" 31 | def __init__(self, command_names: List[str]): 32 | self.command_names = command_names 33 | 34 | def get_completions(self, document, complete_event): 35 | text = document.text_before_cursor.lstrip() 36 | if not text.startswith("/"): 37 | return 38 | token = text[1:] 39 | for name in self.command_names: 40 | if name.startswith(token): 41 | yield Completion( 42 | f"/{name}", start_position=-len(text) 43 | ) 44 | 45 | 46 | async def interactive_mode( 47 | stream_manager: Any = None, 48 | tool_manager: Optional[ToolManager] = None, 49 | provider: str = "openai", 50 | model: str = "gpt-4o-mini", 51 | server_names: Optional[Dict[int, str]] = None, 52 | **kwargs 53 | ) -> bool: 54 | """ 55 | Launch the interactive mode CLI with slash-menu autocompletion. 56 | """ 57 | console = Console() 58 | 59 | # Register commands 60 | register_all_commands() 61 | cmd_names = list(InteractiveCommandRegistry.get_all_commands().keys()) 62 | 63 | # Intro panel 64 | print(Panel( 65 | Markdown( 66 | "# Interactive Mode\n\n" 67 | "Type commands to interact with the system.\n" 68 | "Type 'help' to see available commands.\n" 69 | "Type 'exit' or 'quit' to exit.\n" 70 | "Type '/' to bring up the slash-menu." 71 | ), 72 | title="MCP Interactive Mode", 73 | style="bold cyan" 74 | )) 75 | 76 | # Initial help listing 77 | help_cmd = InteractiveCommandRegistry.get_command("help") 78 | if help_cmd: 79 | await help_cmd.execute([], tool_manager, server_names=server_names) 80 | 81 | # Create a PromptSession with our completer 82 | session = PromptSession( 83 | completer=SlashCompleter(cmd_names), 84 | complete_while_typing=True, 85 | ) 86 | 87 | # Main loop 88 | while True: 89 | try: 90 | raw = await asyncio.to_thread(session.prompt, "> ") 91 | line = raw.strip() 92 | 93 | # Skip empty 94 | if not line: 95 | continue 96 | 97 | # If user types a slash command exactly 98 | if line.startswith("/"): 99 | # strip leading slash and dispatch 100 | cmd_line = line[1:] 101 | else: 102 | # normal entry 103 | cmd_line = line 104 | 105 | # If line was just '/', show help 106 | if cmd_line == "": 107 | if help_cmd: 108 | await help_cmd.execute([], tool_manager, server_names=server_names) 109 | continue 110 | 111 | # Parse 112 | try: 113 | parts = shlex.split(cmd_line) 114 | except ValueError: 115 | parts = cmd_line.split() 116 | 117 | cmd_name = parts[0].lower() 118 | args = parts[1:] 119 | 120 | # Lookup and execute 121 | cmd = InteractiveCommandRegistry.get_command(cmd_name) 122 | if cmd: 123 | result = await cmd.execute(args, tool_manager, server_names=server_names, **kwargs) 124 | if result is True: 125 | return True 126 | else: 127 | print(f"[red]Unknown command: {cmd_name}[/red]") 128 | print("[dim]Type 'help' to see available commands.[/dim]") 129 | 130 | except KeyboardInterrupt: 131 | print("\n[yellow]Interrupted. Type 'exit' to quit.[/yellow]") 132 | except EOFError: 133 | print("\n[yellow]EOF detected. Exiting.[/yellow]") 134 | return True 135 | except Exception as e: 136 | logger.exception("Error in interactive mode") 137 | print(f"[red]Error: {e}[/red]") 138 | 139 | return True 140 | -------------------------------------------------------------------------------- /src/mcp_cli/llm/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrishayuk/mcp-cli/b94b3141a075fa83cbb2c2ba921bbf98dfa2e07e/src/mcp_cli/llm/__init__.py -------------------------------------------------------------------------------- /src/mcp_cli/llm/system_prompt_generator.py: -------------------------------------------------------------------------------- 1 | # mcp_cli/llm/system_prompt_generator.py 2 | import json 3 | 4 | class SystemPromptGenerator: 5 | """ 6 | A class for generating system prompts dynamically based on tools JSON and user inputs. 7 | """ 8 | 9 | def __init__(self): 10 | """ 11 | Initialize the SystemPromptGenerator with a default system prompt template. 12 | """ 13 | self.template = """ 14 | In this environment you have access to a set of tools you can use to answer the user's question. 15 | {{ FORMATTING INSTRUCTIONS }} 16 | String and scalar parameters should be specified as is, while lists and objects should use JSON format. Note that spaces for string values are not stripped. The output is not expected to be valid XML and is parsed with regular expressions. 17 | Here are the functions available in JSONSchema format: 18 | {{ TOOL DEFINITIONS IN JSON SCHEMA }} 19 | {{ USER SYSTEM PROMPT }} 20 | {{ TOOL CONFIGURATION }} 21 | """ 22 | self.default_user_system_prompt = "You are an intelligent assistant capable of using tools to solve user queries effectively." 23 | self.default_tool_config = "No additional configuration is required." 24 | 25 | def generate_prompt( 26 | self, tools: dict, user_system_prompt: str = None, tool_config: str = None 27 | ) -> str: 28 | """ 29 | Generate a system prompt based on the provided tools JSON, user prompt, and tool configuration. 30 | 31 | Args: 32 | tools (dict): The tools JSON containing definitions of the available tools. 33 | user_system_prompt (str): A user-provided description or instruction for the assistant (optional). 34 | tool_config (str): Additional tool configuration information (optional). 35 | 36 | Returns: 37 | str: The dynamically generated system prompt. 38 | """ 39 | 40 | # set the user system prompt 41 | user_system_prompt = user_system_prompt or self.default_user_system_prompt 42 | 43 | # set the tools config 44 | tool_config = tool_config or self.default_tool_config 45 | 46 | # get the tools schema 47 | tools_json_schema = json.dumps(tools, indent=2) 48 | 49 | # perform replacements 50 | prompt = self.template.replace( 51 | "{{ TOOL DEFINITIONS IN JSON SCHEMA }}", tools_json_schema 52 | ) 53 | prompt = prompt.replace("{{ FORMATTING INSTRUCTIONS }}", "") 54 | prompt = prompt.replace("{{ USER SYSTEM PROMPT }}", user_system_prompt) 55 | prompt = prompt.replace("{{ TOOL CONFIGURATION }}", tool_config) 56 | 57 | # return the prompt 58 | return prompt 59 | -------------------------------------------------------------------------------- /src/mcp_cli/logging_config.py: -------------------------------------------------------------------------------- 1 | # mcp_cli/logging_config.py 2 | """ 3 | Centralized logging configuration for MCP CLI. 4 | """ 5 | import logging 6 | import os 7 | import sys 8 | from typing import Optional 9 | 10 | def setup_logging( 11 | level: str = "WARNING", 12 | quiet: bool = False, 13 | verbose: bool = False, 14 | format_style: str = "simple" 15 | ) -> None: 16 | """ 17 | Configure centralized logging for MCP CLI and all dependencies. 18 | 19 | Args: 20 | level: Base logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL) 21 | quiet: If True, suppress most output except errors 22 | verbose: If True, enable debug logging 23 | format_style: "simple", "detailed", or "json" 24 | """ 25 | # Determine effective log level 26 | if quiet: 27 | log_level = logging.ERROR 28 | elif verbose: 29 | log_level = logging.DEBUG 30 | else: 31 | # Parse string level 32 | numeric_level = getattr(logging, level.upper(), None) 33 | if not isinstance(numeric_level, int): 34 | raise ValueError(f'Invalid log level: {level}') 35 | log_level = numeric_level 36 | 37 | # Set environment variable that chuk components respect 38 | os.environ["CHUK_LOG_LEVEL"] = logging.getLevelName(log_level) 39 | 40 | # Clear any existing handlers 41 | root_logger = logging.getLogger() 42 | for handler in root_logger.handlers[:]: 43 | root_logger.removeHandler(handler) 44 | 45 | # Configure format 46 | if format_style == "json": 47 | formatter = logging.Formatter( 48 | '{"timestamp": "%(asctime)s", "level": "%(levelname)s", ' 49 | '"message": "%(message)s", "logger": "%(name)s"}' 50 | ) 51 | elif format_style == "detailed": 52 | formatter = logging.Formatter( 53 | "%(asctime)s [%(levelname)-8s] %(name)s:%(lineno)d - %(message)s" 54 | ) 55 | else: # simple 56 | formatter = logging.Formatter("%(levelname)-8s %(message)s") 57 | 58 | # Create console handler 59 | console_handler = logging.StreamHandler(sys.stderr) 60 | console_handler.setFormatter(formatter) 61 | console_handler.setLevel(log_level) 62 | 63 | # Configure root logger 64 | root_logger.setLevel(log_level) 65 | root_logger.addHandler(console_handler) 66 | 67 | # Silence noisy third-party loggers unless in debug mode 68 | if log_level > logging.DEBUG: 69 | # Silence chuk components unless we need debug info 70 | logging.getLogger("chuk_tool_processor").setLevel(logging.WARNING) 71 | logging.getLogger("chuk_mcp").setLevel(logging.WARNING) 72 | logging.getLogger("chuk_llm").setLevel(logging.WARNING) 73 | 74 | # Silence other common noisy loggers 75 | logging.getLogger("urllib3").setLevel(logging.WARNING) 76 | logging.getLogger("requests").setLevel(logging.WARNING) 77 | logging.getLogger("httpx").setLevel(logging.WARNING) 78 | 79 | # Set mcp_cli loggers to appropriate level 80 | logging.getLogger("mcp_cli").setLevel(log_level) 81 | 82 | 83 | def get_logger(name: str) -> logging.Logger: 84 | """Get a logger with the given name.""" 85 | return logging.getLogger(f"mcp_cli.{name}") 86 | 87 | 88 | # Convenience function for common use case 89 | def setup_quiet_logging() -> None: 90 | """Set up minimal logging for production use.""" 91 | setup_logging(quiet=True) 92 | 93 | 94 | def setup_verbose_logging() -> None: 95 | """Set up detailed logging for debugging.""" 96 | setup_logging(verbose=True, format_style="detailed") -------------------------------------------------------------------------------- /src/mcp_cli/tools/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrishayuk/mcp-cli/b94b3141a075fa83cbb2c2ba921bbf98dfa2e07e/src/mcp_cli/tools/__init__.py -------------------------------------------------------------------------------- /src/mcp_cli/tools/adapter.py: -------------------------------------------------------------------------------- 1 | # mcp_cli/tools/adapter.py 2 | """ 3 | Adapters for transforming tool names and definitions for different LLM providers. 4 | """ 5 | import re 6 | from typing import Dict, List 7 | 8 | from mcp_cli.tools.models import ToolInfo 9 | 10 | 11 | class ToolNameAdapter: 12 | """Handles adaptation between OpenAI-compatible tool names and MCP original names.""" 13 | 14 | @staticmethod 15 | def to_openai_compatible(namespace: str, name: str) -> str: 16 | """ 17 | Convert MCP tool name with namespace to OpenAI-compatible format. 18 | 19 | OpenAI requires tool names to match pattern: ^[a-zA-Z0-9_-]+$ 20 | 21 | Args: 22 | namespace: Tool namespace 23 | name: Tool name 24 | 25 | Returns: 26 | OpenAI-compatible name (namespace_name with invalid chars replaced) 27 | """ 28 | # First combine namespace and name with underscore 29 | combined = f"{namespace}_{name}" 30 | 31 | # Replace any characters that don't comply with OpenAI's pattern 32 | sanitized = re.sub(r'[^a-zA-Z0-9_-]', '_', combined) 33 | 34 | return sanitized 35 | 36 | @staticmethod 37 | def from_openai_compatible(openai_name: str) -> str: 38 | """ 39 | Convert OpenAI-compatible name back to MCP format. 40 | 41 | Args: 42 | openai_name: OpenAI-compatible tool name 43 | 44 | Returns: 45 | Original MCP tool name with namespace (namespace.name) 46 | """ 47 | # Check if there's an underscore to convert back to dot notation 48 | if '_' in openai_name: 49 | parts = openai_name.split('_', 1) 50 | return f"{parts[0]}.{parts[1]}" 51 | return openai_name 52 | 53 | @staticmethod 54 | def build_mapping(tools: List[ToolInfo]) -> Dict[str, str]: 55 | """ 56 | Build a mapping between OpenAI names and original names. 57 | 58 | Args: 59 | tools: List of ToolInfo objects 60 | 61 | Returns: 62 | Dictionary mapping OpenAI names to original names 63 | """ 64 | mapping = {} 65 | for tool in tools: 66 | openai_name = ToolNameAdapter.to_openai_compatible(tool.namespace, tool.name) 67 | original_name = f"{tool.namespace}.{tool.name}" 68 | mapping[openai_name] = original_name 69 | return mapping -------------------------------------------------------------------------------- /src/mcp_cli/tools/models.py: -------------------------------------------------------------------------------- 1 | # mcp_cli/tools/models.py 2 | """Data models used throughout MCP-CLI.""" 3 | from __future__ import annotations 4 | 5 | from dataclasses import dataclass, field 6 | from typing import Any, Dict, List, Optional 7 | 8 | 9 | # ────────────────────────────────────────────────────────────────────────────── 10 | # Tool-related models (unchanged) 11 | # ────────────────────────────────────────────────────────────────────────────── 12 | @dataclass 13 | class ToolInfo: 14 | """Information about a tool.""" 15 | name: str 16 | namespace: str 17 | description: Optional[str] = None 18 | parameters: Optional[Dict[str, Any]] = None 19 | is_async: bool = False 20 | tags: List[str] = field(default_factory=list) 21 | supports_streaming: bool = False # Add this field 22 | 23 | 24 | @dataclass 25 | class ServerInfo: 26 | """Information about a connected server instance.""" 27 | id: int 28 | name: str 29 | status: str 30 | tool_count: int 31 | namespace: str 32 | 33 | 34 | @dataclass 35 | class ToolCallResult: 36 | """Outcome of a tool execution.""" 37 | tool_name: str 38 | success: bool 39 | result: Any = None 40 | error: Optional[str] = None 41 | execution_time: Optional[float] = None 42 | 43 | 44 | # ────────────────────────────────────────────────────────────────────────────── 45 | # NEW – resource-related models 46 | # ────────────────────────────────────────────────────────────────────────────── 47 | @dataclass 48 | class ResourceInfo: 49 | """ 50 | Canonical representation of *one* resource entry as returned by 51 | ``resources.list``. 52 | 53 | The MCP spec does not prescribe a single shape, so we normalise the common 54 | fields we use in the UI. **All additional keys** are preserved inside 55 | ``extra``. 56 | """ 57 | 58 | # Common attributes we frequently need in the UI 59 | id: Optional[str] = None 60 | name: Optional[str] = None 61 | type: Optional[str] = None 62 | 63 | # Anything else goes here … 64 | extra: Dict[str, Any] = field(default_factory=dict) 65 | 66 | # ------------------------------------------------------------------ # 67 | # Factory helpers 68 | # ------------------------------------------------------------------ # 69 | @classmethod 70 | def from_raw(cls, raw: Any) -> "ResourceInfo": 71 | """ 72 | Convert a raw list item (dict | str | int | …) into a ResourceInfo. 73 | 74 | If *raw* is not a mapping we treat it as an opaque scalar and store it 75 | in ``extra["value"]`` so it is never lost. 76 | """ 77 | if isinstance(raw, dict): 78 | known = {k: raw.get(k) for k in ("id", "name", "type")} 79 | extra = {k: v for k, v in raw.items() if k not in known} 80 | return cls(**known, extra=extra) 81 | # primitive – wrap it 82 | return cls(extra={"value": raw}) 83 | -------------------------------------------------------------------------------- /src/mcp_cli/ui/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrishayuk/mcp-cli/b94b3141a075fa83cbb2c2ba921bbf98dfa2e07e/src/mcp_cli/ui/__init__.py -------------------------------------------------------------------------------- /src/mcp_cli/ui/colors.py: -------------------------------------------------------------------------------- 1 | # mcp_cli/ui/colors.py 2 | """ 3 | Color definitions for the MCP CLI UI elements. 4 | """ 5 | 6 | # Border colors 7 | BORDER_PRIMARY = "yellow" 8 | BORDER_SECONDARY = "blue" 9 | 10 | # Text colors and styles 11 | TEXT_NORMAL = "white" 12 | TEXT_EMPHASIS = "bold" 13 | TEXT_DEEMPHASIS = "dim" 14 | TEXT_SUCCESS = "green" 15 | TEXT_ERROR = "red" 16 | TEXT_WARNING = "yellow" 17 | TEXT_INFO = "cyan" 18 | TEXT_HINT = "dim cyan italic" # Added for hints/tips 19 | 20 | # Panel styles 21 | PANEL_DEFAULT = "default" 22 | 23 | # Component-specific colors 24 | SERVER_COLOR = "cyan" 25 | TOOL_COUNT_COLOR = "green" 26 | STATUS_COLOR = "yellow" 27 | TITLE_COLOR = "bold cyan" 28 | 29 | # User/Assistant colors 30 | USER_COLOR = "yellow" 31 | ASSISTANT_COLOR = "blue" 32 | TOOL_COLOR = "magenta" -------------------------------------------------------------------------------- /src/mcp_cli/ui/ui_helpers.py: -------------------------------------------------------------------------------- 1 | # mcp_cli/ui/ui_helpers.py 2 | """ 3 | Shared Rich helpers for MCP-CLI UIs. 4 | """ 5 | from __future__ import annotations 6 | 7 | import asyncio 8 | import gc 9 | import logging 10 | import os 11 | import sys 12 | 13 | from rich.console import Console 14 | from rich.markdown import Markdown 15 | from rich.panel import Panel 16 | from typing import Dict, Any 17 | 18 | 19 | # --------------------------------------------------------------------------- # 20 | # generic helpers # 21 | # --------------------------------------------------------------------------- # 22 | _console = Console() 23 | 24 | 25 | def clear_screen() -> None: 26 | """Clear the terminal (cross-platform).""" 27 | _console.clear() 28 | 29 | 30 | def restore_terminal() -> None: 31 | """Restore terminal settings and clean up asyncio resources.""" 32 | # Restore the terminal settings to normal 33 | os.system("stty sane") 34 | 35 | try: 36 | # Find and close the event loop if one exists 37 | try: 38 | loop = asyncio.get_event_loop_policy().get_event_loop() 39 | if loop.is_closed(): 40 | return 41 | 42 | # Cancel outstanding tasks 43 | tasks = [t for t in asyncio.all_tasks(loop) if not t.done()] 44 | for task in tasks: 45 | task.cancel() 46 | 47 | if tasks: 48 | loop.run_until_complete(asyncio.gather(*tasks, return_exceptions=True)) 49 | 50 | loop.run_until_complete(loop.shutdown_asyncgens()) 51 | loop.close() 52 | except Exception as exc: 53 | logging.debug(f"Asyncio cleanup error: {exc}") 54 | finally: 55 | # Force garbage collection 56 | gc.collect() 57 | 58 | 59 | # --------------------------------------------------------------------------- # 60 | # Chat / Interactive welcome banners # 61 | # --------------------------------------------------------------------------- # 62 | def display_welcome_banner(ctx: Dict[str, Any]) -> None: 63 | """ 64 | Print **one** nice banner when entering chat-mode. 65 | 66 | Parameters 67 | ---------- 68 | ctx 69 | A dict that *at least* contains the keys:: 70 | provider – e.g. "openai" 71 | model – e.g. "gpt-4o-mini" 72 | """ 73 | provider = ctx.get("provider") or "-" 74 | model = ctx.get("model") or "gpt-4o-mini" 75 | 76 | _console.print( 77 | Panel( 78 | Markdown( 79 | f"# Welcome to MCP CLI Chat!\n\n" 80 | f"**Provider:** {provider}" 81 | f" | **Model:** {model}\n\n" 82 | "Type **`exit`** to quit." 83 | ), 84 | title="Welcome to MCP CLI Chat", 85 | border_style="yellow", 86 | expand=True, 87 | ) 88 | ) -------------------------------------------------------------------------------- /src/mcp_cli/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrishayuk/mcp-cli/b94b3141a075fa83cbb2c2ba921bbf98dfa2e07e/src/mcp_cli/utils/__init__.py -------------------------------------------------------------------------------- /src/mcp_cli/utils/async_utils.py: -------------------------------------------------------------------------------- 1 | # src/mcp_cli/utils/async_utils.py 2 | """ 3 | Tiny helper for “run an async coroutine from possibly-sync code”. 4 | 5 | * If no event-loop exists → `asyncio.run`. 6 | * If a loop exists but is **not** running → `loop.run_until_complete`. 7 | * If called **inside** a running loop → we raise, so callers know to 8 | switch to the `*_async` variant instead of silently returning junk. 9 | """ 10 | from __future__ import annotations 11 | 12 | import asyncio 13 | from typing import Awaitable, TypeVar 14 | 15 | T = TypeVar("T") 16 | 17 | 18 | def run_blocking(coro: Awaitable[T]) -> T: 19 | try: 20 | loop = asyncio.get_running_loop() 21 | except RuntimeError: # totally sync context 22 | return asyncio.run(coro) 23 | 24 | if loop.is_running(): 25 | raise RuntimeError( 26 | "run_blocking() called inside a running event-loop – " 27 | "use the async API instead." 28 | ) 29 | return loop.run_until_complete(coro) 30 | -------------------------------------------------------------------------------- /src/mcp_cli/utils/rich_helpers.py: -------------------------------------------------------------------------------- 1 | # mcp_cli/utils/rich_helpers.py 2 | from rich.console import Console 3 | import sys, os 4 | 5 | def get_console() -> Console: 6 | """ 7 | Return a Console configured for the current platform / TTY. 8 | - Disables colour if stdout is redirected. 9 | - Enables legacy Windows support for very old terminals. 10 | - Adds soft-wrap to prevent horizontal overflow. 11 | """ 12 | return Console( 13 | no_color=not sys.stdout.isatty(), 14 | legacy_windows=True, # harmless on mac/Linux, useful on Win ≤8.1 15 | soft_wrap=True, 16 | ) 17 | -------------------------------------------------------------------------------- /test.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrishayuk/mcp-cli/b94b3141a075fa83cbb2c2ba921bbf98dfa2e07e/test.db -------------------------------------------------------------------------------- /test_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "mcpServers": { 3 | "sqlite": { 4 | "command": "mcp-server-sqlite", 5 | "args": [ 6 | "--db-path", 7 | "./test.db" 8 | ] 9 | } 10 | }, 11 | "toolSettings": { 12 | "defaultTimeout": 300.0, 13 | "maxConcurrency": 4 14 | } 15 | } -------------------------------------------------------------------------------- /test_mcp_cli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import os 3 | import sys 4 | sys.path.insert(0, 'src') 5 | 6 | os.environ['MCP_TOOL_TIMEOUT'] = '300' 7 | 8 | import subprocess 9 | result = subprocess.run([ 10 | '/Users/christopherhay/chris-source/mcp-cli/.venv/bin/python3', '-m', 'mcp_cli', 'chat', 11 | '--config', 'test_config.json' 12 | ], env=os.environ) 13 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrishayuk/mcp-cli/b94b3141a075fa83cbb2c2ba921bbf98dfa2e07e/tests/__init__.py -------------------------------------------------------------------------------- /tests/mcp_cli/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrishayuk/mcp-cli/b94b3141a075fa83cbb2c2ba921bbf98dfa2e07e/tests/mcp_cli/__init__.py -------------------------------------------------------------------------------- /tests/mcp_cli/chat/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrishayuk/mcp-cli/b94b3141a075fa83cbb2c2ba921bbf98dfa2e07e/tests/mcp_cli/chat/__init__.py -------------------------------------------------------------------------------- /tests/mcp_cli/chat/test_chat_handler.py: -------------------------------------------------------------------------------- 1 | # tests/mcp_cli/chat/test_chat_handler.py 2 | """Unit‑tests for *mcp_cli.chat.chat_handler* – high‑level happy‑path checks. 3 | 4 | We monkey‑patch the UI layer and the slow bits so the coroutine finishes 5 | immediately without real user interaction or network calls. 6 | """ 7 | from __future__ import annotations 8 | 9 | import asyncio 10 | from types import SimpleNamespace 11 | from typing import Any, Dict 12 | 13 | import pytest 14 | 15 | import mcp_cli.chat.chat_handler as chat_handler 16 | 17 | # --------------------------------------------------------------------------- 18 | # Dummy ToolManager – only the attrs used by handle_chat_mode 19 | # --------------------------------------------------------------------------- 20 | class DummyToolManager: # noqa: WPS110 – test helper 21 | def __init__(self): 22 | self.closed = False 23 | 24 | # ChatContext expects async discovery helpers – keep minimal stubs 25 | async def get_unique_tools(self): 26 | return [] # empty tool list is fine 27 | 28 | async def get_server_info(self): 29 | return [] 30 | 31 | async def get_adapted_tools_for_llm(self, provider: str = "openai"): 32 | return [], {} 33 | 34 | async def get_tools_for_llm(self): 35 | return [] 36 | 37 | async def get_server_for_tool(self, tool_name: str): 38 | return None 39 | 40 | async def close(self): 41 | self.closed = True 42 | 43 | # --------------------------------------------------------------------------- 44 | # Fixtures that silence the UI side‑effects 45 | # --------------------------------------------------------------------------- 46 | @pytest.fixture(autouse=True) 47 | def _silence_rich(monkeypatch): 48 | monkeypatch.setattr(chat_handler, "clear_screen", lambda: None) 49 | monkeypatch.setattr(chat_handler, "display_welcome_banner", lambda *_a, **_k: None) 50 | 51 | 52 | @pytest.fixture() 53 | def dummy_tm(): 54 | return DummyToolManager() 55 | 56 | # --------------------------------------------------------------------------- 57 | # Helper to skip the interactive loop 58 | # --------------------------------------------------------------------------- 59 | @pytest.fixture(autouse=True) 60 | def _short_circuit_run_loop(monkeypatch): 61 | async def fake_run_loop(ui, ctx, convo): # noqa: WPS110 62 | # Simulate one user message so that `_run_chat_loop` finishes nicely. 63 | # We need to mimic the expected API of the real objects just enough so 64 | # the logic paths are exercised. 65 | ctx.exit_requested = True # make the outer loop exit after first check 66 | monkeypatch.setattr(chat_handler, "_run_chat_loop", fake_run_loop) 67 | 68 | # --------------------------------------------------------------------------- 69 | # Monkey‑patch ChatUIManager so we don't need prompt‑toolkit etc. 70 | # --------------------------------------------------------------------------- 71 | class DummyUI: 72 | def __init__(self, ctx): 73 | self.ctx = ctx 74 | 75 | # methods used by handler – all no‑ops 76 | async def get_user_input(self): # pragma: no cover – not reached 77 | return "quit" 78 | 79 | def print_user_message(self, *_a, **_k): 80 | pass 81 | 82 | async def handle_command(self, *_a, **_k): # pragma: no cover 83 | return False 84 | 85 | def cleanup(self): 86 | return None 87 | 88 | @pytest.fixture(autouse=True) 89 | def _patch_ui(monkeypatch): 90 | monkeypatch.setattr(chsat_handler, "ChatUIManager", DummyUI) 91 | 92 | # --------------------------------------------------------------------------- 93 | # Tests 94 | # --------------------------------------------------------------------------- 95 | @pytest.mark.asyncio 96 | async def test_handle_chat_mode_happy_path(dummy_tm): 97 | result = await chat_handler.handle_chat_mode(tool_manager=dummy_tm) 98 | 99 | assert result is True 100 | # ToolManager.close() must have been awaited 101 | assert dummy_tm.closed is True 102 | -------------------------------------------------------------------------------- /tests/mcp_cli/chat/test_ui_manager.py: -------------------------------------------------------------------------------- 1 | """ 2 | Unit tests for the prompt-toolkit completer used in chat / interactive mode. 3 | 4 | The only thing we really care about here is that **no circular-import** or 5 | other initialisation error is triggered when completions are requested. 6 | """ 7 | 8 | from __future__ import annotations 9 | 10 | import pytest 11 | from prompt_toolkit.document import Document 12 | 13 | from mcp_cli.chat.command_completer import ChatCommandCompleter 14 | 15 | 16 | @pytest.fixture() 17 | def completer(): 18 | """A completer instance with a minimal dummy context.""" 19 | return ChatCommandCompleter(context={}) 20 | 21 | 22 | @pytest.mark.parametrize( 23 | "text, expect_some", 24 | [ 25 | ("/to", True), # should suggest /tools, /tools-all, … 26 | ("/xyz", False), # no command starts with /xyz 27 | ("plain text", False), # not a slash-command → no completions 28 | ], 29 | ) 30 | def test_get_completions(completer, text: str, expect_some: bool): 31 | """ 32 | • must **not** raise 33 | • returns an iterator; convert to list so we can inspect the suggestions 34 | """ 35 | comps = list(completer.get_completions(Document(text=text), None)) 36 | if expect_some: 37 | assert comps, f"expected suggestions for {text!r}" 38 | else: 39 | assert not comps, f"unexpected suggestions for {text!r}" 40 | -------------------------------------------------------------------------------- /tests/mcp_cli/cli/test_cli_chat.py: -------------------------------------------------------------------------------- 1 | # tests/test_cli_chat_command.py 2 | 3 | import pytest 4 | from typing import Any 5 | from mcp_cli.cli.commands.chat import ChatCommand 6 | from mcp_cli.tools.manager import ToolManager 7 | 8 | class DummyTM(ToolManager): 9 | pass 10 | 11 | @pytest.mark.asyncio 12 | async def test_chat_execute_forwards_defaults(monkeypatch): 13 | """When no override params are passed, execute() should call handle_chat_mode with defaults.""" 14 | captured: dict[str, Any] = {} 15 | async def fake_handle(tm, provider, model): 16 | captured['tm'] = tm 17 | captured['provider'] = provider 18 | captured['model'] = model 19 | return "CHAT_DONE" 20 | 21 | # Patch the real handle_chat_mode in its module 22 | monkeypatch.setattr( 23 | "mcp_cli.chat.chat_handler.handle_chat_mode", 24 | fake_handle 25 | ) 26 | 27 | cmd = ChatCommand() 28 | tm = DummyTM(config_file="", servers=[]) 29 | 30 | # Call execute without params → uses default provider/model 31 | result = await cmd.execute(tool_manager=tm) 32 | assert result == "CHAT_DONE" 33 | assert captured['tm'] is tm 34 | assert captured['provider'] == "openai" 35 | assert captured['model'] == "gpt-4o-mini" 36 | 37 | @pytest.mark.asyncio 38 | async def test_chat_execute_forwards_explicit(monkeypatch): 39 | """When provider/model overrides are passed, execute() should forward them.""" 40 | captured: dict[str, Any] = {} 41 | async def fake_handle(tm, provider, model): 42 | captured['tm'] = tm 43 | captured['provider'] = provider 44 | captured['model'] = model 45 | return "OK" 46 | 47 | monkeypatch.setattr( 48 | "mcp_cli.chat.chat_handler.handle_chat_mode", 49 | fake_handle 50 | ) 51 | 52 | cmd = ChatCommand() 53 | tm = DummyTM(config_file="", servers=[]) 54 | 55 | result = await cmd.execute( 56 | tool_manager=tm, 57 | provider="myProv", 58 | model="myModel" 59 | ) 60 | assert result == "OK" 61 | assert captured['tm'] is tm 62 | assert captured['provider'] == "myProv" 63 | assert captured['model'] == "myModel" 64 | -------------------------------------------------------------------------------- /tests/mcp_cli/cli/test_cli_interactive.py: -------------------------------------------------------------------------------- 1 | # tests/test_cli_interactive_command.py 2 | import pytest 3 | import asyncio 4 | 5 | from mcp_cli.cli.commands.interactive import InteractiveCommand 6 | from mcp_cli.tools.manager import ToolManager 7 | 8 | class DummyTM(ToolManager): 9 | pass 10 | 11 | @pytest.mark.asyncio 12 | async def test_execute_forwards_defaults(monkeypatch): 13 | """If no params passed, execute() should call interactive_mode with defaults.""" 14 | captured = {} 15 | async def fake_im(tool_manager, provider, model, server_names=None): 16 | captured['tm'] = tool_manager 17 | captured['provider'] = provider 18 | captured['model'] = model 19 | captured['servers'] = server_names 20 | return "RESULT" 21 | 22 | # Patch the real interactive_mode in its defining module 23 | monkeypatch.setattr( 24 | "mcp_cli.interactive.shell.interactive_mode", 25 | fake_im 26 | ) 27 | 28 | cmd = InteractiveCommand() 29 | tm = DummyTM(config_file="", servers=[]) 30 | 31 | result = await cmd.execute(tool_manager=tm) 32 | assert result == "RESULT" 33 | assert captured['tm'] is tm 34 | assert captured['provider'] == "openai" 35 | assert captured['model'] == "gpt-4o-mini" 36 | assert captured['servers'] is None 37 | 38 | @pytest.mark.asyncio 39 | async def test_execute_forwards_explicit_params(monkeypatch): 40 | """If provider/model/server_names passed in, execute() should forward them.""" 41 | captured = {} 42 | async def fake_im(tool_manager, provider, model, server_names=None): 43 | captured['tm'] = tool_manager 44 | captured['provider'] = provider 45 | captured['model'] = model 46 | captured['servers'] = server_names 47 | return "OK" 48 | 49 | monkeypatch.setattr( 50 | "mcp_cli.interactive.shell.interactive_mode", 51 | fake_im 52 | ) 53 | 54 | cmd = InteractiveCommand() 55 | tm = DummyTM(config_file="", servers=[]) 56 | 57 | params = { 58 | "provider": "myprov", 59 | "model": "my-model", 60 | "server_names": {0: "one", 1: "two"} 61 | } 62 | result = await cmd.execute(tool_manager=tm, **params) 63 | assert result == "OK" 64 | assert captured['tm'] is tm 65 | assert captured['provider'] == "myprov" 66 | assert captured['model'] == "my-model" 67 | assert captured['servers'] == {0: "one", 1: "two"} 68 | -------------------------------------------------------------------------------- /tests/mcp_cli/cli/test_cli_registry.py: -------------------------------------------------------------------------------- 1 | # tests/test_cli_registry.py 2 | 3 | import pytest 4 | import logging 5 | import typer 6 | from typing import Any, Callable 7 | 8 | from mcp_cli.cli.registry import CommandRegistry 9 | from mcp_cli.cli.commands.base import BaseCommand, FunctionCommand 10 | 11 | # Silence registry logs below WARNING 12 | logging.getLogger("mcp_cli.cli.registry").setLevel(logging.DEBUG) 13 | 14 | 15 | class DummyCommand(BaseCommand): 16 | """Concrete stub of BaseCommand for testing.""" 17 | 18 | def __init__(self, name: str, help_text: str = ""): 19 | super().__init__(name, help_text) 20 | 21 | async def execute(self, *args, **kwargs) -> Any: 22 | """No-op execute.""" 23 | return None 24 | 25 | 26 | def test_register_and_get(): 27 | CommandRegistry._commands.clear() 28 | 29 | cmd = DummyCommand("foo", "foo help") 30 | CommandRegistry.register(cmd) 31 | 32 | # get_command returns the same object 33 | assert CommandRegistry.get_command("foo") is cmd 34 | 35 | # get_all_commands includes it 36 | all_cmds = CommandRegistry.get_all_commands() 37 | assert cmd in all_cmds and len(all_cmds) == 1 38 | 39 | 40 | def test_register_function(): 41 | CommandRegistry._commands.clear() 42 | 43 | def sample_func(ctx: Any): 44 | """sample help""" 45 | pass 46 | 47 | CommandRegistry.register_function("bar", sample_func, help_text="bar help") 48 | cmd = CommandRegistry.get_command("bar") 49 | 50 | # Should be a FunctionCommand wrapping our function 51 | assert isinstance(cmd, FunctionCommand) 52 | assert cmd.name == "bar" 53 | assert cmd.help == "bar help" 54 | # Underlying function is stored on .func 55 | assert getattr(cmd, "func", None) is sample_func 56 | 57 | 58 | def test_register_with_typer(): 59 | CommandRegistry._commands.clear() 60 | 61 | # Register two dummy commands 62 | cmd1 = DummyCommand("one", "help1") 63 | cmd2 = DummyCommand("two", "help2") 64 | CommandRegistry.register(cmd1) 65 | CommandRegistry.register(cmd2) 66 | 67 | # Fake Typer app supporting .command() 68 | class DummyApp: 69 | def __init__(self): 70 | self.registered = {} 71 | def command(self, name: str, **kwargs): 72 | def decorator(fn): 73 | self.registered[name] = fn 74 | return fn 75 | return decorator 76 | 77 | app = DummyApp() 78 | def runner(fn, config_file, servers, extra_params=None): 79 | pass 80 | 81 | # Should not raise 82 | CommandRegistry.register_with_typer(app, runner) 83 | 84 | # Both commands should be wired into app.registered 85 | assert "one" in app.registered 86 | assert "two" in app.registered 87 | 88 | 89 | def test_create_subcommand_group_logs_warning(caplog): 90 | CommandRegistry._commands.clear() 91 | # Capture warnings from the registry module 92 | caplog.set_level(logging.WARNING, logger="mcp_cli.cli.registry") 93 | 94 | # Only register 'tools list' 95 | cmd_list = DummyCommand("tools list", "list help") 96 | CommandRegistry.register(cmd_list) 97 | 98 | # Fake app only needs add_typer() 99 | class FakeApp: 100 | def add_typer(self, subapp, name, help=None): 101 | pass 102 | fake_app = FakeApp() 103 | 104 | def runner(fn, config_file, servers, extra_params=None): 105 | pass 106 | 107 | # Create subcommand group with missing 'tools call' 108 | CommandRegistry.create_subcommand_group( 109 | app=fake_app, 110 | group_name="tools", 111 | sub_commands=["list", "call"], 112 | run_cmd=runner 113 | ) 114 | 115 | # Assert a warning was logged about missing 'tools call' 116 | assert any( 117 | "Command 'tools call' not found in registry" in rec.message 118 | for rec in caplog.records 119 | ) 120 | -------------------------------------------------------------------------------- /tests/mcp_cli/cli/test_cmd.py: -------------------------------------------------------------------------------- 1 | # tests/test_cmd_command.py 2 | import pytest 3 | import json 4 | import typer 5 | import click 6 | 7 | from mcp_cli.cli.commands.cmd import CmdCommand 8 | 9 | class DummySM: 10 | """A fake stream_manager supporting call_tool and get_internal_tools.""" 11 | def __init__(self): 12 | self.called = [] 13 | 14 | async def call_tool(self, tool_name: str, arguments: dict): 15 | # simulate a successful tool invocation 16 | self.called.append((tool_name, arguments)) 17 | return {"isError": False, "content": {"foo": "bar"}} 18 | 19 | def get_internal_tools(self): 20 | # Not used in the single-tool path 21 | return [] 22 | 23 | @pytest.mark.asyncio 24 | async def test_run_single_tool_success(monkeypatch): 25 | tm = DummySM() 26 | cmd = CmdCommand() 27 | 28 | outputs = [] 29 | # Capture writes 30 | monkeypatch.setattr(cmd, "_write_output", lambda data, path, raw, plain: outputs.append((data, path, raw, plain))) 31 | 32 | result = await cmd.execute( 33 | tool_manager=tm, 34 | tool="mytool", 35 | tool_args='{"a":1}', 36 | output=None, 37 | raw=False 38 | ) 39 | # The returned JSON should match the dummy content 40 | parsed = json.loads(result) 41 | assert parsed == {"foo": "bar"} 42 | # And _write_output was called with that data 43 | assert outputs and json.loads(outputs[0][0]) == {"foo": "bar"} 44 | # And the tool manager saw the correct call 45 | assert tm.called == [("mytool", {"a": 1})] 46 | 47 | @pytest.mark.asyncio 48 | async def test_run_single_tool_invalid_json(): 49 | tm = DummySM() 50 | cmd = CmdCommand() 51 | 52 | with pytest.raises(click.BadParameter): 53 | await cmd.execute(tool_manager=tm, tool="t", tool_args="{bad}") 54 | 55 | @pytest.mark.asyncio 56 | async def test_llm_workflow(monkeypatch): 57 | tm = DummySM() 58 | cmd = CmdCommand() 59 | 60 | # Patch get_llm_client to return a dummy client with async create_completion 61 | class DummyLLMClient: 62 | async def create_completion(self, *args, **kwargs): 63 | return "LLM_RESULT" 64 | monkeypatch.setattr("mcp_cli.llm.llm_client.get_llm_client", lambda *a, **kw: DummyLLMClient()) 65 | 66 | outputs = [] 67 | monkeypatch.setattr(cmd, "_write_output", lambda data, path, raw, plain: outputs.append((data, path, raw, plain))) 68 | 69 | result = await cmd.execute( 70 | tool_manager=tm, 71 | input=None, 72 | prompt="hello", 73 | output="-", # means stdout 74 | raw=True, 75 | provider="p", 76 | model="m", 77 | server_names={}, 78 | verbose=False, 79 | ) 80 | 81 | assert result == "LLM_RESULT" 82 | # And _write_output was invoked once with the raw flag 83 | assert outputs == [("LLM_RESULT", "-", True, False)] 84 | -------------------------------------------------------------------------------- /tests/mcp_cli/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrishayuk/mcp-cli/b94b3141a075fa83cbb2c2ba921bbf98dfa2e07e/tests/mcp_cli/commands/__init__.py -------------------------------------------------------------------------------- /tests/mcp_cli/commands/test_clear.py: -------------------------------------------------------------------------------- 1 | # tests/commands/test_clear.py 2 | import pytest 3 | 4 | from mcp_cli.interactive.commands.clear import ClearCommand 5 | import mcp_cli.commands.clear as clear_module # patch here 6 | 7 | @pytest.mark.asyncio 8 | async def test_clear_command_calls_clear_screen(monkeypatch): 9 | called = {"count": 0} 10 | def fake_clear_screen(): 11 | called["count"] += 1 12 | 13 | # Now patch the shared clear_screen that clear_action() calls 14 | monkeypatch.setattr(clear_module, "clear_screen", fake_clear_screen) 15 | 16 | cmd = ClearCommand() 17 | result = await cmd.execute([], tool_manager=None) 18 | 19 | assert called["count"] == 1, "clear_screen should be called exactly once" 20 | assert result is None 21 | -------------------------------------------------------------------------------- /tests/mcp_cli/commands/test_exit.py: -------------------------------------------------------------------------------- 1 | # tests/commands/test_exit.py 2 | import pytest 3 | 4 | from mcp_cli.interactive.commands.exit import ExitCommand 5 | import mcp_cli.commands.exit as exit_module # patch the shared module 6 | 7 | @pytest.mark.asyncio 8 | async def test_exit_command_prints_and_returns_true(monkeypatch): 9 | # Arrange: capture print calls from exit_action() 10 | printed = [] 11 | def fake_print(*args, **kwargs): 12 | printed.append(args[0]) 13 | 14 | # exit_action() does `from rich import print`, so patch here: 15 | monkeypatch.setattr(exit_module, "print", fake_print) 16 | monkeypatch.setattr(exit_module, "restore_terminal", lambda: None) 17 | 18 | cmd = ExitCommand() 19 | result = await cmd.execute([], tool_manager=None) 20 | 21 | assert result is True 22 | assert any("Exiting… Goodbye!" in str(p) for p in printed) 23 | 24 | 25 | -------------------------------------------------------------------------------- /tests/mcp_cli/commands/test_help.py: -------------------------------------------------------------------------------- 1 | # commands/test_help.py 2 | import pytest 3 | from rich.console import Console 4 | from rich.table import Table 5 | from rich.panel import Panel 6 | 7 | from mcp_cli.commands.help import help_action 8 | from mcp_cli.interactive.registry import InteractiveCommandRegistry 9 | from mcp_cli.interactive.commands.help import HelpCommand 10 | 11 | 12 | class DummyCmd: 13 | def __init__(self, name, help_text, aliases=None): 14 | self.name = name 15 | self.help = help_text 16 | self.aliases = aliases or [] 17 | 18 | 19 | @pytest.fixture(autouse=True) 20 | def clear_registry(): 21 | # Force type to dict in case any other test or code polluted it 22 | InteractiveCommandRegistry._commands = {} 23 | InteractiveCommandRegistry._aliases = {} 24 | yield 25 | InteractiveCommandRegistry._commands = {} 26 | InteractiveCommandRegistry._aliases = {} 27 | 28 | 29 | def test_help_action_list_all(monkeypatch): 30 | # Register two dummy commands 31 | cmd_a = DummyCmd("a", "help A", aliases=["x"]) 32 | cmd_b = DummyCmd("b", "help B", aliases=[]) 33 | InteractiveCommandRegistry.register(cmd_a) 34 | InteractiveCommandRegistry.register(cmd_b) 35 | 36 | printed = [] 37 | monkeypatch.setattr(Console, "print", lambda self, *args, **kw: printed.append(args[0])) 38 | 39 | console = Console() 40 | help_action(console=console) 41 | 42 | # Should have printed a Table of commands 43 | tables = [o for o in printed if isinstance(o, Table)] 44 | assert tables, f"No Table printed, got: {printed}" 45 | table = tables[0] 46 | # Check headers 47 | headers = [col.header for col in table.columns] 48 | assert headers == ["Command", "Aliases", "Description"] 49 | # Two rows 50 | assert table.row_count == 2 51 | 52 | # And a dim hint at the end (string) 53 | hints = [o for o in printed if isinstance(o, str) and "Type 'help <command>'" in o] 54 | assert hints, "Expected hint string at end" 55 | 56 | 57 | def test_help_action_specific(monkeypatch): 58 | # Register one dummy command 59 | cmd = DummyCmd("foo", "Foo does X", aliases=["f"]) 60 | InteractiveCommandRegistry.register(cmd) 61 | 62 | printed = [] 63 | monkeypatch.setattr(Console, "print", lambda self, *args, **kw: printed.append(args[0])) 64 | 65 | console = Console() 66 | # Request help for command "foo" 67 | help_action("foo") 68 | 69 | # Should have printed a Panel 70 | panels = [o for o in printed if isinstance(o, Panel)] 71 | assert panels, f"No Panel printed, got: {printed}" 72 | panel = panels[0] 73 | 74 | # The Panel.renderable should be a Markdown instance 75 | from rich.markdown import Markdown 76 | assert isinstance(panel.renderable, Markdown) 77 | 78 | # Then aliases line (string) should follow 79 | alias_lines = [o for o in printed if isinstance(o, str) and "Aliases:" in o] 80 | assert alias_lines, "Expected an aliases line" 81 | 82 | 83 | @pytest.mark.asyncio 84 | async def test_interactive_wrapper(monkeypatch): 85 | # Register a no-op help command to satisfy registry in shell 86 | # (so that HelpCommand.execute() finds something) 87 | cmd_dummy = DummyCmd("foo", "help foo", aliases=[]) 88 | InteractiveCommandRegistry.register(cmd_dummy) 89 | 90 | printed = [] 91 | monkeypatch.setattr(Console, "print", lambda self, *args, **kw: printed.append(args[0])) 92 | 93 | help_cmd = HelpCommand() 94 | # call wrapper with no args → should call help_action(console, None) 95 | await help_cmd.execute([], tool_manager=None) 96 | # we should see at least one Table 97 | assert any(isinstance(o, Table) for o in printed) 98 | 99 | printed.clear() 100 | # call wrapper with specific arg → get Panel 101 | await help_cmd.execute(["foo"], tool_manager=None) 102 | assert any(isinstance(o, Panel) for o in printed) 103 | -------------------------------------------------------------------------------- /tests/mcp_cli/commands/test_ping.py: -------------------------------------------------------------------------------- 1 | # tests/commands/test_ping.py 2 | import pytest 3 | 4 | # Component under test 5 | from mcp_cli.commands.ping import ping_action_async 6 | 7 | # --------------------------------------------------------------------------- 8 | # Spy / stubs 9 | # --------------------------------------------------------------------------- 10 | 11 | class _DummyServerInfo: 12 | def __init__(self, name: str): 13 | self.name = name 14 | self.id = 0 15 | self.status = "OK" 16 | self.tool_count = 0 17 | 18 | 19 | class DummyToolManager: 20 | """Minimal stand-in that satisfies ping_action_async.""" 21 | 22 | def __init__(self): 23 | # Two mock (read, write) stream pairs – the concrete objects are never 24 | # touched because we monkey-patch _ping_one. 25 | self._streams = [(None, None), (None, None)] 26 | self._server_info = [_DummyServerInfo("ServerA"), _DummyServerInfo("ServerB")] 27 | 28 | # Called *synchronously* 29 | def get_streams(self): 30 | return self._streams 31 | 32 | # Awaited inside ping_action_async 33 | async def get_server_info(self): 34 | return self._server_info 35 | 36 | 37 | # --------------------------------------------------------------------------- 38 | # Fixtures 39 | # --------------------------------------------------------------------------- 40 | 41 | @pytest.fixture() 42 | def dummy_tm(): 43 | """Provide a fresh DummyToolManager for each test.""" 44 | return DummyToolManager() 45 | 46 | 47 | @pytest.fixture() 48 | def ping_spy(monkeypatch): 49 | """Replace _ping_one with a deterministic spy recording the calls.""" 50 | calls = [] 51 | 52 | async def _dummy_ping(idx, name, _r, _w, *, timeout): # noqa: WPS430 53 | calls.append((idx, name)) 54 | # Always report success with constant latency for simplicity 55 | return name, True, 42.0 56 | 57 | monkeypatch.setattr("mcp_cli.commands.ping._ping_one", _dummy_ping) 58 | return calls 59 | 60 | 61 | # --------------------------------------------------------------------------- 62 | # Tests 63 | # --------------------------------------------------------------------------- 64 | 65 | @pytest.mark.asyncio 66 | async def test_ping_all_servers(dummy_tm, ping_spy): 67 | """/ping with no filters should contact every server.""" 68 | ok = await ping_action_async(dummy_tm) 69 | assert ok is True 70 | assert len(ping_spy) == 2 71 | assert {n for _, n in ping_spy} == {"ServerA", "ServerB"} 72 | 73 | 74 | @pytest.mark.asyncio 75 | async def test_ping_filtered_by_index(dummy_tm, ping_spy): 76 | """Filter accepts the numeric *index* of the server.""" 77 | ok = await ping_action_async(dummy_tm, targets=["0"]) 78 | assert ok is True 79 | assert ping_spy == [(0, "ServerA")] 80 | 81 | 82 | @pytest.mark.asyncio 83 | async def test_ping_filtered_by_name(dummy_tm, ping_spy): 84 | """Filter is case-insensitive for *names*.""" 85 | ok = await ping_action_async(dummy_tm, targets=["serverb"]) 86 | assert ok is True 87 | assert ping_spy == [(1, "ServerB")] 88 | 89 | 90 | @pytest.mark.asyncio 91 | async def test_ping_no_match_returns_false(dummy_tm, ping_spy): 92 | """If no targets match, command prints a warning and returns False.""" 93 | ok = await ping_action_async(dummy_tm, targets=["does-not-exist"]) 94 | assert ok is False 95 | assert ping_spy == [] 96 | -------------------------------------------------------------------------------- /tests/mcp_cli/commands/test_prompts.py: -------------------------------------------------------------------------------- 1 | # commands/test_prompts.py 2 | import pytest 3 | from rich.console import Console 4 | from rich.table import Table 5 | 6 | from mcp_cli.commands.prompts import prompts_action_async 7 | 8 | class DummyTMNoPrompts: 9 | async def list_prompts(self): 10 | return [] 11 | 12 | class DummyTMWithPromptsSync: 13 | def __init__(self, data): 14 | self._data = data 15 | 16 | async def list_prompts(self): 17 | return self._data 18 | 19 | class DummyTMWithPromptsAsync: 20 | async def list_prompts(self): 21 | return [ 22 | {"server": "s1", "name": "n1", "description": "d1"} 23 | ] 24 | 25 | 26 | @pytest.mark.asyncio 27 | async def test_prompts_action_no_prompts(monkeypatch): 28 | tm = DummyTMNoPrompts() 29 | printed = [] 30 | monkeypatch.setattr(Console, "print", lambda self, *args, **kw: printed.append(str(args[0]))) 31 | 32 | result = await prompts_action_async(tm) 33 | assert result == [] 34 | assert any("No prompts recorded" in p for p in printed) 35 | 36 | 37 | @pytest.mark.asyncio 38 | async def test_prompts_action_with_prompts_sync(monkeypatch): 39 | data = [ 40 | {"server": "srv", "name": "nm", "description": "desc"} 41 | ] 42 | tm = DummyTMWithPromptsSync(data) 43 | 44 | output = [] 45 | monkeypatch.setattr(Console, "print", lambda self, *args, **kw: output.append(args[0])) 46 | 47 | result = await prompts_action_async(tm) 48 | assert result == data 49 | 50 | tables = [o for o in output if isinstance(o, Table)] 51 | assert tables, f"No Table printed, got {output}" 52 | table = tables[0] 53 | assert table.row_count == 1 54 | headers = [col.header for col in table.columns] 55 | assert headers == ["Server", "Name", "Description"] 56 | 57 | 58 | @pytest.mark.asyncio 59 | async def test_prompts_action_with_prompts_async(monkeypatch): 60 | tm = DummyTMWithPromptsAsync() 61 | 62 | output = [] 63 | monkeypatch.setattr(Console, "print", lambda self, obj, **kw: output.append(obj)) 64 | 65 | result = await prompts_action_async(tm) 66 | assert isinstance(result, list) and len(result) == 1 67 | 68 | tables = [o for o in output if isinstance(o, Table)] 69 | assert tables, f"No Table printed, got {output}" 70 | assert tables[0].row_count == 1 71 | -------------------------------------------------------------------------------- /tests/mcp_cli/commands/test_resources.py: -------------------------------------------------------------------------------- 1 | # commands/test_resources.py 2 | import pytest 3 | from rich.console import Console 4 | from rich.table import Table 5 | 6 | from mcp_cli.commands.resources import resources_action_async 7 | 8 | class DummyTMNoResources: 9 | async def list_resources(self): 10 | return [] 11 | 12 | class DummyTMWithResources: 13 | def __init__(self, data): 14 | self._data = data 15 | 16 | async def list_resources(self): 17 | return self._data 18 | 19 | class DummyTMError: 20 | async def list_resources(self): 21 | raise RuntimeError("fail!") 22 | 23 | 24 | @pytest.mark.asyncio 25 | async def test_resources_action_error(monkeypatch): 26 | tm = DummyTMError() 27 | printed = [] 28 | monkeypatch.setattr(Console, "print", lambda self, *args, **kw: printed.append(str(args[0]))) 29 | 30 | result = await resources_action_async(tm) 31 | assert result == [] 32 | assert any("Error:" in p and "fail!" in p for p in printed) 33 | 34 | 35 | @pytest.mark.asyncio 36 | async def test_resources_action_no_resources(monkeypatch): 37 | tm = DummyTMNoResources() 38 | printed = [] 39 | monkeypatch.setattr(Console, "print", lambda self, *args, **kw: printed.append(str(args[0]))) 40 | 41 | result = await resources_action_async(tm) 42 | assert result == [] 43 | assert any("No resources recorded" in p for p in printed) 44 | 45 | 46 | @pytest.mark.asyncio 47 | async def test_resources_action_with_resources(monkeypatch): 48 | data = [ 49 | {"server": "s1", "uri": "/path/1", "size": 500, "mimeType": "text/plain"}, 50 | {"server": "s2", "uri": "/path/2", "size": 2048, "mimeType": "application/json"}, 51 | ] 52 | tm = DummyTMWithResources(data) 53 | 54 | output = [] 55 | monkeypatch.setattr(Console, "print", lambda self, *args, **kw: output.append(args[0])) 56 | 57 | result = await resources_action_async(tm) 58 | assert result == data 59 | 60 | tables = [o for o in output if isinstance(o, Table)] 61 | assert tables, f"No Table printed, got {output}" 62 | table = tables[0] 63 | 64 | # Two data rows 65 | assert table.row_count == 2 66 | 67 | # Headers 68 | headers = [col.header for col in table.columns] 69 | assert headers == ["Server", "URI", "Size", "MIME-type"] 70 | -------------------------------------------------------------------------------- /tests/mcp_cli/commands/test_servers.py: -------------------------------------------------------------------------------- 1 | # commands/test_servers.py 2 | 3 | import pytest 4 | from rich.console import Console 5 | from rich.table import Table 6 | 7 | from mcp_cli.commands.servers import servers_action_async 8 | from mcp_cli.tools.models import ServerInfo 9 | 10 | class DummyToolManagerNoServers: 11 | async def get_server_info(self): 12 | return [] 13 | 14 | class DummyToolManagerWithServers: 15 | def __init__(self, infos): 16 | self._infos = infos 17 | async def get_server_info(self): 18 | return self._infos 19 | 20 | def make_info(id, name, tools, status): 21 | return ServerInfo(id=id, name=name, tool_count=tools, status=status, namespace="ns") 22 | 23 | @pytest.mark.asyncio 24 | async def test_servers_action_no_servers(monkeypatch): 25 | tm = DummyToolManagerNoServers() 26 | printed = [] 27 | monkeypatch.setattr(Console, "print", lambda self, *args, **kw: printed.append(str(args[0]))) 28 | 29 | await servers_action_async(tm) 30 | assert any("No servers connected" in p for p in printed) 31 | 32 | @pytest.mark.asyncio 33 | async def test_servers_action_with_servers(monkeypatch): 34 | infos = [ 35 | make_info(0, "alpha", 3, "online"), 36 | make_info(1, "beta", 5, "offline"), 37 | ] 38 | tm = DummyToolManagerWithServers(infos) 39 | 40 | output = [] 41 | monkeypatch.setattr(Console, "print", lambda self, *args, **kw: output.append(args[0])) 42 | 43 | await servers_action_async(tm) 44 | 45 | tables = [o for o in output if isinstance(o, Table)] 46 | assert tables, f"Expected a Table, got: {output}" 47 | table = tables[0] 48 | 49 | # Should have exactly two data rows 50 | assert table.row_count == 2 51 | 52 | # Validate column headers 53 | headers = [col.header for col in table.columns] 54 | assert headers == ["ID", "Name", "Tools", "Status"] 55 | -------------------------------------------------------------------------------- /tests/mcp_cli/commands/test_tools.py: -------------------------------------------------------------------------------- 1 | # commands/test_tools.py 2 | 3 | import pytest 4 | import json 5 | 6 | from rich.console import Console 7 | from rich.table import Table 8 | from rich.syntax import Syntax 9 | 10 | import mcp_cli.commands.tools as tools_mod 11 | from mcp_cli.commands.tools import tools_action, tools_action_async 12 | from mcp_cli.tools.models import ToolInfo 13 | 14 | 15 | class DummyTMNoTools: 16 | async def get_unique_tools(self): 17 | return [] 18 | 19 | class DummyTMWithTools: 20 | def __init__(self, tools): 21 | self._tools = tools 22 | async def get_unique_tools(self): 23 | return self._tools 24 | 25 | @pytest.mark.asyncio 26 | async def test_tools_action_no_tools(monkeypatch): 27 | tm = DummyTMNoTools() 28 | printed = [] 29 | monkeypatch.setattr(Console, "print", lambda self, *args, **kw: printed.append(args[0])) 30 | 31 | out = await tools_action_async(tm) 32 | assert out == [] 33 | assert any("No tools available" in str(m) for m in printed) 34 | 35 | def make_tool(name, namespace): 36 | return ToolInfo(name=name, namespace=namespace, description="d", parameters={}, is_async=False, tags=[]) 37 | 38 | @pytest.mark.asyncio 39 | async def test_tools_action_table(monkeypatch): 40 | fake_tools = [make_tool("t1", "ns1"), make_tool("t2", "ns2")] 41 | tm = DummyTMWithTools(fake_tools) 42 | 43 | printed = [] 44 | monkeypatch.setattr(Console, "print", lambda self, *args, **kw: printed.append(args[0])) 45 | 46 | # Monkeypatch create_tools_table to return a dummy Table 47 | dummy_table = Table(title="Dummy") 48 | monkeypatch.setattr(tools_mod, "create_tools_table", lambda tools, show_details=False: dummy_table) 49 | 50 | out = await tools_action_async(tm, show_details=True, show_raw=False) 51 | # Should return the original tool list 52 | assert out == fake_tools 53 | 54 | # printed[0] is the fetching message string 55 | assert isinstance(printed[0], str) 56 | 57 | # Next, the dummy Table 58 | assert any(o is dummy_table for o in printed), printed 59 | 60 | # And finally the summary string 61 | assert any("Total tools available: 2" in str(m) for m in printed) 62 | 63 | @pytest.mark.asyncio 64 | async def test_tools_action_raw(monkeypatch): 65 | fake_tools = [make_tool("x", "ns")] 66 | tm = DummyTMWithTools(fake_tools) 67 | 68 | printed = [] 69 | monkeypatch.setattr(Console, "print", lambda self, *args, **kw: printed.append(args[0])) 70 | 71 | # Call action in raw mode 72 | out = await tools_action_async(tm, show_raw=True) 73 | # Should return raw JSON list 74 | assert isinstance(out, list) and isinstance(out[0], dict) 75 | # And printed a Syntax 76 | assert any(isinstance(o, Syntax) for o in printed) 77 | 78 | # Verify that the JSON inside Syntax matches our tool list 79 | syntax_obj = next(o for o in printed if isinstance(o, Syntax)) 80 | text = syntax_obj.code # the raw JSON text 81 | data = json.loads(text) 82 | assert data[0]["name"] == "x" 83 | assert data[0]["namespace"] == "ns" 84 | -------------------------------------------------------------------------------- /tests/mcp_cli/commands/test_tools_call.py: -------------------------------------------------------------------------------- 1 | # commands/test_tools_call.py 2 | import pytest 3 | from rich.console import Console 4 | from rich.table import Table 5 | 6 | from mcp_cli.commands.resources import resources_action_async 7 | 8 | class DummyTMNoResources: 9 | def list_resources(self): 10 | return [] 11 | 12 | class DummyTMWithResources: 13 | def __init__(self, data): 14 | self._data = data 15 | 16 | def list_resources(self): 17 | return self._data 18 | 19 | class DummyTMError: 20 | def list_resources(self): 21 | raise RuntimeError("fail!") 22 | 23 | 24 | @pytest.mark.asyncio 25 | async def test_resources_action_error(monkeypatch): 26 | tm = DummyTMError() 27 | printed = [] 28 | monkeypatch.setattr(Console, "print", lambda self, msg, **kw: printed.append(str(msg))) 29 | 30 | result = await resources_action_async(tm) 31 | assert result == [] 32 | assert any("Error:" in p and "fail!" in p for p in printed) 33 | 34 | 35 | @pytest.mark.asyncio 36 | async def test_resources_action_no_resources(monkeypatch): 37 | tm = DummyTMNoResources() 38 | printed = [] 39 | monkeypatch.setattr(Console, "print", lambda self, msg, **kw: printed.append(str(msg))) 40 | 41 | result = await resources_action_async(tm) 42 | assert result == [] 43 | assert any("No resources recorded" in p for p in printed) 44 | 45 | 46 | @pytest.mark.asyncio 47 | async def test_resources_action_with_resources(monkeypatch): 48 | data = [ 49 | {"server": "s1", "uri": "/path/1", "size": 500, "mimeType": "text/plain"}, 50 | {"server": "s2", "uri": "/path/2", "size": 2048, "mimeType": "application/json"}, 51 | ] 52 | tm = DummyTMWithResources(data) 53 | 54 | output = [] 55 | monkeypatch.setattr(Console, "print", lambda self, obj, **kw: output.append(obj)) 56 | 57 | result = await resources_action_async(tm) 58 | assert result == data 59 | 60 | tables = [o for o in output if isinstance(o, Table)] 61 | assert tables, f"No Table printed, got {output}" 62 | table = tables[0] 63 | 64 | # Two data rows 65 | assert table.row_count == 2 66 | 67 | # Headers 68 | headers = [col.header for col in table.columns] 69 | assert headers == ["Server", "URI", "Size", "MIME-type"] 70 | -------------------------------------------------------------------------------- /tests/mcp_cli/interactive/test_interactive_registry.py: -------------------------------------------------------------------------------- 1 | # mcp_cli/interactive/test_interactice_registry.py 2 | import sys 3 | import types 4 | import pytest 5 | from typing import List, Any 6 | 7 | # ─── Stub out mcp_cli.interactive.commands so registry can import safely ─── 8 | # Create a dummy package and module for `mcp_cli.interactive.commands` 9 | dummy_pkg = types.ModuleType("mcp_cli.interactive.commands") 10 | dummy_base = types.ModuleType("mcp_cli.interactive.commands.base") 11 | # Give the dummy_base a minimal InteractiveCommand class 12 | class InteractiveCommand: 13 | def __init__(self, name, help_text="", aliases=None): 14 | self.name = name 15 | self.aliases = aliases or [] 16 | dummy_base.InteractiveCommand = InteractiveCommand 17 | 18 | # Insert into sys.modules so that `import mcp_cli.interactive.commands.base` works 19 | sys.modules["mcp_cli.interactive.commands"] = dummy_pkg 20 | sys.modules["mcp_cli.interactive.commands.base"] = dummy_base 21 | 22 | # ─── Now import the registry itself ──────────────────────────────────────── 23 | from mcp_cli.interactive.registry import InteractiveCommandRegistry 24 | 25 | 26 | class DummyCommand(InteractiveCommand): 27 | """ 28 | Minimal command-like object for testing the registry. 29 | Inherits from the stubbed InteractiveCommand to satisfy any isinstance checks. 30 | """ 31 | def __init__(self, name: str, aliases: List[str] = None): 32 | super().__init__(name=name, help_text=f"help for {name}", aliases=aliases or []) 33 | 34 | async def execute(self, args: List[str], tool_manager: Any = None, **kwargs): 35 | return f"{self.name}-ran" 36 | 37 | 38 | @pytest.fixture(autouse=True) 39 | def clear_registry(): 40 | # Ensure registry is empty before each test 41 | InteractiveCommandRegistry._commands.clear() 42 | InteractiveCommandRegistry._aliases.clear() 43 | yield 44 | InteractiveCommandRegistry._commands.clear() 45 | InteractiveCommandRegistry._aliases.clear() 46 | 47 | 48 | def test_register_and_get_by_name(): 49 | cmd = DummyCommand("foo") 50 | InteractiveCommandRegistry.register(cmd) 51 | 52 | # get_command by its name 53 | got = InteractiveCommandRegistry.get_command("foo") 54 | assert got is cmd 55 | 56 | # get_all_commands includes it 57 | all_cmds = InteractiveCommandRegistry.get_all_commands() 58 | assert "foo" in all_cmds 59 | assert all_cmds["foo"] is cmd 60 | 61 | 62 | def test_register_with_aliases(): 63 | cmd = DummyCommand("bar", aliases=["b", "baz"]) 64 | InteractiveCommandRegistry.register(cmd) 65 | 66 | # direct by name 67 | assert InteractiveCommandRegistry.get_command("bar") is cmd 68 | # lookup via each alias 69 | assert InteractiveCommandRegistry.get_command("b") is cmd 70 | assert InteractiveCommandRegistry.get_command("baz") is cmd 71 | 72 | # _aliases maps aliases back to "bar" 73 | assert InteractiveCommandRegistry._aliases["b"] == "bar" 74 | assert InteractiveCommandRegistry._aliases["baz"] == "bar" 75 | 76 | 77 | def test_get_missing_command_returns_none(): 78 | # No registration at all 79 | assert InteractiveCommandRegistry.get_command("nonexistent") is None 80 | # Alias also not found 81 | assert InteractiveCommandRegistry.get_command("x") is None 82 | -------------------------------------------------------------------------------- /tests/mcp_cli/interactive/test_interactive_shell.py: -------------------------------------------------------------------------------- 1 | # tests/interactive/test_interactive_shell.py 2 | import sys 3 | import types 4 | import pytest 5 | 6 | # ─── Stub out mcp_cli.interactive.commands so shell can import ──────────── 7 | dummy_commands = types.ModuleType("mcp_cli.interactive.commands") 8 | # Provide the needed register_all_commands function 9 | dummy_commands.register_all_commands = lambda: None 10 | sys.modules["mcp_cli.interactive.commands"] = dummy_commands 11 | 12 | # Also stub out the base so there are no missing imports 13 | dummy_base = types.ModuleType("mcp_cli.interactive.commands.base") 14 | # Minimal InteractiveCommand for type consistency (not actually used here) 15 | class InteractiveCommand: 16 | def __init__(self, name, help_text="", aliases=None): 17 | self.name = name 18 | self.aliases = aliases or [] 19 | dummy_base.InteractiveCommand = InteractiveCommand 20 | sys.modules["mcp_cli.interactive.commands.base"] = dummy_base 21 | 22 | # ─── Now import the function under test ─────────────────────────────────── 23 | from mcp_cli.interactive.shell import interactive_mode 24 | 25 | 26 | def test_interactive_mode_is_callable(): 27 | # Simply verify that interactive_mode was imported 28 | assert callable(interactive_mode) 29 | -------------------------------------------------------------------------------- /tests/mcp_cli/llm/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrishayuk/mcp-cli/b94b3141a075fa83cbb2c2ba921bbf98dfa2e07e/tests/mcp_cli/llm/__init__.py -------------------------------------------------------------------------------- /tests/mcp_cli/llm/test_system_prompt_generator.py: -------------------------------------------------------------------------------- 1 | # test/llm/test_system_prompt_generator.py 2 | import json 3 | import pytest 4 | 5 | # SystemPromptGenerator tests 6 | from mcp_cli.llm.system_prompt_generator import SystemPromptGenerator 7 | 8 | 9 | class TestSystemPromptGenerator: 10 | """Unit‑tests for the SystemPromptGenerator class.""" 11 | 12 | @pytest.fixture(scope="function") 13 | def tools_schema(self): 14 | """Simple tools JSON schema used across tests.""" 15 | return { 16 | "tools": [ 17 | { 18 | "name": "echo", 19 | "description": "Return whatever text you pass in", 20 | "parameters": { 21 | "type": "object", 22 | "properties": { 23 | "text": {"type": "string"} 24 | }, 25 | "required": ["text"] 26 | } 27 | } 28 | ] 29 | } 30 | 31 | def test_prompt_contains_json_schema(self, tools_schema): 32 | """Generated prompt should embed the tools JSON schema verbatim.""" 33 | gen = SystemPromptGenerator() 34 | prompt = gen.generate_prompt(tools_schema) 35 | pretty_schema = json.dumps(tools_schema, indent=2) 36 | assert pretty_schema in prompt 37 | 38 | def test_default_placeholders_replaced(self, tools_schema): 39 | """All template placeholders must be substituted and defaults used when 40 | optional args are omitted.""" 41 | gen = SystemPromptGenerator() 42 | prompt = gen.generate_prompt(tools_schema) 43 | 44 | # Defaults must appear 45 | assert gen.default_user_system_prompt in prompt 46 | assert gen.default_tool_config in prompt 47 | 48 | # No double‑braces placeholders should remain 49 | assert "{{" not in prompt and "}}" not in prompt 50 | 51 | def test_custom_overrides(self, tools_schema): 52 | """Caller‑supplied user prompt & tool config should override defaults.""" 53 | gen = SystemPromptGenerator() 54 | user_prompt = "You are Jarvis, a helpful assistant." 55 | tool_cfg = "All network calls must go through the proxy." 56 | prompt = gen.generate_prompt(tools_schema, user_system_prompt=user_prompt, tool_config=tool_cfg) 57 | 58 | assert user_prompt in prompt 59 | assert tool_cfg in prompt 60 | # Defaults should no longer be present 61 | assert gen.default_user_system_prompt not in prompt 62 | assert gen.default_tool_config not in prompt 63 | 64 | 65 | # tools_handler.format_tool_response tests 66 | from mcp_cli.llm.tools_handler import format_tool_response 67 | 68 | 69 | class TestFormatToolResponse: 70 | """Unit‑tests for the standalone format_tool_response helper.""" 71 | 72 | def test_text_record_list(self): 73 | """List of text records should be flattened to line‑separated string.""" 74 | records = [ 75 | {"type": "text", "text": "Hello"}, 76 | {"type": "text", "text": "World"}, 77 | ] 78 | out = format_tool_response(records) 79 | assert out == "Hello\nWorld" 80 | 81 | def test_text_record_missing_field(self): 82 | """Missing 'text' field should gracefully substitute placeholder.""" 83 | records = [ 84 | {"type": "text"}, 85 | ] 86 | out = format_tool_response(records) 87 | assert "No content" in out 88 | 89 | def test_data_record_list_serialised_to_json(self): 90 | """Non‑text dict list should be preserved via JSON stringification.""" 91 | rows = [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}] 92 | out = format_tool_response(rows) 93 | # Must be valid JSON and round‑trip equal 94 | assert json.loads(out) == rows 95 | 96 | def test_single_dict_serialised(self): 97 | data = {"status": "ok"} 98 | out = format_tool_response(data) 99 | assert json.loads(out) == data 100 | 101 | @pytest.mark.parametrize("scalar", [42, 3.14, True, None, "plain text"]) 102 | def test_scalar_converted_to_string(self, scalar): 103 | out = format_tool_response(scalar) 104 | assert out == str(scalar) 105 | -------------------------------------------------------------------------------- /tests/mcp_cli/test_config.py: -------------------------------------------------------------------------------- 1 | import json 2 | import pytest 3 | 4 | from mcp_cli.config import load_config 5 | 6 | # If needed, you can define a dummy StdioServerParameters if the real one is not available. 7 | # Uncomment and modify the following block if you must provide a dummy version: 8 | # 9 | # class DummyStdioServerParameters: 10 | # def __init__(self, command, args, env): 11 | # self.command = command 12 | # self.args = args 13 | # self.env = env 14 | # 15 | # # Monkeypatch the StdioServerParameters in the module under test: 16 | # @pytest.fixture(autouse=True) 17 | # def patch_stdio_parameters(monkeypatch): 18 | # monkeypatch.setattr( 19 | # "mcp_cli.config.StdioServerParameters", DummyStdioServerParameters 20 | # ) 21 | 22 | @pytest.mark.asyncio 23 | async def test_load_config_success(tmp_path): 24 | # Create a temporary config file with valid JSON 25 | config_data = { 26 | "mcpServers": { 27 | "TestServer": { 28 | "command": "dummy_command", 29 | "args": ["--dummy"], 30 | "env": {"VAR": "value"} 31 | } 32 | } 33 | } 34 | config_file = tmp_path / "config.json" 35 | config_file.write_text(json.dumps(config_data)) 36 | 37 | # Call load_config with a server that exists in the config 38 | result = await load_config(str(config_file), "TestServer") 39 | 40 | # Verify that the returned StdioServerParameters has the expected attributes. 41 | assert result.command == "dummy_command" 42 | assert result.args == ["--dummy"] 43 | assert result.env == {"VAR": "value"} 44 | 45 | @pytest.mark.asyncio 46 | async def test_load_config_server_not_found(tmp_path): 47 | # Create a config file that does not include the requested server. 48 | config_data = { 49 | "mcpServers": { 50 | "AnotherServer": { 51 | "command": "dummy_command", 52 | "args": [], 53 | "env": {} 54 | } 55 | } 56 | } 57 | config_file = tmp_path / "config.json" 58 | config_file.write_text(json.dumps(config_data)) 59 | 60 | with pytest.raises(ValueError, match=r"Server 'TestServer' not found in configuration file\."): 61 | await load_config(str(config_file), "TestServer") 62 | 63 | @pytest.mark.asyncio 64 | async def test_load_config_file_not_found(tmp_path): 65 | # Create a path that does not exist. 66 | non_existent = tmp_path / "nonexistent.json" 67 | 68 | with pytest.raises(FileNotFoundError, match=r"Configuration file not found:"): 69 | await load_config(str(non_existent), "TestServer") 70 | 71 | @pytest.mark.asyncio 72 | async def test_load_config_invalid_json(tmp_path): 73 | # Create a file containing invalid JSON. 74 | invalid_file = tmp_path / "invalid.json" 75 | invalid_file.write_text("not a json") 76 | 77 | with pytest.raises(json.JSONDecodeError): 78 | await load_config(str(invalid_file), "TestServer") 79 | -------------------------------------------------------------------------------- /tests/mcp_cli/tools/test_adapter.py: -------------------------------------------------------------------------------- 1 | # tools/test_adapter.py 2 | 3 | import pytest 4 | from mcp_cli.tools.adapter import ToolNameAdapter 5 | from mcp_cli.tools.models import ToolInfo 6 | 7 | @pytest.mark.parametrize("namespace,name,expected", [ 8 | ("ns", "tool", "ns_tool"), 9 | ("myNS", "MyTool", "myNS_MyTool"), 10 | ("a", "b", "a_b"), 11 | ]) 12 | def test_to_openai_compatible(namespace, name, expected): 13 | assert ToolNameAdapter.to_openai_compatible(namespace, name) == expected 14 | 15 | @pytest.mark.parametrize("openai_name,expected", [ 16 | ("ns_tool", "ns.tool"), 17 | ("abc_123", "abc.123"), 18 | ("no_underscore", "no.underscore"), 19 | ("single", "single"), # no underscore -> unchanged 20 | ("multi_part_name", "multi.part_name"), # only split on first underscore 21 | ]) 22 | def test_from_openai_compatible(openai_name, expected): 23 | assert ToolNameAdapter.from_openai_compatible(openai_name) == expected 24 | 25 | def test_build_mapping_empty(): 26 | # no tools -> empty mapping 27 | assert ToolNameAdapter.build_mapping([]) == {} 28 | 29 | def test_build_mapping_single(): 30 | ti = ToolInfo(name="t1", namespace="ns", description="", parameters={}, is_async=False) 31 | mapping = ToolNameAdapter.build_mapping([ti]) 32 | # only one entry: "ns_t1" -> "ns.t1" 33 | assert mapping == {"ns_t1": "ns.t1"} 34 | 35 | def test_build_mapping_multiple(): 36 | tools = [ 37 | ToolInfo(name="foo", namespace="a", description=None, parameters=None, is_async=False), 38 | ToolInfo(name="bar", namespace="b", description=None, parameters=None, is_async=False), 39 | ToolInfo(name="baz", namespace="a", description=None, parameters=None, is_async=False), 40 | ] 41 | mapping = ToolNameAdapter.build_mapping(tools) 42 | expected = { 43 | "a_foo": "a.foo", 44 | "b_bar": "b.bar", 45 | "a_baz": "a.baz", 46 | } 47 | assert mapping == expected 48 | -------------------------------------------------------------------------------- /tests/mcp_cli/tools/test_models.py: -------------------------------------------------------------------------------- 1 | # tools/test_models.py 2 | 3 | import pytest 4 | from mcp_cli.tools.models import ( 5 | ToolInfo, 6 | ServerInfo, 7 | ToolCallResult, 8 | ResourceInfo, 9 | ) 10 | 11 | 12 | def test_toolinfo_defaults_and_assignment(): 13 | ti = ToolInfo(name="foo", namespace="bar") 14 | assert ti.name == "foo" 15 | assert ti.namespace == "bar" 16 | # defaults 17 | assert ti.description is None 18 | assert ti.parameters is None 19 | assert ti.is_async is False 20 | assert ti.tags == [] 21 | 22 | # with all fields 23 | ti2 = ToolInfo( 24 | name="x", 25 | namespace="y", 26 | description="desc", 27 | parameters={"p": 1}, 28 | is_async=True, 29 | tags=["a", "b"], 30 | ) 31 | assert ti2.description == "desc" 32 | assert ti2.parameters == {"p": 1} 33 | assert ti2.is_async is True 34 | assert ti2.tags == ["a", "b"] 35 | 36 | 37 | def test_serverinfo_fields(): 38 | si = ServerInfo(id=1, name="s1", status="Up", tool_count=5, namespace="ns") 39 | assert si.id == 1 40 | assert si.name == "s1" 41 | assert si.status == "Up" 42 | assert si.tool_count == 5 43 | assert si.namespace == "ns" 44 | 45 | 46 | def test_toolcallresult_defaults_and_assignment(): 47 | # minimal 48 | tr = ToolCallResult(tool_name="t", success=True) 49 | assert tr.tool_name == "t" 50 | assert tr.success is True 51 | assert tr.result is None 52 | assert tr.error is None 53 | assert tr.execution_time is None 54 | 55 | # full 56 | tr2 = ToolCallResult( 57 | tool_name="u", 58 | success=False, 59 | result={"x": 1}, 60 | error="oops", 61 | execution_time=0.123, 62 | ) 63 | assert tr2.tool_name == "u" 64 | assert tr2.success is False 65 | assert tr2.result == {"x": 1} 66 | assert tr2.error == "oops" 67 | assert tr2.execution_time == pytest.approx(0.123) 68 | 69 | 70 | @pytest.mark.parametrize("raw, expected", [ 71 | ({"id": "i1", "name": "n1", "type": "t1", "foo": 42}, 72 | {"id": "i1", "name": "n1", "type": "t1", "extra": {"foo": 42}}), 73 | ({}, 74 | {"id": None, "name": None, "type": None, "extra": {}}), 75 | ]) 76 | def test_resourceinfo_from_raw_dict(raw, expected): 77 | ri = ResourceInfo.from_raw(raw) 78 | assert ri.id == expected["id"] 79 | assert ri.name == expected["name"] 80 | assert ri.type == expected["type"] 81 | assert ri.extra == expected["extra"] 82 | 83 | 84 | @pytest.mark.parametrize("primitive", [ 85 | "just a string", 123, 4.56, True, None 86 | ]) 87 | def test_resourceinfo_from_raw_primitive(primitive): 88 | ri = ResourceInfo.from_raw(primitive) 89 | # id, name, type stay None 90 | assert ri.id is None and ri.name is None and ri.type is None 91 | # primitive ends up under extra["value"] 92 | assert ri.extra == {"value": primitive} 93 | --------------------------------------------------------------------------------