Plug Quill Into Any Ecosystem: MCP, the Hub, and Tool Interop (smolagents, Module 9)

Module 9 of 15 14 min read Lab code ↗

This is Module 9 of smolagents Mastery, a free 15-module course that takes you from your first code agent to a production-grade one. Start at Module 1 or browse the full syllabus.

By the end of Module 8, Quill is reliable. You hand it a CSV, it profiles the data, writes pandas, draws a chart, packages a validated QuillReport, and self-corrects when a check rejects its answer. It is a real code-first data analyst.

Here is the part that should bother you: every tool Quill owns is a Python function you wrote. load_dataset, profile_dataframe, save_chart — all of them live in Quill’s own repo. So picture the first real deployment. The client already runs a battle-tested SQL service. Their knowledge base is exposed over a standard protocol. The team next door published a perfect geocoding tool to the Hub last week. Are you going to re-implement each one by hand, inside Quill, and then own the maintenance forever?

That is the wrong instinct, and it misses the actual leverage of smolagents. The framework’s value is not only that you can write tools — it is that you can consume and share them through standards. This module opens Quill’s toolbox to the world: it connects to a standardized MCP data server, loads a tool from the Hub, and publishes one of its own back so other people can use it.

In this module

You’ll learn

  1. Explain what MCP is and why it exists — the open standard Anthropic introduced in November 2024, “a USB-C port for AI applications,” with its three primitives (tools / resources / prompts) and its server/client split — and where smolagents plugs in.
  2. Connect a smolagents agent to an MCP server two ways — ToolCollection.from_mcp(...) (a context manager) and MCPClient (explicit lifecycle, multi-server) — across three transports (stdio, streamable-http, the deprecated SSE).
  3. Assess the security of interop: why trust_remote_code=True is mandatory, why a stdio MCP server runs code on your machine, and how that ties back to Module 5’s threat model.
  4. Import tools from other ecosystems — Tool.from_hub / from_space / from_gradio / from_langchain / from_dict / from_code and ToolCollection.from_hub — and pick the right one.
  5. Share a tool: the “pushable” rules, tool.push_to_hub / tool.save / load_tool.

You’ll build — Quill connects to a stdio MCP data server for SQL/file access, loads one extra tool from the Hub, and publishes its own save_chart tool back to the Hub.

Theory areas covered

  • T9 — Tool interoperability: MCP & the Hub ecosystem — 6% of the course. This module owns it in full.
  • T3 (importers & Hub sharing) — part of the 10% Tools area. The Tool.from_* importers and the share path; the rest of T3 (the Tool class, @tool) belongs to Module 3.

Prerequisites — Module 3 (the Tool class, @tool, the runtime agent.tools dict), Module 5 (the threat model — we lean on it for MCP security), Module 8 (the end-of-line Quill: QuillReport, final_answer_checks). HF_TOKEN in .env; uvx available (it ships with uv); the [mcp] extra installed.

Where you are in the series

  • ✅ Modules 1–8 — your first CodeAgent, the ReAct loop, Quill’s data toolbox, the model layer, the sandbox, memory, planning, and the reliability contract (QuillReport + final_answer_checks).
  • 👉 Module 9 — Tool interop: MCP, the Hub, and other ecosystems. You are here.
  • ⬜ Modules 10–15 — multi-agents, multimodal, RAG, deployment, telemetry, and the capstone.

Quill before this module: every tool is local — load_dataset, profile_dataframe, save_chart in its own repo, plus the smolagents web tools. A silo. Quill after this module: connected to an MCP data server (SQL over a SQLite file), able to load one tool from the Hub at runtime, and with its save_chart published to the Hub for anyone to reuse.

The seam you add here — one new extra_tools= argument on build_quill — is also what carries Quill’s tools into the research sub-agent it grows in Module 10. Interop is what turns a clever script into a team asset.


MCP: a universal port for agent tools

Start with the problem MCP solves, because the acronym hides a very concrete pain.

You have M LLM applications (your agent, someone’s chatbot, an IDE assistant) and N tools and data sources (a SQL database, a filesystem, a CRM, a search index). Without a standard, every app writes a bespoke adapter for every source. That is M × N integrations — and every one is code someone has to write, test, and keep working as both ends drift. The combinatorics are the whole problem.

