Back to the map
AgentsCore8h

MCP & tool interfaces

A standard way to expose tools to any model.

Every tool an agent can use has to be described to it in some format, and for a while each product invented its own — so a connector to a company's wiki, written for one assistant, was useless in the next one. The Model Context Protocol is an agreed format for those descriptions. A server offers its tools, files and prompts in that format, and any client that speaks the protocol can use them without either side being rewritten.

The alternative is building the same integration once per application that wants it, and maintaining every copy separately — the work multiplies with the number of tools times the number of clients. A shared format collapses that: a tool written once is available everywhere, and swapping the model or the client does not invalidate it.

It sits on top of tool calling. The protocol governs how tools are described, published and connected, not how a model decides to use one, so the loop and the tool contract underneath are unchanged. The hard part is not the protocol but the tool design: naming, how much one call should do, and what an error message should say to a model rather than to a person. It pairs with prompt injection, since whatever a connected server returns is untrusted text arriving in your context.

A shared format for describing tools, so one is written once and works in any client that speaks it.

In more detailThe Model Context Protocol standardises how tools, resources and prompts are offered to a model, so a capability written once works across clients. The protocol itself is straightforward; the judgement is in tool design, granularity, naming, error messages, and how much a tool should do per call.

Learn first

Section 1 of 2, 3h

What MCP is for

By the end: You can explain why a tool protocol exists and when it is worth using one.

  • The problem it solves
  • The three things a server exposes
  • Client and server, and which you are writing
  • When not to bother

You already know how to give a model tools. The awkward part is that each one ends up welded to a single application and a single provider's format, so the same integration gets written again and again with the same auth and error handling each time. MCP is an attempt to standardise that: expose a capability once as a server, and any client speaking the protocol can use it. This section covers what the protocol offers, which side of it you are writing, and when a plain function in your own codebase is still the better answer.

The repetition problem#

Write tools by hand for a while and a pattern appears. The same integration gets rewritten for every application, in every provider's tool format, with the same auth and error handling each time. Nothing is reusable because every tool is welded to the app that hosts it.

The Model Context Protocol is a standard shape for exposing capabilities to any model client. Write a server once, and any client that speaks the protocol can use it.

Without a protocol:
  3 applications x 5 integrations = 15 implementations

With one:
  5 servers, used by any client that speaks MCP

That arithmetic is only worth anything if the fifteen implementations were actually similar, so it is worth saying what an integration contains. A JSON schema for each argument, in whatever shape the provider expects. Credentials read from somewhere and kept out of the transcript. Pagination, because the underlying API returns more than fits. Truncation, because the model will be handed the result. Errors turned into sentences rather than stack traces. Only the middle bit, the actual call to the underlying system, is specific to the integration. The rest is the same work every time, which is the part a protocol can absorb.

What is actually on the wire#

The protocol is JSON-RPC 2.0 messages passed between a client and a server over a transport, and knowing that much stops it feeling mysterious. A connection opens with an initialize exchange in which both sides state a protocol version, which is a date, and declare their capabilities, so a client can tell whether a server offers tools, resources, prompts or logging before it asks for any of them. After that the client asks what exists and calls what it needs.

client  {"jsonrpc": "2.0", "id": 1, "method": "initialize",
         "params": {"protocolVersion": "...", "capabilities": {...},
                    "clientInfo": {"name": "some-ide", "version": "1.4.0"}}}
server  {"jsonrpc": "2.0", "id": 1,
         "result": {"protocolVersion": "...", "capabilities": {"tools": {}},
                    "serverInfo": {...}, "instructions": "optional hint text"}}
client  {"jsonrpc": "2.0", "method": "notifications/initialized"}

client  {"jsonrpc": "2.0", "id": 2, "method": "tools/list"}
server  {"result": {"tools": [{"name": "find_orders",
                               "description": "...",
                               "inputSchema": {"type": "object", ...}}],
                    "nextCursor": "optional, when there are more"}}

client  {"jsonrpc": "2.0", "id": 3, "method": "tools/call",
         "params": {"name": "find_orders", "arguments": {"email": "..."}}}
server  {"result": {"content": [{"type": "text", "text": "..."}],
                    "isError": false}}
