mcp-servers-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
File without changes
@@ -0,0 +1,136 @@
1
+ """The tool loop: the model asks, the MCP server answers, until the model replies in text.
2
+
3
+ The loop prints nothing. Callers observe it through two hooks, which is how the CLI renders
4
+ progress and how a trace can be recorded without touching the loop.
5
+ """
6
+
7
+ import json
8
+ import time
9
+ from collections.abc import Callable, Mapping, Sequence
10
+ from dataclasses import dataclass
11
+ from typing import Any
12
+
13
+ from fastmcp import Client
14
+ from mcp.shared.exceptions import MCPError
15
+ from mcp.types import CONNECTION_CLOSED
16
+
17
+ from mcp_servers_cli.llm import LLMBackend, Message, ToolCall, ToolResult, ToolSpec
18
+ from mcp_servers_cli.rendering import to_text
19
+
20
+ DEFAULT_MAX_STEPS = 10
21
+ MAX_RESULT_CHARS = 20_000
22
+
23
+ OnToolCall = Callable[[ToolCall], None]
24
+ OnToolResult = Callable[[ToolCall, ToolResult, float], None]
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class AgentRun:
29
+ """How a run ended. `stopped` means the step budget ran out before a text answer."""
30
+
31
+ answer: str
32
+ messages: list[Message]
33
+ steps: int
34
+ stopped: bool
35
+
36
+ @property
37
+ def results(self) -> list[ToolResult]:
38
+ """Every tool result of the run, in order."""
39
+ return [result for message in self.messages for result in message.tool_results]
40
+
41
+ @property
42
+ def failed(self) -> int:
43
+ return sum(result.is_error for result in self.results)
44
+
45
+
46
+ def tool_specs(tools: Sequence[Any]) -> list[ToolSpec]:
47
+ """MCP tools as the model sees them."""
48
+ return [
49
+ ToolSpec(
50
+ name=tool.name,
51
+ description=tool.description or "",
52
+ input_schema=tool.input_schema or {"type": "object", "properties": {}},
53
+ )
54
+ for tool in tools
55
+ ]
56
+
57
+
58
+ def sanitize_args(args: Mapping[str, Any], schema: Mapping[str, Any]) -> dict[str, Any]:
59
+ """Drop arguments the schema does not declare, which models sometimes invent.
60
+
61
+ A schema without `properties`, or one that allows additional properties, is left alone.
62
+ """
63
+ properties = schema.get("properties")
64
+ if not properties or schema.get("additionalProperties") is True:
65
+ return dict(args)
66
+ return {key: value for key, value in args.items() if key in properties}
67
+
68
+
69
+ def result_text(result: Any) -> str:
70
+ """The text a model reads back: content blocks first, structured content as a fallback."""
71
+ text = "\n".join(to_text(result.content))
72
+ if not text and result.structured_content is not None:
73
+ text = json.dumps(result.structured_content, ensure_ascii=False)
74
+ if len(text) > MAX_RESULT_CHARS:
75
+ text = f"{text[:MAX_RESULT_CHARS]}\n[truncated: {len(text)} characters in total]"
76
+ return text
77
+
78
+
79
+ async def execute(client: Client, call: ToolCall, schemas: Mapping[str, Mapping]) -> ToolResult:
80
+ """Run one call. Every failure a model can recover from comes back as an error result."""
81
+ if call.name not in schemas:
82
+ available = ", ".join(sorted(schemas))
83
+ return ToolResult(
84
+ call.id, call.name, f"unknown tool '{call.name}'; available: {available}", True
85
+ )
86
+ arguments = sanitize_args(call.arguments, schemas[call.name])
87
+ try:
88
+ result = await client.call_tool(call.name, arguments, raise_on_error=False)
89
+ except MCPError as exc:
90
+ if exc.code == CONNECTION_CLOSED:
91
+ raise
92
+ return ToolResult(call.id, call.name, str(exc), True)
93
+ return ToolResult(call.id, call.name, result_text(result), result.is_error)
94
+
95
+
96
+ async def first_turn(
97
+ client: Client, backend: LLMBackend, prompt: str, *, system: str = ""
98
+ ) -> Message:
99
+ """One model turn with the server's tools and nothing executed: what the model would do."""
100
+ specs = tool_specs(await client.list_tools())
101
+ return await backend.complete(system, [Message("user", prompt)], specs)
102
+
103
+
104
+ async def run_agent(
105
+ client: Client,
106
+ backend: LLMBackend,
107
+ prompt: str,
108
+ *,
109
+ system: str = "",
110
+ max_steps: int = DEFAULT_MAX_STEPS,
111
+ on_tool_call: OnToolCall | None = None,
112
+ on_tool_result: OnToolResult | None = None,
113
+ ) -> AgentRun:
114
+ """Alternate model turns and tool calls until the model answers or the budget runs out."""
115
+ specs = tool_specs(await client.list_tools())
116
+ schemas = {spec.name: spec.input_schema for spec in specs}
117
+ messages = [Message("user", prompt)]
118
+
119
+ for step in range(1, max_steps + 1):
120
+ reply = await backend.complete(system, messages, specs)
121
+ messages.append(reply)
122
+ if not reply.tool_calls:
123
+ return AgentRun(reply.text, messages, step, stopped=False)
124
+
125
+ results = []
126
+ for call in reply.tool_calls:
127
+ if on_tool_call:
128
+ on_tool_call(call)
129
+ started = time.perf_counter()
130
+ result = await execute(client, call, schemas)
131
+ if on_tool_result:
132
+ on_tool_result(call, result, time.perf_counter() - started)
133
+ results.append(result)
134
+ messages.append(Message("tool", tool_results=tuple(results)))
135
+
136
+ return AgentRun("", messages, max_steps, stopped=True)
@@ -0,0 +1,28 @@
1
+ """Backend registry. A new provider is a new module in this package plus one line here."""
2
+
3
+ from importlib import import_module
4
+
5
+ from mcp_servers_cli.llm import LLMBackend
6
+
7
+ # Backend name -> (module, class). Each backend is named after the SDK it imports.
8
+ BACKENDS: dict[str, tuple[str, str]] = {
9
+ "ollama": ("mcp_servers_cli.backends.ollama", "OllamaBackend"),
10
+ "anthropic": ("mcp_servers_cli.backends.anthropic", "AnthropicBackend"),
11
+ }
12
+
13
+
14
+ def create_backend(name: str, model: str) -> LLMBackend:
15
+ """Import a backend only once chosen, so an optional SDK stays optional."""
16
+ if name not in BACKENDS:
17
+ raise ValueError(f"Unknown backend '{name}'. Available: {sorted(BACKENDS)}")
18
+ module_name, class_name = BACKENDS[name]
19
+ try:
20
+ module = import_module(module_name)
21
+ except ModuleNotFoundError as exc:
22
+ if exc.name != name:
23
+ raise
24
+ raise ModuleNotFoundError(
25
+ f"The {name} backend needs an optional dependency: install mcp-servers-cli[{name}]",
26
+ name=name,
27
+ ) from exc
28
+ return getattr(module, class_name)(model=model)
@@ -0,0 +1,86 @@
1
+ """Anthropic backend: the Messages API. Optional dependency, `mcp-servers-cli[anthropic]`."""
2
+
3
+ from collections.abc import Sequence
4
+ from typing import Any
5
+
6
+ import anthropic
7
+ from anthropic.types import Message as AnthropicMessage
8
+
9
+ from mcp_servers_cli.llm import Message, ToolCall, ToolSpec
10
+
11
+ DEFAULT_MAX_TOKENS = 4096
12
+
13
+
14
+ def to_anthropic_tool(tool: ToolSpec) -> dict[str, Any]:
15
+ return {
16
+ "name": tool.name,
17
+ "description": tool.description,
18
+ "input_schema": tool.input_schema or {"type": "object", "properties": {}},
19
+ }
20
+
21
+
22
+ def to_anthropic_messages(messages: Sequence[Message]) -> list[dict[str, Any]]:
23
+ """Results go back in a user turn, each paired with its call by `tool_use_id`."""
24
+ converted: list[dict[str, Any]] = []
25
+ for message in messages:
26
+ if message.role == "tool":
27
+ blocks = [
28
+ {
29
+ "type": "tool_result",
30
+ "tool_use_id": result.call_id,
31
+ "content": result.content,
32
+ "is_error": result.is_error,
33
+ }
34
+ for result in message.tool_results
35
+ ]
36
+ converted.append({"role": "user", "content": blocks})
37
+ elif message.role == "assistant":
38
+ blocks = [{"type": "text", "text": message.text}] if message.text else []
39
+ blocks += [
40
+ {"type": "tool_use", "id": call.id, "name": call.name, "input": call.arguments}
41
+ for call in message.tool_calls
42
+ ]
43
+ converted.append({"role": "assistant", "content": blocks})
44
+ else:
45
+ converted.append({"role": "user", "content": message.text})
46
+ return converted
47
+
48
+
49
+ def from_anthropic_message(message: AnthropicMessage) -> Message:
50
+ text = "".join(block.text for block in message.content if block.type == "text")
51
+ calls = tuple(
52
+ ToolCall(id=block.id, name=block.name, arguments=dict(block.input))
53
+ for block in message.content
54
+ if block.type == "tool_use"
55
+ )
56
+ return Message(role="assistant", text=text, tool_calls=calls)
57
+
58
+
59
+ class AnthropicBackend:
60
+ """Reads ANTHROPIC_API_KEY from the environment, never from the command line."""
61
+
62
+ def __init__(
63
+ self,
64
+ model: str,
65
+ client: anthropic.AsyncAnthropic | None = None,
66
+ max_tokens: int = DEFAULT_MAX_TOKENS,
67
+ ) -> None:
68
+ self.model = model
69
+ self.max_tokens = max_tokens
70
+ self._client = client or anthropic.AsyncAnthropic()
71
+
72
+ async def complete(
73
+ self, system: str, messages: Sequence[Message], tools: Sequence[ToolSpec]
74
+ ) -> Message:
75
+ optional: dict[str, Any] = {}
76
+ if system:
77
+ optional["system"] = system
78
+ if tools:
79
+ optional["tools"] = [to_anthropic_tool(tool) for tool in tools]
80
+ response = await self._client.messages.create(
81
+ model=self.model,
82
+ max_tokens=self.max_tokens,
83
+ messages=to_anthropic_messages(messages),
84
+ **optional,
85
+ )
86
+ return from_anthropic_message(response)
@@ -0,0 +1,94 @@
1
+ """Ollama backend: local models through the official async client."""
2
+
3
+ from collections.abc import Mapping, Sequence
4
+ from typing import Any
5
+
6
+ import ollama
7
+
8
+ from mcp_servers_cli.llm import Message, ToolCall, ToolSpec
9
+
10
+ SCHEMA_KEYS = {"type", "value", "description"}
11
+
12
+
13
+ def normalize_args(args: Mapping[str, Any]) -> dict[str, Any]:
14
+ """Unwrap values some local models wrap in their schema: {'type': 'string', 'value': 'x'}.
15
+
16
+ Only dicts made of schema keys are unwrapped, so a genuine nested object is left intact.
17
+ """
18
+ return {
19
+ key: value["value"]
20
+ if isinstance(value, dict) and "value" in value and set(value) <= SCHEMA_KEYS
21
+ else value
22
+ for key, value in args.items()
23
+ }
24
+
25
+
26
+ def to_ollama_tool(tool: ToolSpec) -> dict[str, Any]:
27
+ return {
28
+ "type": "function",
29
+ "function": {
30
+ "name": tool.name,
31
+ "description": tool.description,
32
+ "parameters": tool.input_schema or {"type": "object", "properties": {}},
33
+ },
34
+ }
35
+
36
+
37
+ def to_ollama_messages(system: str, messages: Sequence[Message]) -> list[dict[str, Any]]:
38
+ """Ollama pairs a result with its call by position: one tool message per result, in order."""
39
+ converted: list[dict[str, Any]] = [{"role": "system", "content": system}] if system else []
40
+ for message in messages:
41
+ if message.role == "tool":
42
+ converted.extend(
43
+ {
44
+ "role": "tool",
45
+ "tool_name": result.name,
46
+ "content": f"error: {result.content}" if result.is_error else result.content,
47
+ }
48
+ for result in message.tool_results
49
+ )
50
+ elif message.tool_calls:
51
+ converted.append(
52
+ {
53
+ "role": "assistant",
54
+ "content": message.text,
55
+ "tool_calls": [
56
+ {"function": {"name": call.name, "arguments": call.arguments}}
57
+ for call in message.tool_calls
58
+ ],
59
+ }
60
+ )
61
+ else:
62
+ converted.append({"role": message.role, "content": message.text})
63
+ return converted
64
+
65
+
66
+ def from_ollama_message(message: ollama.Message) -> Message:
67
+ """Ollama calls carry no id: number them so their results can be paired back."""
68
+ calls = tuple(
69
+ ToolCall(
70
+ id=f"call_{index}",
71
+ name=call.function.name,
72
+ arguments=normalize_args(call.function.arguments or {}),
73
+ )
74
+ for index, call in enumerate(message.tool_calls or [])
75
+ )
76
+ return Message(role="assistant", text=message.content or "", tool_calls=calls)
77
+
78
+
79
+ class OllamaBackend:
80
+ """Talks to the Ollama server named by OLLAMA_HOST, localhost by default."""
81
+
82
+ def __init__(self, model: str, client: ollama.AsyncClient | None = None) -> None:
83
+ self.model = model
84
+ self._client = client or ollama.AsyncClient()
85
+
86
+ async def complete(
87
+ self, system: str, messages: Sequence[Message], tools: Sequence[ToolSpec]
88
+ ) -> Message:
89
+ response = await self._client.chat(
90
+ model=self.model,
91
+ messages=to_ollama_messages(system, messages),
92
+ tools=[to_ollama_tool(tool) for tool in tools],
93
+ )
94
+ return from_ollama_message(response.message)
mcp_servers_cli/cli.py ADDED
@@ -0,0 +1,233 @@
1
+ """Command line entry point. One command per action, the target given as an option."""
2
+
3
+ import json
4
+ import os
5
+ import sys
6
+ import time
7
+ from importlib import metadata
8
+ from pathlib import Path
9
+ from typing import Annotated
10
+
11
+ from cyclopts import App, Group, Parameter
12
+ from fastmcp import Client
13
+ from rich.console import Console
14
+ from rich.markup import escape
15
+
16
+ from mcp_servers_cli.agent import DEFAULT_MAX_STEPS, first_turn, run_agent
17
+ from mcp_servers_cli.backends import BACKENDS, create_backend
18
+ from mcp_servers_cli.errors import DEBUG_ENV_VAR, describe
19
+ from mcp_servers_cli.inspection import inspect_server
20
+ from mcp_servers_cli.llm import LLMBackend, ToolCall, ToolResult
21
+ from mcp_servers_cli.rendering import (
22
+ render_answer,
23
+ render_blocks,
24
+ render_failures,
25
+ render_plan,
26
+ render_server,
27
+ render_tool_call,
28
+ render_tool_result,
29
+ )
30
+ from mcp_servers_cli.repl import run_repl
31
+ from mcp_servers_cli.trace import Trace, open_trace
32
+ from mcp_servers_cli.transports import config_client, http_client, stdio_client
33
+
34
+ app = App(
35
+ name="mcp-servers-cli",
36
+ help="Inspect, call and drive any MCP server.",
37
+ version=metadata.version("mcp-servers-cli"),
38
+ )
39
+
40
+ TARGET = Group("Target", help="Exactly one transport must be given.")
41
+
42
+ Stdio = Annotated[str | None, Parameter(group=TARGET, help="Command launching the server.")]
43
+ Http = Annotated[str | None, Parameter(group=TARGET, help="URL of a remote server.")]
44
+ Config = Annotated[str | None, Parameter(group=TARGET, help="Path to an mcpServers JSON file.")]
45
+ Server = Annotated[str | None, Parameter(group=TARGET, help="Server name inside --config.")]
46
+ Env = Annotated[list[str] | None, Parameter(help="KEY=VALUE passed to a stdio subprocess.")]
47
+ Quiet = Annotated[bool, Parameter(help="Hide the inspected server's own stderr output.")]
48
+
49
+
50
+ def build_client(
51
+ stdio: str | None,
52
+ http: str | None,
53
+ config: str | None,
54
+ server: str | None,
55
+ env: list[str] | None,
56
+ quiet: bool = False,
57
+ ) -> Client:
58
+ """Resolve the mutually exclusive target options into a Client."""
59
+ chosen = [
60
+ name for name, value in (("stdio", stdio), ("http", http), ("config", config)) if value
61
+ ]
62
+ if len(chosen) != 1:
63
+ raise ValueError("Give exactly one of --stdio, --http or --config.")
64
+
65
+ if stdio:
66
+ return stdio_client(
67
+ stdio,
68
+ env=dict(item.split("=", 1) for item in env) if env else None,
69
+ log_file=Path(os.devnull) if quiet else None,
70
+ )
71
+ if http:
72
+ return http_client(http)
73
+ if not server:
74
+ raise ValueError("--config requires --server.")
75
+ return config_client(config, server)
76
+
77
+
78
+ @app.command
79
+ async def inspect(
80
+ *,
81
+ stdio: Stdio = None,
82
+ http: Http = None,
83
+ config: Config = None,
84
+ server: Server = None,
85
+ env: Env = None,
86
+ quiet: Quiet = False,
87
+ ) -> None:
88
+ """List the tools, resources and prompts a server exposes."""
89
+ async with build_client(stdio, http, config, server, env, quiet) as client:
90
+ render_server(await inspect_server(client))
91
+
92
+
93
+ @app.command
94
+ async def call(
95
+ tool: str,
96
+ arguments: str = "{}",
97
+ *,
98
+ stdio: Stdio = None,
99
+ http: Http = None,
100
+ config: Config = None,
101
+ server: Server = None,
102
+ env: Env = None,
103
+ quiet: Quiet = False,
104
+ ) -> None:
105
+ """Call one tool with JSON arguments and print the result."""
106
+ async with build_client(stdio, http, config, server, env, quiet) as client:
107
+ result = await client.call_tool(tool, json.loads(arguments))
108
+ render_blocks(result.content)
109
+
110
+
111
+ @app.command
112
+ async def read(
113
+ uri: str,
114
+ *,
115
+ stdio: Stdio = None,
116
+ http: Http = None,
117
+ config: Config = None,
118
+ server: Server = None,
119
+ env: Env = None,
120
+ quiet: Quiet = False,
121
+ ) -> None:
122
+ """Read one resource and print its content."""
123
+ async with build_client(stdio, http, config, server, env, quiet) as client:
124
+ render_blocks(await client.read_resource(uri))
125
+
126
+
127
+ @app.command
128
+ async def repl(
129
+ *,
130
+ stdio: Stdio = None,
131
+ http: Http = None,
132
+ config: Config = None,
133
+ server: Server = None,
134
+ env: Env = None,
135
+ quiet: Quiet = False,
136
+ ) -> None:
137
+ """Inspect a server, then take commands interactively."""
138
+ async with build_client(stdio, http, config, server, env, quiet) as client:
139
+ render_server(await inspect_server(client))
140
+ await run_repl(client)
141
+
142
+
143
+ @app.command
144
+ async def agent(
145
+ prompt: str,
146
+ *,
147
+ model: Annotated[str, Parameter(help="Model name, e.g. qwen3.5:4b or claude-sonnet-5.")],
148
+ backend: Annotated[
149
+ str, Parameter(help=f"LLM backend: {', '.join(sorted(BACKENDS))}.")
150
+ ] = "ollama",
151
+ system: Annotated[str, Parameter(help="System prompt given to the model.")] = "",
152
+ max_steps: Annotated[int, Parameter(help="Model turns allowed before giving up.")] = (
153
+ DEFAULT_MAX_STEPS
154
+ ),
155
+ trace: Annotated[
156
+ Path | None, Parameter(help="Write every tool call to this JSONL file.")
157
+ ] = None,
158
+ dry_run: Annotated[bool, Parameter(help="Show the model's first calls, run none.")] = False,
159
+ stdio: Stdio = None,
160
+ http: Http = None,
161
+ config: Config = None,
162
+ server: Server = None,
163
+ env: Env = None,
164
+ quiet: Quiet = False,
165
+ ) -> None:
166
+ """Let a model use the server's tools to answer a prompt."""
167
+ llm = create_backend(backend, model)
168
+ with open_trace(trace) as recorder:
169
+ async with build_client(stdio, http, config, server, env, quiet) as client:
170
+ if dry_run:
171
+ await _plan(client, llm, prompt, system, recorder)
172
+ else:
173
+ await _solve(client, llm, prompt, system, max_steps, recorder)
174
+
175
+
176
+ async def _plan(client: Client, llm: LLMBackend, prompt: str, system: str, recorder: Trace) -> None:
177
+ """--dry-run: one model turn, every requested call shown and none executed."""
178
+ started = time.perf_counter()
179
+ reply = await first_turn(client, llm, prompt, system=system)
180
+ for call in reply.tool_calls:
181
+ recorder.planned(call)
182
+ recorder.end(steps=1, stopped=False, answer=reply.text, elapsed=time.perf_counter() - started)
183
+ if reply.tool_calls:
184
+ render_plan(reply.tool_calls)
185
+ else:
186
+ render_answer(reply.text)
187
+
188
+
189
+ async def _solve(
190
+ client: Client, llm: LLMBackend, prompt: str, system: str, max_steps: int, recorder: Trace
191
+ ) -> None:
192
+ """The full run: every call printed and recorded as it happens, then the answer."""
193
+
194
+ def on_tool_result(call: ToolCall, result: ToolResult, elapsed: float) -> None:
195
+ render_tool_result(call, result, elapsed)
196
+ recorder.tool(call, result, elapsed)
197
+
198
+ started = time.perf_counter()
199
+ run = await run_agent(
200
+ client,
201
+ llm,
202
+ prompt,
203
+ system=system,
204
+ max_steps=max_steps,
205
+ on_tool_call=render_tool_call,
206
+ on_tool_result=on_tool_result,
207
+ )
208
+ recorder.end(
209
+ steps=run.steps,
210
+ stopped=run.stopped,
211
+ answer=run.answer,
212
+ elapsed=time.perf_counter() - started,
213
+ tool_calls=len(run.results),
214
+ failed_calls=run.failed,
215
+ )
216
+ if run.stopped:
217
+ raise RuntimeError(f"no final answer after {max_steps} model turns (see --max-steps)")
218
+ render_answer(run.answer)
219
+ if run.failed:
220
+ render_failures(run.failed, len(run.results))
221
+
222
+
223
+ def main(tokens: list[str] | None = None) -> None:
224
+ """Console entry point: one line per failure, the full trace when the debug variable is set."""
225
+ try:
226
+ app(tokens)
227
+ except KeyboardInterrupt:
228
+ sys.exit(130)
229
+ except Exception as exc:
230
+ if os.environ.get(DEBUG_ENV_VAR):
231
+ raise
232
+ Console(stderr=True, soft_wrap=True).print(f"[red]error:[/red] {escape(describe(exc))}")
233
+ sys.exit(1)
@@ -0,0 +1,30 @@
1
+ """Turning a failure into the one line a terminal user needs. The full trace stays opt-in."""
2
+
3
+ import json
4
+
5
+ from mcp.shared.exceptions import MCPError
6
+ from mcp.types import CONNECTION_CLOSED
7
+
8
+ DEBUG_ENV_VAR = "MCP_SERVERS_CLI_DEBUG"
9
+
10
+
11
+ def leaf(exc: BaseException) -> BaseException:
12
+ """First innermost exception of nested exception groups, as raised by anyio task groups."""
13
+ seen: set[int] = set()
14
+ while isinstance(exc, BaseExceptionGroup) and exc.exceptions and id(exc) not in seen:
15
+ seen.add(id(exc))
16
+ exc = exc.exceptions[0]
17
+ return exc
18
+
19
+
20
+ def describe(exc: BaseException) -> str:
21
+ """One line naming the cause, without the stack."""
22
+ exc = leaf(exc)
23
+ if isinstance(exc, MCPError) and exc.code == CONNECTION_CLOSED:
24
+ return (
25
+ "the server closed the connection: its own error output above gives the cause "
26
+ "(hidden if --quiet was given)"
27
+ )
28
+ if isinstance(exc, json.JSONDecodeError):
29
+ return f"invalid JSON: {exc}"
30
+ return str(exc) or type(exc).__name__