mcp-toolsets-runtime 0.1.3__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_agent/__init__.py +0 -0
- mcp_agent/elements/McpView.jsx +89 -0
- mcp_agent/main.py +376 -0
- mcp_agent/py.typed +0 -0
- mcp_agent/web.py +315 -0
- mcp_cli/__init__.py +1 -0
- mcp_cli/main.py +170 -0
- mcp_cli/py.typed +0 -0
- mcp_runtime/__init__.py +5 -0
- mcp_runtime/credentials.py +80 -0
- mcp_runtime/fastmcp_output.py +112 -0
- mcp_runtime/index.py +167 -0
- mcp_runtime/py.typed +0 -0
- mcp_runtime/server.py +148 -0
- mcp_runtime/tool_result.py +45 -0
- mcp_runtime/views.py +115 -0
- mcp_toolset/__init__.py +1 -0
- mcp_toolset/main.py +387 -0
- mcp_toolset/py.typed +0 -0
- mcp_toolsets_runtime-0.1.3.dist-info/METADATA +117 -0
- mcp_toolsets_runtime-0.1.3.dist-info/RECORD +24 -0
- mcp_toolsets_runtime-0.1.3.dist-info/WHEEL +4 -0
- mcp_toolsets_runtime-0.1.3.dist-info/entry_points.txt +7 -0
- mcp_toolsets_runtime-0.1.3.dist-info/licenses/LICENSE +21 -0
mcp_agent/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Renders a toolset's UI view in a sandboxed iframe and implements the *host*
|
|
2
|
+
// end of the MCP Apps `ui/*` JSON-RPC-over-postMessage bridge (the same protocol
|
|
3
|
+
// Claude, ChatGPT, Goose and VS Code speak; the view's end is the standard
|
|
4
|
+
// @modelcontextprotocol/ext-apps SDK — see toolsets/*/ui/src/host.ts).
|
|
5
|
+
//
|
|
6
|
+
// view "ui/initialize" (request) -> we reply with host info + context
|
|
7
|
+
// view "ui/notifications/initialized" -> we push tool-input then tool-result
|
|
8
|
+
// view "ui/message" (request) -> sendUserMessage(), then reply {}
|
|
9
|
+
// view "ui/notifications/size-changed" -> ignored (the panel frame is fixed)
|
|
10
|
+
//
|
|
11
|
+
// Props (from web.py): { html: the ui:// resource bundle, data: structuredContent }.
|
|
12
|
+
// The bundle is rendered via srcdoc, so the frame has an opaque origin and needs
|
|
13
|
+
// only allow-scripts — never allow-same-origin.
|
|
14
|
+
import { useEffect, useRef } from "react";
|
|
15
|
+
|
|
16
|
+
const PROTOCOL_VERSION = "2026-01-26"; // ext-apps LATEST_PROTOCOL_VERSION
|
|
17
|
+
|
|
18
|
+
export default function McpView() {
|
|
19
|
+
const ref = useRef(null);
|
|
20
|
+
const { html, data } = props;
|
|
21
|
+
// Each turn mounts a fresh element, so data is stable per instance; hold it in
|
|
22
|
+
// a ref so the mount-once listener always sends the current value.
|
|
23
|
+
const dataRef = useRef(data);
|
|
24
|
+
dataRef.current = data;
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
const iframe = ref.current;
|
|
28
|
+
if (!iframe) return;
|
|
29
|
+
const post = (message) => iframe.contentWindow?.postMessage(message, "*");
|
|
30
|
+
|
|
31
|
+
function onMessage(event) {
|
|
32
|
+
if (event.source !== iframe.contentWindow) return;
|
|
33
|
+
const message = event.data;
|
|
34
|
+
if (!message || message.jsonrpc !== "2.0") return;
|
|
35
|
+
|
|
36
|
+
if (message.method === "ui/initialize") {
|
|
37
|
+
post({
|
|
38
|
+
jsonrpc: "2.0",
|
|
39
|
+
id: message.id,
|
|
40
|
+
result: {
|
|
41
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
42
|
+
hostInfo: { name: "chainlit-mcpview", version: "1.0.0" },
|
|
43
|
+
hostCapabilities: { message: { text: {} } },
|
|
44
|
+
hostContext: {},
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
} else if (message.method === "ui/notifications/initialized") {
|
|
48
|
+
// tool-input MUST precede tool-result per the spec; the view only needs
|
|
49
|
+
// the result, and we don't track the call's arguments here, so send an
|
|
50
|
+
// empty set to satisfy the ordering.
|
|
51
|
+
post({
|
|
52
|
+
jsonrpc: "2.0",
|
|
53
|
+
method: "ui/notifications/tool-input",
|
|
54
|
+
params: { arguments: {} },
|
|
55
|
+
});
|
|
56
|
+
post({
|
|
57
|
+
jsonrpc: "2.0",
|
|
58
|
+
method: "ui/notifications/tool-result",
|
|
59
|
+
params: { content: [], structuredContent: dataRef.current },
|
|
60
|
+
});
|
|
61
|
+
} else if (message.method === "ui/message" && message.id !== undefined) {
|
|
62
|
+
// content is a ContentBlock[]; join the text blocks into the user turn.
|
|
63
|
+
const text = (message.params?.content ?? [])
|
|
64
|
+
.filter((block) => block?.type === "text" && typeof block.text === "string")
|
|
65
|
+
.map((block) => block.text)
|
|
66
|
+
.join("");
|
|
67
|
+
if (text) sendUserMessage(text);
|
|
68
|
+
post({ jsonrpc: "2.0", id: message.id, result: {} });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
window.addEventListener("message", onMessage);
|
|
73
|
+
return () => window.removeEventListener("message", onMessage);
|
|
74
|
+
}, []);
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<iframe
|
|
78
|
+
ref={ref}
|
|
79
|
+
srcDoc={html}
|
|
80
|
+
sandbox="allow-scripts"
|
|
81
|
+
style={{
|
|
82
|
+
width: "100%",
|
|
83
|
+
height: "100%",
|
|
84
|
+
border: "1px solid var(--border, #e4e7ec)",
|
|
85
|
+
borderRadius: 10,
|
|
86
|
+
}}
|
|
87
|
+
/>
|
|
88
|
+
);
|
|
89
|
+
}
|
mcp_agent/main.py
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
"""Chat with every toolset behind an mcp-toolsets index URL.
|
|
2
|
+
|
|
3
|
+
Point ``mcp-agent`` at an index root (anything serving a ``connections`` map
|
|
4
|
+
shaped for ``MultiServerMCPClient``) or directly at a single MCP endpoint;
|
|
5
|
+
it loads every server's tools and lets a chat model drive them in an
|
|
6
|
+
interactive chat.
|
|
7
|
+
|
|
8
|
+
The model is provider-agnostic and no provider ships by default: pick one by
|
|
9
|
+
setting ``PROVIDER_MODEL`` to a ``provider:model`` string for LangChain's
|
|
10
|
+
``init_chat_model`` (e.g. ``openai:gpt-4o-mini``,
|
|
11
|
+
``anthropic:claude-3-5-haiku-latest``) and installing that provider's package
|
|
12
|
+
(``uv add langchain-openai``). ``PROVIDER_API_KEY`` (the chosen provider's key)
|
|
13
|
+
and ``PROVIDER_MODEL`` are read from the environment or a ``.env`` file.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
from collections.abc import Iterator
|
|
18
|
+
from contextlib import contextmanager
|
|
19
|
+
from contextvars import ContextVar
|
|
20
|
+
from importlib import resources
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Annotated, Any, cast
|
|
23
|
+
|
|
24
|
+
import httpx
|
|
25
|
+
import typer
|
|
26
|
+
from langchain.agents import create_agent
|
|
27
|
+
from langchain.chat_models import init_chat_model
|
|
28
|
+
from mcp.client.streamable_http import create_mcp_http_client
|
|
29
|
+
from mcp.shared.exceptions import McpError
|
|
30
|
+
from langchain_core.messages import BaseMessage, HumanMessage
|
|
31
|
+
from langchain_core.tools import BaseTool
|
|
32
|
+
from langchain_mcp_adapters.client import MultiServerMCPClient
|
|
33
|
+
from pydantic import Field, SecretStr, ValidationError
|
|
34
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
35
|
+
from rich.console import Console
|
|
36
|
+
from rich.markdown import Markdown
|
|
37
|
+
|
|
38
|
+
# How to set PROVIDER_MODEL when it is missing; shown in the error message.
|
|
39
|
+
PROVIDER_HELP = (
|
|
40
|
+
"Set PROVIDER_MODEL (e.g. openai:gpt-4o-mini) and PROVIDER_API_KEY in the "
|
|
41
|
+
"environment or .env, and install the provider package "
|
|
42
|
+
"(e.g. uv add langchain-openai)."
|
|
43
|
+
)
|
|
44
|
+
SYSTEM_PROMPT = (
|
|
45
|
+
"You are a helpful assistant with tools from one or more MCP toolsets. "
|
|
46
|
+
"Use them whenever they can ground your answer; otherwise answer directly."
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
app = typer.Typer(no_args_is_help=True, help=__doc__)
|
|
50
|
+
console = Console()
|
|
51
|
+
|
|
52
|
+
# Chainlit host elements shipped as package data (src/mcp_agent/elements/). The
|
|
53
|
+
# web agent renders tool views via a Chainlit CustomElement Chainlit loads from
|
|
54
|
+
# <app-root>/public/elements/, which defaults to ./public/elements relative to
|
|
55
|
+
# where you launch it.
|
|
56
|
+
HOST_ELEMENTS = ("McpView.jsx",)
|
|
57
|
+
DEFAULT_ELEMENTS_DIR = Path("public/elements")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def install_host_elements(target: Path) -> list[Path]:
|
|
61
|
+
"""Copy the packaged Chainlit host element(s) into ``target``.
|
|
62
|
+
|
|
63
|
+
Returns the paths written. Deterministic and idempotent: it always writes
|
|
64
|
+
the version shipped with the installed package, so an upgrade + reinstall
|
|
65
|
+
refreshes the element with no drift. Meant to be run at build time (see the
|
|
66
|
+
``install-elements`` command) rather than as a runtime side effect.
|
|
67
|
+
"""
|
|
68
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
source = resources.files("mcp_agent") / "elements"
|
|
70
|
+
written = []
|
|
71
|
+
for name in HOST_ELEMENTS:
|
|
72
|
+
dest = target / name
|
|
73
|
+
dest.write_text((source / name).read_text(encoding="utf-8"), encoding="utf-8")
|
|
74
|
+
written.append(dest)
|
|
75
|
+
return written
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class AgentSettings(BaseSettings):
|
|
79
|
+
"""Agent configuration, validated from the environment or a .env file.
|
|
80
|
+
|
|
81
|
+
The CLI takes the URL and model as arguments; the web UI (``web.py``)
|
|
82
|
+
reads ``MCP_URL`` and ``PROVIDER_MODEL`` from here instead.
|
|
83
|
+
``PROVIDER_MODEL`` and ``PROVIDER_API_KEY`` are required — there is no
|
|
84
|
+
default provider; ``PROVIDER_API_KEY`` is passed straight to
|
|
85
|
+
``init_chat_model``.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
|
89
|
+
|
|
90
|
+
provider_api_key: SecretStr
|
|
91
|
+
provider_model: str
|
|
92
|
+
mcp_url: str = "http://localhost:8000/mcp"
|
|
93
|
+
chainlit_port: int = Field(default=8080, ge=1, le=65535)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def connections_from(url: str, payload: Any) -> dict[str, Any]:
|
|
97
|
+
"""Extract an index payload's connections map, else treat url as one server."""
|
|
98
|
+
if isinstance(payload, dict) and isinstance(payload.get("connections"), dict):
|
|
99
|
+
return payload["connections"]
|
|
100
|
+
return {"server": {"transport": "streamable_http", "url": url}}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def credential_headers_from(payload: Any) -> dict[str, list[str]] | None:
|
|
104
|
+
"""Per-toolset credential header names from an index payload.
|
|
105
|
+
|
|
106
|
+
``None`` means the payload was not an index (a direct single-server URL),
|
|
107
|
+
so no declarations are available.
|
|
108
|
+
"""
|
|
109
|
+
if not (isinstance(payload, dict) and isinstance(payload.get("toolsets"), list)):
|
|
110
|
+
return None
|
|
111
|
+
return {
|
|
112
|
+
entry["name"]: [
|
|
113
|
+
header.lower() for header in entry.get("credential_headers", [])
|
|
114
|
+
]
|
|
115
|
+
for entry in payload["toolsets"]
|
|
116
|
+
if isinstance(entry, dict) and entry.get("name")
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# Connection failures an agent should report rather than crash on.
|
|
121
|
+
CONNECT_ERRORS = (httpx.HTTPError, OSError, McpError)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def first_leaf(error: BaseException) -> BaseException:
|
|
125
|
+
"""Unwrap (possibly nested) ExceptionGroups to the first real exception."""
|
|
126
|
+
while isinstance(error, BaseExceptionGroup):
|
|
127
|
+
error = error.exceptions[0]
|
|
128
|
+
return error
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def connect_error_hint(url: str) -> str:
|
|
132
|
+
"""A nudge for the most common misconfiguration: a missing /mcp path."""
|
|
133
|
+
if url.rstrip("/").endswith("/mcp"):
|
|
134
|
+
return ""
|
|
135
|
+
return (
|
|
136
|
+
" Hint: single-toolset servers serve MCP under /mcp "
|
|
137
|
+
"(e.g. http://localhost:8000/mcp); only an index is served at the root."
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def health_url_for(url: str) -> str | None:
|
|
142
|
+
"""Derive a direct MCP endpoint's sibling /health URL, if there is one."""
|
|
143
|
+
base = url.rstrip("/")
|
|
144
|
+
return base.removesuffix("/mcp") + "/health" if base.endswith("/mcp") else None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
async def single_server_credential_headers(
|
|
148
|
+
client: httpx.AsyncClient, url: str
|
|
149
|
+
) -> dict[str, list[str]] | None:
|
|
150
|
+
"""Ask a direct MCP endpoint's /health which credential headers it reads.
|
|
151
|
+
|
|
152
|
+
Returns ``None`` when there is no health route or it doesn't advertise
|
|
153
|
+
credentials (e.g. a non-mcp-toolsets server).
|
|
154
|
+
"""
|
|
155
|
+
health_url = health_url_for(url)
|
|
156
|
+
if health_url is None:
|
|
157
|
+
return None
|
|
158
|
+
try:
|
|
159
|
+
health = (await client.get(health_url)).json()
|
|
160
|
+
headers = health.get("credential_headers")
|
|
161
|
+
except (httpx.HTTPError, ValueError, AttributeError):
|
|
162
|
+
return None
|
|
163
|
+
if not isinstance(headers, list):
|
|
164
|
+
return None
|
|
165
|
+
return {"server": [str(header).lower() for header in headers]}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
async def fetch_connections(
|
|
169
|
+
url: str,
|
|
170
|
+
) -> tuple[dict[str, Any], dict[str, list[str]] | None]:
|
|
171
|
+
"""Resolve a URL to a MultiServerMCPClient config plus credential needs."""
|
|
172
|
+
async with httpx.AsyncClient(follow_redirects=True, timeout=10.0) as client:
|
|
173
|
+
try:
|
|
174
|
+
payload = (await client.get(url)).json()
|
|
175
|
+
except (httpx.HTTPError, ValueError):
|
|
176
|
+
payload = None
|
|
177
|
+
connections = connections_from(url, payload)
|
|
178
|
+
required = credential_headers_from(payload)
|
|
179
|
+
if required is None:
|
|
180
|
+
required = await single_server_credential_headers(client, url)
|
|
181
|
+
return connections, required
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
_credentials: ContextVar[dict[str, str] | None] = ContextVar(
|
|
185
|
+
"user_credentials", default=None
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@contextmanager
|
|
190
|
+
def user_credentials(headers: dict[str, str] | None) -> Iterator[None]:
|
|
191
|
+
"""Provide the calling user's credential headers for the duration.
|
|
192
|
+
|
|
193
|
+
This is how an agent passes a user's secrets to the tools without the
|
|
194
|
+
model ever seeing them: they ride the MCP transport, not the conversation.
|
|
195
|
+
The agent is built once; wrap each turn (``run_turn``) in this and the
|
|
196
|
+
tool calls made inside read the values at request time, so one long-lived
|
|
197
|
+
agent serves many users with different credentials.
|
|
198
|
+
"""
|
|
199
|
+
token = _credentials.set(headers)
|
|
200
|
+
try:
|
|
201
|
+
yield
|
|
202
|
+
finally:
|
|
203
|
+
_credentials.reset(token)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def credential_client_factory(allowed: list[str] | None) -> Any:
|
|
207
|
+
"""Build an httpx client factory injecting the current user's credentials.
|
|
208
|
+
|
|
209
|
+
Only headers named in ``allowed`` (the toolset's advertised declaration)
|
|
210
|
+
are injected, so unrelated toolsets never receive them; ``None`` means no
|
|
211
|
+
declaration was discoverable (a server the user pointed at directly) and
|
|
212
|
+
every provided header is sent.
|
|
213
|
+
"""
|
|
214
|
+
wanted = None if allowed is None else {header.lower() for header in allowed}
|
|
215
|
+
|
|
216
|
+
def factory(
|
|
217
|
+
headers: dict[str, str] | None = None,
|
|
218
|
+
timeout: httpx.Timeout | None = None,
|
|
219
|
+
auth: httpx.Auth | None = None,
|
|
220
|
+
) -> httpx.AsyncClient:
|
|
221
|
+
provided = _credentials.get() or {}
|
|
222
|
+
send = {
|
|
223
|
+
header: value
|
|
224
|
+
for header, value in provided.items()
|
|
225
|
+
if wanted is None or header.lower() in wanted
|
|
226
|
+
}
|
|
227
|
+
return create_mcp_http_client(
|
|
228
|
+
headers={**(headers or {}), **send}, timeout=timeout, auth=auth
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
return factory
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def with_credential_support(
|
|
235
|
+
connections: dict[str, Any], required: dict[str, list[str]] | None
|
|
236
|
+
) -> dict[str, Any]:
|
|
237
|
+
"""Wire each connection to inject per-user credentials at call time."""
|
|
238
|
+
return {
|
|
239
|
+
name: {
|
|
240
|
+
**connection,
|
|
241
|
+
"httpx_client_factory": credential_client_factory(
|
|
242
|
+
None if required is None else required.get(name, [])
|
|
243
|
+
),
|
|
244
|
+
}
|
|
245
|
+
for name, connection in connections.items()
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
async def build_agent(
|
|
250
|
+
url: str, model: str, api_key: SecretStr
|
|
251
|
+
) -> tuple[Any, dict[str, Any], list[BaseTool]]:
|
|
252
|
+
"""Discover the servers behind ``url`` and build a tool-calling agent.
|
|
253
|
+
|
|
254
|
+
Built once per process/session: per-user credentials are not baked in but
|
|
255
|
+
read from :func:`user_credentials` on every tool call. ``model`` is a
|
|
256
|
+
``provider:model`` string for :func:`init_chat_model` and ``api_key`` is
|
|
257
|
+
that provider's key, so the agent is provider-agnostic.
|
|
258
|
+
"""
|
|
259
|
+
connections, required = await fetch_connections(url)
|
|
260
|
+
tools = await MultiServerMCPClient(
|
|
261
|
+
with_credential_support(connections, required)
|
|
262
|
+
).get_tools()
|
|
263
|
+
agent = create_agent(
|
|
264
|
+
init_chat_model(model, api_key=api_key.get_secret_value()),
|
|
265
|
+
tools,
|
|
266
|
+
system_prompt=SYSTEM_PROMPT,
|
|
267
|
+
)
|
|
268
|
+
return agent, connections, tools
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
async def run_turn(
|
|
272
|
+
agent: Any, messages: list[BaseMessage], text: str
|
|
273
|
+
) -> tuple[list[BaseMessage], list[BaseMessage]]:
|
|
274
|
+
"""Run one chat turn; return the full history and this turn's new messages."""
|
|
275
|
+
state = {"messages": [*messages, HumanMessage(text)]}
|
|
276
|
+
result = await agent.ainvoke(cast(Any, state))
|
|
277
|
+
history: list[BaseMessage] = result["messages"]
|
|
278
|
+
return history, history[len(messages) + 1 :]
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
async def chat_loop(url: str, model: str, api_key: SecretStr) -> None:
|
|
282
|
+
try:
|
|
283
|
+
agent, connections, tools = await build_agent(url, model, api_key)
|
|
284
|
+
except* CONNECT_ERRORS as group:
|
|
285
|
+
console.print(
|
|
286
|
+
f"[red]Could not reach the MCP server(s) behind {url}: "
|
|
287
|
+
f"{first_leaf(group)}[/red]"
|
|
288
|
+
)
|
|
289
|
+
if hint := connect_error_hint(url):
|
|
290
|
+
console.print(f"[yellow]{hint.strip()}[/yellow]")
|
|
291
|
+
raise typer.Exit(1) from None
|
|
292
|
+
|
|
293
|
+
console.print(
|
|
294
|
+
f"Connected to [bold]{len(connections)}[/bold] server(s): "
|
|
295
|
+
f"{', '.join(connections)}"
|
|
296
|
+
)
|
|
297
|
+
console.print(f"[dim]{len(tools)} tools: {', '.join(t.name for t in tools)}[/dim]")
|
|
298
|
+
console.print("[dim]Type a message, or quit to exit.[/dim]")
|
|
299
|
+
|
|
300
|
+
messages: list[BaseMessage] = []
|
|
301
|
+
while True:
|
|
302
|
+
try:
|
|
303
|
+
line = console.input("[bold cyan]you>[/bold cyan] ").strip()
|
|
304
|
+
except (EOFError, KeyboardInterrupt):
|
|
305
|
+
break
|
|
306
|
+
if not line:
|
|
307
|
+
continue
|
|
308
|
+
if line in ("quit", "exit"):
|
|
309
|
+
break
|
|
310
|
+
try:
|
|
311
|
+
messages, new_messages = await run_turn(agent, messages, line)
|
|
312
|
+
except Exception as error: # noqa: BLE001 - keep the chat alive
|
|
313
|
+
console.print(f"[red]{error}[/red]")
|
|
314
|
+
continue
|
|
315
|
+
for message in new_messages:
|
|
316
|
+
for call in getattr(message, "tool_calls", None) or []:
|
|
317
|
+
console.print(f"[dim]→ {call['name']} {call['args']}[/dim]")
|
|
318
|
+
console.print(Markdown(str(messages[-1].content)))
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
@app.command()
|
|
322
|
+
def chat(
|
|
323
|
+
url: Annotated[
|
|
324
|
+
str | None,
|
|
325
|
+
typer.Argument(
|
|
326
|
+
help="Index URL serving a connections map, or a single MCP endpoint. "
|
|
327
|
+
"Defaults to MCP_URL from the environment / .env.",
|
|
328
|
+
),
|
|
329
|
+
] = None,
|
|
330
|
+
model: Annotated[
|
|
331
|
+
str | None,
|
|
332
|
+
typer.Option(
|
|
333
|
+
"--model",
|
|
334
|
+
help="Chat model as provider:model (e.g. openai:gpt-4o-mini). "
|
|
335
|
+
"Overrides PROVIDER_MODEL from the environment / .env.",
|
|
336
|
+
),
|
|
337
|
+
] = None,
|
|
338
|
+
) -> None:
|
|
339
|
+
"""Discover the MCP servers behind URL and chat with their tools.
|
|
340
|
+
|
|
341
|
+
URL and model are read from the environment / .env (the same place as
|
|
342
|
+
PROVIDER_API_KEY) when omitted, so the agent can be configured entirely
|
|
343
|
+
by a .env file; CLI arguments override it.
|
|
344
|
+
"""
|
|
345
|
+
try:
|
|
346
|
+
settings = AgentSettings(provider_model=model) if model else AgentSettings()
|
|
347
|
+
except ValidationError:
|
|
348
|
+
console.print(f"[red]{PROVIDER_HELP}[/red]")
|
|
349
|
+
raise typer.Exit(1) from None
|
|
350
|
+
asyncio.run(
|
|
351
|
+
chat_loop(
|
|
352
|
+
url or settings.mcp_url, settings.provider_model, settings.provider_api_key
|
|
353
|
+
)
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
@app.command("install-elements")
|
|
358
|
+
def install_elements(
|
|
359
|
+
target: Annotated[
|
|
360
|
+
Path,
|
|
361
|
+
typer.Argument(
|
|
362
|
+
help="Directory to install the Chainlit host element(s) into, "
|
|
363
|
+
"typically your app root's public/elements.",
|
|
364
|
+
),
|
|
365
|
+
] = DEFAULT_ELEMENTS_DIR,
|
|
366
|
+
) -> None:
|
|
367
|
+
"""Copy the packaged Chainlit host element(s) into a chainlit app root.
|
|
368
|
+
|
|
369
|
+
The web agent (``mcp-agent-web``) renders tool views via a Chainlit
|
|
370
|
+
CustomElement named "McpView", which Chainlit loads from
|
|
371
|
+
``<app-root>/public/elements/``. Run this at build time — e.g. in your
|
|
372
|
+
Dockerfile: ``RUN mcp-agent install-elements`` — so the element is present
|
|
373
|
+
without the package writing to your filesystem at runtime.
|
|
374
|
+
"""
|
|
375
|
+
for dest in install_host_elements(target):
|
|
376
|
+
console.print(f"[green]installed[/green] {dest}")
|
mcp_agent/py.typed
ADDED
|
File without changes
|