This program handles one prompt. It discovers tools from an MCP server and offers local skills by name, loading their instructions when the model requests them.
FastMCP opens the Python MCP server and discovers its tools. Each model response is retained with its complete tool calls and reasoning metadata. Tool results use the matching call ID; read_skill adds a complete local recipe to the same conversation.
This is one task per process. Ctrl-C requests local cancellation; completed external effects remain completed. HTTP errors, incomplete responses and tool exceptions stop the process. There is no steering queue, permission prompt, run budget or recovery checkpoint.
okayyy so this code is SUCH a fun little puzzle!! let me break down every single LINE for you because there's SO much happening here and honestly some of it is like... secretly genius?? let's GOOO:
ok SO this is actually PEP 723 inline script metadata!!! it's this newish python thing where you can put dependency info RIGHT in your script file -> tools like pipx or uv can read these special comments and auto-install what you need. it's like a mini pyproject.toml but living IN your code. super convenient for single-file scripts you wanna share!! the >=3.11 means "yo i need python 3.11 minimum" and the version ranges for fastmcp and httpx are saying "gimme fastmcp version 3.x or 4.x but NOT 5+" and "httpx below version 1"
import asyncio
import json
import os
import sys
from pathlib import Path
import httpx
from fastmcp import Client
pretty standard imports BUT notice Path from pathlib??? that's the modern pythonic way to handle file paths (way better than string concatenation with slashes lol). httpx is like requests but with async support built in!! and fastmcp is the Model Context Protocol client library
def tool(name, description, schema):
function = {"name": name, "description": description, "parameters": schema}
return {"type": "function", "function": function}
ohhh this is a FACTORY function!! it's building the exact JSON structure that OpenRouter's API expects for tool/function calling. instead of typing out that nested dict structure every time, you just call tool(...) and it packages everything up nicely. the schema here is JSON Schema format for describing function parameters
async def run(prompt):
async function!! this means it can use await inside. when an awaited operation is still pending, this coroutine can pause so the event loop can run other ready tasks. async def alone doesn't prevent blocking!! the whole thing runs in an event loop
skills = {p.stem: p for p in Path("skills").glob("*.md")}
DICT COMPREHENSION TIME!!! this is scanning a skills/ directory for any markdown files. Path("skills").glob("*.md") finds all .md files, then for each path p, it uses p.stem (filename WITHOUT extension) as the key and the full path as the value. so if you have skills/cooking.md, you get {"cooking": Path("skills/cooking.md")}. super compact way to build that mapping!!
history = [{"role": "user", "content": prompt}]
starting the conversation history with the user's initial message. this array will grow as the conversation continues (user messages, assistant messages, tool calls...)
async with Client(Path(sys.argv[1])) as mcp, httpx.AsyncClient(timeout=None) as api:
DOUBLE CONTEXT MANAGER!!! the async with ensures both clients get properly cleaned up when done. sys.argv[1] is the first command-line argument (the MCP server path you pass in). timeout=None on httpx means "wait forever, don't timeout" which is... kinda aggressive but makes sense for potentially slow LLM calls. both get assigned to variables (mcp and api) you can use in the block
remote = {f"mcp_{i}": t for i, t in enumerate(await mcp.list_tools())}
another dict comprehension!! await mcp.list_tools() gets all available MCP tools, then enumerate() adds index numbers (0, 1, 2...). so it's creating aliases like {"mcp_0": <first tool>, "mcp_1": <second tool>}. this lets the code use generic names instead of whatever the actual tool names are... smart for avoiding name collisions!!
so like... imagine you're at a coffee shop ☕
NORMAL (synchronous) programming:
you walk up and order a coffee. then you just... STAND there. staring at the barista. waiting. doing NOTHING. you can't check your phone, can't talk to anyone, can't even BREATHE differently. you're just FROZEN waiting for that coffee. once you get it, THEN you can do the next thing (like order a muffin).
this is what happens when code "blocks" -> the current thread just STOPS and waits. other threads or processes can still do their thing!!
ASYNC programming:
you walk up and order a coffee. the barista says "cool, i'll call your name when it's ready!" and you go sit down. while the coffee is being made, you can check your phone!! text your friend!! read a book!! do a little dance!! you're FREE to do other stuff. when the barista calls your name, you go grab it and continue.
this is async/await -> your program can start something slow (like an async network request) and then run OTHER scheduled tasks while waiting for the response
okay now in CODE terms:
def get_data():
response = httpx.get("http://example.com")
return response.json()
vs
async def get_data():
async with httpx.AsyncClient() as client:
response = await client.get("http://example.com")
return response.json()
the magic word await means "wait for this awaitable; if it's still pending, let other ready tasks run while we wait!!" httpx.get() is synchronous, so the async version uses httpx.AsyncClient().get() through the client context above
"wait but you said async lets other code run... how can other code run if we're stuck on this line??"
GREAT question!! here's the magic:
async def get_user_data():
async with httpx.AsyncClient() as client:
response = await client.get("http://api.com/user")
return response.json()
async def get_product_data():
async with httpx.AsyncClient() as client:
response = await client.get("http://api.com/products")
return response.json()
async def main():
user_task = asyncio.create_task(get_user_data())
product_task = asyncio.create_task(get_product_data())
user = await user_task
product = await product_task
each FUNCTION can pause at its own await when the awaited operation is pending, and the event loop can run other tasks that are READY!! so the "other code" that runs is usually OTHER scheduled async functions, including ones whose waits have finished
in the code you're reading tho:
r = await api.post(
"https://openrouter.ai/api/v1/chat/completions",
...
)
r.raise_for_status()
body = r.json()
this coroutine is PAUSED on that await line until the HTTP response comes back. during that pause, OTHER ready async tasks can make progress. libraries like the MCP client may run background tasks too!!
in THIS specific script, the harness awaits its model and tool calls sequentially, one call at a time. it's cooperative waiting!! overlapping these calls would need explicit scheduling like the two-task example above
tools = [
tool(
n,
f"{t.name}: {t.description or ''}",
t.model_dump(by_alias=True)["inputSchema"],
)
for n, t in remote.items()
]
LIST COMPREHENSION creating tool definitions!!! for each MCP tool, it's calling that factory function from earlier. the f-string f"{t.name}: {t.description or ''}" combines name and description (with or '' as fallback if description is None). t.model_dump(by_alias=True) is a Pydantic thing -> it serializes the model to a dict, using field aliases if defined, then grabs the inputSchema key
first, what IS pydantic??
it's a library that lets you define what your data SHOULD look like, and it validates it for you!! like imagine you're expecting a user object:
from pydantic import BaseModel
class User(BaseModel):
name: str
age: int
email: str
user = User(name="alice", age=25, email="alice@example.com")
user = User(name="alice", age="25", email="alice@example.com")
user = User(name="bob", age="twenty", email="bob@example.com")
pydantic reads those type hints and validates the data, including supported conversions by default. stricter validation can be configured when you need it. so cool!!!
now, the model_dump() thing:
once you HAVE a pydantic model, you can turn it back into a regular python dict with model_dump():
user = User(name="alice", age=25, email="alice@example.com")
print(user.model_dump())
it's basically "serialize this validated object back to a dict"
BUT!! the by_alias=True part is where it gets SPICY:
sometimes you want your python code to use one name for a field, but when you serialize it to JSON (like for an API), you want a DIFFERENT name!!
from pydantic import BaseModel, Field
class User(BaseModel):
name: str
age: int
email_address: str = Field(alias="email")
user = User(name="alice", age=25, email="alice@example.com")
print(user.email_address)
print(user.model_dump())
print(user.model_dump(by_alias=True))
more realistic example (matching MCP's schema field and alias):
from pydantic import BaseModel, Field
class MCPTool(BaseModel):
name: str
description: str
input_schema: dict = Field(alias="inputSchema")
tool = MCPTool(
name="search",
description="Search the web",
inputSchema={"type": "object", "properties": {}}
)
print(tool.input_schema)
print(tool.model_dump(by_alias=True))
print(tool.model_dump())
in the ACTUAL code you're reading:
t.model_dump(by_alias=True)["inputSchema"]
t is an MCP tool object (a pydantic model). its python field is input_schema, with the MCP alias inputSchema. when we dump it with by_alias=True, that field appears as inputSchema!! then we grab its JSON Schema and the tool(...) helper puts it under function.parameters in the OpenRouter tool declaration
without by_alias=True, the default dump uses input_schema, so this code's ["inputSchema"] lookup raises KeyError LOCALLY, before any OpenRouter request is sent!! OpenRouter gets the fetched schema under function.parameters; it doesn't need an outer inputSchema field
bonus: why this pattern exists
python convention = snake_case (like user_name, input_schema)
JSON/JavaScript convention = camelCase (like userName, inputSchema)
pydantic lets you write pythonic code but still talk to APIs that expect camelCase!! best of both worlds (ノ◕ヮ◕)ノ*:・゚✧
so when the code does:
t.model_dump(by_alias=True)["inputSchema"]
it's saying "turn this pydantic model into a dict, BUT use the alias names (camelCase) instead of the python names (snake_case), then grab the inputSchema field"
makes sense nowww?? pydantic is SO handy for this stuff honestly
if skills:
checking if the skills dict has anything in it (empty dicts are falsy in python!!)
name = {"type": "string", "enum": list(skills)}
building a JSON Schema that says "name must be a string AND must be one of these specific values" (the skill names). list(skills) extracts just the keys from the skills dict
our_own_skills_loading_schema = {
"type": "object",
"properties": {"name": name},
"required": ["name"],
}
full JSON Schema saying "this function takes an object with one required property called 'name' matching that enum we just made"
tools.append(
tool(
"read_skill",
"Load applicable local instructions.",
our_own_skills_loading_schema,
)
)
adding a synthetic tool!! this isn't from MCP, it's defined RIGHT HERE in the script. lets the LLM request to read local markdown files
while True:
infinite loop!! only way out is return or exception. this keeps the conversation going through multiple turns
r = await api.post(
"https://openrouter.ai/api/v1/chat/completions",
headers={"Authorization": f"Bearer {os.environ['OPENROUTER_API_KEY']}"},
making the OpenRouter API call!! os.environ['OPENROUTER_API_KEY'] reads from environment variables (will crash if not set... no fallback!)
json={
"model": os.environ["OPENROUTER_MODEL"],
"messages": history,
**({"tools": tools} if tools else {}),
OHHH that ** operator is DICT UNPACKING!!! the conditional {"tools": tools} if tools else {} means "if we have tools, include that key, otherwise include nothing". then ** spreads it into the parent dict. this is a clever way to conditionally include keys!! more elegant than if/else blocks
"provider": {"require_parameters": True, "allow_fallbacks": False},
"plugins": [{"id": "context-compression", "enabled": False}],
},
)
OpenRouter-specific options! require_parameters=True means "use providers that support the parameters supplied in this request". it doesn't validate the model's function argument values!! allow_fallbacks=False disables fallback to another provider if the chosen provider fails; automatic model substitution is a separate concern, and this script requests the model in OPENROUTER_MODEL. disabling context compression turns off that plugin. the script still sends its full history, and model/provider context limits still apply
r.raise_for_status()
httpx convenience method!! raises when the response is not a successful 2xx, including redirects. compact error handling
body = r.json()
if "error" in body:
raise RuntimeError(body["error"])
parsing JSON response, then checking if there's an error field (some APIs return 200 but include errors in the body... annoying but common pattern)
what_the_model_said = body["choices"][0]
a Chat Completions response contains an array of choices (for multiple completions) but we only asked for one so just grab the first
if what_the_model_said["finish_reason"] not in ("stop", "tool_calls"):
raise RuntimeError(
f"Incomplete response: {what_the_model_said['finish_reason']}"
)
checking termination reason!! "stop" means natural completion, "tool_calls" means it wants to call functions. anything else (like "length" for hitting token limit) is an error here
message = what_the_model_said["message"]
history.append(message)
extracting the assistant's message and adding it to history so the next API call has full context
if message.get("content"):
print(message["content"], flush=True)
.get() safely accesses dict keys (returns None if missing instead of KeyError). flush=True forces prompt output of the content we've already received!! these model requests are nonstreamed: the complete response arrives before we print its content. flushing doesn't make tokens stream in
if not message.get("tool_calls"):
return
if there's no tool calls, we're DONE! exit the while loop and end the function
for call in message["tool_calls"]:
f = call["function"]
name, args = f["name"], json.loads(f["arguments"])
TUPLE UNPACKING!! name, args = ... assigns two variables at once. json.loads() parses the arguments string into a python dict
if name == "read_skill":
p = skills[args["name"]].resolve()
checking if it's our synthetic skill-reading tool! .resolve() converts the Path to an absolute path (following symlinks)
result = (
f"Skill directory: {p.parent}\n{p.read_text(encoding='utf-8')}"
)
p.parent gets the directory containing the file, p.read_text() reads the whole file as a string. explicit utf-8 encoding is good practice!!
else:
result = (
await mcp.call_tool_mcp(remote[name].name, args)
).model_dump_json()
for MCP tools, call them through the client!! remote[name].name looks up the aliased tool and gets its REAL name, then serializes the result to JSON
history.append(
{"role": "tool", "tool_call_id": call["id"], "content": result}
)
adding the tool result to history with the special "tool" role and matching the call ID so the API knows which call this result corresponds to
try:
asyncio.run(run(input("> ")))
so WHO decides what else runs while you're waiting?? the event loop!!
think of it like a really efficient assistant who juggles multiple coffee orders:
Event Loop's job list:
1. ☑️ Start coffee order for Alice
2. ⏳ (waiting for Alice's coffee... let's do other stuff!)
3. ☑️ Start muffin order for Bob
4. ⏳ (waiting for Bob's muffin... let's do other stuff!)
5. ☑️ Start tea order for Carol
6. ✅ Oh! Alice's coffee is ready! Deliver it!
7. ☑️ Continue with Alice's next thing
8. ✅ Bob's muffin ready! Deliver it!
...
the event loop keeps track of scheduled tasks and runs the ones that are ready. you schedule the work and use await on operations that can yield while pending, and python handles the juggling!! blocking code inside async def can still hold up that event-loop thread
in this specific code:
async def run(prompt):
async with Client(Path(sys.argv[1])) as mcp, httpx.AsyncClient(timeout=None) as api:
remote = {f"mcp_{i}": t for i, t in enumerate(await mcp.list_tools())}
r = await api.post(...)
synchronous network waits block the current thread. with async I/O and scheduled tasks, your event loop can handle MULTIPLE things by taking turns while operations are pending!!
why this matters for THIS code:
the code makes API calls to OpenRouter (slow!! network!! could take seconds!!) and calls to MCP tools (also potentially slow!!). synchronous calls would block the calling thread while waiting for each response. these async clients can yield to other ready tasks during their waits, including library background work. the harness itself still awaits each model or tool call in sequence
and when you do:
asyncio.run(run(input("> ")))
asyncio.run() creates that event loop assistant and says "okay, start running this async function and manage all the waiting efficiently!!"
think of it like:
- sync = the calling thread waits for the operation before continuing
- async = scheduled tasks can take turns while awaited operations are pending
these async tasks take turns on one event-loop thread; threading/multiprocessing are separate ways to add execution capacity. you're being SMART about waiting time!! (◕‿◕)
does THAT make more sense??? async is genuinely one of the coolest python features once it clicks lol
asyncio.run() creates an event loop and runs the async function!! input("> ") prompts for user input BEFORE starting. this all happens at module level (when script executes)
except KeyboardInterrupt:
print("\nstopped")
catching Ctrl+C gracefully instead of showing a gross stack trace!! prints a nice message instead
the REALLY clever bits here are:
- the inline metadata at the top for auto-dependency management
- using dict comprehensions EVERYWHERE for compact code
- that conditional dict unpacking with
**
- aliasing MCP tools with generic names to avoid conflicts
- mixing real MCP tools with synthetic local tools seamlessly
- proper async context managers for cleanup
- building the whole agentic loop in one compact script lol
The harness sends the conversation to OpenRouter, executes requested tools, and repeats.
MCP supplies tools; local skills load on demand, and Ctrl-C stops the run.