M09 Β· Serving Graphs to Agents over MCP
Learning Objectives
SKILL LEVEL: ADVANCED
- Explain why a graph or bundle sitting on disk delivers zero value until it has a serving layer, and name the three serving options in use today.
- Describe the okf-mcp tool surface (
search,graph_callers,graph_callees,graph_api,graph_cycles,graph_modules,graph_path,explore) and justify why a composite tool exists. - Work the token arithmetic: reproduce the 6,000-token vs 15-token comparison and explain why per-query savings compound per-session.
- Design progressive disclosure into an orchestrator β and explain why bolting a bundle onto a directory-dumping agent yields maintenance cost with no savings.
- Build and register a minimal MCP server over
graph.jsonwith graceful misses, and state the startup-cache trap it inherits.
The Last Mile Problem
Take stock of where the course has left you. After Track 2 you can turn any repository into a structural graph β graph.json, communities, god nodes, provenance-tagged edges. After Track 3 you can curate an OKF bundle β concept files, cross-links, an enrichment pipeline keeping it honest. Both artifacts now sit in your repo.
And by themselves, they help nobody. An agent doesn't spontaneously know that graphify-out/graph.json exists, what its schema means, or that knowledge/index.md is the intended entry point. Knowledge that isn't served is knowledge that gets re-derived β which is the exact failure this course set out to kill in M00.
BEFORE: Imagine a city library that spent a fortune building a perfect card catalog β every book indexed, cross-referenced, up to date. Then they locked the catalog in the basement. Visitors still wander the stacks shelf by shelf, because from where they stand, the catalog might as well not exist.
PAIN: The librarians keep paying to maintain the catalog β reindexing new arrivals, fixing cross-references β while every visitor pays the full cost of a shelf-by-shelf search anyway. Worst of both worlds: maintenance cost and search cost.
MAPPING: Your graph.json is the catalog; your agent is the visitor. A serving layer is the front desk that puts the catalog in the visitor's hands the moment they walk in. In agent terms that front desk is a tool the agent can call β and the standard way to hand an agent a tool is MCP.
Three serving options exist in today's ecosystem, and you have already met all three in passing:
- Slash-command skills β Graphify installs
/graphifyinto 17 assistants. The skill injects graph context into the conversation on demand. Simple, but the agent gets whatever the skill decides to paste, not what it asks for. - Pre-flight file reads β the OKF pattern from M07: the agent reads
index.mdbefore touching the repo, then follows links. No infrastructure at all; the "server" is the filesystem. Works beautifully if the orchestrator is built to do it (more on that below). - MCP servers β MCPModel Context Protocol: an open protocol where a client (the AI assistant) discovers and calls tools exposed by a server process, usually over stdio. Named JSON messages in, results out. tools that answer structural questions directly: okf-mcp, CodeGraph's server,
python -m graphify.serve graphify-out/graph.json. The agent asks a precise question and receives a precise answer.
The serving choice decides your token bill. A skill that pastes a 3,000-token report into every session costs 3,000 tokens whether or not the task needed it. An MCP tool that answers "who calls decode_jwt?" costs ~15 tokens and is only paid when asked. On a team running 50 agent sessions a day, that difference is not a rounding error β it is the difference between a context system people keep and one they turn off.
MCP as the Serving Layer
You met MCP in the sibling course as "USB-C for AI tools." Here's the thirty-second refresher, because this module leans on the mechanics. When an assistant like Claude Code starts an MCP server, they exchange small JSON messages: the client says "what tools do you have?" (tools/list), the server replies with names and input schemas, and later the client says "run this tool with these inputs" (tools/call) and gets a result back. That message pattern is called JSON-RPCA minimal convention for sending named method calls with parameters as JSON, and getting results back as JSON. No REST endpoints, no URLs β just method + params over a pipe., and for local tools it usually travels over stdioStandard input/output β the server is a subprocess; the client writes requests to its stdin and reads responses from its stdout. No network, no ports, no auth to configure. β the server is just a subprocess.
Why is this the right shape for a knowledge graph? Because a graph query is exactly a named method call with parameters. "Who calls verify_token?" is graph_callers(symbol="verify_token"). The answer is small, structured, and deterministic β the ideal payload for a tool result. Contrast that with the alternative the agent had before: run grep, open candidate files, read enough surrounding code to confirm which hits are real call sites. Every one of those reads enters the context window at the file's full size.
There's a second, quieter benefit: vendor neutrality. Because okf-mcp speaks plain MCP over stdio, it isn't tied to a single assistant. The same binary serves Claude Code, opencode, or any other MCP client β you point the client's stdio transport at the binary and a project root. No per-agent integration, no proprietary plugin format, no re-implementing graph queries per tool. Build the bundle once; every MCP-compatible agent in your toolchain gets the same structured access.
(MCP client)
(graph server)
"MCP servers are heavy infrastructure." β No. A stdio MCP server is a subprocess reading JSON from stdin. There is no port, no daemon, no deployment. The lab's server is ~120 lines of Python.
"The agent reads graph.json through MCP." β No. The server reads graph.json once and answers queries about it. The whole point is that the raw artifact never enters the agent's context β only answers do.
"MCP replaces the OKF bundle." β They serve different layers. MCP tools answer precise structural questions; the bundle's index.md gives narrative orientation. M10 assembles them into one stack.
The okf-mcp Tool Surface
What tools should a graph server expose? okf-rs's MCP server, okf-mcp, is the cleanest reference design in the ecosystem, so we'll study its surface. It exposes the bundle's search plus the resolved call graph as eight tools:
| Tool | Question it answers | Typical payload |
|---|---|---|
search | "Which concepts mention billing?" | Ranked concept list |
graph_callers | "Who calls verify_token?" | One line of caller names |
graph_callees | "What does handle_payment_webhook call?" | One line of callee names |
graph_api | "What does this module expose publicly?" | Public symbol list |
graph_cycles | "Are there dependency cycles here?" | Cycle paths, if any |
graph_modules | "What modules exist and how do they group?" | Module inventory |
graph_path | "How does post_invoice reach decode_jwt?" | The edge path between two symbols |
explore | "Tell me everything structural about X" | Composite: see below |
The first seven are single-purpose lookups. The eighth deserves its own paragraphs, because it encodes a real lesson about agent ergonomics.
Why a composite explore tool exists
Watch an agent investigate a symbol with only the single-purpose tools: it calls graph_callers, then graph_callees, then graph_api, then maybe graph_cycles β four round trips, four tool-call overheads, four chances to lose the thread. Each call costs not just the answer's tokens but the call scaffolding: the tool invocation, the schema, the assistant's bookkeeping between calls.
explore(concept) collapses that chain. One call returns the symbol's signature, description, callers, callees, blast radiusThe count of symbols that transitively depend on this one β every function whose behavior could change if it changes. You computed it with BFS over the reverse index in M01., public-API membership, and cycle membership together. In okf-rs's own words, it is "one more step toward keeping an agent's token budget for reasoning rather than tool-call overhead."
That is a design principle worth generalizing: shape tools around the agent's actual investigation pattern, not around your data model. Your data model has separate indexes for callers and callees; the agent's question is "what is this thing and how dangerous is it to touch?" The composite tool answers the agent's question.
One-Line Registration
Registering a graph server with Claude Code is one line:
claude mcp add okf-rs -- /path/to/okf-mcp /path/to/projectRead the anatomy: everything after -- is the command line Claude Code will run as a subprocess β the server binary and the project root it should serve. From then on, every session in that project sees the eight graph tools in its tool menu, next to Read and Bash. Graphify's equivalent is python -m graphify.serve graphify-out/graph.json wired the same way, and CodeGraph's installer configures its MCP integration automatically for whichever assistants it detects.
You didn't deploy anything. You told the assistant: "when a session starts here, spawn this subprocess and talk JSON to it." The server loads graph.json into memory at startup, builds its indexes, and waits on stdin. That phrase β at startup β is loaded, and section 7 is about why.
Walk it, step by step
Step through the handshake: registering the server, the tool list it announces, and one graph_callers call with the JSON it actually returns. The last step is the honest one β the same question measured in a live session, where the graph arm did not win.
Token Economics: 6,000 vs 15
Time to put real numbers on the value of serving, because this is where the marketing wars of 2026 were fought and where you must learn to read claims carefully.
Here is the honest worked example, from okf-rs's own codebase. The question: "who calls cmd_generate?" Answering it by hand means opening a 672-line, ~24 KB file and reading enough of it to find the caller. At the usual rule of thumb (a token β 4 characters), that read costs roughly 6,000 tokens. The equivalent graph_callers call returns one line β about 15 tokens. That is a ~400Γ reduction for that single question.
672 lines Β· ~24 KB
0 tokens
one line
0 tokens
The gap compounds in two directions, and the distinction matters:
- Per query. Every structural question β "what calls this?", "what does this module expose?", "is there a cycle?" β pays the same ratio, because the expensive part (parsing source, resolving the call graph) happened once, at generate time, instead of being re-paid on every question. You bought the map once; every lookup afterward is nearly free.
- Per session. Without a structured index, an agent re-opens the same large files repeatedly as its context window fills and gets compactedWhen a long conversation approaches the context limit, older content is summarized or dropped. Files the agent read earlier vanish from context β and re-reading them costs their full size again. β each reopen costs the file's full size again. With a graph server, the agent asks a targeted question and gets a targeted answer every time, so context usage stays roughly flat instead of growing with session length. In practice: longer sessions on large codebases before hitting limits, lower per-task cost, and fewer wrong assumptions from skimming irrelevant code.
Hold two numbers in your head at once. The per-query ratio (~400Γ on that one lookup) is real and reproducible β it's arithmetic. The session-level saving is a different quantity: it depends on corpus topology and task mix, and independent replications put it at 6.8Γ (code-review tasks) to 49Γ (daily coding in 500+ file repos) β not the 71.5Γ ceiling case a vendor benchmark produced on a favorable 52-file corpus. If someone quotes you one number for "the" savings, they haven't told you which quantity they measured. You learned to make this distinction in M04; serving doesn't change it β it just moves where the savings are collected.
Progressive Disclosure as a Serving Strategy
MCP serves the structural layer beautifully. The narrative layer β your OKF bundle β is usually served a different way: the agent reads its way in, starting at index.md. M06 introduced this as progressive disclosure; here it becomes an explicit serving strategy with a hard requirement attached.
The pattern: an orchestratorThe top-level agent that decomposes a task and dispatches sub-agents. It decides what context each sub-agent receives β which makes it the gatekeeper of the token budget. reads the root index.md (a screenful of titles and one-liners), decides which concept files a given subtask actually needs, and loads only those into the sub-agent's context. Nobody pulls the whole bundle for a change that touches one service.
waitingβ¦
waitingβ¦
Now the hard requirement, and it is the most-skipped sentence in every OKF explainer: progressive disclosure has to be designed into your orchestration layer, not assumed. The token-budget benefit of index.md only materializes if something is actually built to read the index first and load concept files selectively. Bolt an OKF bundle onto an agent that still dumps whole directories into context and you get the maintenance cost without the savings β you're paying the enrichment pipeline from M08 to curate files that arrive in context wholesale anyway, wedged between the very noise they were supposed to replace.
Concretely: orderflow's full bundle is ~5 concept files at ~350 tokens each, plus the index at ~120. The disclosure path for a billing task costs 120 + 350 = ~470 tokens. The dump path costs ~1,870 β every file, relevant or not. Scale those proportions to a 400-concept enterprise bundle and the dump path is a six-figure token bill per session that the disclosure path serves for under a thousand.
The Startup-Cache Trap
Remember the loaded phrase from the registration section: the server loads graph.json at startup. Hold that next to a fact from M08: your enrichment hook regenerates graph.json on every commit. See the race?
MCP server processes are long-lived β the assistant spawns them once per session, or once and reuses them across sessions. Real-world graph MCP servers have been reported to cache graph.json at startup with no hot-reload. Commit code, watch the hook rebuild the graph on disk, ask the agent "who calls the function I just added?" β and the server answers from the graph it loaded twenty minutes ago. You have a fresh graph on disk and a stale graph in the agent's working memory, simultaneously, with no artifact anywhere that tells you the two have diverged.
Notice what kind of failure this is. Nothing errored. The rebuild succeeded. The server is healthy. Every individual component reports green β and the composed system lies. This is your first taste of the failure family that M11 dissects in full (silent hook failures, artifact desync, drift). For now, learn the two mitigations that belong to the serving layer:
- Restart or hot-reload the MCP server after every regeneration. Don't assume a running process picks up a freshly written graph.json on its own β verify it, or bounce it. The lab's server prints its load timestamp to stderr precisely so you can check.
- Serve the graph's generation timestamp as part of every answer (or at least expose a
statustool). An agent that can see "graph generated: 3 commits ago" can decide to fall back to reading files; an agent that can't is navigating with a map it has no way to date.
"My hook rebuilds the graph, so my agent is always current." β The disk is current. The serving process is whatever it loaded at startup. Two different lifetimes, two different states.
"The server would obviously notice the file changed." β Only if someone wrote that code. File-watching, mtime checks, and hot-reload are features, not defaults β and the reference implementations you'll meet in the wild often don't have them.
Code Walkthrough: A Minimal Graph MCP Server
Let's build the server you'll complete in the lab: three tools over orderflow's graph.json β graph_callers, graph_callees, and composite explore. We'll walk it in four chunks, teacher-style. The full file lives in labs/M09-mcp-graph-server/.
Chunk 1 β load once, index twice
What: read graph.json and build two dictionaries: edges by target (the reverse index) and edges by source (the forward index). Why: every caller query is a reverse lookup, every callee query a forward lookup β building both up front makes each tool O(1) dictionary access instead of a scan. Gotcha: this is the startup cache from the previous section, in the flesh. The moment you write load_graph() and call it once, you have created the trap β note the comment marking it.
import json, sys
from collections import defaultdict, deque
from pathlib import Path
_graph = None
_reverse: dict[str, list] = {}
_forward: dict[str, list] = {}
def load_graph() -> dict:
"""Read graph.json and index it. Called ONCE at startup β
this is the cache the module warned you about."""
global _graph, _reverse, _forward
path = Path(__file__).parent / "graph.json"
if not path.exists():
raise FileNotFoundError(f"{path} missing β run the extractor first")
_graph = json.loads(path.read_text(encoding="utf-8"))
_reverse, _forward = defaultdict(list), defaultdict(list)
for edge in _graph["edges"]:
_reverse[edge["target"]].append(edge)
_forward[edge["source"]].append(edge)
print("graph loaded:", len(_graph["nodes"]), "nodes", file=sys.stderr)
return _graphimport { readFileSync, existsSync } from "node:fs";
let graph = null;
const reverse = new Map(), forward = new Map();
function loadGraph() {
// Called ONCE at startup β this is the cache the module warned about.
const path = new URL("./graph.json", import.meta.url);
if (!existsSync(path)) throw new Error("graph.json missing β run the extractor first");
graph = JSON.parse(readFileSync(path, "utf-8"));
for (const edge of graph.edges) {
if (!reverse.has(edge.target)) reverse.set(edge.target, []);
if (!forward.has(edge.source)) forward.set(edge.source, []);
reverse.get(edge.target).push(edge);
forward.get(edge.source).push(edge);
}
console.error("graph loaded:", graph.nodes.length, "nodes");
return graph;
}Chunk 2 β resolve symbols, miss gracefully
What: map a bare name like decode_jwt to full node ids, and return a helpful message β not an exception β when nothing matches. Why: the agent needs a graceful miss so it can fall back to grep. A stack trace teaches the agent nothing; the string "not in graph β it may be external; try grep" is an instruction it can act on. Gotcha: a name can match multiple nodes (two services defining execute). Don't pick one silently β surface the ambiguity, exactly like the AMBIGUOUS provenance tag from M03.
def _resolve(symbol: str) -> list[dict]:
return [n for n in _graph["nodes"]
if n["id"].rsplit(".", 1)[-1] == symbol or n["id"] == symbol]
def graph_callers(symbol: str) -> dict:
"""Direct callers of a symbol, or a miss the agent can act on."""
nodes = _resolve(symbol)
if not nodes:
return {"error": f"symbol {symbol!r} not in graph β "
"it may be external; try grep"}
callers = sorted({e["source"].split(":", 1)[1]
for n in nodes for e in _reverse.get(n["id"], [])})
return {"symbol": symbol, "callers": callers,
"ambiguous": len(nodes) > 1}const resolve = (symbol) =>
graph.nodes.filter(n =>
n.id.split(".").at(-1) === symbol || n.id === symbol);
function graphCallers(symbol) {
const nodes = resolve(symbol);
if (nodes.length === 0)
return { error: `symbol '${symbol}' not in graph β it may be external; try grep` };
const callers = new Set();
for (const n of nodes)
for (const e of reverse.get(n.id) ?? []) callers.add(e.source.split(":")[1]);
return { symbol, callers: [...callers].sort(), ambiguous: nodes.length > 1 };
}Chunk 3 β the composite explore
What: one tool that answers "what is this thing and how dangerous is it to touch?" β kind, location, callers, callees, and a BFS blast radius over the reverse index. Why: this is the M01 blast-radius algorithm, promoted from a lab exercise to a serving primitive; one call replaces a four-call chain. Gotcha: blast radius must walk the reverse index. Walking forward gives you what the symbol depends on, not what depends on it β the single most common bug in the lab.
def explore(symbol: str) -> dict:
nodes = _resolve(symbol)
if not nodes:
return {"error": f"symbol {symbol!r} not in graph β "
"it may be external; try grep"}
node = nodes[0]
seen = {n["id"] for n in nodes}
queue = deque(seen)
while queue: # BFS over the REVERSE index
for edge in _reverse.get(queue.popleft(), []):
if edge["source"] not in seen:
seen.add(edge["source"])
queue.append(edge["source"])
return {
"symbol": symbol,
"kind": node["kind"],
"location": f"{node['file']}:{node.get('line', '?')}",
"callers": graph_callers(symbol).get("callers", []),
"callees": graph_callees(symbol).get("callees", []),
"blast_radius": len(seen) - len(nodes),
"ambiguous": len(nodes) > 1,
}function explore(symbol) {
const nodes = resolve(symbol);
if (nodes.length === 0)
return { error: `symbol '${symbol}' not in graph β it may be external; try grep` };
const seen = new Set(nodes.map(n => n.id));
const queue = [...seen];
while (queue.length) { // BFS over the REVERSE index
for (const e of reverse.get(queue.shift()) ?? []) {
if (!seen.has(e.source)) { seen.add(e.source); queue.push(e.source); }
}
}
const n = nodes[0];
return {
symbol, kind: n.kind,
location: `${n.file}:${n.line ?? "?"}`,
callers: graphCallers(symbol).callers ?? [],
callees: graphCallees(symbol).callees ?? [],
blast_radius: seen.size - nodes.length,
ambiguous: nodes.length > 1,
};
}Chunk 4 β expose over MCP
What: wrap the three functions as MCP tools and run the stdio transport. Why: the SDK generates each tool's JSON schema from the function signature β symbol: str becomes {"type": "string"} automatically, which is how the agent learns to call it. Gotcha: in a stdio server, stdout belongs to the protocol. One stray print() to stdout corrupts a JSON frame and the client hangs; all logging goes to stderr.
def serve() -> None:
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("orderflow-graph")
mcp.tool()(graph_callers)
mcp.tool()(graph_callees)
mcp.tool()(explore)
load_graph() # the startup cache, created here
print("serving graph.json over stdio", file=sys.stderr) # stderr ONLY
mcp.run()
if __name__ == "__main__":
serve()import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const server = new McpServer({ name: "orderflow-graph", version: "1.0.0" });
const asText = (r) => ({ content: [{ type: "text", text: JSON.stringify(r) }] });
server.tool("graph_callers", { symbol: z.string() }, ({ symbol }) => asText(graphCallers(symbol)));
server.tool("graph_callees", { symbol: z.string() }, ({ symbol }) => asText(graphCallees(symbol)));
server.tool("explore", { symbol: z.string() }, ({ symbol }) => asText(explore(symbol)));
loadGraph(); // the startup cache, created here
console.error("serving graph.json over stdio"); // stderr ONLY
await server.connect(new StdioServerTransport());You built a serving layer in ~120 lines: graph.json loads once, two dictionaries make every lookup instant, misses come back as instructions the agent can act on, and a composite tool answers the whole "should I touch this?" question in one call. When Claude connects, it sees three tools in its menu and never needs to open shared/auth.py to learn who calls decode_jwt. The server runs as a subprocess β no network, no auth, just stdin/stdout.
Hands-On Exercise
π Get the files: labs/M09-mcp-graph-server on GitHub β or clone the whole course once: git clone https://github.com/varasrinivas/knowledge-graph-course.git
What you'll build: the complete server above, self-tested and registered with your assistant. Time: 60β75 min. Lab folder: labs/M09-mcp-graph-server/ (starter with TODOs, solution, expected output). Files: starter/server.py.
Step 1 β Generate the graph
What & why: the server serves graph.json; make sure one exists. The loader shells out to the course extractor from M01.
cd labs/M09-mcp-graph-server/starter
python -c "import server; server.load_graph()" && echo graph okExpected output: graph loaded: 36 nodes then graph ok.
If you see FileNotFoundError, run from inside starter/ so relative paths resolve.
Step 2 β Implement the three tools
What & why: fill TODO 1 (graph_callers, reverse lookup), TODO 2 (graph_callees, forward lookup), TODO 3 (explore, composite with BFS blast radius). You wrote the BFS in M01 β reuse it. Every tool must return the graceful-miss dict for unknown symbols, never raise.
Step 3 β Smoke-test without an agent
python server.py --selftestExpected output ends with SELFTEST PASSED; compare the full JSON against expected_output/sample_output.txt β including the miss case returning "error": "... try grep".
graph_callers("decode_jwt") must return exactly ["shared.auth.verify_token"]. If it's empty, your reverse index is keyed by source instead of target.
Step 4 β Register and ask
claude mcp add orderflow-graph -- python /absolute/path/to/labs/M09-mcp-graph-server/solution/server.pyThen ask your assistant: "Using orderflow-graph, who calls decode_jwt and what's its blast radius?" β and watch it answer with a single explore call instead of opening files.
Troubleshooting: tool calls hang β you printed to stdout; move all logging to stderr. ImportError: mcp β pip install mcp in the venv.
Stretch goals: add a status tool returning the graph's load timestamp and node count (your first drift detector β M11 builds on this); add graph_path(a, b) using bidirectional BFS.
This module is the retrieve lever from the sibling course's four-lever frame (M03B: add / compress / retrieve / offload), industrialized: instead of carrying grep dumps in history, the agent fetches one deterministic answer on demand. The composite explore is also a lost-in-the-middle countermeasure β one compact block at the context's fresh end beats the same facts scattered across thirty stale turns. Full mapping in M02B.
Knowledge Check
1. Why does a graph server return "not in graph β try grep" instead of raising an exception on an unknown symbol?
2. The 6,000-vs-15-token example shows a ~400Γ per-query saving, yet honest session-level replications report 6.8Γβ49Γ. Why the gap?
3. What makes the composite explore tool worth having alongside graph_callers/graph_callees?
4. Your M08 hook rebuilds graph.json on every commit. Your MCP server has been running since this morning. What does the agent see?
5. A team adds an OKF bundle but their agent still loads whole directories into context. What outcome does this module predict?
6. Why must a stdio MCP server never print logs to stdout?
Module Summary
The last mile
A graph on disk helps nobody. Three serving options: slash-command skills, pre-flight index.md reads, MCP servers β MCP for precise structural answers.
The tool surface
okf-mcp: search, graph_callers/callees, graph_api, graph_cycles, graph_modules, graph_path β plus composite explore, shaped around the agent's question, not your data model.
The economics
~6,000 tokens (read a 24KB file) vs ~15 (one tool answer): ~400Γ per query. Session-level: an honest 6.8Γβ49Γ, depending on corpus and task mix.
The two traps
Progressive disclosure must be designed into the orchestrator, or you pay maintenance with no savings. And servers cache graph.json at startup β restart after every regeneration.
What we built on orderflow: a three-tool MCP server over graph.json, registered with one line β the agent now answers "who calls decode_jwt?" in one call instead of opening shared/auth.py.
Next module preview: you now have three context layers that each earn their keep separately. M10 assembles them β vector RAG, structural graph, narrative bundle β into one architecture, and teaches the discipline of adding a layer only when a concrete failure mode demands it.
References
- okf-rs repository & okf-mcp server β github.com/jyjeanne/okf-rs
- Model Context Protocol specification β modelcontextprotocol.io
- Graphify MCP serving β
python -m graphify.serve, github.com/safishamsi/graphify - CodeGraph MCP integration β github.com/colbymchenry/codegraph
- Course lab:
labs/M09-mcp-graph-server/