MCP (Model Context Protocol) is the open standard that collapses that explosion. Anthropic introduced it in November 2024 to standardize how LLM applications connect to external tools and data. Their analogy, which is worth memorizing because it is exactly right: MCP is a USB-C port for AI applications. One protocol on both sides turns M × N bespoke adapters into M + N — each app speaks MCP once, each source exposes MCP once, and anything plugs into anything. (Source: the Anthropic MCP announcement.)

The subtle part — and the one tutorials skip — is what boundary MCP standardizes. MCP standardizes the boundary between an agent and its tools and data. It does not standardize the boundary between one agent and another agent (that is a different protocol entirely, and a different module — Quill grows a teammate in Module 10, not here). Keep those two boundaries separate in your head; conflating them is a common conceptual slip.

The three primitives

An MCP server exposes capabilities through three primitive types. Each is “controlled” by a different party, which tells you who decides when it is used:

PrimitiveWho controls itWhat it is
toolsmodel-controlledFunctions the model can decide to call (e.g. read_query, geocode). This is the one that matters for an agent.
resourcesapp-controlledContext the application chooses to expose — files, documents, records the model can read.
promptsuser-controlledReusable prompt templates a user invokes deliberately.

For a CodeAgent, tools are the headline primitive: the model decides, mid-run, to call an MCP-served tool the same way it calls one you wrote. smolagents focuses there.

Servers, clients, and where smolagents sits

The architecture has two halves. MCP servers expose data and tools. MCP clients are the applications that connect to them. smolagents is an MCP client — it consumes servers, it is not one.

The bridge that makes this work is a library called mcpadapt. It ships in the [mcp] extra (pip install 'smolagents[mcp]' pulls in mcpadapt>=0.1.13 and mcp), and its job is one sentence: turn each tool a remote MCP server exposes into a normal smolagents Tool object, so the rest of the agent never knows the tool came from across a process or network boundary. Both smolagents MCP entry points forward their adapter_kwargs straight to MCPAdapt, so anything that library supports is reachable. (If you came from LangGraph, this is the same role langchain-mcp-adapters plays there — different library, identical idea.)

Here is the full picture for Quill. One agent, one smolagents MCP client, two kinds of server it could attach:

flowchart LR
    Q["Quill (CodeAgent)"] --> C["smolagents MCP client<br/>(mcpadapt bridge)"]
    C -->|stdio: subprocess on YOUR machine| S1["stdio MCP server<br/>uvx mcp-server-sqlite<br/>(read_query, list_tables, …)"]
    C -->|streamable-http: remote URL| S2["remote MCP server<br/>http://host/mcp"]
    S1 -.->|tools as smolagents Tool objects| C
    S2 -.->|tools as smolagents Tool objects| C
    style S1 fill:#ffe9d6,stroke:#c80
    style S2 fill:#dfeefe,stroke:#3a7

The lab runs the orange path — a local stdio server uvx launches as a subprocess. The blue path is a server someone already hosts. Both arrive at the agent as ordinary Tool objects. The difference between them, as we’ll see, is not how Quill uses them — it is where their code runs, which is the entire security story.


Connecting smolagents to MCP: two paths, three transports

smolagents gives you two API paths into MCP, and they are not redundant — they trade off convenience against control. Both are verified against smolagents 1.26.0.

Path 1 — ToolCollection.from_mcp (the context manager)

ToolCollection.from_mcp is the one-shot path. Its shape:

ToolCollection.from_mcp(server_parameters, trust_remote_code=False, structured_output=False)

It must be used as a context manager. The reason is mechanical: under the hood it spins up a background thread running an asyncio loop that talks to the MCP server, and the with block owns that thread’s lifetime. Enter the block, the connection is live and the tools are on tool_collection.tools; exit the block, the connection — and, for a stdio server, the subprocess — is torn down. Step outside the with and the connection is gone.

This is exactly how Quill’s new run_with_mcp works. The real code from quill/agent.py:

def run_with_mcp(task, server_parameters=None, *, model=None, trust_remote_code=True):
    from smolagents import ToolCollection

    if server_parameters is None:
        server_parameters = data_mcp_server_params()

    with ToolCollection.from_mcp(
        server_parameters,
        trust_remote_code=trust_remote_code,  # the security gate — stdio runs local code (M5)
        structured_output=False,              # default will flip to True — pin it (more below)
    ) as tool_collection:
        with build_quill(model=model, extra_tools=[*tool_collection.tools]) as agent:
            return agent.run(task)

