My AI Couldn’t See My Files
A clear and practical article about artificial intelligence for a professional audience.
Tags
Quick summary
A clear and practical article about artificial intelligence for a professional audience.
My AI Couldn’t See My Files — I Built a Zero-Dependency MCP Server
I spend most of my day in a terminal. My notes live in Markdown files scattered across project directories, my code is in Git repos, and my task lists sit in plain text. When large language models first arrived on the scene, I was excited to use them to navigate this mess. I set up local inference with Ollama, pulled a capable open-weight model, and fired off a prompt: “Summarize the architecture decisions in `~/projects/api/README.md`.” The response was polite, confident, and completely useless. “I’m sorry, but I don’t have access to your local files.”
The model was running on my machine, consuming my electricity, and yet it was blind to the data right next to it. That disconnect is one of the most frustrating parts of working with local AI. You have a powerful reasoning engine sitting inches away from your data, separated by an invisible wall. I knew I needed a bridge. I considered heavy frameworks, cloud APIs, and elaborate RAG pipelines, but what I really wanted was something minimal: a tiny, transparent server that could hand my local AI a file when it asked for one, with no external dependencies, no mystery packages, and no magic.
That is how I ended up building a zero-dependency Model Context Protocol (MCP) server.
The Problem: A Smart Brain with No Eyes
Local AI has come a long way. Open-weight models from Mistral AI and the broader community, along with tools like Ollama, have made it trivial to run capable assistants on consumer hardware. The catch is that these models are intentionally sandboxed. They read from standard input and write to standard output. They do not browse your file system, query your database, or check your calendar unless something explicitly connects them to those resources.
You can work around this by copying and pasting file contents into the chat window, but that breaks flow and quickly runs into context limits. You can build a full retrieval-augmented generation pipeline, but that is often overkill for simple tasks like “read this file” or “list the files in that directory.” What is missing is a lightweight, standardized way for the model to request context from the outside world while it is thinking.
The Model Context Protocol was designed to fill exactly that gap. It defines how an AI assistant can discover and call tools, read resources, and exchange structured messages with external systems. The protocol is transport-agnostic, but for local use cases, the standard input/output (stdio) transport is particularly elegant. The model speaks to your server over stdin and stdout as if it were chatting with any other command-line utility.
Why Zero Dependencies?
When I started looking into MCP, I found SDKs and helper libraries that could scaffold a server in minutes. They are excellent for production use, but they come with costs: additional packages to audit, another layer of abstraction to debug, and a dependency graph that might change underneath you. For a tool whose sole purpose is to read local files, importing a heavy framework felt like using a freight truck to deliver a letter.
A zero-dependency approach means writing the server using only the standard library of your chosen language. In Python, that is `sys`, `json`, `os`, and `pathlib`. The benefits are immediate. The code is fully auditable in a single sitting. It runs on any system that has Python installed without `pip install` steps. There is no supply-chain risk from a compromised third-party package. If something breaks, you can trace the logic from the network boundary to the file system call in a few dozen lines.
This philosophy aligns well with the spirit of the local-AI movement. Communities around open models, such as those shared via Hugging Face or run locally with Ollama, frequently emphasize transparency and user control. A zero-dependency server extends that ethos to the integration layer. You own the entire stack, from the weights to the wiring.
Designing the Server
An MCP server over stdio is essentially a JSON-RPC 2.0 peer that reads newline-delimited messages from stdin and writes responses to stdout. The protocol has a well-defined lifecycle. First, the client sends an `initialize` request with protocol version and capabilities. The server responds in kind. After initialization, the client typically requests the list of available tools via `tools/list`. Finally, when the model decides it needs external data, the client sends a `tools/call` request.
For my use case, I needed exactly one tool: `read_file`. It takes a file path and returns the contents as text. I intentionally kept the server read-only. Giving an AI unrestricted write access to your file system is a powerful feature, but it is also a security nightmare. A read-only tool is easier to reason about, easier to debug, and sufficient for the vast majority of “please look at this” tasks.
The server also needs to handle two practical concerns: path validation and error reporting. Without validation, a clever prompt or a hallucinated path could trick the tool into reading sensitive system files. The server should reject paths that escape a predefined working directory and should return clean error messages that the model can understand and relay back to the user.
A Minimal Implementation
Below is the complete server I settled on. It uses only Python standard library modules. Save it as `file_mcp_server.py`, make it executable, and you have a working MCP tool provider.
```python #!/usr/bin/env python3 import sys import json import os from pathlib import Path
My AI Couldn’t See My Files — I Built a Zero-Dependency MCP Server
ALLOWED_BASE = Path.home() / "projects"
def send_message(msg: dict): """Write a JSON-RPC message to stdout with a Content-Length header.""" payload = json.dumps(msg, ensure_ascii=False) raw = payload.encode("utf-8") sys.stdout.buffer.write(f"Content-Length: {len(raw)}\r\n\r\n".encode("utf-8")) sys.stdout.buffer.write(raw) sys.stdout.buffer.flush()
def read_message() -> dict | None: """Read a JSON-RPC message from stdin using the LSP-style header.""" headers = {} while True: line = sys.stdin.readline() if not line: return None line = line.strip() if line == "": break key, value = line.split(":", 1) headers[key.strip()] = value.strip() length = int(headers.get("Content-Length", 0)) if length == 0: return None raw = sys.stdin.read(length) return json.loads(raw)
def validate_path(raw_path: str) -> Path: """Ensure the requested path is inside ALLOWED_BASE and exists.""" target = (ALLOWED_BASE / raw_path).resolve()
Resolve the base as well to handle symlinks consistently.
base_resolved = ALLOWED_BASE.resolve() if base_resolved not in target.parents and target != base_resolved: raise ValueError("Access denied: path escapes allowed directory.") if not target.is_file(): raise ValueError("File not found.") return target
def handle_request(req: dict) -> dict: """Route JSON-RPC requests to handlers.""" method = req.get("method") req_id = req.get("id") params = req.get("params", {})
if method == "initialize": return { "jsonrpc": "2.0", "id": req_id, "result": { "protocolVersion": "2024-11-05", "capabilities": {}, "serverInfo": {"name": "zero-dependency-filesystem", "version": "1.0.0"}, }, }
if method == "tools/list": return { "jsonrpc": "2.0", "id": req_id, "result": { "tools": [ { "name": "read_file", "description": "Read the contents of a text file.", "inputSchema": { "type": "object", "properties": { "path": { "type": "string", "description": "Relative path to the file under the allowed base directory.", } }, "required": ["path"], }, } ] }, }
if method == "tools/call": name = params.get("name") arguments = params.get("arguments", {}) if name == "read_file": try: target = validate_path(arguments["path"]) content = target.read_text(encoding="utf-8") return { "jsonrpc": "2.0", "id": req_id, "result": { "content": [{"type": "text", "text": content}], "isError": False, }, } except Exception as exc: return { "jsonrpc": "2.0", "id": req_id, "result": { "content": [{"type": "text", "text": str(exc)}], "isError": True, }, }
Default fallback for unhandled methods.
return { "jsonrpc": "2.0", "id": req_id, "error": {"code": -32601, "message": f"Method '{method}' not found."}, }
def main(): while True: msg = read_message() if msg is None: break response = handle_request(msg) if response: send_message(response)
if __name__ == "__main__": main() ```
The code is intentionally boring. It does not use async frameworks, worker pools, or type hints that require `typing_extensions`. It reads a header, reads a JSON payload, routes the request, and writes a response. The `validate_path` function is the security gate. It resolves the requested path against `ALLOWED_BASE` and refuses to serve anything outside that boundary. If you want to adapt this for your own directories, change `ALLOWED_BASE` at the top of the script.
Wiring It Up to the Client
Once the server exists, the remaining step is telling your MCP client how to launch it. Most MCP-compatible clients support a configuration file that maps tool server names to shell commands. You add an entry pointing to `python3 /absolute/path/to/file_mcp_server.py`, restart the client, and the `read_file` tool appears in the model’s environment.
From the user’s perspective, nothing changes in the chat interface. You still type natural language. But behind the scenes, when the model recognizes that it needs file content to answer your question, it emits a structured tool call. The client forwards that call to your zero-dependency server over stdio. The server reads the disk and returns the text. The client injects that text into the conversation context, and the model finally answers with the details you requested.
This flow feels magical the first time it works, but there is no magic in the implementation. It is plain text, standard streams, and a bit of JSON. That simplicity makes debugging straightforward. If the model claims it cannot read a file, you can launch the server manually in a terminal, send a JSON-RPC request by hand, and watch exactly what happens.
Security Guardrails
Giving an AI access to your files, even read-only access, deserves caution. The `validate_path` check shown above is the first line of defense, but it should not be the only one. Consider these additional practices when deploying a personal MCP server.
First, run the server with the lowest privileges necessary. Do not execute it as root or administrator. Create a dedicated user or simply run it under your normal account with a tightly scoped `ALLOWED_BASE`. Second, prefer relative paths inside a known workspace. Absolute paths are harder to validate and easier to abuse. Third, log every request. A simple `print` to stderr inside the server is enough to create an audit trail of which files the model asked for and when. If you see a request for `/etc/passwd`, you know something has gone wrong.
If you later expand the server with more tools—listing directories, searching with `grep`, or computing
Sources
FAQ
What is this article about?
This article covers “My AI Couldn’t See My Files” in the Local models category. A clear and practical article about artificial intelligence for a professional audience.
Who is this useful for?
It is useful for readers who want a practical understanding of AI tools, models, and workflows.
What should I do next?
Read the article, review the listed sources, and test the most relevant ideas in your own workflow.



