DS 6042 · Lab 06 · 2026

microMCP — guided walkthrough

A 140-line MCP server. One file. Stdlib only. The whole protocol on one screen.

MCP is the standard Anthropic shipped in late 2024 to give LLMs a uniform way to call external tools — files, databases, APIs, IDE actions. Within a year every major host (Claude Desktop, OpenAI Apps, Continue, Cursor, LMStudio) adopted it. That broad adoption is also why MCP attacks matter: a tool surface compromised at the server level is a tool surface compromised on every host that connects to it.

The protocol is smaller than it sounds: JSON-RPC 2.0 over some transport, three core methods, a tool catalog. That's it. The interesting parts are the trust assumptions the protocol bakes in — the host trusts the server's tool descriptions, the server trusts whatever the host sends as arguments, and both trust each other to render content safely. Read the microMCP code below and you will see, in 140 lines, exactly where each of those trust assumptions lives. The next lab attacks all three.

protocol spec: modelcontextprotocol.io/specification · transport reference: JSON-RPC 2.0 · companion walkthrough: Lab 04 · microagent · production server and attacks: Lab 07 · Attacking MCP · MITRE ATLAS coverage: T0010.005 (Supply Chain), T0051.001 (Indirect Injection), T0085 (Data from AI Services).
important · inline questions

Short questions appear in boxes like this one throughout the lab. They mirror the kind of question that will show up on the midterm and the final, so answer them in your head before clicking Show answer. There are six in total.

Where to find it

To follow along on Rivanna, pull the whole folder with one curl and an unzip — or grab the ↓ lab-06-code.zip directly:

mkdir -p lab06 && cd lab06
BASE=https://researcher111.github.io/ML-Security-Public/labs/lab-06
curl -sO "$BASE/lab-06-code.zip"
unzip -q lab-06-code.zip        # -> server/  client/  microdata/  README.md

python client/microclient.py    # stdlib only, no pip install — opens the REPL

1 · Anatomy of MCP

An MCP deployment has three roles: a host, a server, and an LLM.

  1. The host — the user-facing IDE / chat / agent. Claude Desktop, OpenAI's Apps SDK, Continue, Cursor, LMStudio. The host decides which servers to spawn, presents tool prompts to the user, and routes the LLM's tool-calls.
  2. The server — a process that exposes one or more tools (and optionally resources, prompts, and UI resources). It receives JSON-RPC calls and returns structured results. microMCP is one of these.
  3. The LLM — runs inside the host, reads the tool catalog the server published, decides which tool to call and with what arguments.