Method names and result fields as the official SDKs define them. Resources and prompts have the same pattern, resources/list and resources/read, prompts/list and prompts/get. Servers can also push notifications, such as one saying the tool list has changed, which is why a client cannot cache that list forever.

Two transports matter in practice. Standard input and output, where the client starts your server as a child process on the same machine and talks down a pipe, which is how local servers in an IDE work and needs no ports, no auth and no deployment. And HTTP, for a server that runs somewhere else and is shared, which does need all three. The message shapes above are identical either way, so the choice is an operational one rather than a protocol one.

One thing the shape makes obvious: the description and the schema of every tool are strings a server hands to a client, which the client puts in front of a model. Nothing validates that they are honest. That is the whole of the security story, and it is covered properly in the prompt injection topic; the short version is that installing a server is closer to installing a browser extension than to adding a dependency.

Three kinds of thing#

  • Tools: actions the model can take. Search a database, send a message, create a record. These are the ones you will write most.
  • Resources: data the client can read. A file, a record, a document. Read-only, addressed by URI.
  • Prompts: reusable prompt templates the server offers, which a client can surface to the user as a command.

The distinction that makes them easy to remember is who decides when each is used. A tool is chosen by the model, in the middle of a turn, from the description you wrote. A resource is chosen by the application, which decides what to attach before the model sees anything, so it fits material a user or a client picks rather than material a model goes looking for. A prompt is chosen by the person, usually from a menu of commands. Getting this backwards produces servers that expose a file reader as a tool and then wonder why the model reads the wrong files.

Most servers only implement tools, and that is a perfectly reasonable server.

Which side are you on#

A client is the application holding the conversation, such as an IDE or a chat product. A server exposes capabilities. You will almost always be writing servers, because that is where your organisation's data and actions live.

It is worth being precise about what the client does, because it is more than transport. The client starts or connects to each server, performs the handshake, collects every tool list, hands the combined list to the model as tool definitions, runs the loop when the model asks for a call, routes that call to the right server, and puts the result back into the conversation. The protocol standardises the connection and the descriptions. It does not standardise the loop, the prompt, or how a model decides, and none of those stop being your problem because a server is involved.

What the protocol costs#

Two costs get asserted without numbers in most discussions of this, so here they are measured. The first is the extra hop: a tool that used to be a function call in your process is now a message to another process. The second is the tool list, which is text in the model's context on every request of the conversation.

import json, subprocess, sys, time

# A server that speaks just enough JSON-RPC to answer a tool call.
SERVER = """
import json, sys
for line in sys.stdin:
    request = json.loads(line)
    reply = {"jsonrpc": "2.0", "id": request["id"],
             "result": {"content": [{"type": "text", "text": "ok"}], "isError": False}}
    sys.stdout.write(json.dumps(reply) + chr(10))
    sys.stdout.flush()
"""

started = time.perf_counter()
proc = subprocess.Popen([sys.executable, "-c", SERVER], stdin=subprocess.PIPE,
                        stdout=subprocess.PIPE, text=True, bufsize=1)

def call_over_the_pipe(i):
    proc.stdin.write(json.dumps({"jsonrpc": "2.0", "id": i, "method": "tools/call",
                                 "params": {"name": "ping", "arguments": {}}}) + "\n")
    proc.stdin.flush()
    return json.loads(proc.stdout.readline())

call_over_the_pipe(0)
startup = (time.perf_counter() - started) * 1000

def call_in_process(i):
    return {"content": [{"type": "text", "text": "ok"}], "isError": False}

for label, fn in (("over the pipe", call_over_the_pipe), ("in process", call_in_process)):
    t0 = time.perf_counter()
    for i in range(1, 2001):
        fn(i)
    print("%-14s %8.4f ms per call" % (label, (time.perf_counter() - t0) / 2000 * 1000))
print("%-14s %8.1f ms, once" % ("start the server", startup))
proc.stdin.close()
proc.wait()

