CourseModel Context Protocol: The Complete Course · Module 1: Foundations · part 9 of 77
Part 9 · Module 1: Foundations

Lesson 3: A first MCP server and client, in memory and as a process

19 min read·13 Oct 2026
Module 1 · Lesson 3 · About 40 minutes · Needs: Lesson 2 (NoteStore and native function calling)

The situation

Monday 7 September 2026. In Lesson 2 you gave a model a search_notes tool with native function calling, but the schema and the code that runs it live inside one program. Nare uses four AI applications and wants all of them to answer "What did we decide about the nap study?" from her notes.

So today the same search becomes an MCP server: a separate program that offers the tool to any application that speaks the protocol. You will connect to it two ways, break the launch on purpose and read the messages underneath. The search for "nap study" runs through the whole lesson.

A real case, first

On 8 July 2025 a developer opened issue #1103 on the official MCP Python SDK: starting a stdio server from a Jupyter notebook failed inside subprocess.Popen with "UnsupportedOperation fileno", while the same code worked in IDLE (python-sdk issue #1103). A week later another user reported the same error on Google Colab, suspecting that the notebook replaces sys.stderr.

Commenters traced the cause: stdio_client uses sys.stderr by default for the server's error output, and starting a child process needs a real file descriptor for it. A notebook's replacement stream may not have one. The workaround offered was to pass errlog explicitly. On 3 October 2026 a commenter found that real JupyterLab on macOS did not reproduce it, while a stream without a usable descriptor did. The issue is still open.

PYTHON-SDK ISSUE #1103 stdio_client() starts the server Popen needs a descriptor notebook stderr no file descriptor errlog = a file real descriptor UnsupportedOperation fileno versus: child starts
A stdio launch hands the child a descriptor for its error output: a notebook stream without one fails (red), a real log file works (green).

The point for you: a stdio server is a real child process with real streams. Every launch in this course gives errlog a file, and a break-it step below triggers the same error on purpose.

What you will be able to do

  • Turn a Python function over NoteStore into a tool on an MCP server.
  • Connect a client to it in memory and as a child process over stdio.
  • Diagnose a failing stdio launch, starting from the server's error output.
  • Say what the host, each client and each server are responsible for.
  • Read and write the JSON-RPC 2.0 messages underneath.

Setup: the notes and the store

This lesson calls no model, so it needs only the MCP install cell from course-guide/02.

python
%pip install -q mcp==2.3.0
import logging
for name in ("mcp", "httpx2", "uvicorn"):
    logging.getLogger(name).setLevel(logging.WARNING)

Code walkthrough

  • The SDK is pinned to the tested version; the loop keeps library log messages out of your output.
python
from pathlib import Path

NOTES = {   # two of the eight notes in course-guide/06, word for word
    "lab-sync-2026-09-02": """---
title: Lab sync, 2 September 2026
tags: [meeting, sleep]
created: 2026-09-02
---
Attendees: Priya, Tomas, Nare.
Decision: run the nap study with 24 participants instead of 16, budget approved by Priya.
Action: Tomas books the sleep lab for the weeks of 5 and 12 October.
Action: Nare drafts the consent form by 15 September.
Risk: EEG caps are shared with another group, so booking conflicts are likely.
""",
    "sleep-and-memory": """---
title: Sleep and memory consolidation
tags: [sleep, memory, neuroscience]
created: 2026-03-02
---
Slow-wave sleep replays the day's hippocampal activity and moves it into long-term cortical storage.
Walker (2017) reports that a night without sleep cuts new memory formation by about 40 percent.
A 20 minute nap after learning helps recall of word pairs, but only if the nap reaches stage 2 sleep.
Open question: does REM sleep matter more for procedural skills than for facts?
""",
}

Code walkthrough

  • NOTES maps each note id to the exact text of its file: the anchor note and the one other note that mentions naps.

The server needs Lesson 2's store. These cells paste notes_assistant/store.py unchanged.

python
from pathlib import Path
Path("notes_assistant").mkdir(exist_ok=True)
Path("notes_assistant/__init__.py").write_text('"""Nare\'s notes assistant."""\n')

Code walkthrough

  • A folder with an __init__.py file is a package Python can import; the store goes inside it.
