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.
- mcp_servers_cli/__init__.py +0 -0
- mcp_servers_cli/agent.py +136 -0
- mcp_servers_cli/backends/__init__.py +28 -0
- mcp_servers_cli/backends/anthropic.py +86 -0
- mcp_servers_cli/backends/ollama.py +94 -0
- mcp_servers_cli/cli.py +233 -0
- mcp_servers_cli/errors.py +30 -0
- mcp_servers_cli/inspection.py +108 -0
- mcp_servers_cli/llm.py +51 -0
- mcp_servers_cli/rendering.py +108 -0
- mcp_servers_cli/repl.py +55 -0
- mcp_servers_cli/trace.py +103 -0
- mcp_servers_cli/transports.py +79 -0
- mcp_servers_cli-0.1.0.dist-info/METADATA +263 -0
- mcp_servers_cli-0.1.0.dist-info/RECORD +18 -0
- mcp_servers_cli-0.1.0.dist-info/WHEEL +4 -0
- mcp_servers_cli-0.1.0.dist-info/entry_points.txt +2 -0
- mcp_servers_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
File without changes
|
mcp_servers_cli/agent.py
ADDED
|
@@ -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__
|