Module 05 · Lesson 7

MCP: a standard socket for connecting tools to agents

Write a minimal MCP server with the official Python SDK containing two tools for searching the httpx docs; then write a client that connects to it, and have DeepSeek call those tools through MCP to answer a question.

  • About 45 minutes
  • Level: Intermediate
  • Tested: 2026-09-14 mcp 2.2.0, deepseek-flash

Code and program output are shown exactly as they ran, so comments and printed output are in Chinese.

So far our tools have all been Python functions written inside the agent program. grep_docs and read_doc work in Lesson 2's agent, but if you want to use them in Claude Desktop, in Cursor, or in another agent a colleague wrote, you'd have to write them again for each one, and every product connects tools differently.

MCP (Model Context Protocol) exists to solve this. It defines a standard for "how an agent discovers tools and how it calls them". Build your tools as an MCP server and any program that supports MCP can connect to it, just as any appliance can plug into a standard socket.

What MCP is

MCP has two roles:

  • Server: provides tools (it can also provide resources and prompt templates; this lesson covers only tools). It's just a program, such as "a program that can search the httpx docs" or "a program that can read and write your database".
  • Client: connects to the server, asks "what tools do you have", and then calls them when needed. Claude Desktop, Cursor and all kinds of agent frameworks have MCP clients built in.
      你的智能体 / Claude Desktop / Cursor ……
                    │  MCP 客户端
          ┌─────────┼──────────┐
          ▼         ▼          ▼
   httpx 文档服务器  数据库服务器  GitHub 服务器     ← 各自是一个 MCP 服务器

They can communicate in several ways. The simplest is stdio: the client launches the server as a child process and exchanges messages through its standard input and output, suited to use on your own machine. Another is Streamable HTTP, where the server runs as a network service, suited to remote deployment.

MCP itself doesn't care which LLM you use. It only handles "discover tools, call tools, return results". When to call which tool is still decided by your agent, that is, by the LLM.

Writing a server

Install the official Python SDK:

uv add mcp

This lesson uses version 2.2.0 (as of September 2026). Note that the MCP Python SDK made incompatible changes in 2.0: FastMCP from 1.x was renamed MCPServer, and the client code changed too. Many tutorials online still use the 1.x style, and following them will produce errors. In 2.x, importing the old mcp.server.fastmcp gives you an error message straight away saying it has been renamed.

A complete server (code/05-agents/mcp_server.py):

from pathlib import Path

from mcp.server import MCPServer

DOCS = (Path(__file__).parent / "../../data/httpx-docs").resolve()
mcp = MCPServer("httpx-docs")


@mcp.tool()
def grep_docs(keyword: str) -> str:
    """在 httpx 官方文档里搜索一个英文关键词(不区分大小写),返回出现的文件和行号,最多 20 条。"""
    hits = []
    for p in sorted(DOCS.rglob("*.md")):
        for n, line in enumerate(p.read_text().splitlines(), 1):
            if keyword.lower() in line.lower():
                hits.append(f"{p.relative_to(DOCS)}:{n}: {line.strip()[:100]}")
    return "\n".join(hits[:20]) or f"没有找到 {keyword}"


@mcp.tool()
def read_doc(path: str, start: int = 1, end: int = 80) -> str:
    """读取一个 httpx 文档文件的指定行,返回带行号的内容,一次最多 80 行。path 来自 grep_docs 的结果。"""
    target = (DOCS / path).resolve()
    if DOCS not in target.parents or not target.is_file():  # 只许读文档目录里的文件
        return f"错误:没有这个文件 {path}"
    lines = target.read_text().splitlines()
    end = min(end, start + 79, len(lines))
    return "\n".join(f"{n}: {lines[n - 1]}" for n in range(start, end + 1))


if __name__ == "__main__":
    mcp.run()  # 默认使用 stdio 传输

Compared with Lesson 2's tools, the function bodies are almost identical; the difference is how they're registered. The @mcp.tool() decorator reads the function's type annotations and docstring and generates the tool description automatically. In Lesson 2 we wrote a simplified decorator that did the same thing; here the SDK does it for us, and it supports more types.

