This project is based on and modified from the Anthropic online learning course. The original used Anthropic's Claude via Google Vertex AI; this version has been migrated to use the OpenAI API.
MCP Chat is a command-line interface application for interactive AI chat, powered by OpenAI models. It supports document retrieval via @mentions, command-based prompts via /commands, and extensible tool integrations through the MCP (Model Context Protocol) architecture.
- Python 3.10+
- An OpenAI API key
Create or edit the .env file in the project root:
OPENAI_API_KEY="sk-..." # Your OpenAI API key
OPENAI_MODEL="gpt-4o-mini" # Or any other OpenAI model (e.g. gpt-4o)
USE_UV=1 # Set to 0 if not using uv
uv is a fast Python package installer and resolver.
- Install uv if not already installed:
pip install uv- Install dependencies:
uv sync- Run the project:
uv run main.py- Create and activate a virtual environment:
python -m venv .venv
source .venv/bin/activate # On Windows: .venv\Scripts\activate- Install dependencies:
pip install openai python-dotenv prompt-toolkit "mcp[cli]>=1.8.0"- Run the project:
python main.pyType your message and press Enter:
> What is the capital of France?
Use @ followed by a document ID to include a document's content in your query:
> Summarize @deposition.md
Documents are injected directly into the prompt — no extra tool call needed.
Use / to run predefined prompt commands defined in the MCP server:
> /summarize deposition.md
Press Tab to autocomplete available commands and document IDs.
Pass extra server scripts as arguments when starting the app:
uv run main.py my_custom_server.pyMCP Inspector is a browser-based tool for inspecting and testing your MCP server — you can browse available tools, resources, and prompts, and call them interactively without running the full app.
To launch it:
uv run mcp dev mcp_server.pyThen open the URL shown in the terminal (usually http://localhost:5173) in your browser.
- When calling
read_doc_contents, use the full filename including extension as thedoc_id, e.g.deposition.md. - Available document IDs are:
deposition.md,report.pdf,financials.docx,outlook.pdf,plan.md,spec.txt.
You might notice that after calling edit_document in the Inspector, the docs dictionary inside mcp_server.py doesn't change. This is expected — and understanding why is a useful lesson.
The docs dict lives in memory (RAM), not in the file. When the server starts, Python reads mcp_server.py and loads docs into memory. edit_document modifies that in-memory copy, which is why a subsequent read_doc_contents call returns the updated content. But the source file itself is never touched — it's just the recipe book. The cook writes on a notepad, not back into the book.
When you restart the server, the notepad is thrown away and docs is reloaded from the original source file — so all edits disappear.
This is a known limitation of the current design. If you want edits to persist across restarts, docs would need to be backed by a real store (a JSON file, SQLite, etc.) rather than an in-memory dict.
The fastest way to verify the whole system is working is to run through these five cases in order:
1. Basic chat — confirms OpenAI connectivity
> What is 1 + 1?
2. @ document injection — confirms MCP server is running and document content is injected into the prompt
> What is @deposition.md about?
3. Tool use — confirms the agentic loop, ToolManager, and MCP tool execution are all working
> Use the read_doc_contents tool to read report.pdf and tell me what it says.
4. Tool use + edit — confirms multi-turn tool use and in-memory edits
> Use edit_document to change the word "condenser" to "cooling" in report.pdf, then read it back to confirm.
5. / command — confirms MCP Prompt retrieval and the full command pipeline
> /format financials.docx
Each case exercises a different layer of the stack. If all five pass, the entire end-to-end flow is verified.
The /format command is a good illustration of how all the layers work together:
_process_command()incore/cli_chat.pydetects the/prefix and fetches theformatprompt from the MCP server- The prompt instructs the model to reformat the document using Markdown and use the
edit_documenttool to save the result - The model calls
edit_document— the agentic loop incore/chat.pydetectsfinish_reason == "tool_calls"and executes the tool - The result is added back to the message history, the loop runs again, and the model outputs the final formatted document
One thing to be aware of: if the original document is very short (like financials.docx, which is just one sentence), the model may invent plausible-sounding content — budget figures, categories, etc. — that doesn't exist in the source. This is normal LLM behaviour: when information is sparse, the model fills in what seems reasonable rather than saying "I don't know."
In real applications, guard against this by adding an explicit instruction to the prompt: only use information already present in the document, do not add anything new.
You might wonder why mcp_server.py and mcp_client.py don't need to be started separately. The answer is in main.py — it launches mcp_server.py automatically as a subprocess:
command, args = ("uv", ["run", "mcp_server.py"])
doc_client = await stack.enter_async_context(
MCPClient(command=command, args=args)
)MCPClient.connect() hands this command to the OS, which starts mcp_server.py as a background process. The two processes then talk to each other over stdin / stdout — this is MCP's stdio transport.
You run: uv run main.py
│
├── spawns subprocess ──→ mcp_server.py (background process)
│ ↑ ↓ stdin / stdout
└── MCPClient ─────────────────┘
(mcp_client.py, imported as a class)
mcp_server.pyis launched bymain.py— you never start it manuallymcp_client.pyis imported as a class — it's not a script you run- When
main.pyexits, the subprocess is cleaned up automatically viaAsyncExitStack
The reason stdin / stdout is used as the transport is that it makes MCP servers language-agnostic — the server could be written in Python, Node.js, or Rust. As long as it speaks the MCP protocol over standard I/O, the client doesn't care.
Edit the docs dictionary in mcp_server.py to add new documents.
Any Python script that implements the MCP protocol can be passed as an argument to main.py. Each server runs as its own subprocess and exposes its tools automatically to the model.
No lint or type checks are currently configured.
Imagine you're a chef with many recipes (projects), each calling for different ingredients (packages).
Here's the problem: recipe A calls for "soy sauce v1.0," recipe B calls for "soy sauce v2.0," and the two versions are incompatible, their APIs don't match. If your kitchen (your computer) only has one bottle of soy sauce, you're stuck.
venv and uv both exist to solve this problem.
venv is a tool built into Python for creating a "virtual environment".
The idea behind a virtual environment is simple: it creates an independent copy of the Python runtime inside your project folder, so the packages installed for this project are completely isolated from other projects and don't interfere with each other.
python -m venv .venv # Create a virtual environment (creates the .venv folder)
source .venv/bin/activate # "Enter" this environment
pip install openai # Now openai is installed only for this projectThe openai package you install in this project won't affect any other project on your computer. That's what "isolation" means.
The .venv folder holds that independent copy of Python plus all its packages, which is why it's often several hundred MB in size: it really is a complete runtime environment.
uv is a more modern tool that replaces the combination of pip and venv.
Think of it as venv + pip + package version locking, all bundled into one faster, smarter tool.
# The old way (three steps)
python -m venv .venv
source .venv/bin/activate
pip install openai
# The uv way (one step)
uv add openaipip installs packages one at a time, downloading and installing sequentially. uv is written in Rust and works almost entirely in parallel, making it 10 to 100 times faster.
You don't need to manually run python -m venv .venv and then activate it. Running uv run main.py automatically finds (or creates) the right virtual environment and runs your script inside it.
The uv.lock file in the project records the exact version of every package. This means that even if you switch computers months later, or hand the project to a colleague, running uv sync restores the exact same environment, avoiding the classic "works on my machine" problem.
uv is a Python package and project manager developed by the Astral team, and it has become extremely popular in the Python ecosystem recently. Astral is also the team behind the well-known Python linter Ruff. uv itself is written in Rust, so its core characteristics are speed, simplicity, and reproducibility.
uv's goal is to replace common tools like pip, pip-tools, venv, and pyenv. Its most notable feature is extreme speed, often 10 to 100 times faster than traditional Python package management tools.
- Powered by Rust: it takes advantage of Rust's concurrency and efficient memory management, making package resolution and installation very fast.
- A single binary:
uvis a standalone tool, so you don't need a pre-configured Python environment before using it. - Global cache:
uvuses content-addressed storage. If multiple projects use the same package, only one copy needs to be kept on disk; when building an environment,uvlinks it into the project via hard links, which keeps installation fast and saves disk space.
uv run is one of uv's most powerful features, which you can think of as "on-demand environment management".
- Automatic virtual environment creation: when you run
uv run,uvchecks whether a usable virtual environment already exists in the current directory. If not, it automatically creates.venvand ensures the dependencies match the project configuration. - Seamless script dependency integration: if a Python script includes metadata following PEP 723 at the top,
uv runcan automatically prepare the packages the script needs based on that metadata. Once the script finishes, there's no need to manually clean up any temporary environments.
python -c is a standard Python feature meaning "execute the following string as Python code".
uv run python -c "print('hello from uv')"Here, -c stands for command, and it's commonly used for quick tests, one-liner scripts, or embedding a small piece of Python logic in a shell script.
Here's what happens when you run this command:
uv run python -c "..."It triggers a modern Python execution workflow:
- Environment preparation:
uvquickly confirms that the current project has the correct Python virtual environment. - Dependency resolution: if the code or script metadata requires specific packages,
uvensures they are installed. - Code execution:
uvruns the code afterpython -cinside the isolated project environment. - Consistency: as long as
uvis available on the machine, the same command is more likely to produce consistent results across different computers, without needing to manually runpip installor activate a virtual environment first.
Your computer
│
├── Project A/
│ └── .venv/ ← Project A's independent environment
│ └── openai 1.0
│
├── Project B/
│ └── .venv/ ← Project B's independent environment
│ └── openai 2.0
│
└── System Python ← Best to avoid installing anything here
Each .venv is its own independent bubble, isolated from the others. uv is the modern tool that manages these bubbles for you.
| Scenario | Command |
|---|---|
| Set up the project for the first time | uv sync |
| Add a package | uv add openai |
| Remove a package | uv remove anthropic |
| Run the program | uv run main.py |
No manual activation. No fiddling with pip. uv handles everything.