Lesson 3: A first MCP server and client, in memory and as a process
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.
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
NoteStoreinto 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.
%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.
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
NOTESmaps 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.
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__.pyfile is a package Python can import; the store goes inside it.
%%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
%%writefilesaves 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.
%%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_idCode walkthrough
-aappends.Note,SearchHit,slugifyandvalidate_note_idare unchanged from Lesson 2;snippet()returns only the first body line, which matters below.
%%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
_parsesplits a file into frontmatter and body;NoteStore(root)fails at once if the folder is missing.
%%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
searchscores each query word 1 per body match, 3 per title match and 2 per tag, and breaks ties by id.
%%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
createwrites 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.
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.
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.
%%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 defaultCode walkthrough
NOTES_DIRcomes 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()publishessearch_notes, whose body is one call to Lesson 2's store.log.info(...)records each query.log.propagate = Falsekeeps 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, thenmcp.run()serves over standard input and output.
Now ask the server what it offers, the way any client would.
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 setNOTES_DIRin the notebook, soimport notes_serverfinds a folder. Client(notes_server.mcp)connects to the server object.async withandawaitwork at the top of a Colab or Jupyter cell.list_tools()is one protocol request. The output wasprotocol 2026-07-28 | server notes, the docstring, thenqueryas a required string andlimitas 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.
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.
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.
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_toolsends the tool name and arguments.structured_contentis the result as JSON for your code (a list is wrapped underresult);contentis the same result as text for a model.- The output:
is_error: False, then2 lab-sync-2026-09-02 | Attendees: Priya, Tomas, Nare.and2 sleep-and-memory | Slow-wave sleep replays..., the tie broken by id.
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.
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.
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
StdioServerParameterssays how to start the server: this notebook's Python, the file, andenv, the settings the child receives (section 3 shows why).stdio_client(params, errlog=log)starts the child and sends its stderr toserver.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.
Same answer, different cost. The next cell measures both.
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_callstimes opening the connection, then averages 20 calls, usingtime.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.
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
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
brokenleaves outenv, as a host configuration does when it forgets a setting. Thewhileloop unwraps the exception groups the SDK raises.- The output was
client says: MCPError Connection closedandserver.log says: notes server: NOTES_DIR is not set, so there is no notes folder to serve.
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
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 stillerrlog=sys.stderr(checked October 2026), so keep passing a file.
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.
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.
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.
Test that on the notes server: the host holds Nare's question, and the server's log shows what reached it.
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
conversationis the host's own record and never goes to the server.argsis all the client sends; fixed here so the run repeats.- The output was
host holds 2 messages; the question is 39 charactersandserver 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.
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".
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.
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.Popenstarts the server with three pipes.sendwrites one message per line, the stdio rule, and reads a reply only when the message has anid.- The output was
7 lab-sync-2026-09-02: the reply carried the request'sid, which is how clients match replies to requests.
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.
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/deleteis not an MCP method: error-32601, "Method not found". Leaving out_metagives-32602, "Invalid params".delete_notesis not a tool: a normal result withisErrorTrue. The notification has noid, 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.
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):
{
"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
commandis absolute because a host does not use your terminal'sPATH;envpassesNOTES_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.
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).
What you added to the notes assistant
notes_server.py: onesearch_notestool overNoteStore, configured byNOTES_DIR, logging to stderr. The lab turns it intoexamples/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
- 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.
- The notebook sets
NOTES_DIRand 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.
- Which get a reply: a request with
id7, a notification, a request fornotes/delete?
Both requests (the second with error -32601). The notification gets none.
- 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.
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.
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
missingpoints at a folder that does not exist, soNoteStoreraises as the server starts. The last stderr line wasnotes_assistant.store.NoteError: Notes folder not found: /no/such/folder.
Key terms
| Term | Meaning |
|---|---|
| MCP server | A program that offers tools to any MCP client |
| Tool | A server function a model may ask to run, with a name, description and JSON Schema |
| MCP client | The connector in a host that talks to one server |
| Host | The application the user runs; holds the model, conversation and consent |
| In memory | Client and server in one process; for tests |
| stdio | The client starts the server as a child and exchanges one JSON message per line |
| Allow-list | The few environment variables a stdio child inherits |
| JSON-RPC 2.0 | Requests, responses and notifications in JSON |
_meta envelope | Protocol 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