Notice the nesting: the MCP connection wraps the agent, and the agent (a CodeAgent) is itself a context manager (Module 5, for deterministic sandbox cleanup). On the way out, the agent’s sandbox is cleaned up first, then the MCP server subprocess is killed. No dangling processes, no leaked containers.

Path 2 — MCPClient (explicit lifecycle, multi-server)

When you need finer control — a long-running service, or several servers at once — reach for MCPClient (from smolagents import MCPClient). It is lower-level:

  • The constructor MCPClient(server_parameters, adapter_kwargs=None, structured_output=False) calls connect() for you in __init__.
  • get_tools() -> list[Tool] returns the tools.
  • disconnect() closes the connection.

server_parameters can be a single StdioServerParameters, a dict (for HTTP), or a list of either — that list form is how you attach several MCP servers in one client. There are two usage styles. The context-manager style is preferred and yields the tools directly:

from smolagents import MCPClient

# `tools` is a list[Tool]; pass a LIST of params to attach several servers at once.
with MCPClient([sqlite_params, other_params]) as tools:
    agent = build_quill(extra_tools=tools)
    # ... use the agent ...
# disconnect() runs on `with` exit.

Or manage the lifecycle by hand when the connection must outlive a single with:

client = MCPClient(server_parameters)   # connect() runs here
try:
    tools = client.get_tools()
    agent = build_quill(extra_tools=tools)
    # reuse `agent` across many runs without reconnecting...
finally:
    client.disconnect()                 # always close it

So the internals are simple to state: ToolCollection.from_mcp is context-manager sugar for a single shot — connect, run, disconnect, all bound to one with. MCPClient is the durable, multi-server primitive — connect once, hold the tools, reuse them across many runs, and disconnect on shutdown. In a request-per-second service, from_mcp would relaunch the server subprocess on every request; MCPClient connects once and amortizes it. That distinction is the whole production reason the two exist.

The three transports

The shape of server_parameters selects the transport. There are three, and one of them is a freshness trap.

Transportserver_parametersWhere the code runsStatus (as of smolagents 1.26.0)When to use
stdioStdioServerParameters(command=..., args=[...], env={...})a subprocess on YOUR machinecurrent (local)a trusted local server (SQLite, filesystem) launched via uvx
streamable-http{"url": "http://host:8000/mcp", "transport": "streamable-http"}server-side (remote)the current defaulta server someone already hosts
HTTP+SSE{"url": ".../sse"} or "transport": "sse"server-side (remote)deprecatedlegacy servers only

The internals worth knowing: the default transport for the HTTP dict form flipped from SSE to streamable-http in smolagents 1.21.0. A great many pre-2026 tutorials still show sse as the default — they are wrong as of smolagents 1.26.0. SSE still works if you ask for it explicitly, but only reach for it to talk to an old server that has not migrated.

Quill’s quill/tools/mcp.py builds both the stdio params (what the lab runs) and the streamable-http dict (shown for completeness). The stdio builder is pure — it describes a server without starting it, which is what makes it safe to import and test offline:

def data_mcp_server_params(db_path="data/sales.db", package="mcp-server-sqlite"):
    return StdioServerParameters(
        command="uvx",
        args=[package, "--db-path", db_path],     # `uvx` fetches & runs the server, no global install
        env={"UV_PYTHON": "3.12", **os.environ},  # pin the interpreter, forward PATH; no secret added
    )

def http_server_params(url="http://127.0.0.1:8000/mcp", transport="streamable-http"):
    return {"url": url, "transport": transport}   # default is streamable-http, NOT sse

The subprocess only starts at the with ToolCollection.from_mcp(...) call site. Building the params is a side-effect-free function — which is exactly why the offline tests can assert its shape without ever opening a connection.


The interop security gate: trust_remote_code and stdio

You will have noticed trust_remote_code=True in every snippet so far. It is not boilerplate. It is the single most important line in this module.

Why it is mandatory

To actually run tools from an MCP server — and to load a tool from the Hub with load_tool / Tool.from_hub — you must pass trust_remote_code=True. Leave it at its default False and the load fails. smolagents makes you opt in on purpose. Here is why, in the library’s own words from the docs:

“Stdio-based MCP servers will always execute code on your machine.”

and:

“Only use MCP servers from trusted sources. Malicious servers can execute harmful code on your machine.”

