2.05

2.05

Runtime tool discovery over MCP

Trace one mcp_server_url line through tools/list and tools/call to live tools on an agent, read a run of the catalog server and the Librarian, and write tool descriptions the model can route on.

Time
~38 min
Builds on
agent 1.06 syndicate 2.01 output schema 2.01

Objectives

  • State what MCP changes: capability becomes a property of a connection, in both directions
  • Name what an MCP server is for: putting a language interface in front of a database, an internal service, or a system too old to have one
  • Trace tool discovery from one config line to a live tool on the agent's belt

Every tool your agents have used so far was compiled into the application. An engineer wrote a function, registered its schema with the runtime, and shipped a release. Adding one capability meant changing code and deploying the service again.

The Model Context Protocol (MCP) replaces that pattern with an open network standard. Your agent connects to an MCP server, queries the tools it offers, and calls them on demand. The tools run beside the data they touch, on infrastructure maintained by the service owner, and the agent discovers them at runtime.

The primary motivation lies in existing infrastructure. Production environments depend on systems that were built long before language models existed and will not be rewritten for them: a customer database with thirty years of records, an internal billing service, a warehouse system reachable only through a SOAP endpoint, or an issue-tracking API. An MCP server provides a thin translation layer in front of those systems, publishing a few operations in a format an agent can parse and invoke. Nothing behind the server changes. The mainframe does not adapt to language models; the server speaks to it using existing interfaces, and speaks MCP to the agent.

The communication flow also reverses, which engineering teams frequently underestimate. When you publish an MCP server in front of your own application, you equip it with a natural-language interface without building a custom conversational layer. You implement no chat window, no intent classifier, and no new front end. The operations you already expose become actions a person can request in a sentence, routed through whatever agent they use.

Two ideas govern this architecture. The first is reach: an agent’s capabilities become a dynamic property of a live connection rather than a static fixture of the codebase. The second is the trust boundary: every payload that returns across that connection is data for the agent to analyze, never an instruction to execute.

Read tools widen what an agent can see; write tools let it change what it finds. Our analysis of the action surface paired each surface of the agent loop with a responsibility, and for the action surface the asymmetry is sharp. A read tool returns text into the agent’s context, and the next model request carries that text to the agent’s model provider, so a read aimed at the wrong records discloses them even though it changes none of them. A write tool alters state on an external system the moment it executes, before any downstream review can intervene: a write that goes wrong has already happened. MCP is where that stops being theory, which is why the second half of this module focuses on safety boundaries rather than transport mechanics.

The working example is an Archivist orchestrator that talks to patrons and delegates every catalog operation to a Librarian. The Librarian declares no tools in its own definition. Its tools arrive over MCP from a small catalog server. By the end of this module, you will trace how a line of configuration becomes a live tool, why read and write tools require distinct operational policies, and how to enforce boundaries in your runtime before dispatching network calls.

One statement about this module’s numbers and pictures, made once. Everything named in this module is measured from the running system. The catalog server listens on port 8931 and holds four tools whose two descriptions quoted later are its real ones, and the Librarian’s trace records a real run. Only one object is written for the page: the careless description “Searches for books.” shows what a bad contract omits. The two flow diagrams idealize the machinery in three ways. Each draws one dial and one call with the arrows running one way. In execution, the agent’s MCP client lists tools once at startup and calls them as often as its loop decides; results travel back across that same connection, which is where untrusted text arrives; and the diagrams omit the underlying transport because that layer changes between servers.

MCP adds capabilities at runtime, over one connection

The Librarian’s definition declares no tools, yet when it runs it can search the catalog, read a scroll, borrow one, and annotate one. Where do four tools come from, and how does a definition with no tool code acquire them?

Your agent definition begins with a single line specifying a remote endpoint: mcp_server_url. When the runtime initializes the agent, its built-in client dials that network address and invokes tools/list. The remote server responds with a JSON payload cataloging every tool it exposes, complete with functional names, detailed descriptions, and exact parameter schemas. The runtime translates those remote definitions into standard agent tools and binds them to the running instance. From the model’s perspective, there is no structural difference between a function compiled into your application and one imported over the wire; both appear as ordinary tools with parameter schemas the model can target.

When the agent runs, the action loop operates normally: the model generates a structured call naming a tool and its arguments. The client packages that call and transmits it to the server’s execution endpoint, tools/call. The server executes the function against its underlying database or API and returns the tool result, which enters the context as a fresh observation.

In our specimen, the catalog server provides four tools: search_catalog, read_scroll, borrow_scroll, and annotate_scroll. A production server in front of a thirty-year database is the same object at scale: it exposes more tools and executes against a durable datastore, but the runtime mechanics remain identical.

This separates capability from deployment. What the agent can do is governed by the server it dials. When the operator publishes a new tool or changes what an existing tool accepts, your agent inherits that change on its next connection, with no code written and nothing deployed on your side.