the transport boundary stdio (local) · HTTP+SSE (remote) behind the server — the real systems HOST the app you use — Claude Desktop, Cursor… LLM chooses which tool to call MCP Client one per server MCP Client talks JSON-RPC JSON-RPC 2.0 MCP Server microMCP read_file · get_greeting MCP Server another server db_query · run_report Local filesystem microdata/*.md Database External API weather, calendar, payments…

The flow is one-directional in initiative: Host → Client → Server → (the tool runs) → back to the host. The host never speaks JSON-RPC itself — each MCP client is a thin connector (one per server) that wraps the LLM's chosen call into a JSON-RPC request and relays the result; the server only ever responds, never starts the conversation. A host can run many clients and servers at once, but every client is bonded to exactly one server. The piece that matters for security is on the right: an MCP server is a thin adapter in front of a real backend — the filesystem, a database, a third-party API. The tools are just the verbs it exposes over that backend, so compromising a server hands the attacker whatever sits behind it. In this lab the backend is the sandboxed microdata/ folder, the connector is client/microclient.py, and the server is server/micromcp.py; the transport is plain stdio, swapped for HTTP+SSE in the Lab 07 production twin.

microMCP is a toy, but the shape is exactly the shape of the servers people actually wire into Claude Desktop, Cursor, and Continue today. The seven official reference servers in modelcontextprotocol/servers are deliberately small and educational; the ones doing real work in production are company-maintained and listed in the official MCP registry. The column that matters for this course is the last one — read "if this server is compromised, or its tool descriptions are poisoned, the attacker gets…":

ServerMaintained byBackend it frontsBlast radius if compromised
Filesystemofficial referencelocal disk, scoped to configured rootsread/write every file under the allowed roots — the exact path-traversal sandbox this lab is about (CVE-2025-53109/53110)
Gitofficial referencea local repositoryfull history, every branch and diff — including credentials committed by mistake
Fetchofficial referencearbitrary outbound HTTPa server-side request forgery pivot: the server will fetch any URL the LLM names, including internal-only and cloud-metadata endpoints
Memoryofficial referencea persistent knowledge-graph storedurable cross-session memory an attacker can poison once and trigger later — Lab 05's memory-poisoning, MCP-shaped
GitHubGitHub (company)the GitHub API under your tokenevery repo, issue, PR, and Action your token's scope allows — read and write
Slackcompany-maintaineda Slack workspacechannel history and the ability to post messages as you
Postgres / Supabasecommunity + companya SQL database under one roleevery row that role can read; an over-privileged role hands over the whole database — Lab 07 §4's db_query

The reference servers ship as educational examples, not hardened products — Anthropic says so in the repo's own README. That is precisely why reading every line of one (next section) is a security skill: the server you connect on Monday may be a 140-line file just like microMCP, and the only thing standing between an attacker and the backend in that last column is the kind of six-line guard you are about to read.

Think · Pair · Share · who calls the tool
I've done the Think step — reveal Pair & Share
  • Pair · 3 minCompare assignments with your partner. Did one of you put "the user approves the call" where the other put "the host"? Who reads the description — the LLM, the user, or both?
  • Share · 2 minCommit to a 3-row table (role → what it controls). Then check it against the three roles list and the Try it · the three roles exercise just below — note especially who sees the description that the user often doesn't.
Try it · step through a real MCP conversation
The whole MCP protocol fits into three round trips. Press ▶ Step to send the next request and watch the server's reply. Each round trip is one JSON object on stdin / one on stdout — same wire format the real Anthropic SDK uses. The right panel shows the running message log.
0 / 3 round trips
Wire trace · what the transport carries
What's happening

Real MCP runs over either stdio (host spawns the server as a subprocess and pipes JSON on stdin/stdout — microMCP's default) or HTTP+SSE (host POSTs JSON-RPC to a remote endpoint — what the production server in Lab 07 uses). The JSON shapes are identical; only the transport changes.

Two transports are common:

ComponentmicroMCP (this lab)baseline_server (Lab 07)Real production
Transportstdio · 1 fileHTTP + JSON-RPC · FastAPIHTTP + SSE / Streamable HTTP
Tools2 · get_greeting, read_file7 · format, fs, db, sprintdozens · per server
Sandboxresolve + prefix check (correct order)prefix check + resolve (broken order)vendor-dependent
Authnone — same machinenone — single userOAuth, mTLS, API keys
UI renderingn/an/a in this lab · MCP Apps in Lab 07 narrativeiframes via _meta.ui.resourceUri
Code~140 lines~250 linesthousands
All three columns speak the same JSON-RPC shape. microMCP is that shape with everything else stripped; baseline_server is that shape with seven tools and seven planted vulnerabilities; real production servers wrap a vendor SDK around it.
Try it · the three roles

Which of the three MCP roles (host, server, LLM) decides which tool gets called with which arguments? Which decides whether to actually run that call? Which has visibility into the tool's description?

Show answer
The LLM picks the tool and the arguments (based on the catalog the server published and the user's request). The host decides whether to actually dispatch the call (some hosts ask the user to confirm each tool invocation; others auto-approve). All three see the description: the server wrote it, the host shows it to the LLM, and (depending on the host) the user may see it too. That last fact — the LLM sees descriptions the user often doesn't — is exactly what makes description-poisoning so productive in Lab 07.

2 · The microMCP server code

Think · Pair · Share · sketch the 140 lines
I've done the Think step — reveal Pair & Share
  • Pair · 4 minCompare lists. Did one of you assume a decorator or schema-validation library does the wiring? Where does each of you think the description string lives — next to the function, or in a separate catalog?
  • Share · 2 minAgree on a short table of the four parts you expect (data root · tools · catalog · request loop), then read §2.3 to see that the catalog is plain data, fully decoupled from the function it names in DISPATCH.

Open server/micromcp.py in your editor. ↓ download lab-06-code.zip The file has four sections: one data root, two tools, a tool catalog, and the JSON-RPC loop. We walk each one below.

2.1 The data root

Single Path, scoped to the microdata/ folder sitting next to the server. Every file read goes through this root. If a request resolves outside it, the read is refused.

DATA = Path(__file__).resolve().parent.parent / "microdata"

2.2 Two tools

Each tool is a plain Python function. No decorators, no schemas, no MCP-specific magic — the catalog wiring happens later. The first tool is trivial:

def get_greeting(name: str = "world") -> str:
    """Return a deterministic greeting. Useful for testing connectivity."""
    return f"Hello, {name}! This greeting came from micromcp."

The second tool, read_file, is where every byte of the security story lives — because it touches the filesystem. Here is the version almost everyone writes first. It scopes reads to microdata/ with a single, reasonable-looking prefix check:

def read_file(path: str) -> str:                 # ⚠ this version is BROKEN — see 2.2.1
    """Read a UTF-8 file from the microdata/ folder. Path must be relative."""
    p = DATA / path                              # join — but do NOT normalize
    if not str(p).startswith(str(DATA)):         # check the raw, un-resolved string
        return "error: path escapes the sandbox"
    return p.read_text(encoding="utf-8", errors="replace")

It looks airtight: every path is forced under DATA before anything is read. It is not — and the gap is a single missing method call.

Think · Pair · Share · break the guard
I've done the Think step — reveal Pair & Share
  • Pair · 3 minCompare candidate paths with a neighbour. Did you both reach for ..? Whose string actually escapes — and which line were you each attacking, the check or the read?
  • Share · 2 minCommit to one path string, then check it against the trace in 2.2.1 below.

2.2.1 · Break it — the .. escape

An attacker — or an LLM that swallowed a poisoned instruction — calls read_file with a path that climbs out of microdata/ using .. (the parent-directory segment):

read_file("../server/micromcp.py")   # read the server's own source

Trace it through the broken guard, line by line:

  1. JoinDATA / "../server/micromcp.py" is plain concatenation; the .. stays literal:
    /home/you/lab06/microdata/../server/micromcp.py
  2. Check the raw string — does it start with DATA? Yes — the literal microdata/ sits right at the front, so the guard is satisfied and lets the read through:
    "/home/you/lab06/microdata/../server/micromcp.py".startswith("/home/you/lab06/microdata")  →  True
  3. Readp.read_text() opens that path, and now the operating system resolves the .. for real, climbing out of microdata/ and handing back the server's own source.

The escape succeeds. The check and the open disagree about what the path means: the check tested an un-normalized string, but the OS followed the .. at open time. That gap is the whole vulnerability — and it is exactly CVE-2025-53109/53110 in Anthropic's official filesystem MCP server. Lab 07 §3 weaponizes the same gap against the production server.

2.2.2 · Secure it — resolve, then check

The fix is one method call in the right place. .resolve() normalizes the path — collapsing every .. and .before the prefix check, so the string you test is the same path the OS will actually open. This is the version micromcp.py ships:

def read_file(path: str) -> str:
    """Read a UTF-8 file from the microdata/ folder. Path must be relative."""
    p = (DATA / path).resolve()                       # normalize FIRST — .. collapses now
    # The simplest possible sandbox: refuse anything that escapes microdata/.
    if not str(p).startswith(str(DATA.resolve())):
        return "error: path escapes the sandbox"
    if not p.exists() or not p.is_file():
        return f"error: not found: {path}"
    return p.read_text(encoding="utf-8", errors="replace")
the correct order of operations

The only change from the broken version in 2.2 is the order: resolve first (.resolve() collapses the ..), then check whether the result is still under DATA. Now the check tests the same path the OS will open, so the disagreement that powered the escape in 2.2.1 is gone. Reverse the two operations again — raw string first, resolve second — and you have re-introduced CVE-2025-53109/53110 exactly.

Those two lines — (DATA / path).resolve() then str(p).startswith(str(DATA)) — are the entire sandbox. Type a path argument below and watch it run through both steps. The first step normalizes the path (every . and .. is collapsed, absolute paths replace the root); the second is a plain string-prefix test against DATA. A path escapes only if, after resolving, it no longer starts with the data root.

The read_file sandbox, traced. Edit the path or click a preset; each stage updates live.

DATA the one data root
1 DATA / path — raw join, not yet normalized
.resolve() collapses . / .., makes it absolute
2 resolved canonical path
str(p).startswith(str(DATA))
3 prefix check

Step 1 keeps the .. segments literal — the join is just string concatenation. Step .resolve() is where they collapse; only then is the prefix test meaningful. Swap the two steps (check the raw string from step 1, resolve afterward) and ../server/micromcp.py sails through, because the raw join still starts with DATA. That swap is CVE-2025-53109/53110.

Now re-run the same escape from 2.2.1 against this version. ../server/micromcp.py resolves to /home/you/lab06/server/micromcp.py before the check — and that no longer starts with DATA — so the read is refused before the file is ever opened:

python client/microclient.py call read_file path=../server/micromcp.py
error: path escapes the sandbox
how many .. does it take?

Each .. cancels one directory after resolution. ../X lands beside microdata/; ../../X climbs above the lab folder; ../../../../../../etc/passwd walks all the way to the filesystem root and back down to /etc/passwd. The count is just how many levels the attacker climbs — and because the fixed check runs after .resolve(), every one of those lands outside DATA and is refused identically. Paste any of them into the tracer above to confirm, then read §4 to see the production server with the two steps swapped back — the bug that made it a real CVE.

2.3 The tool catalog

This is the data structure the host's LLM will read to decide what to call. Each entry has a name, a description, and an input schema written in JSON-Schema lite.

TOOLS = [
    {
        "name": "get_greeting",
        "description": "Return a friendly greeting. Useful for testing the connection.",
        "inputSchema": {
            "type": "object",
            "properties": {
                "name": {"type": "string", "description": "who to greet"},
            },
            "required": [],
        },
    },
    {
        "name": "read_file",
        "description": "Read a UTF-8 text file from the micromcp knowledge base.",
        "inputSchema": {
            "type": "object",
            "properties": {
                "path": {"type": "string", "description": "file path relative to microdata/"},
            },
            "required": ["path"],
        },
    },
]


DISPATCH = {
    "get_greeting": get_greeting,
    "read_file":    read_file,
}
Try it · where does the LLM see the description?

The description field is just a Python string. At what point in the lifecycle does the LLM actually read it, and through which JSON-RPC method? What does the user see?

Show answer
The host calls tools/list when the server first connects. The server returns the entire TOOLS array — including every description — and the host injects those descriptions into the LLM's system prompt. The user typically sees only the name, sometimes a one-line summary. The description is hidden from the user but read fully by the LLM — which is exactly why Lab 07's first attack hides an instruction in the description.

2.4 The JSON-RPC handlers

Three methods cover the entire MCP-tools surface: initialize, tools/list, tools/call. Each one is a plain function returning a dict.

def handle_initialize(params: dict) -> dict:
    """Tell the client what this server supports."""
    return {
        "protocolVersion": "0.1",
        "capabilities": {"tools": {}},
        "serverInfo": {"name": "micromcp", "version": "0.1.0"},
    }


def handle_tools_list(params: dict) -> dict:
    """Return the tool catalog. The LLM (or client) decides what to call."""
    return {"tools": TOOLS}


def handle_tools_call(params: dict) -> dict:
    """Run one tool. The result becomes an MCP `content` block."""
    name = params.get("name", "")
    args = params.get("arguments", {}) or {}
    fn = DISPATCH.get(name)
    if fn is None:
        return {"isError": True, "content": [{"type": "text", "text": f"unknown tool: {name}"}]}
    try:
        text = fn(**args)
    except TypeError as e:
        return {"isError": True, "content": [{"type": "text", "text": f"bad arguments: {e}"}]}
    return {"content": [{"type": "text", "text": text}]}


METHODS = {
    "initialize": handle_initialize,
    "tools/list": handle_tools_list,
    "tools/call": handle_tools_call,
}

That is the entire protocol surface for MCP-tools. Resources, prompts, and UI add more methods but follow the same JSON-RPC pattern. Lab 07 §5 walks the MCP-Apps UI extension when we attack it.

2.5 The main loop · reading and writing over stdio

The server talks to the host over stdio: it reads requests from standard input and writes replies to standard output — the same two streams a program uses when you run it in a terminal. There are no sockets, ports, or HTTP here; the host simply launches micromcp.py as a child process and pipes JSON to it. So the server's entire job is one loop — read a line, parse it as a JSON-RPC request, dispatch it, write the one-line reply, repeat — and that loop plus the tiny reply() helper is the whole transport, about six lines of real work.

def reply(rid, result=None, error=None) -> None:
    msg = {"jsonrpc": "2.0", "id": rid}
    if error is not None: msg["error"] = error
    else:                 msg["result"] = result
    sys.stdout.write(json.dumps(msg) + "\n")
    sys.stdout.flush()


def main() -> None:
    for line in sys.stdin:
        line = line.strip()
        if not line: continue
        try:                       req = json.loads(line)
        except json.JSONDecodeError as e:
            reply(None, error={"code": -32700, "message": f"parse error: {e}"})
            continue
        rid     = req.get("id")
        method  = req.get("method", "")
        params  = req.get("params", {}) or {}
        handler = METHODS.get(method)
        if handler is None:
            reply(rid, error={"code": -32601, "message": f"unknown method: {method}"})
            continue
        try:                                      reply(rid, result=handler(params))
        except Exception as e:                    reply(rid, error={"code": -32000, "message": f"server error: {e}"})

A line dropped on stdin can end exactly four ways: skipped (blank), rejected as unparseable, rejected for naming an unknown method, or dispatched to a handler that writes a reply. Click an input below to feed it to the loop and watch which branch fires.

One line in on stdin → the main() loop → one line out on stdout. Pick an input; the path it takes through the loop lights up, dimmed steps were never reached.

stdin →
for line in sys.stdin:
line = line.strip()  # if not line: continue
req = json.loads(line)  # JSONDecodeError → -32700
method = req.get("method", "")
handler = METHODS.get(method)  # None → -32601
reply(rid, result=handler(params))  # Exception → -32000
stdout ←

Watch the ../escape case: a path-escape is not a JSON-RPC error. The loop dispatched it fine and read_file returned its refusal as ordinary content. Tool-level failures ride back as results; only malformed lines and unknown methods become protocol-level error objects. That distinction — in-band tool error vs. out-of-band protocol error — is one an attacker reads carefully.

that's the entire protocol

140 lines. No dependencies. Two tools. The Anthropic Python SDK, FastMCP, MCP-Inspector — they all wrap the same JSON-RPC shape with more ergonomics. Reading this file once is enough to recognize what those wrappers are doing under the hood, and where the attack surfaces live.

3 · A tiny MCP client

The client side is even smaller. Spawn the server as a subprocess, write one JSON object per request, read one back. The convenience wrappers (initialize(), tools_list(), tools_call()) match the names you'll see in the official SDKs.

class MCPClient:
    def __init__(self, server_path: Path = SERVER):
        self.proc = subprocess.Popen(
            [sys.executable, str(server_path)],
            stdin=subprocess.PIPE, stdout=subprocess.PIPE,
            stderr=subprocess.PIPE, text=True, bufsize=1,
        )
        self._ids = itertools.count(1)

    def request(self, method: str, params: dict | None = None) -> dict:
        rid = next(self._ids)
        msg = {"jsonrpc": "2.0", "id": rid, "method": method, "params": params or {}}
        self.proc.stdin.write(json.dumps(msg) + "\n")
        self.proc.stdin.flush()
        return json.loads(self.proc.stdout.readline())

    def initialize(self) -> dict:               return self.request("initialize")
    def tools_list(self) -> list[dict]:         return self.request("tools/list")["result"]["tools"]
    def tools_call(self, tool_name: str, /, **kwargs) -> str:
        result = self.request("tools/call", {"name": tool_name, "arguments": kwargs})["result"]
        return result["content"][0]["text"]
what's itertools.count(1)?

itertools.count(1) is a stdlib infinite counter — an iterator that yields 1, 2, 3, … forever, one value at a time. Each next(self._ids) in request() pulls the next integer, so every request gets a fresh, monotonically increasing id without a manual self._id += 1. JSON-RPC uses that id to match each response back to the request that asked for it; since our client sends one request and blocks for its reply, any unique-and-increasing value works — count is just the tidiest way to mint them.

The client's whole job, traced across the pipe: turn one REPL command into a JSON-RPC request, push it down stdin, and unwrap the one line that comes back on stdout. Type a command (or pick a preset) and press Run — the round trip steps left → right: microclient.py builds the request, the pipe carries it, micromcp.py dispatches and replies, and the client unwraps the result.

mcp>
client · microclient.py
1 · parse command
2 · build request
7 · unwrap result
printed to you
3 · stdin →
6 · ← stdout
server · micromcp.py
4 · dispatch
5 · result object

The same shape you stepped through in the Try-it demo, now split across the boundary that matters for security: everything on the right runs in the server's trust domain. The client chooses the method and arguments; the server decides what to do with them. Run the ../escape case — the sandbox refusal rides back as ordinary content, so from the client's side a blocked attack and a successful read look almost identical on the wire.

3.1 Run it

$ cd Class/labs/lab-06
$ python client/microclient.py
connected: {'name': 'micromcp', 'version': '0.1.0'}

commands: list | call NAME [key=value]... | quit

mcp> list
  get_greeting    Return a friendly greeting. Useful for testing the connection.
  read_file       Read a UTF-8 text file from the micromcp knowledge base.
mcp> call get_greeting name=alice
Hello, alice! This greeting came from micromcp.
mcp> call read_file path=password_policy.md
# Password Reset Policy

Visit https://password.megacorpone.local to reset your password.
...
mcp> call read_file path=../server/micromcp.py
error: path escapes the sandbox

The last line is the sandbox doing its job. The escape attempt resolved to a path outside microdata/; the post-resolve check refused it.

Try it · break the sandbox

Without modifying the server, can you craft a path argument that escapes the sandbox? (Try absolute paths, symlinks, weird Unicode.) If none works, what one-line change to read_file would re-introduce the bug?

Show answer
You can't — (DATA / path).resolve() followed by a string-prefix check on the resolved result catches every traversal. The one-line break is to swap the order: check the raw path argument before resolving. That's the production bug demonstrated in Lab 07 §3 and assigned CVE-2025-53109 / 53110 in Anthropic's official filesystem server.

4 · Scaling up · the production server ~40 min

baseline_server.py is the MCP backend for a fictional company's internal AI assistant — call it MegaCorpAI. The idea is the one MCP exists for: an employee chats with an LLM host ("summarize last sprint", "what's our password policy", "look up customer 42"), and the host calls this server's tools to do the real work. Everything the assistant can do is one of its seven tools, which fall into four jobs:

None of that is contrived — it is exactly the shape of a real internal tool server. The lab's point is that every one of those legitimate capabilities trusts its inputs, and §4.1–4.3 walk what happens when that trust is misplaced. Keep the business purpose of each tool in mind as you read: the vulnerability is always the gap between what the tool is for and what its code will actually do.

Think · Pair · Share · where bugs enter at scale
I've done the Think step — reveal Pair & Share
  • Pair · 4 minCompare picks. Did someone choose the description-from-a-file (an attacker who can write that file ships the LLM a hidden instruction)? Did anyone catch that going from stdio to network-HTTP turns a single-user server into a remotely reachable one?
  • Share · 2 minAs a table, map each addition (HTTP · 7 tools · poisonable description) to the trust it newly extends, then check your map against the four marked vulnerabilities below and the four-surfaces callout that closes the section.

Open ../lab-07/server/baseline_server.py in a second editor pane. ↓ download lab-07-code.zip It's ~250 lines and structurally a microMCP with the same JSON-RPC shape, but with three things scaled up:

To pull it onto Rivanna, same curl-and-unzip as §0 — only this server has real dependencies (FastAPI, uvicorn, Jinja2). Install them into a virtual environment so they stay isolated from the shared Python and from your other labs:

mkdir -p lab07 && cd lab07
BASE=https://researcher111.github.io/ML-Security-Public/labs/lab-07
curl -sO "$BASE/lab-07-code.zip"
unzip -q lab-07-code.zip            # -> server/  attacks/  data/  README.md

python3 -m venv .venv               # create an isolated environment in ./.venv
source .venv/bin/activate           # activate it — your prompt now shows (.venv)
pip install -r server/requirements.txt
python server/init_db.py            # build the demo SQLite DB the db_query tool reads

A venv is just a folder holding its own copy of pip and the packages, so pip install can't touch the system Python you share with the class. Run source .venv/bin/activate in every new shell before launching the server; deactivate when you're done. (On Windows it's .venv\Scripts\activate.)

see inside megacorp.db — a SQLite viewer for VS Code

init_db.py writes data/megacorp.db, the SQLite database behind the §4.2 db_query tool. To look at it visually instead of typing sqlite3 queries, install a viewer extension in Code Server (the in-browser VS Code on Open OnDemand) from the Extensions panel:

VS Code Extensions Marketplace with 'sqlite viewer' searched; the top result, 'SQLite Viewer' by Florian Klampfer (identifier qwtel.sqlite-viewer), is selected with an Install button. The alexcvzz 'SQLite' extension appears lower in the list.
In the Extensions panel, search sqlite viewer and install the highlighted top result — SQLite Viewer by Florian Klampfer (qwtel.sqlite-viewer). The blue-feather SQLite by alexcvzz further down is the query-runner alternative.

Open megacorp.db and you'll see the point of §4.2 at a glance: the same connection db_query uses reaches not just customers but customer_pii, api_keys, and financial_records — tables its "read-only customer lookup" description never mentions.

Because the transport is now plain HTTP, you don't need a client at all to poke it — start the server with uvicorn server.baseline_server:app --port 8080 and curl speaks the exact same JSON-RPC shape the §3 client does.

shared box · pick your own port

You're sharing this machine with the rest of the class, so two people can't both bind --port 8080 — whoever starts second just gets ERROR: [Errno 98] Address already in use. Pick a port that's yours (say 8000 + your seat number, anything free in the 1024–65535 range) and use the same number in the uvicorn command and every curl URL. Before you launch, see what's already taken:

ss -tlnp                              # Linux: listening TCP ports + the PID that owns each
# macOS / no ss:  lsof -nP -iTCP -sTCP:LISTEN

Pick a number that does not appear in the Local Address column, then launch on it: uvicorn server.baseline_server:app --port <your-port> (and swap 8080 for <your-port> in the curl commands below).

uvicorn runs in the foreground — it takes over the terminal with request logs and keeps running until you stop it with Ctrl-C. So start it in one terminal and leave it there, then open a second terminal for the curl commands. In your Open OnDemand session that's a new terminal tab (or another SSH session) cd'd into the same lab07/ folder:

# terminal 1 — start the server, leave it running
source .venv/bin/activate
uvicorn server.baseline_server:app --port 8080

# terminal 2 — same lab07/ folder, run the client here
cd lab07

(Activate the venv in terminal 2 as well once you start running the Python attack scripts; plain curl doesn't need it.)

With your server running in terminal 1, list the tools from terminal 2:

curl -s http://localhost:8080/ \
  -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'

…and call one. tools/call still wraps the tool name and its arguments in the same {"name": …, "arguments": …} envelope from §3 — it just rides in an HTTP POST body now instead of a stdin line:

curl -s http://localhost:8080/ \
  -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/call",
       "params":{"name":"read_document",
                 "arguments":{"path":"/data/documents/policies/password_policy.md"}}}'

Same envelope, different pipe. Every attack in Lab 07 is one of these POSTs with a hostile arguments payload — which is exactly why a server reachable over HTTP, by many users at once, is a bigger attack surface than a stdio subprocess only its parent can talk to.

The vulnerabilities are clearly marked with # VULNERABILITY · ... comments. As you read each one, ask yourself the lab-04 question: what does the new piece trust, and what happens if that trust is misplaced? Lab 07 calls each one out explicitly with the code line range and a matching MITRE ATLAS technique.

4.1 The path-traversal vulnerability · two lines

def read_document(path: str) -> str:
    # 1. Prefix check on the RAW string. Vulnerable.
    if not path.startswith(LOGICAL_ROOT + "/"):
        return f"error: path must start with {LOGICAL_ROOT}/"
    # 2. Translate logical → physical, then resolve. By this point the
    #    traversal has slipped past the gate.
    suffix = path[len(LOGICAL_ROOT) + 1:]
    real = (DOCS_ROOT / suffix).resolve()
    # NOTE: no second check that `real` is still under DOCS_ROOT —
    # that re-check is exactly what secure_server.py adds.
    if not real.exists() or not real.is_file():
        return f"error: not found: {path}"
    return real.read_text(encoding="utf-8", errors="replace")

Compare this with microMCP's read_file in §2.2: same six lines, but the prefix check has moved before the resolve. That single swap is enough to read /data/.secrets/credentials.json via the documents sandbox.

worked example · walking one path past the gate

Hand read_document the path /data/documents/../.secrets/credentials.json and trace it through the two lines:

  1. Prefix check (step 1): "/data/documents/../.secrets/…".startswith("/data/documents/")True. The raw string literally begins with the logical root, so the gate opens.
  2. Resolve (step 2): suffix = "../.secrets/credentials.json", then (DOCS_ROOT / suffix).resolve() collapses the .. and climbs out of documents/ to /data/.secrets/credentials.json — a path the prefix check already waved through and nothing re-checks.

Over HTTP that's a single POST (run it against your own server from §4):

curl -s http://localhost:8080/ -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
       "params":{"name":"read_document",
                 "arguments":{"path":"/data/documents/../.secrets/credentials.json"}}}'

…and the documents sandbox returns the production secrets it was supposed to protect:

{ "database": { "username": "api_prod", "password": "Pr0d_DB_S3cret!2025" },
  "stripe":   { "secret_key": "sk_live_megacorp_abc123def456" },
  "aws":      { "access_key_id": "AKIA3MEGACORPAI2026",
                "secret_access_key": "wJalrXUtn/K7MDENG/bPxRfiCYEXAMPLEKEY" } }

The fix is the second check microMCP already has and secure_server.py restores: after .resolve(), confirm the resolved path is still under DOCS_ROOT before opening it.

4.2 The over-privileged database tool

def db_query(sql: str) -> str:
    """Execute one read-only SQL statement against megacorp.db.
    VULNERABILITY: the connection has access to every table, not just the
    one the tool's docstring implies."""
    if not sql.strip().lower().startswith("select"):
        return "error: only SELECT statements are allowed"
    conn = sqlite3.connect(DB_PATH)
    rows = conn.execute(sql).fetchall()
    return "\n".join("  ".join(str(c) for c in r) for r in rows)

The tool's description says "execute a read-only SQL query against the customer database." The tool's connection reaches every table in the schema — including customer_pii, api_keys, and financial_records. Lab 07 §4 walks the four queries that produce a complete credentials dump from a single LLM chat session.

SQLite Viewer open on megacorp.db; the Tables list shows api_keys, customer_pii, customers, and financial_records. The customer_pii table is selected, showing rows with ssn, date_of_birth, and home_address columns.
megacorp.db in the SQLite Viewer extension. The db_query tool advertises a "customer lookup," but the same connection sees every table in the sidebar — here customer_pii with raw ssn, date_of_birth, and home_address, alongside api_keys and financial_records. The over-privilege is visible the moment you open the file.

4.3 The SSTI (Server-Side Template Injection) chain · three tools, one vulnerability

First, what is Jinja2? It is Python's most widely used template engine — a library that builds a finished string (an HTML page, an email, a report) from a template with placeholders. You write the template once with two kinds of markers: {{ ... }} for an expression to evaluate and drop in, and {% ... %} for logic like loops and ifs. Flask and FastAPI use it to render HTML, Ansible uses it for config files, and report generators like the one below use it for documents. The mental model: a template is a fill-in-the-blanks form, and .render(...) fills the blanks.

from jinja2 import Template
Template("Hello {{ name }}, you have {{ count }} open tickets.").render(name="Dana", count=3)
# -> "Hello Dana, you have 3 open tickets."

The safe assumption baked into that design: the template is trusted (you wrote it) and only the values passed to .render() come from outside. SSTI — Server-Side Template Injection — is what happens when attacker-controlled text becomes part of the template string itself instead of being passed in as a value. Now the attacker gets to write their own {{ ... }}, and Jinja will faithfully evaluate it. Because those expressions are real Python, an attacker can climb from a rendered report all the way to os.popen("…") — arbitrary command execution on the server. (It is the templating cousin of SQL injection: the danger is always data that gets promoted into code.) That trapdoor is exactly what the code below leaves open.

def render_report(report_data: str) -> str:
    """VULNERABILITY: this hands report_data straight to Jinja2 with no sandboxing.
    Any {{ ... }} or {% ... %} in the data gets EVALUATED."""
    rule = "=" * 40
    template = Template(
        f"Sprint Report — MegaCorpAI\n{rule}\n{{{{ data }}}}\n\n{rule}\nEnd of Report\n"
    )
    rendered_once = template.render(data=report_data)
    # The vulnerability is the SECOND render: the engine evaluates whatever
    # template syntax survived the first pass — i.e., whatever the attacker
    # stored in a ticket.
    return Template(rendered_once).render()

The bug is that render_report calls .render() twice — look at the two render lines:

  1. Render #1template.render(data=report_data) fills the {{ data }} placeholder with the ticket text. The attacker's {{ … }} is treated as a plain value here, so it is copied in literally: the output string now contains the characters {{ lipsum.__globals__['os'].popen('id').read() }}, sitting in the report as text — not yet evaluated.
  2. Render #2Template(rendered_once).render() takes that output and parses it again as a brand-new template. Now the attacker's {{ … }} — which is just sitting in the text from step 1 — is read as Jinja syntax and evaluated. That is the moment it runs.

So the danger is not either render alone; it is feeding render #1's output back into render #2. update_ticket stored the payload as a plain string (no validation), compile_sprint joined it in (no escaping), and the second render is where attacker data finally gets promoted to code — executing inside the Jinja2 sandbox-that-isn't-a-sandbox. Lab 07 §6 confirms RCE with one ticket.

Watch the double render with your own eyes. Toggle the ticket between a normal note and the attack payload, and follow the same text through render_report's two .render() calls. The whole lesson is the contrast in the last box: render #2 is a no-op for ordinary data and catastrophic for data that itself contains {{ … }}.

report_data — the ticket text an attacker controls

  
render #1 — drop report_data into the report template's {{ data }} slot as a value
rendered_once — the slot is now filled with literal text

    
  
render #2 — parse that entire string again, as if it were a fresh template
③ final output — render #2's result

    
  

The orange text is attacker-controlled. In box ② it is just characters sitting in the report — render #1 copied it in as a value and ran nothing. Box ③ is the second render: for the benign note there is no {{ }} to act on, so the text is unchanged; for the payload, render #2 parses the {{ … }} it finds and replaces it with the output of the command it runs. Same code path, two very different endings — decided entirely by whether the data contained template syntax.

What is RCE? Remote Code Execution is the worst outcome in security: an attacker, over the network, makes a server run commands of their choosing — with the server's own privileges. The SSTI above is one road there. Pick an attacker goal, then press Run to watch one ticket field climb from stored text to a command running on the box.

1 · storedticket text in the DB just data — looks harmless
2 · joinedcompile_sprint concatenated, not escaped
3 · renderedJinja .render() data is now a template — code
4 · reaches osos.popen() expression touches Python's OS module
5 · executesserver shell runs the attacker's command
payload in a ticket
what the server runs
what comes back
why it matters

The first two nodes are green — the bytes are still data. Node 3 is the flip: .render() reinterprets that data as a Jinja template, so the attacker's {{ … }} becomes code. Once code runs, the §4.1 path-sandbox and the §4.2 SQL allowlist don't matter — popen does whatever the server's OS user can do. That equivalence — any injection that reaches an interpreter can become RCE — is why RCE sits at the top of every severity scale.

scaling up · the four big surfaces

Every MCP attack surface fits into one of four categories: (1) the description shipped to the LLM, (2) the inputs the LLM gets to choose, (3) the data the tool returns to the LLM, (4) the trust boundary between two tools chained together. microMCP has all four; we just gave them small enough versions that the attack surface is small too. baseline_server has the same four — just with more attackers' worth of work to find them all.

5 · What's next · before the attacks

Lab 07 · Attacking MCP takes the same baseline_server.py you just read and runs five canonical attacks against it. Before you click through, take a few minutes with §4. Four of the additions there each open a different kind of trouble:

  1. The description file that's read at startup and shipped to the LLM verbatim.
  2. The path-traversal in read_document (prefix-check-before-normalize).
  3. The over-privileged database role behind db_query.
  4. The Jinja2 SSTI at the end of the sprint chain.

For each one, see how far you can sketch an attack on your own. Lab 07 calls out the vulnerability line by line, walks the attack, applies a defense, then asks you to bypass the defense. The further you can get with your own pencil-and-paper version first, the more useful that lab will be.

Assignment · constrain the tools ~2 hours · autograded

⚠ A tool runs whatever it's handed — put a gate in front of it

microMCP as written runs whatever tool the client names, with whatever arguments it sends. That's the §4 production server's whole problem in miniature: the LLM (or an attacker who reaches it) chooses the method and arguments, and the server trusts them. Your job is the Secure step — add one policy gate, guard(), that every tools/call must clear before the tool runs. The tool proposes; the policy decides.

What you build

Edit assignment/micromcp.py — you implement exactly one function, guard(name, arguments). It returns None to allow a call, or a short reason string to block it (the server then replies REFUSE: <reason> and the tool never runs). The JSON-RPC loop already calls guard() before dispatch — you only fill in the three constraints:

  1. Allowlist. Only the tools in ALLOWED_TOOLS ({"get_greeting", "read_file"}) may be called. Any other name → block.
  2. read_file path. The path argument must be a non-empty string, not absolute (no leading /), free of parent traversal (no .. segment), and a markdown file (ends with .md).
  3. get_greeting name. If a name argument is given, it must be a string, at most 32 characters, and a single line (no \n / \r).

Downloads

Interface (don't break it — the autograder reads it)

guard(name: str, arguments: dict) -> str | None   # None = allow; str = block-reason
ALLOWED_TOOLS = {"get_greeting", "read_file"}      # the only callable tools
# a blocked call returns {"content":[{"type":"text","text":"REFUSE: ..."}], "isError": true}
# leave the tools, the catalog, and the JSON-RPC loop unchanged

The contract, as a table

callguard returnsresult content
read_file path=hello.mdNone (allow)file contents
get_greeting name=aliceNone (allow)Hello, alice! …
read_file path=../server/x.pyreason (.. traversal)REFUSE: …
read_file path=/etc/passwdreason (absolute)REFUSE: …
read_file path=notes.txtreason (not .md)REFUSE: …
get_greeting name=<40 chars>reason (too long)REFUSE: …
exec cmd=idreason (not allowlisted)REFUSE: …

Get the code & run it (in your Rivanna session)

Pull the template and its data with curl:

mkdir -p lab06/microdata && cd lab06
BASE=https://researcher111.github.io/ML-Security-Public/labs/lab-06/assignment
curl -sO "$BASE/micromcp.py"
curl -sO "$BASE/test_micromcp.py"
for f in hello.md network_help.md password_policy.md; do curl -s "$BASE/microdata/$f" -o "microdata/$f"; done

No pip install — Python 3.10+ stdlib only. Edit micromcp.py, then run the local autograder (it spawns your server and pipes JSON-RPC calls through it, exactly as Gradescope will):

python3 test_micromcp.py        # benign allowed, adversarial refused with REFUSE:

What the autograder tests

Gradescope spawns your micromcp.py as a subprocess and drives it over the real JSON-RPC/stdio transport — it never imports or execs your code, and every adversarial input is just a path or a string, so a buggy guard() can't damage the grader. A call counts as blocked iff its result content starts with REFUSE:. It scores five things, all with partial credit:

  1. Smoke testinitialize and tools/list still work.
  2. Benign allowed — legit get_greeting / read_file calls are not over-blocked and return real content.
  3. Path constraints — traversal, absolute paths, non-.md, empty, and non-string path are refused.
  4. Greeting constraints — over-long, multi-line, and non-string name are refused.
  5. Allowlist — tools outside the allowlist are refused.

The grader uses its own inputs (a superset of test_micromcp.py), so an "allow everything" guard() fails (3)–(5), and a "refuse everything" one fails (2). You need the balanced policy above.

Submission

Upload your edited micromcp.py to Gradescope. (No transcript, no writeup required — it's autograded.)

Rubric

component (autograded)points
Server smoke test (initialize + tools/list)10
Benign calls are allowed (no over-blocking)25
read_file path constraints enforced35
get_greeting argument constraints enforced15
Allowlist enforced (unknown tools refused)15
Total100
Going further · optional, ungraded

Your guard() constrains arguments. The §4 production server shows the constraints that need to live elsewhere: a description the LLM is told to trust, a database connection scoped wider than its tool implies, a template engine that evaluates tool output. Pick one §4 vulnerability and sketch the guard-style check that would have blocked it — then carry it into Lab 07, where you bypass exactly these defenses.

FAQ

Why not use the official MCP Python SDK?

The SDK is the right answer for production servers — schema validation, transport abstractions, capability negotiation, all done. But it hides exactly the layers this lab is trying to make visible. microMCP exposes the JSON-RPC shape, the description-as-data fact, and the tool-as-plain-function pattern in 140 lines you can hold in your head. Once you can hold microMCP in your head, the SDK's source becomes readable.

Why does the server use the "user" role for tool observations… oh wait, this lab doesn't.

Right — microMCP doesn't drive an LLM at all. It exposes tools; the host's LLM is what decides to call them. The trust-boundary problem from Lab 04 (LLM can't tell user from tool observation) still exists in MCP — it just lives inside the host, not the server. Lab 07's tool-poisoning attacks are the MCP-shaped instance of that same problem.

What's the relationship to Lab 04 (microagent)?

Lab 04 built an agent: an LLM + a tool-dispatch loop + memory. Lab 06 builds the tool-dispatch protocol that production agents use to talk to their tools. Most real agents in 2026 don't have an in-process TOOLS dict like microagent does — they spawn or connect to MCP servers like microMCP. Together, the two labs cover both sides of the agent-tool boundary.

Will this work against Claude Desktop / Continue / Cursor?

Yes. Add a stanza to your host's MCP config pointing at server/micromcp.py and the host will spawn it on connect. Don't do this on a workstation handling sensitive data without reading every line of server/micromcp.py first; this is the actual point of Lab 07.

What's a "stanza," and how do I add one?

A stanza is just one named entry in a host's MCP config file — the block that tells the host how to start one server. (The word comes from config files generally: a "stanza" is a self-contained block of settings, like a verse in a poem.) Every host keeps a JSON file with an mcpServers object, and each key inside it is one stanza: a server name plus the command and args the host runs to launch it. microMCP speaks stdio, so the stanza just names the Python interpreter and the script:

{
  "mcpServers": {
    "micromcp": {
      "command": "python",
      "args": ["/absolute/path/to/lab-06/server/micromcp.py"]
    }
  }
}

Add that block, save, and restart the host — it re-reads the config on launch and spawns micromcp, after which its two tools show up in the tool list. The config file lives where each host keeps it; for Claude Desktop it is:

# macOS
~/Library/Application Support/Claude/claude_desktop_config.json
# Windows
%APPDATA%\Claude\claude_desktop_config.json

Use an absolute path in args (the host's working directory is not your shell's), and make sure command points at a Python that can run the file. If the server fails to appear, check the host's MCP logs — a bad path or a server that crashes on startup is the usual cause.

What's MITRE ATLAS coverage here?

Lab 07's five attacks cover T0010.005 (AI Supply Chain Compromise — tool description poisoning), T0051.001 (LLM Prompt Injection: Indirect — via tool metadata), T0085 (Data from AI Services — over-privileged db tool), and the SSTI chain rolls into T1059 (Command and Scripting Interpreter) from the broader MITRE ATT&CK matrix once it lands as RCE.