A stdio MCP server is a local subprocess. uvx mcp-server-sqlite is, mechanically, no different from a subprocess.run(...) — it is arbitrary code, executing on your machine, with your permissions, on your files. trust_remote_code=True is you signing off that you trust this server as much as you trust your own code. It is not a formality; it is a decision.

This is the same threat model you met in Module 5. Recall the four vectors there — plain LLM error, supply-chain attack, prompt injection, and exploitation of public agents. An MCP server, or the content it serves, is a fresh entrance for prompt injection: a malicious server (or a poisoned record it returns) becomes a path to arbitrary local code, then to the same blast radius Module 5 named — arbitrary code execution → data exfiltration → resource abuse. Every third-party server you attach widens your attack surface by exactly one server’s worth of trust.

A remote streamable-http server is different in one respect: it does not execute code on your machine — its code runs server-side. That lowers the local-execution risk, but the docs are still blunt: “still proceed with caution.” A remote server can still feed your agent hostile content. stdio simply demands the maximum trust, because it runs locally.

structured_output: informational, not validation

The second MCP-specific flag is structured_output, available on both entry points: MCPClient(..., structured_output=True) and ToolCollection.from_mcp(..., structured_output=True). When on, it reads a tool’s outputSchema from the MCP spec (the outputSchema primitive landed in MCP spec 2025-06-18) and enriches the CodeAgent’s system prompt with that JSON schema, so the model can see the shape of a tool’s output before it calls it.

Two things you must get right about it, both freshness-sensitive:

  1. Its default is False as of smolagents 1.26.0 — and the docs warn it will flip to True in a future release (for backward compatibility it is off today). Because the default is changing, Quill pins it explicitly (structured_output=False) rather than relying on the default. Pin volatile defaults; do not inherit them.
  2. It does not validate anything. output_schema is informational — it adds the schema to the prompt; it does not check that a tool’s output conforms. If you need to validate Quill’s answer, that is a different mechanism entirely: the final_answer_checks you wired in Module 8. Form (a schema in the prompt) is not validity (a check that rejects a bad answer). Keep them separate, and do not reach for structured_output expecting it to police output.

⚠️ Common misconception: “MCP makes my agent safer because it’s a standard.”

It does not. MCP standardizes the connection, never the trust. A stdio MCP server executes local code exactly like subprocess does; trust_remote_code=True deliberately disables a guardrail. The standard reduces your integration cost — it does not reduce your attack surface. In fact the surface grows with every third-party server you plug in. “It speaks MCP” tells you nothing about whether it is safe to run.


Importing and sharing tools across ecosystems

MCP is one ecosystem. The Tool class can also import tools from several others, and Quill can publish its own back. This is the T3 importer-and-share surface.

The Tool.from_* importers

These are classmethods on Tool. Each adapts a tool from a different source into a smolagents Tool:

  • Tool.from_hub(repo_id, token=None, trust_remote_code=False) — download a tool published as a Hub Space repo and run its code locally. trust_remote_code=True required.
  • Tool.from_space(space_id, name, description, api_name=None, token=None) — wrap a deployed Gradio Space (via gradio-client); the work runs on the Space, not your machine.
  • Tool.from_gradio(gradio_tool) — wrap a gradio_tools object.
  • Tool.from_langchain(langchain_tool) — wrap a LangChain tool; smolagents delegates to its run().
  • Tool.from_dict(...) / Tool.from_code(...) — reconstruct a tool from a dict or from source.

And the naming trap that this whole module guards against: there is no Tool.from_mcp. MCP lives only on ToolCollection.from_mcp and MCPClient. Inventing Tool.from_mcp is the single most common stale-tutorial error in this corner of the library — the lab even has a test that asserts not hasattr(Tool, "from_mcp") to keep it honest. There is also no dedicated Anthropic-tool adapter in tools.py: to reach Anthropic-style tools you route through MCP or through the model layer (LiteLLM), never through a Tool.from_* that does not exist.

ToolCollection.from_hub: a whole collection

ToolCollection.from_hub(collection_slug, token=None, trust_remote_code=False) loads all the Spaces in a Hub collection as tools in one call (models and datasets in the collection are ignored). The tools load lazily — the underlying Space is only contacted when a tool is actually called — so attaching a big collection is cheap until the model reaches for one.

Sharing a tool: save, push_to_hub, load_tool

The share path has three pieces (all verified against smolagents 1.26.0):