one line of config becomes live tools
config
mcp_server_url: “https://…“
one line in the agent definition, no tool code imported
tools/list
the server answers with its menu
names, descriptions, and the exact parameters each tool expects
tools/call
the model emits a request; the server executes it
against the real database, beside which the tool lives
note
the tools are set by the server, and can change without your deploy
which is the convenience, and the reason for the second half of this module
Discovery at runtime is what separates MCP from an imported library. The agent’s reach is a property of a connection rather than of your codebase.

the protocol is json-rpc over streamable http or stdio

You have seen what the two calls do. Here is what they are, because the shape of the wire explains several things that otherwise look like bugs.

MCP is JSON-RPC 2.0 carried over a transport. There are no REST routes and no URL per resource. The current specification defines two standard transports: standard input and output (stdio), for a server the client runs as a local process, and Streamable HTTP for a server on the network. Under Streamable HTTP, the client sends each message as an HTTP POST to a single endpoint, and the server answers with a JSON response or opens an event stream. Streamable HTTP replaced the older HTTP+SSE transport of protocol version 2024-11-05, in which the client holds one Server-Sent Events stream open with a GET and sends its messages as POSTs to a second address; the catalog server serves that legacy transport at /sse and /messages.

Clients differ in which transport they dial. A client that supports both tries Streamable HTTP first and falls back to the legacy transport when the server refuses that first POST with a 4xx status other than 401 or 403, and a configuration key can pin it to one transport and skip the refused request. Every mcp_server_url in this module ends in /sse because the catalog server serves the legacy transport only. tools/list returns an array in which each entry carries an identifier, a description, and an inputSchema naming every parameter and its type. tools/call sends a name and its arguments and gets back a structured content block.

One consequence to have in hand before you try to inspect a server: opening http://localhost:8931/sse in a browser will not render a page, and nothing is broken when it does not. That address is an event stream expecting an MCP client on the other end, not a web server with a homepage.

The server lives beside the data: the database, the internal service, the API too old to know what an agent is. The client lives inside the runtime, negotiates the connection at startup, and registers whatever it receives as ordinary tools.

In the team definition, that configuration is one key on the agent, mcp_server_url:

- name: "Librarian"
  model: "gemini-3.5-flash-lite"
  mcp_server_url: "http://localhost:8931/sse"

Read that as: this sub-agent has no tools: key at all. Whatever the server at that address publishes is what the Librarian can do, and the runtime finds out what that is when it starts.

When the two agents start, the runtime dials port 8931, receives four schemas (search_catalog, read_scroll, borrow_scroll and annotate_scroll), and binds them to the Librarian. The Librarian’s definition names a server and nothing else, and all four capabilities arrive from the far side of that address.

capability as a connection, discovered at runtimeyour agentinstruction · doctrinesMCP clientno tool code importedthe trust boundaryMCP serversearch_catalogread_scrollborrow_scrollannotate_scrollsits beside its own datatools/listtools/call
The dashed line is the part to remember. Everything to the right of it is somebody else’s code running on somebody else’s machine, and everything crossing it is untrusted. The four tools drawn are the Librarian’s real four; Results travel back across the same line, which is where the untrusted text arrives.

a tool description is the contract the model routes on

Watch how the model actually chooses a tool. It never sees the server’s TypeScript, its SQL, or its tables. Three things reach the context window: the tool’s name, its parameter schema, and its description.

An orchestrator selects a sub-agent by reading its description field, and that text is an interface rather than documentation. Tool selection works the same way. A description that is vague or badly bounded produces an agent that reaches for the wrong tool, and no amount of rewriting your own system prompt fully repairs it, because the sentence doing the damage is on the other side of the connection.

The difference from ordinary programming is what the text is doing. A docstring is passive documentation, written for a developer who may never read it. A tool description is an active routing contract, read on every pass by the thing deciding whether to call the tool, which one to prefer over its neighbor, and what arguments to supply.

Written carelessly, it looks like this:

{
  "name": "search_catalog",
  "description": "Searches for books."
}

Reading only that, what can the model not tell?

Nothing there says what a match is, what comes back, or when to use something else, so the model guesses: wrong arguments, or a call it should not have made, or a tool left unused when it was exactly right.

Here is the same tool as the catalog server actually publishes it, alongside its neighbor:

{
  "name": "search_catalog",
  "description": "Search the library catalog by words from a title, author, or topic. Returns matching scrolls with id, title, author, and status."
}
{
  "name": "borrow_scroll",
  "description": "Borrow an available scroll for a named reader, or return it (pass borrower: \"\") — this MODIFIES the catalog."
}

Each one states what it takes and what comes back, and the two write tools announce themselves in capitals, which is the server telling the model that this call has consequences. Add the negative half as well when two tools are close enough to confuse: saying that search_catalog does not return a scroll’s notes or borrower, and that read_scroll is where those live, decides a routing question before the model has to guess at it.

There is a second payoff. Boundaries written into the description keep low-level tool instructions out of your system prompt. Every clause you put beside the tool is a clause you do not have to add to the agent’s instruction, where it would compete for attention with everything else the agent is trying to be. When you publish a server of your own, write each description as the interface it is.

End of module 2.05

You have reached the end of this module. Marking it finished is your own call, saved in this browser, and you can undo it at any time.

0 of 34 modules marked finished.