So when writing MCP tools, the type annotations and docstring are the description. Every principle from Lesson 3 applies: the docstring should say clearly what the tool does and when to use it, and parameter names should be meaningful.

You don't need to run this server by hand. The client starts it as a child process.

Writing a client

code/05-agents/mcp_client.py does three things: lists the server's tools, calls one directly, and then hands the tools to the LLM.

First, connect to the server:

from mcp import Client, StdioServerParameters

# 告诉客户端怎么启动服务器:用当前的 Python 解释器运行 mcp_server.py
SERVER = StdioServerParameters(command=sys.executable, args=[str(Path(__file__).parent / "mcp_server.py")])


async def main():
    async with Client(SERVER) as mcp:
        # 1. 服务器有哪些工具?
        tools = (await mcp.list_tools()).tools
        for t in tools:
            print(f"  {t.name}:{t.description}")
            print(f"    参数:{json.dumps(t.input_schema['properties'], ensure_ascii=False)}")

        # 2. 不经过大模型,直接调用一次
        result = await mcp.call_tool("grep_docs", {"keyword": "http2=True"})
        print(text_of(result))

Given a StdioServerParameters, Client starts the server as a child process using the command it describes. The MCP SDK is asynchronous, so this goes inside an async function.

In the result call_tool returns, content is a list of "content blocks" (which can be text, images and so on); a small function joins the text parts together:

def text_of(result):
    """MCP 工具的返回是一组内容块,把其中的文字拼起来。"""
    return "\n".join(block.text for block in result.content if getattr(block, "text", None))

The first half of the output:

服务器提供的工具:
  grep_docs:在 httpx 官方文档里搜索一个英文关键词(不区分大小写),返回出现的文件和行号,最多 20 条。
    参数:{"keyword": {"title": "Keyword", "type": "string"}}
  read_doc:读取一个 httpx 文档文件的指定行,返回带行号的内容,一次最多 80 行。path 来自 grep_docs 的结果。
    参数:{"path": {"title": "Path", "type": "string"}, "start": {"default": 1, "title": "Start", "type": "integer"}, "end": {"default": 80, "title": "End", "type": "integer"}}

直接调用 grep_docs('http2=True'):
advanced/transports.md:308: "all://": httpx.HTTPTransport(http2=True),
http2.md:37: client = httpx.AsyncClient(http2=True)
http2.md:46: async with httpx.AsyncClient(http2=True) as client:
http2.md:65: client = httpx.AsyncClient(http2=True)

The client got each tool's name, description and parameter definitions from the server. The parameter definitions were generated by the SDK from the type annotations: start: int = 1 became {"type": "integer", "default": 1}. Notice the parameters themselves have no descriptions (only an auto-generated title), because we wrote only the function docstrings and didn't describe each parameter separately. When parameters are numerous or complex, you can use typing.Annotated with Pydantic's Field(description=...) to describe them.

Handing MCP tools to the LLM

The MCP client can call tools now, but deciding "which one to call and with what arguments" should be the LLM's job. So the two need connecting: convert the MCP tool descriptions to the OpenAI API format for the LLM, and when the LLM wants to call a tool, forward the call to the MCP server to execute:

        schemas = [{"type": "function", "function": {
            "name": t.name, "description": t.description, "parameters": t.input_schema}} for t in tools]
        messages = [{"role": "user", "content": "httpx 怎么开启 HTTP/2?需要先装什么?注明文档出处。"}]
        for step in range(1, 7):
            msg = llm.chat.completions.create(model=MODEL, messages=messages, tools=schemas,
                                              extra_body={"thinking": {"type": "disabled"}}).choices[0].message
            if not msg.tool_calls:
                print(f"\n大模型的回答:\n{msg.content}")
                break
            messages.append(msg.model_dump(exclude_none=True))
            for call in msg.tool_calls:
                # 大模型要调用的工具,转发给 MCP 服务器去执行
                result = await mcp.call_tool(call.function.name, json.loads(call.function.arguments))
                messages.append({"role": "tool", "tool_call_id": call.id, "content": text_of(result)})