tool.save(output_dir, tool_file_name="tool", make_gradio_app=True)
# writes <output_dir>/<tool_file_name>.py + app.py + requirements.txt — inspect it locally

tool.push_to_hub(repo_id, commit_message="Upload tool", private=None, token=None, create_pr=False)
# uploads the same as a Space repo (with a Gradio UI); returns the Space URL

load_tool(repo_id, model_repo_id=None, token=None, trust_remote_code=False)
# the top-level function that reloads a Hub tool — the twin of Tool.from_hub

There is also launch_gradio_demo(tool) for a local Gradio demo of a single tool.

The “pushable” rules — and why Quill obeyed them since Module 3

Here is where the fil rouge pays off. A tool can only be saved or pushed if it follows three rules:

  1. Methods are self-contained — they use only their arguments and the class’s own attributes.
  2. Every import lives inside a function or method, never at module top level.
  3. If you override __init__, it takes no argument other than self. Init arguments are not serializable to the Hub; hard-code anything constant as a class attribute instead.

The why is the important part, and it explains all three rules at once: when you push a tool, the Hub re-executes its code in a fresh Space, with none of your original environment. A top-level import would not exist there. An __init__(self, model_name) has no way to reconstruct its argument. So save() and push_to_hub() raise if you break a rule — they are protecting you from publishing a tool that cannot run where it lands.

Now re-read save_chart from quill/tools/data.py (frozen since Module 3). Its setup() does import matplotlib inside the method. Its forward() does import matplotlib.pyplot as plt inside the method. It never overrides __init__, so rule 3 is trivially satisfied. We wrote it that way on purpose in Module 3save_chart was push-ready from day one. Module 9 publishes it with zero rewrites, and that clean publish is the proof the Module 3 contract was right.

Choosing an importer

When do you reach for which? The decision is by source of the tool:

ImporterSourceProcesstrust_remote_code?When to use it
ToolCollection.from_mcpan MCP server (any language)stdio (local) or HTTP (remote)yesa shared, multi-language standard that’s already deployed
Tool.from_langchaina Python LangChain toolin-process (delegates to run())noyou already have LangChain tool code
Tool.from_hub / load_toola Hub Space toolHub code runs locallyyesa published, reusable tool on the Hub

The pattern to internalize: MCP for a deployed, multi-language, shared standard; from_langchain when the code already exists in LangChain; from_hub for a published Hub tool. And anything that runs someone else’s code on your machine (from_mcp stdio, from_hub) needs trust_remote_code=True — that requirement is the safety boundary.


Build it: Quill opens its toolbox

The lab does three things end to end: connect Quill to a stdio MCP SQL server, load one Hub tool, and publish save_chart. The full code is in smolagents-course-labs/module-09; the offline tests pass with no token, no network, and no MCP connection.

Setup

Install the MCP extra on top of what earlier modules already pulled in, and confirm uvx is available:

uv pip install "smolagents[toolkit,litellm,openai,docker,mcp]==1.26.0" \
  "huggingface_hub>=1.0,<2" "pandas>=2.2.3" matplotlib
uvx --version   # uvx ships with uv; we use it to launch the stdio MCP SQLite server

[mcp] brings mcpadapt>=0.1.13 + mcp. Copy .env.example to .env and put your HF_TOKEN there — never committed. It powers the model calls and, for the Hub push, needs write access.

Step 1 — build the database the server serves

The MCP SQLite server serves a SQLite file. quill/scripts/build_sales_db.py materializes it from Quill’s frozen data/sales.csv — pure local pandas + sqlite3, idempotent:

uv run python -m quill.scripts.build_sales_db   # data/sales.csv -> data/sales.db (table "sales")
Built data/sales.db: table 'sales' with 108 rows, columns ['month', 'region_code', 'category', 'units', 'net_rev', 'churn_flag'].

Step 2 — extend build_quill with extra_tools

The only change to Quill’s construction is one new keyword-only argument, defaulting to the no-op case so every prior call site is byte-for-byte unchanged. From quill/agent.py:

tools = [
    load_dataset,
    profile_dataframe,
    save_chart(),       # the frozen M3 Tool, instantiated
    WebSearchTool(),    # name="web_search"
    VisitWebpageTool(), # name="visit_webpage"
]
if extra_tools:
    tools.extend(extra_tools)   # MCP / Hub / LangChain tools appended after the local toolbox