# over the pipe    0.0307 ms per call
# in process       0.0002 ms per call
# start the server     30.9 ms, once
The hop is about 150 times a function call and still three hundredths of a millisecond, which is nothing beside the database query or HTTP request inside the tool. Timings move a little run to run; the ratio does not. The number worth remembering is the other one: starting a stdio server costs around 30 ms of interpreter startup, paid once per session, which matters only if something is spawning servers per request.
TOOL = {"name": "find_orders",
        "description": "Find a customer's orders by email address. Use when the user "
                       "asks about order status, delivery or refunds. Returns at most "
                       "10 recent orders.",
        "inputSchema": {"type": "object",
                        "properties": {
                            "email": {"type": "string", "description": "The customer's email address."},
                            "status": {"type": "string", "description": "Optional: open, shipped, cancelled."}},
                        "required": ["email"]}}

chars = len(json.dumps(TOOL))
print("one tool definition: %d characters, roughly %d tokens" % (chars, chars / 4))
for n in (5, 20, 50):
    per_request = n * chars / 4
    print("%2d tools  %5.0f tokens per request  %7.0f over a 20-step task"
          % (n, per_request, per_request * 20))

# one tool definition: 417 characters, roughly 104 tokens
#  5 tools    521 tokens per request    10425 over a 20-step task
# 20 tools   2085 tokens per request    41700 over a 20-step task
# 50 tools   5212 tokens per request   104250 over a 20-step task
Four characters to a token is the usual approximation for prose and is optimistic for JSON, so read these as a floor. Connecting five servers of ten tools each puts something like 5,000 tokens in front of the model before the user has said anything, on every step. The tool list is a stable prefix, which is exactly what prompt caching is for, and caching the cost is not the same as not having it.

So the honest summary of the cost is that latency is not the problem and context is. A protocol hop you cannot feel, a session startup you pay once, and a tool list that grows with every server you connect and is charged on every request. The failure that follows from that is not slowness, it is a model choosing badly between forty descriptions, which is the subject of the next section.

What it buys over just writing a function#

Against a plain function in your own codebase, a server buys four things and nothing else. A second client can use it without you writing anything. Someone else's client can use it, including ones you have never seen. It runs as a separate process with its own dependencies and its own credentials, which is a real isolation boundary. And the tool list is discovered at connect time rather than compiled in, so a server can gain a tool without the client being rebuilt.

What it costs you is equally concrete. A schema that is now a published interface you cannot change casually, a process that can be absent or out of date when a client expects it, a version negotiation to think about, and a security boundary where previously there was a function call. The question to ask is not whether the protocol is good. It is whether more than one client will ever call this, and whether you would be comfortable with one of them being written by someone else.

When it is not worth it#

One app, three tools, no protocol needed

If you have a single application with a handful of tools and no intention of sharing them, plain function calling is simpler and has less to go wrong. MCP earns its cost when the same capability is wanted by more than one client, or when you want other people's servers to work with your app.

Beyond that, there are cases where a server is the wrong shape rather than merely unnecessary.

  • The call is on a hot path with a latency budget. Not because of the hop, which is measured above and is nothing, but because a separate process is a separate thing to keep warm, monitor and restart, and an in-process call has none of that.
  • The tool needs the caller's request context: a transaction already open, a user session, an object graph you are halfway through building. Serialising that across a process boundary costs more than the tool does.
  • The interface is still changing weekly. A published schema is a promise, and making it while the design moves means breaking clients you cannot see.
  • The capability is one line of business logic over data you already hold in memory. Wrapping it earns nothing and adds a process.

The reverse case is worth naming too, because it is the one people miss: consuming servers is often more valuable than writing them. Connecting an existing server for a system you already run gets you an integration you did not have to build, and evaluating whether its tools are well designed is the same skill as designing your own.

Where people go wrong

Adopting the protocol for its own sake on a project with one client. You gain a transport layer and a schema to maintain, and lose the ability to change a tool signature without thinking about it.

Build this: List the tools in a project you have built and decide honestly which would be genuinely reusable by another application. That answer tells you whether a server is worth writing.

Open this section on its own page
Section 2 of 2, 5h

Building and shipping a server

By the end: You have a working MCP server in a public repository that someone else can run.

  • The shape of a minimal server
  • Designing tools people can actually use
  • Auth and secrets
  • Publishing it so it counts

This is the practical half: writing a server, then putting it somewhere someone else can run it. The code is short, since the SDK handles transport and your type hints become the tool schemas, so nearly all of the work is design. Tools that are too many, too vague or too chatty make an agent worse rather than better, and a server with a hardcoded secret cannot be shared at all. A published server a stranger can install is also the most concrete evidence of this skill you can point at.