python
%%writefile notes_assistant/store.py
"""Nare's notes as a folder of Markdown files. This file knows nothing about MCP."""
from __future__ import annotations

import re
from dataclasses import dataclass
from datetime import date
from pathlib import Path

NOTE_ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$")
WORD_PATTERN = re.compile(r"[a-z0-9]+")
MAX_TITLE_LENGTH = 120
MAX_BODY_LENGTH = 20_000


class NoteError(Exception):
    """Base class for every error the store raises on purpose."""


class NoteNotFound(NoteError):
    """No note has this id."""


class NoteExists(NoteError):
    """A note with this id already exists. The store never overwrites."""


class InvalidNote(NoteError):
    """An id, title, body or query the store refuses."""

Code walkthrough

  • %%writefile saves the cell as a file, because a server started as a separate process imports its code from disk. The constants and the four error classes are Lesson 2's.
python
%%writefile -a notes_assistant/store.py
@dataclass(frozen=True)
class Note:
    note_id: str
    title: str
    tags: tuple[str, ...]
    created: str
    body: str

    def snippet(self, length: int = 120) -> str:
        """The first line of the body, cut to `length` characters."""
        first = self.body.splitlines()[0] if self.body else ""
        return first if len(first) <= length else first[: length - 3] + "..."


@dataclass(frozen=True)
class SearchHit:
    note_id: str
    title: str
    score: int
    snippet: str
    tags: tuple[str, ...] = ()


def slugify(title: str) -> str:
    """'Nap study: consent form!' -> 'nap-study-consent-form'."""
    return "-".join(WORD_PATTERN.findall(title.lower()))[:64].strip("-")


def validate_note_id(note_id: str) -> str:
    """Refuse any id that is not a simple slug, before a file is touched."""
    if not NOTE_ID_PATTERN.fullmatch(note_id):
        raise InvalidNote(f"Invalid note id {note_id!r}. Use lowercase letters, digits "
                          "and hyphens, for example 'sleep-and-memory'.")
    return note_id

Code walkthrough

  • -a appends. Note, SearchHit, slugify and validate_note_id are unchanged from Lesson 2; snippet() returns only the first body line, which matters below.
python
%%writefile -a notes_assistant/store.py
def _parse(note_id: str, text: str) -> Note:
    """Split a file into its frontmatter fields and its body."""
    meta, body = {}, text
    if text.startswith("---\n"):
        header, _, body = text[4:].partition("\n---\n")
        for line in header.splitlines():
            key, _, value = line.partition(":")
            meta[key.strip()] = value.strip()
    tags = tuple(t.strip() for t in meta.get("tags", "").strip("[]").split(",") if t.strip())
    return Note(note_id, meta.get("title", note_id), tags, meta.get("created", ""), body.strip())


class NoteStore:
    """A folder of Markdown notes, one file per note, named <note_id>.md."""

    def __init__(self, root: Path | str) -> None:
        self.root = Path(root)
        if not self.root.is_dir():
            raise NoteError(f"Notes folder not found: {self.root}")

    def _path(self, note_id: str) -> Path:
        return self.root / f"{validate_note_id(note_id)}.md"   # the only place an id becomes a path

    def list_notes(self) -> list[Note]:
        return [self.get(p.stem) for p in sorted(self.root.glob("*.md"))]

    def get(self, note_id: str) -> Note:
        path = self._path(note_id)
        if not path.is_file():
            raise NoteNotFound(f"No note with id {note_id!r}.")
        return _parse(note_id, path.read_text(encoding="utf-8"))

Code walkthrough

  • _parse splits a file into frontmatter and body; NoteStore(root) fails at once if the folder is missing.
python
%%writefile -a notes_assistant/store.py
    def search(self, query: str, limit: int = 5, tag: str | None = None) -> list[SearchHit]:
        """Rank notes by query words: 1 per body match, 3 per title match, 2 per tag match."""
        terms = WORD_PATTERN.findall(query.lower())
        if not terms:
            raise InvalidNote("The query must contain at least one letter or digit.")
        hits = []
        for note in self.list_notes():
            if tag and tag not in note.tags:
                continue
            body = WORD_PATTERN.findall(note.body.lower())
            title = WORD_PATTERN.findall(note.title.lower())
            tags = [t.lower() for t in note.tags]
            score = sum(body.count(t) + 3 * title.count(t) + 2 * tags.count(t) for t in terms)
            if score > 0:
                hits.append(SearchHit(note.note_id, note.title, score, note.snippet(), note.tags))
        hits.sort(key=lambda h: (-h.score, h.note_id))   # ties broken by note id
        return hits[:limit]