The local tools stay first and keep their names; ecosystem tools are appended. The agent’s toolbox is ultimately a name-keyed dict, so a real MCP tool like read_query slots in with no clash. Crucially, adding tools here does not touch the frozen least-privilege import lock — MCP tools run outside the sandbox (in the server subprocess); only the Python Quill writes to call them runs inside the sandbox. The offline test pins this: agent.additional_authorized_imports stays exactly ["pandas", "numpy", "matplotlib.*", "json", "statistics"], never "*".

Step 3 — run Quill against the MCP server

run_with_mcp (shown earlier) opens ToolCollection.from_mcp, wires the server’s tools in via extra_tools, runs the task, and tears the connection down. The end-to-end demo:

uv run python -m quill.demos.mcp_demo "Which product category grew fastest last quarter?"
[Quill] Connecting to MCP server -> stdio: uvx mcp-server-sqlite --db-path data/sales.db
[Quill] Question: Which product category grew fastest last quarter?
...
 ─ Executing: rows = read_query("SELECT category, SUM(net_rev) ... GROUP BY category")
Observation: [("Team", 184213.0), ("Pro", 151002.0), ("Free", 0.0)]
...                       # Quill draws a chart with matplotlib, calls save_chart, builds a report
===== REPORT (rendered Markdown) =====
# Which product category grew fastest last quarter?

## Findings
- Team grew fastest in the final quarter, leading net revenue.

## Charts
- `outputs/category_growth.png`

[Quill] Done. (The MCP server subprocess was torn down on the with-block exit.)

Quill is interleaving an MCP tool (read_query, served by the subprocess) with its own local matplotlib — and still returning the validated QuillReport from Module 8. The exact trajectory varies (LLMs are non-deterministic); what is guaranteed is that Quill calls an MCP tool and returns a validated report.

Step 4 — load one tool from the Hub

quill/scripts/load_hub_tool.py loads a published Hub tool and attaches it to Quill at runtime. Because the Hub code runs locally, trust_remote_code=True is required:

from smolagents import load_tool

tool = load_tool("m-ric/text-to-image", trust_remote_code=True)  # runs the Hub code locally
agent.tools[tool.name] = tool                                    # the toolbox is a name-keyed dict

The runtime agent.tools[name] = t is the same seam extra_tools= uses at build time — a tool is a tool, whenever it arrives. (Re-verify the example repo exists the day you run this; Hub repos move.)

Step 5 — publish save_chart

quill/scripts/push_save_chart.py first saves the tool locally (no network — this is the pushable-rules proof) and then, only with --push and HF_TOKEN, uploads it:

tool = save_chart()
tool.save("build/save_chart_tool", tool_file_name="save_chart")  # save_chart.py + app.py + requirements.txt
tool.push_to_hub(repo_id, token=os.environ["HF_TOKEN"])          # a Space repo with a Gradio UI
uv run python -m quill.scripts.push_save_chart                          # local save only (the proof)
uv run python -m quill.scripts.push_save_chart --push --repo <you>/quill-save-chart   # publish (HF_TOKEN write)

A clean save() is the proof the rules held — a top-level import or an __init__ argument would make it raise. The token is read with os.environ["HF_TOKEN"], never hard-coded.

Run the tests

uv run pytest module-09/tests/                    # offline: no token, no MCP connection, no Docker
QUILL_LIVE_TESTS=1 uv run pytest module-09/tests/ # also the real stdio-MCP run (needs uvx + HF_TOKEN + sales.db)

The offline tests open no MCP connection — they assert the helper shapes, import the entry points, and prove Tool.from_mcp does not exist. The live MCP test (@pytest.mark.live) launches a real uvx mcp-server-sqlite, runs one question, and asserts Quill returns a QuillReport with a chart — and it skips cleanly if uvx, HF_TOKEN, or data/sales.db are missing. The whole live budget across the carried-forward suite stays under ~5 LLM runs.

What this lab does not do

No multi-agents (Module 10), no pushing an agent / GradioUI / app.py (Module 13), no telemetry on MCP calls (Module 14), no writing an MCP server from scratch, no hosted streamable-http (stdio only, to stay laptop-friendly), and no RAG (Module 12) — even though an MCP server could serve a corpus.