A minimal server#

A server declares which tools it has, and handles a call when one is invoked. The rest is transport, which the SDK handles.

# orders_server.py
from mcp.server.mcpserver import MCPServer    # this class was called FastMCP before mcp 2.0
from mcp.server.mcpserver.exceptions import ToolError

mcp = MCPServer("orders")

ORDERS = [{"id": "ord_1001", "email": "ana@example.com", "status": "shipped", "total": 19.99},
          {"id": "ord_1002", "email": "ana@example.com", "status": "open", "total": 42.50}]

@mcp.tool()
def find_orders(email: str, status: str | None = None) -> list[dict]:
    """Find a customer's orders by email address.

    Use when the user asks about order status, delivery or refunds.
    Returns at most 10 of the most recent orders.
    """
    rows = [o for o in ORDERS
            if o["email"] == email and (status is None or o["status"] == status)]
    if not rows:
        raise ToolError("No orders for " + email + ". Check the address with the customer.")
    return rows[:10]

if __name__ == "__main__":
    mcp.run()
A whole server. The docstring becomes the tool description the model reads, so write it for the model rather than for a developer. The list stands in for a database so the file runs as it is. Note the class name: this was FastMCP in mcp 1.x and is MCPServer from 2.0, which is the kind of rename that makes older examples fail on import.

Type hints become the argument schema automatically, which is one more reason the typing work earlier pays off. That claim is easy to check rather than believe, and checking it is the same as proving the server works, because the only honest test of a server is a client connecting to it.

Run it from a client#

A stdio server is started by its client as a child process, so a client is thirty lines and needs no deployment. This one performs the handshake, reads the tool list the SDK generated, calls the tool twice and prints what comes back.

# Save the block above as orders_server.py, then run this beside it.
import asyncio, os, sys
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client

async def main():
    params = StdioServerParameters(command=sys.executable, args=["orders_server.py"])
    log = open(os.devnull, "w")        # the server logs to stderr; keep it out of the listing
    async with stdio_client(params, errlog=log) as (read, write):
        async with ClientSession(read, write) as session:
            info = await session.initialize()
            print("connected to", info.server_info.name, "on", info.protocol_version)
            for t in (await session.list_tools()).tools:
                print("tool:", t.name)
                print("  required:", t.input_schema["required"])
                print("  properties:", list(t.input_schema["properties"]))
            good = await session.call_tool("find_orders", {"email": "ana@example.com"})
            print("is_error", good.is_error, "->", good.content[0].text[:60].replace("\n", " "))
            bad = await session.call_tool("find_orders", {"email": "nobody@example.com"})
            print("is_error", bad.is_error, "->", bad.content[0].text)

asyncio.run(main())

# connected to orders on 2025-11-25
# tool: find_orders
#   required: ['email']
#   properties: ['email', 'status']
# is_error False -> {   "id": "ord_1001",   "email": "ana@example.com",   "statu
# is_error True -> Error executing tool find_orders: No orders for
#                  nobody@example.com. Check the address with the customer.
Nothing here was written by hand: the required list and the properties come from the type hints, and the date is the protocol version the two sides negotiated. The python SDK spells the fields in snake_case, input_schema and is_error, where the wire format and the TypeScript SDK use inputSchema and isError.

This is also your test suite. A server can be exercised completely without a model in the loop: connect, list the tools, call each one with good arguments, call each one with arguments a confused model would produce, and assert on what comes back. Those tests are fast, deterministic and free, and they catch the majority of what goes wrong, which is tools that raise on empty results, tools whose schema does not match the docstring, and tools that return half a megabyte.

Designing tools people can use#

Tool design is the part that decides whether a server is any good, and it is judgement rather than protocol knowledge.

  • One clear job per tool. A tool that does four things gets called for the wrong one.
  • Say when to use it, not only what it does. That sentence is how the model chooses between your tools.
  • Cap what comes back. Unbounded results land in the context and stay there.
  • Return errors as readable text the model can act on. Customer not found is useful; a stack trace is not.
  • Name arguments the way a person would say them. email beats customer_identifier_string.