Code walkthrough

  • search scores each query word 1 per body match, 3 per title match and 2 per tag, and breaks ties by id.
python
%%writefile -a notes_assistant/store.py
    def create(self, title: str, body: str, tags=(), *, created: date) -> Note:
        """Write a new note stamped with `created`. Never overwrites an existing note."""
        title = title.strip()
        if not 1 <= len(title) <= MAX_TITLE_LENGTH:
            raise InvalidNote(f"The title must be 1 to {MAX_TITLE_LENGTH} characters.")
        if not body.strip() or len(body) > MAX_BODY_LENGTH:
            raise InvalidNote(f"The body must be 1 to {MAX_BODY_LENGTH:,} characters.")
        note_id = slugify(title)
        if not note_id:
            raise InvalidNote("The title must contain at least one letter or digit.")
        path = self._path(note_id)
        if path.exists():
            raise NoteExists(f"A note with id {note_id!r} already exists.")
        clean_tags = [slugify(t) for t in tags if slugify(t)]
        header = f"---\ntitle: {title}\ntags: [{', '.join(clean_tags)}]\ncreated: {created.isoformat()}\n---\n"
        path.write_text(header + body.strip() + "\n", encoding="utf-8")
        return self.get(note_id)

Code walkthrough

  • create writes a new note, never overwriting; this lesson does not call it.

1. The same capability as an MCP server (Essential)

Lesson 2's tool worked for one program only. Behind a server, all four of Nare's applications can use it without new glue for each.

LESSON 2: INSIDE ONE APP NOW: A SERVER your application schema dispatcher NoteStore app 1 app 2 notes server: search_notes NoteStore
In Lesson 2 the schema and dispatcher lived inside one application (red); as a server, the same tool is offered to any application that connects (green).

On the left is Lesson 2: the application holds a hand-written schema and its own dispatcher. On the right, both move into a server. The application keeps its model and interface, and asks the server what it offers.

YOUR FUNCTION TOOL DEFINITION def search_notes name: search_notes docstring description query: str, limit: int = 5 inputSchema -> list[dict] outputSchema
The SDK builds the tool definition from the function itself: its name, docstring, type hints and return type.

A tool is a function on a server that a model may ask to run. With the Python SDK you write an ordinary function and add one decorator. The SDK publishes the function's name, docstring and type hints as the tool's definition, so the description cannot drift away from the code.

python
%%writefile notes_server.py
import logging, os, sys
from mcp.server import MCPServer
from notes_assistant.store import NoteStore

log = logging.getLogger("notes_server")
log.propagate = False                      # this server's log lines go only where main() sends them
NOTES_DIR = os.environ.get("NOTES_DIR")
if not NOTES_DIR:
    sys.exit("notes server: NOTES_DIR is not set, so there is no notes folder to serve")
store = NoteStore(NOTES_DIR)
mcp = MCPServer("notes", instructions="Search Nare's research notes.")

@mcp.tool()
def search_notes(query: str, limit: int = 5) -> list[dict]:
    """Search Nare's research notes by keyword and return the best matching notes."""
    log.info("search_notes query=%r limit=%d", query, limit)
    hits = store.search(query, limit=limit)
    return [{"note_id": h.note_id, "title": h.title, "score": h.score, "snippet": h.snippet} for h in hits]

if __name__ == "__main__":
    handler = logging.StreamHandler(sys.stderr)          # logs to stderr, never stdout
    handler.setFormatter(logging.Formatter("%(name)s: %(message)s"))
    log.addHandler(handler)
    log.setLevel(logging.INFO)
    mcp.run()                                            # stdio by default

Code walkthrough

  • NOTES_DIR comes from an environment variable, the way a host passes settings to a server it starts. Without it the server exits with a clear message.
  • MCPServer("notes", instructions=...) creates the server. @mcp.tool() publishes search_notes, whose body is one call to Lesson 2's store.
  • log.info(...) records each query. log.propagate = False keeps those lines out of the notebook when the server runs in memory.
  • The __main__ block sends log lines to stderr, because stdout is reserved for protocol messages, then mcp.run() serves over standard input and output.