Try it yourself

  1. Multi-server. Pass a list to MCPClient([sqlite_params, other_params]) (add a second stdio server, e.g. a filesystem one) and watch Quill pick the right tool per question.
  2. A whole collection. Load every tool in a Hub collection with ToolCollection.from_hub(collection_slug=..., trust_remote_code=True) (Spaces only, lazy load) and see which ones Quill reaches for unprompted.

In production

Three things change once this leaves the lab.

Server governance. In production you never pin “the latest uvx <pkg>.” You pin an audited version of an audited server (e.g. mcp-server-sqlite==<x.y.z>) and you read its source. Every third-party server is one more block of code running with your trust — I cap MCP servers to ones I’ve actually read, because trust_remote_code is a yes/no on the whole server, not on individual calls.

Lifecycle. ToolCollection.from_mcp closes the connection on with exit, which is perfect for a script and wasteful for a service — it would relaunch the server subprocess on every request. A long-running service uses MCPClient: connect once at startup, reuse the tools across many runs, disconnect() at shutdown.

Latency and versioning. Each MCP tool call is an inter-process or network round-trip; for a high-volume agent, that adds up, so batch or cache where you can (the “building good agents” frugality from Module 7 applies). And a Hub tool can change under you — pin the revision you loaded, the same way you pin a server version.

When Quill grows a research sub-agent in Module 10, the MCP and Hub tools you wired here travel with it.


Concept check

Why this matters. Interop is the line between a clever script and a team asset. A silo agent re-implements everything; a connected agent consumes standardized tools (via MCP), imports published ones (from the Hub or LangChain), and publishes its own for others — and it does all of that while keeping the security boundary explicit. This module owns T9 (6%) and the importer/share half of T3. It also leans on Module 3 (the Tool class and the runtime agent.tools dict you extend here), Module 5 (the threat model that justifies trust_remote_code), and Module 8 (the final_answer_checks you must not confuse with structured_output).

Quiz. Five scenario questions; answers and explanations are grouped after all five.

1. You launch an MCP server with uvx mcp-server-sqlite as a stdio server and connect Quill to it. Where does that server’s code execute, and which transport would not run code on your machine?

  • A. It runs in a remote container; stdio is always remote.
  • B. It runs as a subprocess on your machine; a remote streamable-http server runs server-side, not on your machine.
  • C. It runs inside Quill’s sandbox; nothing executes locally.
  • D. It runs in the LLM provider’s environment; all transports are remote.

2. A teammate wants to attach a third-party MCP server “because MCP is a standard, so it’s safe.” What is the right correction, and what does trust_remote_code=True actually mean?

  • A. They’re right — the MCP standard guarantees the server is sandboxed.
  • B. MCP standardizes the connection, not the trust; a stdio server runs local code, and trust_remote_code=True means you trust that server as much as your own code (a prompt-injection / arbitrary-code path — the Module 5 vectors).
  • C. trust_remote_code=True just enables caching; safety is automatic.
  • D. It’s safe as long as you set structured_output=True.

3. You must integrate three things: (a) a LangChain tool you already wrote in Python, (b) a multi-language team service that’s already deployed as a server, and (c) a tool a colleague published to the Hub. Which importer for each?

  • A. Tool.from_mcp for all three.
  • B. (a) Tool.from_langchain, (b) ToolCollection.from_mcp, (c) Tool.from_hub / load_tool.
  • C. (a) Tool.from_hub, (b) Tool.from_mcp, (c) Tool.from_langchain.
  • D. (a) ToolCollection.from_mcp, (b) Tool.from_langchain, (c) Tool.from_space.

4. Your tool.push_to_hub(...) raises. The tool has import pandas as pd at the top of the file and an __init__(self, model_name). Which rules are violated, and what’s the fix?

  • A. None — push_to_hub is just flaky; retry it.
  • B. Two pushable rules: top-level imports must move inside the methods, and __init__ must take no argument other than self (hard-code constants as class attributes). The Hub re-executes the code in a fresh Space.
  • C. Only the import; the __init__ argument is fine.
  • D. You need trust_remote_code=True on the push call.

5. A developer sets structured_output=True on ToolCollection.from_mcp expecting it to validate a tool’s output. Why is that wrong, and what is the right mechanism?

  • A. It’s correct — structured_output rejects any output that doesn’t match the schema.
  • B. structured_output is informational: it adds the tool’s output_schema to the system prompt; it does not validate. To validate Quill’s answer, use final_answer_checks (Module 8). Also, its default is False today (and will flip to True), so pin it.
  • C. It’s wrong because structured_output doesn’t exist on from_mcp.
  • D. Validation happens automatically once you set trust_remote_code=True.

