v1: The MCP Server That Taught Us What Not to Do
- macula
- MCP
- TypeScript
- security
- API design
- production
Model Context Protocol's becoming the standard way to connect AI agents to external data sources. When we decided to expose our Unified Link service through MCP, we ran into an unusual constraint: we needed a public, read-only MCP server that could serve AI agents without authentication while still being production-grade secure.
We already had a public API (unified-link) that served metadata about files, users, and keywords. We wanted AI agents to access this data through MCP, but the requirements were specific. No authentication (the data's already public). Read-only only. Production-grade rate limiting, input validation, and SQL injection protection. It needed to scale horizontally. And we wanted minimal dependencies.
Here's how we built it.
Our Design Choices
Direct SDK, Not Wrappers
We used @modelcontextprotocol/sdk directly with no wrapper libraries. That gave us full control over transport behavior, kept abstractions out of the way, and gave us direct access to the MCP spec.
Stateless Mode
The most impactful decision was using stateless mode for session management.
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined, // Stateless mode
});
Benefits: no session memory leaks, works with any number of server instances (no sticky sessions), no Redis needed for session storage, restart tolerant, simpler code.
The trade-off is that agents reinitialize after a disconnect. For a read-only public service, that's fine.
Zod for Validation
We went with Zod for input validation. It was already in our codebase, so it was an easy choice:
export const GetFileInputSchema = z.object({
unifiedId: z
.string()
.min(1, 'unifiedId is required')
.max(64, 'unifiedId must be at most 64 characters')
.regex(/^[a-zA-Z0-9_-]+$/, 'unifiedId must contain only alphanumeric characters'),
});
Defense in Depth
We built multiple security layers:
| Layer | Protection |
|---|---|
| Slow-down | Progressive delays after 100 requests |
| Rate limiting | Hard limit at 200 requests/minute |
| Input validation | Zod schemas reject invalid input |
| Input sanitization | Remove dangerous characters |
| SQL injection | Parameterized queries |
| Request timeout | 30 seconds max |
| Tool annotations | readOnly hints for agents |
Architecture
┌─────────────────────────────────────────────────────────────┐
│ AI Agent │
└─────────────────────────┬─────────────────────────────────┘
│ JSON-RPC
▼
┌─────────────────────────────────────────────────────────────┐
│ MCP Server │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ StreamableHTTPServerTransport (stateless) │ │
│ └─────────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Rate Limiting (slow-down + hard limit) │ │
│ └─────────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ McpServer │ │
│ │ - 14 Tools │ │
│ │ - 14 Prompts │ │
│ │ - 1 Resource │ │
│ └─────────────────────────────────────────────────────┘ │
│ │ │
│ ┌──────────────┴──────────────┐ │
│ ▼ ▼ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Redis │ │ PostgreSQL │ │
│ │ (cache + │ │ (Prisma) │ │
│ │ rate limit) │ │ │ │
│ └─────────────────┘ └─────────────────┘ │
└─────────────────────────────────────────────────────────────┘
Our 14 Tools
We exposed 14 read-only tools organized by domain:
Files (6)
| Tool | Input | Description |
|---|---|---|
get_file |
unifiedId |
Full metadata (title, description, creator, links, AI info) |
get_file_metadata |
unifiedId, a? |
EXIF/XMP metadata |
get_file_presets |
unifiedId |
Available renditions |
get_file_json_schema |
N/A | Schema for type-safe code |
get_metadata_json_schema |
N/A | Metadata schema |
list_files_for_ai |
allowed, limit?, page? |
Files with AI/data mining allowed |
Users (3)
| Tool | Input | Description |
|---|---|---|
get_user |
nickname |
User profile and directories |
list_user_files |
nickname, show?, take?, cursor?, sort? |
Paginated file listing |
list_random_files |
what, limit? |
Random file discovery |
Directories (2)
| Tool | Input | Description |
|---|---|---|
get_directory |
nickname, pathCid |
Directory metadata |
get_directory_files |
nickname, pathCid, take?, page? |
Files in directory |
Search (3)
| Tool | Input | Description |
|---|---|---|
search_keywords |
search?, page?, limit? |
Search keywords |
list_files_by_keyword |
keyword |
Files for a keyword |
list_files_by_license |
license, limit?, page? |
Files by license |
Our 14 Prompts
We built specialized prompts for common agent workflows:
| Prompt | Description |
|---|---|
discover_user_content |
Explore a creator's content |
content_analysis |
Analyze file metadata and quality |
search_discovery |
Find content via keywords |
license_discovery |
Find content by license type |
content_curation |
Curate content collections |
rights_audit |
Audit usage rights |
rights_verification |
Verify specific rights |
rendition_optimization |
Find optimal file versions |
creator_ecosystem |
Explore creator's network |
metadata_extraction |
Extract structured metadata |
random_exploration |
Discover random content |
directory_deep_dive |
Explore directory contents |
service_info |
Get service information |
data_mining_discovery |
Find AI-friendly content |
One Resource
| Resource | Description |
|---|---|
instructions |
Service usage guidelines for agents |
Security Implementation
Input Sanitization
We strip dangerous characters before any processing:
const DANGEROUS_CHARS_REGEX = /[<>'";&\\]/g;
export function sanitizeInput(input: string, maxLength = 100): string {
return input.replace(DANGEROUS_CHARS_REGEX, '').replace(/\s+/g, ' ').trim().slice(0, maxLength);
}
SQL Injection Prevention
All raw SQL uses parameterized queries:
// Good
await prismaClient.$queryRawUnsafe<...>('SELECT ... LIMIT $1', limit);
// Bad (never do this)
await prismaClient.$queryRawUnsafe<...>(`SELECT ... LIMIT ${limit}`);
Rate Limiting
Two-layer protection:
// Layer 1: Slow-down (progressive delays)
await instance.register(slowDownPlugin, {
delayAfter: 100,
delay: '100ms',
timeWindow: '1 minute',
});
// Layer 2: Hard limit
await instance.register(fastifyRateLimit, {
max: 200,
timeWindow: '1 minute',
redis, // Redis-backed for multi-instance
});
Input Validation Rules
| Field | Constraints |
|---|---|
unifiedId |
1-64 chars, regex ^[a-zA-Z0-9_-]+$ |
nickname |
1-32 chars, regex ^[a-zA-Z0-9_]+$ |
pathCid |
1-200 chars |
keyword |
1-100 chars |
limit |
1-100 |
page |
≥ 0 or ≥ 1 |
Development vs Production
When isDev is true, rate limiting and slow-down are disabled. Makes local testing easy while keeping production secure.
Results
| Metric | Value |
|---|---|
| Tools | 14 |
| Prompts | 14 |
| Resources | 1 |
| Security layers | 6 |
| Code complexity | Low |
| Dependencies | Minimal |
| Scalability | Horizontal |
Lessons Learned
What Went Right
Stateless mode was perfect for public read-only services. Direct SDK usage meant no abstraction overhead. Defense in depth gave us multiple security layers. Zod schemas became a single source of truth for validation and types.
What We'd Do Differently
Stateless from day one. We initially used stateful sessions, then switched. Would have saved the migration effort.
Centralized error handling. We built error responses ad-hoc and paid for it later. A single error handler from the start would have saved time.
Tool categories. Grouping tools would help agent discovery. Not critical with 4 tools, but worth planning for.
Building a public MCP server doesn't require complex authentication or session management. Stateless design, defense in depth, and existing tools (Zod, Prisma, Redis) got us a production-ready MCP server that's simple to maintain and scale.
Public, read-only MCP servers are much simpler than authenticated ones. That's the main lesson from this build. If you don't need auth, you can leave a lot of baggage behind.
Resources
Built with Fastify, TypeScript, Prisma, Redis, and ❤️
Published