GENERATED INPUTSCHEMA FOR SEARCH_NOTES query type: string required limit type: integer default 5, optional written by hand in Lesson 2; generated from type hints here
The generated input schema: query is a required string (green) and limit an optional integer with default 5 (grey).

Now ask the server what it offers, the way any client would.

python
import os
NOTES_DIR = str(Path("notes").resolve())
os.environ["NOTES_DIR"] = NOTES_DIR
root = Path(NOTES_DIR); root.mkdir(exist_ok=True)
for note_id, text in NOTES.items():
    (root / f"{note_id}.md").write_text(text, encoding="utf-8")

import notes_server
from mcp import Client

async with Client(notes_server.mcp) as client:
    print("protocol", client.protocol_version, "| server", client.server_info.name)
    tool = (await client.list_tools()).tools[0]
print(tool.name, "|", tool.description)
print("input:", tool.input_schema["properties"], "required:", tool.input_schema["required"])

Code walkthrough

  • The first lines write the notes to notes/ and set NOTES_DIR in the notebook, so import notes_server finds a folder.
  • Client(notes_server.mcp) connects to the server object. async with and await work at the top of a Colab or Jupyter cell.
  • list_tools() is one protocol request. The output was protocol 2026-07-28 | server notes, the docstring, then query as a required string and limit as an integer with default 5: Lesson 2's hand-written schema, now generated.

2. A client, in memory and over stdio (Essential)

A server does nothing until something connects, and how you connect decides whether you are testing quickly or running it for real.

Client( ... ) server object in memory StdioServerParameters child process, stdio a URL Streamable HTTP Module 2
One Client class, three connections, chosen by what you pass: the server object, a program to start, or a URL (grey: Module 2).

An MCP client connects to one server, asks what it offers and calls it. The SDK's Client picks the connection from what you give it. The URL form, Streamable HTTP, is Module 2's subject.

ONE PYTHON PROCESS: THE NOTEBOOK Client notes server NoteStore no launch, no port, no bytes on a wire
In memory, client and server share one Python process: nothing to launch and nothing on a wire, which suits tests.

In memory means client and server share one Python process: in the SDK's words, "no subprocess, no port, no bytes on a wire". Right for tests, wrong for proving a deployment works.

python
async with Client(notes_server.mcp) as client:
    result = await client.call_tool("search_notes", {"query": "nap study", "limit": 2})

print("is_error:", result.is_error)
for hit in result.structured_content["result"]:
    print(hit["score"], hit["note_id"], "|", hit["snippet"])
print("text for the model starts:", result.content[0].text[:40].replace("\n", " "))

Code walkthrough

  • call_tool sends the tool name and arguments. structured_content is the result as JSON for your code (a list is wrapped under result); content is the same result as text for a model.
  • The output: is_error: False, then 2 lab-sync-2026-09-02 | Attendees: Priya, Tomas, Nare. and 2 sleep-and-memory | Slow-wave sleep replays..., the tie broken by id.
LAB-SYNC-2026-09-02, BODY Attendees: Priya, Tomas, Nare. Decision: 24 participants instead of 16 Action: Tomas books the sleep lab Action: Nare drafts the consent form Risk: EEG caps are shared snippet in result line 2 never arrives
The result carries only each note's first line (green), so the decision about 24 participants on line 2 (grey) does not reach the caller.

The anchor note's snippet is "Attendees: Priya, Tomas, Nare." The decision is on line 2, so the result alone cannot answer Nare. Module 4 lets the model open the whole note.

notebook client, parent notes_server.py child process stdin: requests stdout: replies server.log stderr
Over stdio, requests go in on stdin and replies come back on stdout, while stderr carries the server's logs to server.log (grey: background).

stdio is the other way: the client starts the server as a child process, writes messages to its standard input and reads replies from its standard output. The third stream, stderr, carries logs. Desktop applications run local servers this way.

python
import sys
from mcp import StdioServerParameters
from mcp.client.stdio import stdio_client

params = StdioServerParameters(command=sys.executable, args=["notes_server.py"],
                               env={"NOTES_DIR": NOTES_DIR})
