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 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