Questions that came up while building, with short answers. New entries go at the bottom.
MCP (Model Context Protocol) is a shared language between AI apps and the tools they use. An MCP server offers tools. An MCP client uses them. Because both sides speak the same protocol, any client can talk to any server without custom code.
- Server: has the capabilities. It publishes a list of tools, each with a name, a description and an input schema, and runs one when asked.
- Client: has the model. It connects to a server, asks which tools exist, gives that list to the LLM, and when the model picks a tool it sends the call to the server and returns the result.
client (has the LLM) server (has the tools)
│ "what tools do you have?" → │
│ ← [get_order, list_products] │
│ "call get_order(id: 1)" → │
│ ← { status: "shipped", ... } │
Examples: Claude Desktop and MCP Inspector are clients. A server that exposes store data is a server. An app can be both at once.
RubyLLM's own tool calling is not MCP. A RubyLLM::Tool runs inside the Rails process, and only that
app can use it. RubyLLM's MCP code (ruby_llm/mcp) is a client only, so it can use someone else's
server but cannot publish tools.
| Where | Role | What happens |
|---|---|---|
| Lesson 3 | neither | tools run in-process through RubyLLM, no protocol involved |
| Lesson 5 | server | the same Ruby tools are published with the mcp gem, so Claude and Inspector can call them |
This is why a tool is a plain Ruby class (for example GetOrder) with a thin wrapper per consumer:
one RubyLLM::Tool wrapper for the agent, and later one MCP::Tool wrapper for the server. The
logic is written once.
They do two different jobs, in opposite directions.
- RubyLLM tools let your own app's model call your own code. The tool runs in the same process and nobody outside can see it.
- An MCP server lets someone else's AI app (Claude, MCP Inspector) call your code over the network. That needs an endpoint that speaks the MCP protocol: listing tools, calling them, sessions and authentication.
RubyLLM's MCP support (the ruby_llm/mcp code, checked in ruby_llm 2.1.0 on 2026-10-10) is a client
only. It can use tools from someone else's server, but it cannot publish yours. Publishing is what
the mcp gem does.
The standalone ruby_llm-mcp gem is the same client. Version 2.1.0 of ruby_llm already ships that
code, with a different setup style (a RubyLLM::MCP subclass passed to chat.with_mcp), so the
separate gem is likely not needed. Check its releases page before adding it.
Yes, but only through the MCP client and a running server. An MCP::Tool is not a RubyLLM::Tool,
so it cannot be handed to chat.with_tools directly.
agent ──(MCP client)──► your MCP server ──► MCP::Tool ──► your code
Whether RubyLLM can call an MCP::Tool class in the same process, without a transport, has not been
checked here. Do not plan around it.
Costs of going through MCP for tools that live in the same app:
- A network hop (HTTP or a subprocess) on every tool call, even though the code is local.
- A server has to be running for specs, the console and the agent.
- Harder tests and traces: the model, the client, the server and the tool are four layers.
- Authentication and sessions to manage, which in-process calls skip.
- Results are wrapped in an MCP response and unwrapped again, so types like dates become text twice.
- If the logic itself lives in the
MCP::Toolclass, the agent path depends on MCP too.
When it is the right choice: the tools live in a different service than the agent, several unrelated clients need the same tools, or you want to test the agent against exactly what Claude sees.
The agent acts as an MCP client. In RubyLLM 2.x you describe the server in a RubyLLM::MCP
subclass and attach it to the chat or agent:
class Linear < RubyLLM::MCP
url "https://mcp.linear.app/mcp"
bearer_token { user.linear_token }
end
chat.with_mcp(Linear)RubyLLM lists the server's tools and offers them to the model like any other tool, over Streamable HTTP
or a local stdio process. Hosted servers usually need OAuth or a bearer token.
This project does not use it yet (checked 2026-10-10): the course plan has the MCP server side in
Lesson 5 and no lesson where the agent calls an outside server. The ai_shop_assistant project
does this against Shopify's Catalog API.
It is the bridge between the model and the Ruby tool classes. The model only reads and writes text, so
two translations are needed, and AgentTools does both:
- Code to model.
definitionsturns the tool wrapper classes into the JSONtoolslist that Groq expects (name, description, argument schema). It is sent with every request. - Model to code.
run(name, arguments)takes the tool name the model asked for ("get_order") and its parsed arguments, finds the matching tool and executes it.
It is also the allowlist. The model can only run the tools passed to AgentTools.new; an invented name
such as delete_order returns an error hash and nothing runs.
AgentLoop keeps the messages, repeats until the model answers
├─ LlmClient talks to Groq
└─ AgentTools lists the tools and runs them by name
└─ GetOrderTool → GetOrder → database
The wrapper classes (RubyLLM::Tool) stay the only place that describes a tool, so the hand-written
loop and SupportAgent send the model identical tool descriptions. Only the declarations are reused;
the loop itself is hand-written. AgentLoop parses the raw arguments JSON string before calling
run, so AgentTools only ever sees a hash.
An agent loop is a loop around the model call. The model can only read and write text, so it cannot
look anything up itself. It asks for a tool, the code runs it, and the result goes back to the model.
This repeats until the model answers in plain text.
The model has no memory between calls, so the loop keeps the messages array (the context) and sends
all of it every time. Each round adds to it:
- The user question is added.
- The model replies with
tool_calls(a request, nocontent) or withcontent(the answer). - The model's reply is added to
messages, so the history stays complete. - For each tool call, the code runs the tool and adds a
toolmessage. Itstool_call_idlinks the result to the request it answers, and itscontentmust be a string (result.to_json). - The model is called again with the longer
messages.
ask model → tool_calls? → yes → run tools, add results to messages → ask model again
→ no → the content is the answer, stop
The model decides how many rounds happen, which is what makes it an agent and not a fixed pipeline. A normal LLM call is one round trip. An agent loop is many, with tool results in between.
The stop condition is "the reply has no tool_calls". Because the model controls it, the loop needs
safety rails: a step limit (with a stop reason), unknown tool names, and arguments that are not valid
JSON. Without a step limit, a model that keeps asking for tools loops forever.
Frameworks such as RubyLLM run this same loop inside chat.ask. Writing it by hand once (Lesson 4)
shows what they hide.
They overlap and people use the words loosely, but in this project they mean two layers.
Safety rails are technical limits that keep the agent loop from breaking. They do not judge what the agent says. Examples: a step limit so the loop cannot run forever, an unknown tool name returning an error instead of running something, and invalid JSON arguments being caught instead of crashing the run. They matter even if the model behaves well, because it can still make mechanical mistakes.
Guardrails is the broader industry term for rules about content and behavior:
- Input checks: block prompt injection, detect personal data, refuse off-topic requests.
- Output checks: do not leak another customer's data, do not promise a refund the policy does not allow.
- Action limits: a human approves risky actions (the approval screen for write tools, Lesson 9).
- Permissions: the allowlist of tools, and read tools kept apart from write tools.
| Safety rails | Guardrails | |
|---|---|---|
| Question | Can the loop run without breaking? | Should the agent say or do this? |
| Example | Stop after 5 steps | A refund over a limit needs approval |
| Where | Inside AgentLoop |
Around it, and in tools, prompts and approval screens |
Guardrails include some safety rails: the AgentTools allowlist is both a loop-robustness feature and a
guardrail on what the model may do.
"Hand-written" means the loop is plain Ruby in this repo (AgentLoop), not hidden inside a library.
The result is the same. The difference is whether you can see it.
- With RubyLLM:
chat.ask(question)runs the whole loop in one call. It sends the messages, seestool_calls, runs the tool, adds the result and asks again until the model answers. The loop is there, but you never see it. - By hand (Lesson 4):
AgentLoopis the same loop in about 25 lines. We build themessagesarray, call Groq through Faraday, parse the arguments and add each tool result ourselves.
Reasons to write it once:
- Understanding. A hidden loop is hard to debug. After writing it, you know what a framework does when it misbehaves.
- Interviews. "How does an agent work?" is easy to answer when you have built the loop: what
tool_callslooks like, why the reply goes back into the history, why a step limit is needed. - Failure modes. Writing the safety rails (step limit, invalid JSON, unknown tool) shows why a framework needs them too.
It is not the better way for real work. From Lesson 5 on the project uses RubyLLM, which also handles streaming, retries, structured output and several providers, none of which are worth rebuilding.
The lesson ends with a check: the same question goes through AgentLoop and through SupportAgent
(RubyLLM), and both should make the same tool calls. If they do, the framework is doing what the
hand-written loop does.