with open("server.log", "w") as log:
    async with Client(stdio_client(params, errlog=log)) as client:
        result = await client.call_tool("search_notes", {"query": "nap study", "limit": 2})
print("stdio:", [h["note_id"] for h in result.structured_content["result"]])
print("server.log:", open("server.log").read().strip())

Code walkthrough

  • StdioServerParameters says how to start the server: this notebook's Python, the file, and env, the settings the child receives (section 3 shows why).
  • stdio_client(params, errlog=log) starts the child and sends its stderr to server.log, the fix from the real case.
  • The output was the same two note ids and server.log: notes_server: search_notes query='nap study' limit=2.
MEASURED, LOG SCALE (MS) connect, in memory 0.6 ms connect, stdio 208 ms per call, in memory 0.38 ms per call, stdio 0.93 ms
Measured in the course's run: stdio took 208 ms to start and 0.93 ms per call; in memory, 0.6 ms and 0.38 ms. The cost of stdio is the launch.

Same answer, different cost. The next cell measures both.

python
import time

async def time_calls(target, n=20):
    t0 = time.perf_counter()
    async with Client(target) as client:
        t1 = time.perf_counter()
        for _ in range(n):
            await client.call_tool("search_notes", {"query": "nap study"})
        t2 = time.perf_counter()
    return (t1 - t0) * 1000, (t2 - t1) * 1000 / n

with open("server.log", "w") as log:
    for name, target in [("in memory", notes_server.mcp), ("stdio", stdio_client(params, errlog=log))]:
        start_ms, call_ms = await time_calls(target)
        print(f"{name:>9}: connect {start_ms:6.1f} ms, then {call_ms:.2f} ms per call")

Code walkthrough

  • time_calls times opening the connection, then averages 20 calls, using time.perf_counter(), a clock for short intervals.
  • In the course's run (a laptop, not Colab), in memory took 0.6 ms to connect and 0.38 ms per call; stdio took 208 ms to connect, which includes starting a new Python process, and 0.93 ms per call. Your numbers will differ; the gap will not.

3. Diagnosing a broken stdio launch (Recommended)

The first stdio failure most people meet is a server that dies before it says anything, leaving the client with a vague error.

NOTEBOOK ENVIRONMENT CHILD ENVIRONMENT HOME, PATH, USER ... NOTES_DIR (notebook) everything else allow-list six names HOME, PATH, USER ... NOTES_DIR from env= env={...}
A stdio child receives the allow-listed variables plus whatever you pass in env; the notebook's own NOTES_DIR is dropped (red).

The SDK's documentation says it plainly: "The child does not inherit your environment." It gets a short allow-list (on Linux and macOS: HOME, LOGNAME, PATH, SHELL, TERM, USER) plus whatever you pass in env.

Break it: launch without env

python
broken = StdioServerParameters(command=sys.executable, args=["notes_server.py"])   # no env=
try:
    with open("server.log", "w") as log:
        async with Client(stdio_client(broken, errlog=log)) as client:
            await client.list_tools()
except Exception as exc:
    while isinstance(exc, BaseExceptionGroup):      # unwrap to the first real error
        exc = exc.exceptions[0]
    print("client says:", type(exc).__name__, exc)
print("server.log says:", open("server.log").read().strip())

Code walkthrough

  • broken leaves out env, as a host configuration does when it forgets a setting. The while loop unwraps the exception groups the SDK raises.
  • The output was client says: MCPError Connection closed and server.log says: notes server: NOTES_DIR is not set, so there is no notes folder to serve.
client child server start notes_server.py exits: no NOTES_DIR server.log Connection closed
The child exits before answering: the client reports only "Connection closed", and the real reason is in server.log.