Answers.

1 — B. A stdio MCP server is a local subprocess — it runs on your machine, exactly like subprocess. A remote streamable-http server runs server-side, so it does not execute code on your machine (though the docs still say “proceed with caution”). stdio is the maximum-trust case; it does not run inside Quill’s sandbox.

2 — B. MCP standardizes how you connect, not whether the server is trustworthy. A stdio server executes local code; trust_remote_code=True deliberately disables a guardrail and signs off that you trust the server as much as your own code. It is a fresh prompt-injection / arbitrary-code path — the Module 5 threat model. “It’s a standard” is not a security control. structured_output is unrelated to safety.

3 — B. Pick by source: Tool.from_langchain wraps existing LangChain code (in-process), ToolCollection.from_mcp connects to a deployed multi-language server, and Tool.from_hub / load_tool reconstructs a published Hub tool. There is no Tool.from_mcp — every option that uses it is wrong by construction.

4 — B. Two violations. Pushable rule 2: every import must live inside a function/method (a top-level import pandas cannot be reconstructed in a fresh Space). Pushable rule 3: __init__ may take no argument other than self (init args aren’t serializable to the Hub) — hard-code constants as class attributes. The Hub re-executes the tool’s code in a fresh Space, which is why the rules exist.

5 — B. structured_output reads the MCP output_schema and adds it to the system prompt so the model can see the output’s shape — it is informational and validates nothing. Output validation is final_answer_checks (Module 8). And its default is False as of smolagents 1.26.0, with the docs warning it will flip to True, so pin it explicitly.

Common pitfalls.

  • Tool.from_mcp does not exist. MCP lives only on ToolCollection.from_mcp / MCPClient. Inventing Tool.from_mcp is the #1 stale-tutorial error in this area — countless cached snippets show it, and they are all wrong.
  • “MCP equals security.” MCP standardizes the connection, not the trust. A stdio server runs local code, and every third-party server you attach widens your attack surface. The standard lowers integration cost, not risk.
  • Confusing structured_output with validation. output_schema enriches the prompt; it does not check anything. Its default is False (and will flip), so pin it. Quill’s output validation stays final_answer_checks (Module 8). And do not confuse either with response_format (Module 8) or the removed grammar=.

Key takeaways

  • MCP is “a USB-C port for AI applications” — the open standard Anthropic introduced in November 2024 to collapse M × N tool adapters into M + N. It standardizes the agent↔tools/data boundary (three primitives: tools, resources, prompts) — not the agent↔agent boundary.
  • smolagents is an MCP client; the bridge is mcpadapt (the [mcp] extra = mcpadapt>=0.1.13 + mcp), which turns each remote MCP tool into an ordinary smolagents Tool.
  • Two paths into MCP: ToolCollection.from_mcp is a one-shot context manager; MCPClient is the explicit-lifecycle, multi-server primitive (pass a list of server_parameters) for long-running services.
  • stdio runs code on your machine, so trust_remote_code=True is a deliberate act of trust, not a formality — and it ties straight back to Module 5’s threat model. Remote streamable-http runs server-side but still “proceed with caution.”
  • streamable-http is the default transport as of smolagents 1.26.0; SSE is deprecated (the default flipped in 1.21.0). Many older tutorials still show SSE — they’re wrong.
  • Tool.from_mcp does not exist. Use Tool.from_hub / from_space / from_gradio / from_langchain / from_dict / from_code per source; use ToolCollection.from_hub for a whole collection.
  • The pushable rules — imports inside functions, __init__ with no argument beyond self, self-contained methods — exist because the Hub re-executes the code in a fresh Space. save_chart obeyed them since Module 3, so Module 9 published it with zero rewrites.
  • structured_output (MCP) is informational, defaults to False (and will flip), and does not validate. Pin it; validate answers with final_answer_checks (Module 8).

Quill can now borrow tools from anywhere. Next, in Module 10, it gets a whole teammate: a research sub-agent it manages — and those MCP and Hub tools travel with the team.

Want the full smolagents concept-check bank and the next module in your inbox? Subscribe here — it’s free, like everything in this series.

Links: Lab code (module-09) · Module 8 — Reliable Agents: Structured Output, Validation, and Errors · Module 10 — Multi-Agent Systems: Quill Gets a Research Team · Course index


References