The second of those is the one that carries the most weight and is the least often done, so it is worth measuring rather than asserting. Below, the same six tools are described twice, once as a developer would name them and once with a sentence saying when to reach for each, and a crude nearest-description matcher picks one per request. The matcher is not a model. It is choosing between exactly the strings a model would be choosing between, which is enough to show what a vague description costs.

from sklearn.feature_extraction.text import TfidfVectorizer

VAGUE = {
    "find_orders": "Get orders.",
    "refund_order": "Handle refunds.",
    "return_label": "Handle returns.",
    "update_address": "Change account data.",
    "reset_password": "Account help.",
    "search_docs": "Search.",
}
SPECIFIC = {
    "find_orders": "Find a customer's orders by email address. Use when the user asks "
                   "where an order is or whether it has shipped.",
    "refund_order": "Start a refund for one delivered order. Use when the customer wants "
                    "money back for something they already have.",
    "return_label": "Create a prepaid return label. Use when the customer wants to post "
                    "an item back to us.",
    "update_address": "Change the delivery address on an order that has not been "
                      "dispatched. Use before dispatch only.",
    "reset_password": "Send a password reset link to the address on the account. Use when "
                      "the customer cannot sign in.",
    "search_docs": "Search the written policies for a rule or a timescale. Use for "
                   "questions about how things work rather than about one order.",
}
REQUESTS = [
    ("where has my parcel got to", "find_orders"),
    ("I want my money back for the coat that arrived yesterday", "refund_order"),
    ("I need to post this jacket back to you", "return_label"),
    ("I typed the wrong street and it has not been dispatched", "update_address"),
    ("I cannot sign in to my account", "reset_password"),
    ("what is your policy on refunds", "search_docs"),
]

def evaluate(catalogue, label):
    """Pick the nearest description. A model is not a TF-IDF matcher, but it is
    choosing between these same strings."""
    names = list(catalogue)
    vec = TfidfVectorizer(analyzer="char_wb", ngram_range=(4, 5), sublinear_tf=True)
    M = vec.fit_transform([n.replace("_", " ") + ". " + catalogue[n] for n in names])
    right = 0
    for request, want in REQUESTS:
        got = names[(M @ vec.transform([request]).T).toarray().ravel().argmax()]
        right += got == want
        if got != want:
            print("   %-52s -> %s (wanted %s)" % (request, got, want))
    print("%-22s %d of %d chosen correctly" % (label, right, len(REQUESTS)))

evaluate(VAGUE, "one-line names")
evaluate(SPECIFIC, "with a use-when line")

#    I want my money back for the coat that arrived yesterday -> find_orders (wanted refund_order)
#    I need to post this jacket back to you               -> find_orders (wanted return_label)
#    I typed the wrong street and it has not been dispatched -> find_orders (wanted update_address)
#    what is your policy on refunds                       -> refund_order (wanted search_docs)
# one-line names         2 of 6 chosen correctly
#    what is your policy on refunds                       -> refund_order (wanted search_docs)
# with a use-when line   5 of 6 chosen correctly
Two of six becomes five of six, and the only change is a sentence per tool saying when to use it. The failure that survives is the interesting one: a question about the refund policy still routes to the tool that performs a refund, because it is the most refund-shaped thing on offer.

That surviving failure is worth designing around rather than describing around, because it is the general case. An action and a question about that action look almost identical as strings, and a model that reaches for refund_order when the customer asked what the policy is will do something irreversible on the strength of a guess. Two defences work. Say the boundary explicitly in both descriptions, as in this tool refunds money and does not explain the rules. And make the dangerous one require an argument the model can only have if it did the lookup first, such as an order id, so a wrong choice fails loudly instead of succeeding.

Too many tools is its own failure

Past roughly twenty tools, selection accuracy degrades noticeably. If you need more, group them behind fewer tools with a mode argument, or split into several servers by domain.

Cap what comes back#

A tool result is not a return value, it is text appended to a conversation that will be re-sent on every following step. The natural implementation, returning the rows the underlying API gave you, is expensive in a way that does not show up until an agent has run for a while.

import json