The client only knows the other end hung up. The rule for every stdio problem: read the server's stderr first. The rohlik-mcp project met the host-side version: npx worked in the user's terminal, but Claude Desktop reported "spawn npx ENOENT" until the configuration used an absolute path (rohlik-mcp issue #6, 2025).

Break it: no usable stderr

python
import io

try:
    async with Client(stdio_client(params, errlog=io.StringIO())) as client:
        await client.list_tools()
except Exception as exc:
    while isinstance(exc, BaseExceptionGroup):
        exc = exc.exceptions[0]
    print(type(exc).__name__, exc)

Code walkthrough

  • io.StringIO() is a text stream in memory with no operating-system file descriptor, like the notebook streams in the real case.
  • The output was UnsupportedOperation fileno, the error from issue #1103. In mcp 2.3.0 the default is still errlog=sys.stderr (checked October 2026), so keep passing a file.
A STDIO SERVER WILL NOT START first, always: read the server's stderr 1. command on PATH or absolute? 2. arguments file exists there? 3. environment all settings in env? 4. stderr going to a file?
Check a failing stdio launch in this order: command, arguments, environment, stderr, after reading what the server wrote to stderr.

When a stdio server will not start, read its stderr, then check four things in order. The Agentic AI Engineer course's MCP module also breaks a server with one print() on stdout; Module 2 here does too.

4. Hosts, clients and servers (Essential)

MCP names a third part besides client and server, and knowing who owns what tells you where every later safeguard goes.

HOST: NARE'S ASSISTANT model conversation client 1 notes server client 2 calendar client 3 references consent the user decides
The host holds the model (amber), the conversation and the user's consent, and runs exactly one client per server.

The host is the application the user runs: a desktop assistant, an editor, or the lab assistant you build in Module 6. It holds the model, the conversation and the user's consent. Inside it, each client talks to exactly one server, "a 1:1 relationship" in the specification's words.

host notes server calendar: crashed cannot see each other one client lost still working
Servers cannot see each other or the conversation; anything that crosses between them goes through the host, and a crash costs one client (red).

Why one client per server? Isolation. The specification says servers "should not be able to read the whole conversation, nor 'see into' other servers", and cross-server interactions "are controlled by the host". The Agentic AI Engineer course shows the same roles on its returns desk; this course goes deeper.

THE HOST HOLDS THE SERVER SAW "What did we decide about the nap study?" (39 chars) conversation: 2 messages query='nap study' limit=1
The host kept Nare's 39-character question and the conversation; the server's log shows it received only query and limit (green).

Test that on the notes server: the host holds Nare's question, and the server's log shows what reached it.

python
question = "What did we decide about the nap study?"
conversation = [{"role": "user", "content": question}]          # stays in the host
args = {"query": "nap study", "limit": 1}                          # chosen by a fixed rule here; by a model in Module 6
with open("server.log", "w") as log:
    async with Client(stdio_client(params, errlog=log)) as client:
        result = await client.call_tool("search_notes", args)
conversation.append({"role": "tool", "content": result.content[0].text})
print("host holds", len(conversation), "messages; the question is", len(question), "characters")
print("server saw:", open("server.log").read().strip())

Code walkthrough

  • conversation is the host's own record and never goes to the server. args is all the client sends; fixed here so the run repeats.
  • The output was host holds 2 messages; the question is 39 characters and server saw: notes_server: search_notes query='nap study' limit=1.

5. JSON-RPC 2.0 as the foundation (Essential)

Every MCP message is JSON-RPC 2.0, a small standard for calling a method with parameters in JSON. Its three message kinds let you read any MCP log, in any language.

request id + method one reply expected response same id result or error notification method, no id no reply
JSON-RPC has three message kinds: a request expects exactly one reply, a response answers it by id, a notification (grey) gets none.

A request has an id and a method and expects one reply. A response copies the id and carries a result or an error. A notification has no id and gets no reply. MCP adds that an id "MUST NOT be null".

id: 7 method: tools/call name, arguments: query 'nap study' _META, REQUIRED protocolVersion: 2026-07-28 clientCapabilities: {} reply id: 7
Every 2026-07-28 request carries its own protocol version and client capabilities in _meta (green); the reply echoes id 7.

Under revision 2026-07-28 every request also carries the _meta envelope: protocol version and client capabilities, both required, so no request depends on an earlier one (Lesson 4). Lesson 1 sent two requests by hand; this cell talks to the real server with no SDK client.

python
import json, subprocess

META = {"io.modelcontextprotocol/protocolVersion": "2026-07-28",
        "io.modelcontextprotocol/clientCapabilities": {}}
proc = subprocess.Popen([sys.executable, "notes_server.py"], text=True, stdin=subprocess.PIPE,
                        stdout=subprocess.PIPE, stderr=open("raw.log", "w"), env={**os.environ, "NOTES_DIR": NOTES_DIR})

def send(message):
    proc.stdin.write(json.dumps(message) + "\n")      # one message per line
    proc.stdin.flush()
    if "id" in message:                               # only requests get a reply
        return json.loads(proc.stdout.readline())

call = {"name": "search_notes", "arguments": {"query": "nap study", "limit": 1}, "_meta": META}
reply = send({"jsonrpc": "2.0", "id": 7, "method": "tools/call", "params": call})
print(reply["id"], reply["result"]["structuredContent"]["result"][0]["note_id"])

Code walkthrough

  • subprocess.Popen starts the server with three pipes. send writes one message per line, the stdio rule, and reads a reply only when the message has an id.
  • The output was 7 lab-sync-2026-09-02: the reply carried the request's id, which is how clients match replies to requests.
PROTOCOL ERRORS TOOL ERROR notes/delete -32601 not found no _meta -32602 invalid delete_notes result, isError JSON-RPC error object a model can read it
Protocol mistakes come back as JSON-RPC error codes (red); a failing tool returns a normal result marked isError, which a model can read.

Errors come in two layers: a protocol error is a JSON-RPC error with a code; a failing tool returns a normal result marked isError, which a model can read.

python
print(send({"jsonrpc": "2.0", "id": 8, "method": "notes/delete", "params": {"_meta": META}})["error"])
print(send({"jsonrpc": "2.0", "id": 9, "method": "tools/list", "params": {}})["error"]["code"])
bad_tool = {"name": "delete_notes", "arguments": {}, "_meta": META}
print(send({"jsonrpc": "2.0", "id": 10, "method": "tools/call", "params": bad_tool})["result"]["isError"])
send({"jsonrpc": "2.0", "method": "notifications/cancelled", "params": {"requestId": 7}})
proc.stdin.close()
print("server exited with", proc.wait(timeout=5))

Code walkthrough

  • notes/delete is not an MCP method: error -32601, "Method not found". Leaving out _meta gives -32602, "Invalid params".
  • delete_notes is not a tool: a normal result with isError True. The notification has no id, so nothing is read back.
  • Closing stdin is the specification's shutdown signal. The output ended server exited with 0.

6. Other MCP hosts: desktop apps, editors and coding agents (Optional)

Your server runs unchanged in any host that is told how to start it.

HOST WHERE KEY Claude Desktop claude_desktop_config.json mcpServers Cursor .cursor/mcp.json mcpServers VS Code .vscode/mcp.json servers Claude Code claude mcp add, .mcp.json mcpServers each entry: command, args, env
Four hosts, four formats (checked October 2026), the same three facts in each entry: command, arguments, environment.

Claude Desktop and Cursor use a top-level mcpServers key, VS Code uses servers in .vscode/mcp.json, and Claude Code adds servers with claude mcp add. The Claude Desktop entry, with the absolute paths its documentation requires (not run here, checked against "Connect to local MCP servers", October 2026):

json
{
  "mcpServers": {
    "notes": {
      "command": "/absolute/path/to/notes-assistant/.venv/bin/python",
      "args": ["/absolute/path/to/notes-assistant/notes_server.py"],
      "env": {"NOTES_DIR": "/absolute/path/to/notes-assistant/notes"}
    }
  }
}

Code walkthrough

  • command is absolute because a host does not use your terminal's PATH; env passes NOTES_DIR. On macOS the server's stderr lands in ~/Library/Logs/Claude/mcp-server-notes.log.

Host configuration changes often; check each host's page before copying a format.

notes_server.py desktop app code editor coding agent lab assistant each starts its own copy over stdio
One notes_server.py serves all four of Nare's applications; each host starts its own copy over stdio.

In production

The specification says the client shuts it down by closing its input, waiting, then forcing termination if needed, and a server "SHOULD exit promptly" when its input closes; yours exited with 0. If a server dies unexpectedly the client "SHOULD restart it", and because each request stands alone, a lost request can be retried. Stdio suits one user on one machine; servers shared with Priya and Tomas use HTTP (Module 2) with authorisation (Module 7).

close stdin wait exit code 0 force: SIGTERM SIGKILL crashed? restart
Shutdown: close stdin and wait (the notes server exited with 0); force termination (grey) only if it does not exit, and restart a server that crashed.

What you added to the notes assistant

  • notes_server.py: one search_notes tool over NoteStore, configured by NOTES_DIR, logging to stderr. The lab turns it into examples/m01_first_server.py.
  • A client that reaches it in memory for tests and over stdio as a real process.
  • A launch checklist, and the habit of reading the server's stderr first.

Check yourself

  1. A stdio launch prints MCPError: Connection closed. What do you read first?

The server's stderr (here server.log). The client only learns that the other end hung up; the server writes its reason to stderr.

  1. The notebook sets NOTES_DIR and the in-memory client works, but stdio fails. Why?

A stdio child gets only the allow-listed variables plus env. In memory, the server runs inside the notebook and sees its environment.

  1. Which get a reply: a request with id 7, a notification, a request for notes/delete?

Both requests (the second with error -32601). The notification gets none.

  1. A host uses the notes and calendar servers. How many clients, and can one server see the other's results?

Two clients. No: the host controls anything that crosses between servers.

Exercise

Write launch_report(params): try to start a server and list its tools; return ("ok", [tool names]), or ("failed", last line of the server's stderr). Try it on params and on a launch whose NOTES_DIR does not exist.

python
async def launch_report(p):
    with open("launch.log", "w") as log:
        try:
            async with Client(stdio_client(p, errlog=log)) as client:
                return "ok", [t.name for t in (await client.list_tools()).tools]
        except Exception:
            pass
    lines = open("launch.log").read().strip().splitlines()
    return "failed", lines[-1] if lines else "(no stderr)"

Code walkthrough

  • The log closes before its last line is read; on any failure the function returns the server's own explanation.
python
missing = StdioServerParameters(command=sys.executable, args=["notes_server.py"],
                                env={"NOTES_DIR": "/no/such/folder"})
good, bad = await launch_report(params), await launch_report(missing)
print(good)
print(bad)
assert good == ("ok", ["search_notes"])
assert bad[0] == "failed" and "/no/such/folder" in bad[1]
print("check passed")

Code walkthrough

  • missing points at a folder that does not exist, so NoteStore raises as the server starts. The last stderr line was notes_assistant.store.NoteError: Notes folder not found: /no/such/folder.

Key terms

TermMeaning
MCP serverA program that offers tools to any MCP client
ToolA server function a model may ask to run, with a name, description and JSON Schema
MCP clientThe connector in a host that talks to one server
HostThe application the user runs; holds the model, conversation and consent
In memoryClient and server in one process; for tests
stdioThe client starts the server as a child and exchanges one JSON message per line
Allow-listThe few environment variables a stdio child inherits
JSON-RPC 2.0Requests, responses and notifications in JSON
_meta envelopeProtocol version and client capabilities on every 2026-07-28 request

Sources

  • kdheeraz and others, python-sdk issue #1103, "MCP client not able to initiate StdioServerParameters while running in Jupyter Notebook", 2025 to 2026. https://github.com/modelcontextprotocol/python-sdk/issues/1103
  • rohlik-mcp issue #6, "MCP server fails to spawn npx (ENOENT) inside Claude Desktop", 2025. https://github.com/tomaspavlin/rohlik-mcp/issues/6
  • MCP specification 2026-07-28, "Architecture". https://modelcontextprotocol.io/specification/2026-07-28/architecture
  • MCP specification 2026-07-28, "Overview" (messages, _meta). https://modelcontextprotocol.io/specification/2026-07-28/basic
  • MCP specification 2026-07-28, "stdio". https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/stdio
  • MCP Python SDK, "Client transports" and "Running a server", checked October 2026. https://py.sdk.modelcontextprotocol.io/client/transports/ and https://py.sdk.modelcontextprotocol.io/run/
  • MCP, "Connect to local MCP servers", checked October 2026. https://modelcontextprotocol.io/docs/develop/connect-local-servers
  • VS Code, "Add and manage MCP servers", 2026. https://code.visualstudio.com/docs/copilot/customization/mcp-servers
  • Cursor, "Model Context Protocol", checked October 2026. https://cursor.com/docs/context/mcp
  • Anthropic, "Connect Claude Code to tools via MCP", checked October 2026. https://code.claude.com/docs/en/mcp