I’ve been absolutely fascinated by the Model Context Protocol (MCP) lately—it’s one of those technologies that makes you go “why didn’t we have this sooner?” After building a couple of MCP servers (gopher-mcp and openzim-mcp), I’m excited to share what I’ve discovered about this game-changing approach to AI tooling.
Update (June 2025): I’ve split this comprehensive guide into two focused articles for better readability:
- Gopher MCP Server: Bringing 1991’s Internet to Modern AI - Focuses on the Gopher protocol, its history, and practical applications
- OpenZIM MCP Server: Offline Knowledge for AI Assistants - Covers offline Wikipedia access and ZIM format optimization
What is the Model Context Protocol?
Picture this: you want your AI assistant to access some external data or tool, but you need it to be secure, standardized, and not a complete nightmare to implement. That’s exactly what MCP solves.
It’s essentially a bridge that lets AI models safely interact with external resources—whether that’s browsing vintage internet protocols, searching through offline knowledge bases, or connecting to APIs. The brilliant part is how it standardizes these interactions.
Why MCP Gets Me Excited
- Security that actually works: Sandboxed execution with explicit permissions (no more “hope for the best” approaches)
- Standards done right: One consistent interface instead of reinventing the wheel for every tool
- Extensibility: Adding new capabilities feels natural, not like fighting the system
- Performance: Built-in caching and efficient resource handling
Architecture Patterns I’ve Discovered
Building MCP servers has been like solving a series of interesting puzzles. Each one taught me something new about how to structure these systems for maximum flexibility and maintainability. Here are the patterns that emerged from my experiments:
1. Resource-Centric Design
#[derive(Debug, Clone)]
pub struct Resource {
pub uri: String,
pub name: String,
pub description: Option<String>,
pub mime_type: Option<String>,
}
pub trait ResourceProvider {
async fn list_resources(&self) -> Result<Vec<Resource>, Error>;
async fn read_resource(&self, uri: &str) -> Result<Vec<u8>, Error>;
}
This pattern was a real “aha!” moment for me. By separating resource discovery from resource access, you can swap out backends without touching the core logic. It’s like having a universal adapter for different data sources.
2. Protocol Abstraction Layer
Here’s where things got interesting with gopher-mcp. I needed to support both Gopher and Gemini protocols, and initially thought about handling them separately. Then I realized they could share a common abstraction:
pub trait ProtocolHandler {
async fn fetch(&self, url: &str) -> Result<ProtocolResponse, Error>;
fn supports_url(&self, url: &str) -> bool;
}
pub struct GopherHandler;
pub struct GeminiHandler;
impl ProtocolHandler for GopherHandler {
async fn fetch(&self, url: &str) -> Result<ProtocolResponse, Error> {
// Gopher-specific implementation
}
fn supports_url(&self, url: &str) -> bool {
url.starts_with("gopher://")
}
}
The beauty of this approach? Adding support for a new protocol becomes almost trivial. No need to touch the core MCP logic—just implement the trait and you’re done.
3. Async-First Architecture
One thing I learned quickly: MCP servers need to handle multiple requests concurrently, and blocking operations are the enemy of responsiveness. Rust’s async ecosystem turned out to be perfect for this:
use tokio::sync::RwLock;
use std::collections::HashMap;
pub struct CachedResourceProvider {
cache: RwLock<HashMap<String, CachedResource>>,
provider: Box<dyn ResourceProvider + Send + Sync>,
}
impl CachedResourceProvider {
pub async fn get_resource(&self, uri: &str) -> Result<Vec<u8>, Error> {
// Check cache first
{
let cache = self.cache.read().await;
if let Some(cached) = cache.get(uri) {
if !cached.is_expired() {
return Ok(cached.data.clone());
}
}
}
// Fetch and cache
let data = self.provider.read_resource(uri).await?;
let mut cache = self.cache.write().await;
cache.insert(uri.to_string(), CachedResource::new(data.clone()));
Ok(data)
}
}
Case Study: OpenZIM MCP Server
Building openzim-mcp was like solving a performance puzzle with a really satisfying payoff. The goal was simple: make offline Wikipedia searches blazingly fast. The reality? ZIM files are compressed archives that require some clever engineering to search efficiently.
ZIM File Handling
Here’s the challenge that kept me up late: ZIM files contain compressed Wikipedia content, and you need to search through millions of articles without decompressing everything. It’s like trying to find a specific book in a library where all the books are in sealed boxes.
use zim::Zim;
use tantivy::{Index, schema::*, collector::TopDocs};
pub struct ZimResourceProvider {
zim: Zim,
search_index: Index,
}
impl ZimResourceProvider {
pub async fn search(&self, query: &str, limit: usize) -> Result<Vec<SearchResult>, Error> {
let reader = self.search_index.reader()?;
let searcher = reader.searcher();
let query_parser = QueryParser::for_index(&self.search_index, vec![self.content_field]);
let query = query_parser.parse_query(query)?;
let top_docs = searcher.search(&query, &TopDocs::with_limit(limit))?;
let mut results = Vec::new();
for (_score, doc_address) in top_docs {
let retrieved_doc = searcher.doc(doc_address)?;
results.push(self.doc_to_search_result(retrieved_doc)?);
}
Ok(results)
}
}
Performance Tricks I Discovered
The performance optimizations were where this project got really interesting:
- Lazy Loading: Don’t load what you don’t need—ZIM entries are loaded on demand
- Full-Text Search: Tantivy turned out to be perfect for this—fast indexing and lightning-quick searches
- Memory Mapping: Let the OS handle caching with memory-mapped files (sometimes the OS is smarter than you are)
- Connection Pooling: Reuse expensive resources instead of recreating them
Case Study: Gopher MCP Server
Now, gopher-mcp was a completely different kind of fun. Who knew that protocols from the early 90s could teach you so much about clean, simple design?
Protocol Implementation
use tokio::net::TcpStream;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
pub struct GopherClient;
impl GopherClient {
pub async fn fetch(&self, url: &GopherUrl) -> Result<GopherResponse, Error> {
let mut stream = TcpStream::connect((url.host.as_str(), url.port)).await?;
// Send Gopher request
let request = format!("{}\r\n", url.selector);
stream.write_all(request.as_bytes()).await?;
// Read response
let mut buffer = Vec::new();
stream.read_to_end(&mut buffer).await?;
Ok(GopherResponse::parse(buffer, url.item_type)?)
}
}
Content Type Detection
Gopher uses a simple but effective type system:
#[derive(Debug, Clone, Copy)]
pub enum GopherItemType {
TextFile = b'0',
Directory = b'1',
PhoneBook = b'2',
Error = b'3',
BinHexFile = b'4',
BinaryFile = b'9',
// ... more types
}
impl GopherItemType {
pub fn to_mime_type(self) -> &'static str {
match self {
Self::TextFile => "text/plain",
Self::Directory => "text/gopher-menu",
Self::BinaryFile => "application/octet-stream",
// ... more mappings
}
}
}
Best Practices for MCP Server Development
1. Error Handling
Implement comprehensive error handling with context:
use thiserror::Error;
#[derive(Error, Debug)]
pub enum McpError {
#[error("Network error: {0}")]
Network(#[from] std::io::Error),
#[error("Protocol error: {message}")]
Protocol { message: String },
#[error("Resource not found: {uri}")]
ResourceNotFound { uri: String },
}
2. Configuration Management
Use structured configuration with validation:
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, Serialize)]
pub struct ServerConfig {
pub bind_address: String,
pub max_connections: usize,
pub cache_size: usize,
pub timeout_seconds: u64,
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
bind_address: "127.0.0.1:8080".to_string(),
max_connections: 100,
cache_size: 1024 * 1024 * 100, // 100MB
timeout_seconds: 30,
}
}
}
3. Testing Strategy
Implement comprehensive testing including integration tests:
#[cfg(test)]
mod tests {
use super::*;
use tokio_test;
#[tokio::test]
async fn test_resource_provider() {
let provider = MockResourceProvider::new();
let result = provider.read_resource("test://example").await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_protocol_handler() {
let handler = GopherHandler::new();
assert!(handler.supports_url("gopher://example.com/"));
assert!(!handler.supports_url("http://example.com/"));
}
}
Performance Considerations
Memory Management
- Use streaming for large resources
- Implement proper caching strategies
- Monitor memory usage in production
Concurrency
- Design for high concurrency from the start
- Use appropriate synchronization primitives
- Consider backpressure mechanisms
Network Efficiency
- Implement connection pooling
- Use compression when appropriate
- Handle network timeouts gracefully
Deployment and Monitoring
Docker Deployment
FROM rust:1.75 as builder
WORKDIR /app
COPY . .
RUN cargo build --release
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates
COPY --from=builder /app/target/release/mcp-server /usr/local/bin/
EXPOSE 8080
CMD ["mcp-server"]
Health Checks
Implement health check endpoints for monitoring:
pub async fn health_check() -> impl Reply {
warp::reply::with_status("OK", StatusCode::OK)
}
What’s Next in MCP Land
The MCP ecosystem is evolving fast, and there are some exciting directions I’m exploring:
- Streaming Responses: For when you’re dealing with massive datasets and don’t want users waiting forever
- Authentication: Figuring out secure access patterns that don’t make developers cry
- Federation: Connecting multiple MCP servers—imagine the possibilities!
- Observability: Better monitoring and debugging tools (because debugging distributed systems is hard enough already)
Wrapping Up
Building MCP servers has been one of those projects where each challenge taught me something new. The patterns I’ve shared here emerged from real-world experimentation, plenty of “that didn’t work” moments, and the occasional “wait, that’s actually brilliant” breakthrough.
The key insight? Start simple, measure everything, and don’t be afraid to iterate. The Model Context Protocol is still young, but it’s already changing how we think about AI tooling. By building robust, well-designed servers, we’re not just solving today’s problems—we’re laying the foundation for AI assistants that are more capable, more secure, and more useful than ever before.
And honestly? That’s pretty exciting.
Dive Deeper
For more focused, practical guides on building specific types of MCP servers, check out these detailed articles:
- Gopher MCP Server: Bringing 1991’s Internet to Modern AI - Learn about implementing protocol handlers, Gopher’s fascinating history, and practical applications for alternative internet protocols
- OpenZIM MCP Server: Offline Knowledge for AI Assistants - Discover how to build offline knowledge systems, optimize ZIM file handling, and create AI assistants that work without internet connectivity
Want to explore these concepts further? Check out the gopher-mcp and openzim-mcp repositories for complete implementations.