def fake_orders(n):
    return [{"id": "ord_%05d" % i, "placed": "2026-03-%02d" % (i % 28 + 1),
             "status": ["open", "shipped", "delivered"][i % 3],
             "total_pence": 1999 + i * 137, "currency": "GBP",
             "items": [{"sku": "SKU-%04d" % (i * 7 % 900), "qty": 1 + i % 3,
                        "name": "Merino crew neck jumper, navy"}],
             "address": {"line1": "%d Example Street" % (i + 1), "city": "Leeds",
                         "postcode": "LS1 4AB", "country": "GB"}}
            for i in range(n)]

everything = json.dumps(fake_orders(50))
print("50 whole order objects    %6d chars  about %5d tokens"
      % (len(everything), len(everything) / 4))

def summarise(orders, limit=5):
    lines = ["%s  %s  %-9s  GBP %.2f" % (o["id"], o["placed"], o["status"],
                                         o["total_pence"] / 100)
             for o in orders[:limit]]
    if len(orders) > limit:
        lines.append("%d more not shown. Narrow by status or date."
                     % (len(orders) - limit))
    return "\n".join(lines)

capped = summarise(fake_orders(50))
print("5 rows, readable fields   %6d chars  about %5d tokens"
      % (len(capped), len(capped) / 4))
print(capped)

# 50 whole order objects     14422 chars  about  3605 tokens
# 5 rows, readable fields      264 chars  about    66 tokens
# ord_00000  2026-03-01  open       GBP 19.99
# ord_00001  2026-03-02  shipped    GBP 21.36
# ord_00002  2026-03-03  delivered  GBP 22.73
# ord_00003  2026-03-04  open       GBP 24.10
# ord_00004  2026-03-05  shipped    GBP 25.47
# 45 more not shown. Narrow by status or date.
3,605 tokens against 66, for a result that answers the question better. The last line is the part people leave out: saying how many rows were dropped and how to narrow the search turns a truncation into something the model can act on, rather than a silent lie about how many orders exist.

The same discipline applies to fields. Postcodes, currency codes and SKUs are in the response because the API returns them, not because anyone reading the answer needs them, and every one is charged on every subsequent step of the task. Decide what the model needs to answer with and return that, with an identifier it can use to fetch the rest if it turns out to matter.

Errors the model can act on#

A tool failure is not a server failure, and the protocol is explicit about the difference. A failure the tool anticipated comes back as an ordinary result carrying isError and your message, so the model reads it and can try something else. A protocol-level error is for the call itself being impossible: no such tool, malformed request. Using the second for the first hides the reason from the model, which is the one participant that could have recovered.

In the python SDK that distinction is a specific exception. Raising ToolError from a tool sends your text back to the model and logs the failure without a traceback, which is what the server above does when no orders match. Letting any other exception escape is treated as a crash: the model receives only the words error executing tool and the name, with your message deliberately withheld, and the server logs the traceback. That is a sensible default, because an arbitrary exception message can contain a connection string, but it means every failure you can foresee needs converting into a sentence you chose.

  • No results is not an error. Return an empty list and say so in words; raising here makes the model retry the same call.
  • Say what to do next. Not found for that email, ask the customer to confirm it, beats not found.
  • Never put a secret, a connection string or a stack trace in the text, because everything you return becomes part of the conversation and may be logged with it.
  • Rate limits and timeouts are anticipated failures. Say which one happened and whether waiting would help.

Secrets#

The server holds credentials, never the model. Read them from the environment, and never accept a key as a tool argument, because anything in an argument has passed through the model and into the conversation history.

import os
from mcp.server.mcpserver import MCPServer

server = MCPServer("search")

# Wrong: the key is now in the transcript.
@server.tool()
def search_with_key(query: str, api_key: str) -> list[dict]:
    ...

# Right: the server already knows who it is.
API_KEY = os.environ.get("SEARCH_API_KEY", "")

@server.tool()
def search(query: str) -> list[dict]:
    ...
The difference is one argument, and it decides whether your key ends up in a conversation history you do not control.

Which raises the question of whose credentials these are. A local stdio server runs as the person who started it and inherits their access, so the model can do anything that person can, and the only limit is what you gave the server. A remote HTTP server serves many people and has to know which one is asking, so it needs real authentication and per-user scoping, and the tool has to apply it rather than trusting an argument the model supplied. Deciding which of these you are building, before writing tools, saves rewriting all of them.