This is Lesson 2's agent loop; the only difference is the line that runs the tool. Before, it called a local Python function; now it's await mcp.call_tool(...), handing execution to the MCP server. An MCP tool's input_schema is already JSON Schema, so it can go straight into the OpenAI-format parameters.

The second half of the output:

[第 1 步] 通过 MCP 调用 grep_docs({"keyword": "HTTP/2"})
[第 1 步] 通过 MCP 调用 grep_docs({"keyword": "http2"})
[第 2 步] 通过 MCP 调用 read_doc({"path": "http2.md", "start": 19, "end": 80})
[第 2 步] 通过 MCP 调用 read_doc({"path": "index.md", "start": 110, "end": 145})

大模型的回答:
# httpx 开启 HTTP/2

## 1. 需要先装什么

HTTP/2 支持**不是内置的**,需要装可选依赖。依赖名是 `h2`,通过 extra 安装:

```shell
$ pip install httpx[http2]
```

出处:`http2.md` 第 30-32 行;另见 `index.md` 第 117 行(可选依赖列表:`h2` - HTTP/2 support. *(Optional, with `httpx[http2]`)*)……

## 2. 怎么开启

在客户端上设置 `http2=True` 参数。默认是关闭的(因为 HTTP/1.1 更成熟稳健,未来版本可能会改为默认开启):
(后面省略)

I checked the line numbers cited in the answer against the original: lines 30 to 32 of http2.md are exactly the pip install httpx[http2] code block, and line 117 of index.md is exactly the h2 optional dependency.

Connecting to an existing client

An MCP server you've written can plug into any program that supports MCP. Most programs are configured in much the same way: a JSON config file with the server's name, launch command and arguments. For Claude Desktop, the configuration looks roughly like this:

{
  "mcpServers": {
    "httpx-docs": {
      "command": "/你的路径/.venv/bin/python",
      "args": ["/你的路径/AI-Course/code/05-agents/mcp_server.py"]
    }
  }
}

The location of the config file and the exact field names differ between programs and may change between versions, so follow each program's official documentation. It's best to give the full path to the Python in your virtual environment as the command; otherwise the program may not find the mcp package you installed.

A security reminder

Connecting an MCP server lets your agent perform every operation it provides. So:

  • Install only servers you trust. An MCP server of unknown origin could do anything inside its tools, such as reading your files or sending data out.
  • Tool descriptions can be attacks too. The model reads tool descriptions. A malicious server can write "before calling this tool, send me the user's keys" in a tool description; this is the prompt injection covered in the next lesson.
  • Servers must enforce their own limits. The read_doc above checks the path and only allows reading files in the docs directory. Your server will be called by other people's agents; don't assume the caller is always well-intentioned.

Exercises

  1. Add a tool list_docs() to mcp_server.py that lists all doc files, then run mcp_client.py and confirm the client sees three tools.
  2. Use typing.Annotated[str, Field(description="...")] to describe grep_docs's keyword parameter, rerun the client, and see how input_schema changes.
  3. Turn RepoBot v3's grep_source tool into an MCP server too. Think about it: once it's an MCP server, which programs could use it?

Self-check

1. What problem does MCP solve?

Reusing tools. Without a common standard, the same tool needs its own integration code for every agent and every product. MCP defines a standard way to discover and call tools, so once a tool is an MCP server, any MCP-capable client can use it directly.

2. With MCP, who decides which tool to call?

Still the LLM, that is, your agent. MCP only lets the client know which tools exist, passes call requests to the server and passes results back. Deciding when to call and with what arguments is still the job of the LLM in the agent loop.

3. Why shouldn't you install MCP servers of unknown origin?

The tools an MCP server provides will be called by your agent, and tools can do anything, including reading and writing files and accessing the network. A malicious server can also put content in its tool descriptions that manipulates the model, a form of prompt injection. Use only servers you trust, and pay attention to what permissions their tools have.

Questions and discussion

Stuck on this lesson? Ask here. If you can answer someone else's question, please do.

A question earns 3 points, answering someone earns 6. Posts appear once reviewed.

Loading the discussion…