Ship it publicly#

This is asked as an ownership question

Reported interviews probe MCP by asking whether you have personally implemented a server, not by quizzing protocol internals. A public repository with a readable README, a real integration and a working example is the answer that lands. Having read the specification is not.

  • Solve a real problem, even a small one. A wrapper around an API you actually use beats a toy.
  • README with installation, configuration and one worked example.
  • Handle the failures: bad credentials, rate limits, empty results.
  • Run it from a client you did not write, and say so in the README.

Where people go wrong

Exposing your entire internal API surface as tools. A model with forty near-identical options picks badly, and every definition costs tokens on every call.

Build this: Build a server wrapping an API you already use, with three well-described tools, real error handling and a README. Publish it, then use it from a client you did not write.

Open this section on its own page

Where you use this

A topic is easier to care about when you can see what it is for. These projects lean on it at a named stage.

An agent that uses tools
  • Stage 1, One tool, and the schema that describes it: The tool you are defining by hand is the same shape an MCP server exposes, which is why this transfers.

Now actually do it

Reading a topic is not learning it. These are the hours that count, and each one tells you when you are finished.

1Ship a server publicly

6h

Wrap an API you actually use in an MCP server with three well-described tools, real error handling, bounded results and credentials from the environment. Public repo, README with a worked example.

Done when: Someone else can install it from your README and it works.

2Use it from a client you did not write

2h

Connect your server to an existing MCP client and complete a real task with it. Fix whatever breaks.

Done when: It works in a client you did not build, which is the point of the protocol.

8 hours of practice

Words used here

1 term this topic teaches, each with a one-line definition if one of them stopped you.

MCP
An open protocol for exposing tools, data and prompts to any model client over a common interface, so one server works with every host that speaks it instead of being wired to one application.

Interview questions

7 questions, grouped by the section that teaches the answer. An answer you have read is not an answer you can give, so use Test yourself and say it out loud first.

What MCP is for

What is MCP and what problem does it solve?

A standard protocol for exposing tools, resources and prompt templates to model clients. It removes the repetition of rewriting the same integration per application and per provider format, so a capability written once works with any client that speaks it.

Then they push further

How is that different from defining tools in your API call?

Tools in an API call are defined by your application for that request. MCP puts them behind a server speaking a standard protocol, so one integration can be used by many clients and agents without each rewriting it.

What are the security concerns with MCP servers?

A server's tool descriptions and results go straight into the model's context, so a malicious or compromised server can inject instructions. Servers often hold credentials too. Install only servers you trust, scope their credentials, and review what they expose.

What gets marked up. Marked up for describing it as a standard interface between agents and tools, and for raising the trust questions unprompted.

What is the difference between a tool and a resource in MCP?

A tool is an action the model can invoke and which usually changes something. A resource is read-only data the client can fetch, addressed by URI. Most servers implement only tools.

When would you not use MCP?

A single application with a few tools that nobody else needs. Plain function calling is simpler and has fewer moving parts. The protocol pays off with multiple clients or when you want to consume servers written by others.

Building and shipping a server

Have you built an MCP server?

Answer with the specific thing you built, what it wrapped, and one design decision you had to make, such as how you capped result size or handled auth. This is asked to find out whether you have shipped one, so a concrete link beats a description of the protocol.

What makes a good tool definition?

One clear job, a description that says when to use it rather than only what it does, bounded results, readable errors, and argument names a person would use. The description is what the model reads to choose between tools, so vague ones cause wrong selection.

How do you handle credentials in an MCP server?

The server reads them from the environment and the model never sees them. Never accept a key as a tool argument, because arguments are generated by the model and end up in conversation history.

What happens if a server exposes fifty tools?

Selection accuracy degrades, because the model is choosing between too many similar descriptions. Group related operations behind fewer tools with a mode argument, or split by domain into several servers.

These questions on their own page, one link per question

Resources

Free to watch and read, picked for this topic.

Stop 17 of 50 on the AI Engineer route

0 of 50 topics done · 0h behind you, 603h to go

Next on this routeMulti-agent systemsSplitting work across agents, and when not to.10h

What you have marked done is stored in this browser only. It does not reach a server, and clearing site data will clear it.