Content Management MCP Server: Design Decisions & Architectural Reasoning

Content Management MCP Server: Design Decisions & Architectural Reasoning
Technology Stack
| Component | Technology | Purpose |
|---|---|---|
| Runtime | Node.js 20 | JavaScript runtime for serverless functions |
| Framework | TypeScript | Type safety and development experience |
| Protocol | Model Context Protocol (MCP) | AI agent communication standard |
| Database | Firebase Firestore | Document-based NoSQL database |
| Authentication | Firebase Auth | User authentication and authorization |
| Cloud Platform | Google Cloud Functions | Serverless request handling |
| Transport | HTTP/HTTPS | MCP message transport layer |
| Build System | npm + tsc | Package management and compilation |
| Testing | Jest + Supertest | Unit and integration testing |
| Validation | Zod | Runtime schema validation |
Table of Contents
- Constitutional Principles Framework
- Core Architectural Decisions
- Technical Evolution & Tradeoffs
- Implementation Patterns
- Testing Strategy
- Tradeoff Analysis
Constitutional Principles Framework
The project operates under a "constitutional" set of architectural principles that govern all feature development. This creates consistency and maintainability across the entire system.
Core Constitutional Principles
1. Stateless Architecture
Principle: Every request creates a fresh MCP server instance with no session state.
// functions/src/index.ts
app.post('/', verifyFirebaseAuth, async (req, res) => {
try {
// Create NEW server instance per request (stateless pattern)
const server = getServer();
// Create NEW transport per request
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined // Explicitly no sessions
});
// Clean up resources when response closes
res.on('close', () => {
try {
transport.close();
server.close();
} catch (error) {
console.error('Error cleaning up:', error);
}
});
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
} catch (err) {
// Error handling...
}
});
Reasoning: Prevents session conflicts when multiple AI clients use the server concurrently. This trade-off accepts higher memory/CPU overhead for better concurrency safety.
2. Comprehensive Testing Strategy
Principle: All tools MUST have unit tests running against Firebase emulators. Tests MUST use isolated environments with seeded data to ensure reproducible results.
// functions/test/tools/registry.test.ts
describe('Registry Tools', () => {
beforeEach(async () => {
// Fresh emulator state for each test
await clearFirestoreEmulator();
await seedTestRegistry();
});
it('should return dataset info with proper schema validation', async () => {
const result = await getDatasetInfoResult({ siteId: 'test-site' });
expect(result.datasets).toHaveLength(2);
expect(result.datasets[0]).toMatchObject({
name: 'sportsbooks',
label: 'Sportsbooks',
fieldCount: 15
});
});
it('should handle missing datasets gracefully', async () => {
const result = await getDatasetInfoResult({ siteId: 'nonexistent' });
expect(result.datasets).toHaveLength(0);
expect(result.warnings).toContain('No datasets found for site');
});
});
Testing Requirements:
- Emulator isolation: Each test gets a clean Firestore state
- Seeded data: Consistent test datasets across all test runs
- Real queries: Tests use actual Firestore queries, not mocks
- Edge cases: Missing data, malformed inputs, timeout scenarios
Rationale: Emulator-based testing provides fast feedback loops while ensuring production compatibility and preventing regression in complex Firestore query logic. Mocking Firestore would miss query optimization issues and compound index requirements.
TDD for AI-Assisted Development (Vibe Coding)
The comprehensive testing strategy becomes even more critical when using AI assistants for code generation. Tests serve as the source of truth for AI implementations:
// AI uses this test to understand what needs to be implemented
it('should resolve tokens with soft validation enabled', async () => {
const html = '<p>Price: {{ds:sportsbooks/draftkings.bonus.amount|$100}}</p>';
const result = await resolveTokens(html, {
siteId: 'test-site'
}, true); // soft validation = true
expect(result.html).toBe('<p>Price: $250</p>');
expect(result.warnings).toHaveLength(0);
expect(result.errors).toHaveLength(0);
});
AI Development Workflow:
- Write test first - Defines expected behavior precisely
- AI implements - Uses test as specification
- Run test - Immediate verification of AI implementation
- Iterate - AI fixes failures based on test output
Why TDD Works for AI:
- Unambiguous specification: Tests remove interpretation ambiguity
- Instant feedback: AI knows immediately if implementation is correct
- Regression prevention: Previous implementations stay working
- Edge case coverage: Tests force AI to handle error conditions
This approach transforms AI from "code generator" to "implementation partner" where tests guide the AI toward correct solutions.
3. Security-First Authentication
Principle: All endpoints MUST require valid Firebase ID tokens. Development shortcuts that bypass authentication MUST be explicitly documented and disabled in production.
// Primary authentication: Firebase ID tokens with API key fallback
export async function verifyFirebaseAuth(req: Request, res: Response, next: NextFunction) {
ensureFirebaseInitialized();
// Extract Bearer token from Authorization header
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({
jsonrpc: '2.0',
error: { code: 401, message: 'Missing or malformed bearer token' },
id: null,
});
}
const token = authHeader.substring(7);
try {
// Primary: Firebase ID token verification
const decodedToken = await getAuth().verifyIdToken(token);
req.user = decodedToken;
next();
} catch (authError) {
console.error('Firebase auth verification failed, trying API key:', authError);
// Fallback: API key authentication
try {
const apiKeyUser = await verifyApiKey(token);
if (apiKeyUser) {
req.user = apiKeyUser;
return next();
}
} catch (apiKeyError) {
console.error('API key verification also failed:', apiKeyError);
}
// Both authentication methods failed - return appropriate error
let message = 'Invalid or expired bearer token';
if (authError && typeof authError === 'object' && 'code' in authError) {
const code = (authError as { code: string }).code;
if (code === 'auth/id-token-expired') message = 'Bearer token has expired';
else if (code === 'auth/id-token-revoked') message = 'Bearer token has been revoked';
}
res.status(403).json({
jsonrpc: '2.0',
error: { code: 403, message },
id: null,
});
}
}
API Key Security with Audit Trail:
async function verifyApiKey(keyValue: string): Promise<DecodedIdToken | null> {
const db = getFirestore();
// Find active API key with compound security checks
const apiKeysQuery = await db
.collection('api_keys')
.where('keyValue', '==', keyValue)
.where('status', '==', 'active')
.limit(1)
.get();
if (apiKeysQuery.empty) return null;
const keyDoc = apiKeysQuery.docs[0];
const keyData = keyDoc.data();
// Update usage tracking atomically
await keyDoc.ref.update({
lastUsedAt: new Date(),
usageCount: (keyData.usageCount || 0) + 1,
});
// Create comprehensive audit trail
await db.collection('api_keys_audit').add({
action: 'used',
keyId: keyDoc.id,
userId: keyData.userId || 'unknown',
keyData: {
name: keyData.name,
status: keyData.status,
usageCount: (keyData.usageCount || 0) + 1,
lastUsedAt: new Date(),
},
timestamp: new Date(),
requestId: `req_${Date.now()}_${Math.random().toString(36).substring(2)}`,
});
// Return Firebase-compatible user context
return {
uid: `apikey:${keyDoc.id}`,
email: `api-key+${keyDoc.id}@example.com`,
} as DecodedIdToken;
}
Development Security Controls:
// Explicit development bypass with safety controls
const forceAuthHeader = req.headers['x-test-force-auth'];
const envAllowsBypass = process.env.MCP_DEV_ALLOW_ANY_BEARER === 'true';
const testForcesAuth = forceAuthHeader === 'true';
// Only bypass if environment allows it AND test doesn't force authentication
const shouldBypass = envAllowsBypass && !testForcesAuth;
if (shouldBypass) {
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
// Create mock user object for development
req.user = {
uid: 'dev-user',
email: 'dev@example.com',
} as DecodedIdToken;
return next();
}
}
TypeScript Integration for User Context:
// Extend Express Request type to include authenticated user
declare module 'express-serve-static-core' {
interface Request {
user?: DecodedIdToken;
}
}
// All endpoints have guaranteed user context after authentication
app.post('/', verifyFirebaseAuth, async (req, res) => {
const userContext = req.user; // TypeScript knows this exists and is typed
const server = getServer(userContext);
// ... rest of MCP handling
});
Reasoning:
- Dual authentication paths: Firebase ID tokens (primary) with API key fallback for different client types
- Comprehensive audit trails: Every API key usage creates detailed audit logs with request tracking
- Development safety: Environment-based bypasses with explicit test override controls
- Error specificity: Detailed error codes help clients understand authentication failures
- TypeScript safety: User context is properly typed and guaranteed after middleware
- Resource tracking: API key usage counting prevents abuse and enables billing/analytics
This approach ensures enterprise-grade security while maintaining flexibility for different authentication scenarios and comprehensive audit compliance.
4. Comprehensive Observability
Principle: Every operation must be logged and traceable.
// functions/src/tools/registry.ts
export function registerRegistryTools(server: McpServer): void {
server.registerTool('get_dataset_info', { /* schema */ }, async (args) => {
const requestRef = await logRequest({
toolId: 'get_dataset_info',
input: args
});
try {
const result = await getDatasetInfoResult(metadata);
await logResponse({
requestRef,
ok: true,
outputPreview: `Found ${result.datasets.length} datasets`
});
return { content: [{ type: 'json', json: result }] };
} catch (error) {
await logResponse({
requestRef,
ok: false,
outputPreview: error.message
});
throw error;
}
});
}
Reasoning: Provides debugging capability and usage analytics, but adds latency and storage costs to every operation.
Core Architectural Decisions
1. Token Grammar Evolution
Before: Ambiguous Parsing
{{ds:sportsbooks.draftkings.bonus.amount}}
Problem: Is draftkings.bonus the key or is draftkings the key with bonus.amount as the field path?
After: Explicit Structure
{{ds:sportsbooks/draftkings.bonus.amount}}
Solution: The / separator makes it clear: sportsbooks is dataset, draftkings is key, bonus.amount is field path.
// functions/src/types.ts
export interface DatasetTokenData {
dataset: string; // e.g., "sportsbooks"
key: string; // e.g., "draftkings"
field: string; // e.g., "bonus.amount"
defaultValue?: string;
}
Reasoning: Eliminates parsing ambiguity when keys contain dots. Required migrating existing content but provided clearer semantics.
2. Dual Interface Strategy
MCP Protocol (for AI assistants)
// functions/src/tools/registry.ts
server.registerTool('get_dataset_info', {
title: 'Dataset Discovery',
description: 'Returns comprehensive information about available datasets',
inputSchema: {
metadata: MetadataSchema.optional(),
}
}, async ({ metadata }: { metadata?: Metadata }) => {
const result = await getDatasetInfoResult(metadata);
return {
content: [{
type: 'text',
text: JSON.stringify(result, null, 2)
}]
};
});
REST API (for external systems)
// functions/src/api.ts
app.get('/api/schema', async (req: Request, res: Response) => {
try {
const { dataset, siteId, stateCode, locale } = req.query;
const metadata = siteId ? { siteId, stateCode, locale } : undefined;
if (dataset) {
const result = await getSchemaResult(dataset as string, metadata);
res.json(result);
} else {
const result = await getAllSchemasResult(metadata);
res.json(result);
}
} catch (error) {
res.status(500).json({
ok: false,
error: { code: 'INTERNAL_ERROR', message: error.message }
});
}
});
Reasoning: AI assistants need rich, structured MCP tools while WordPress/external systems need simple HTTP endpoints. By implementing both interfaces in the same project, we achieve:
Shared Business Logic: Core functions like getSchemaResult(), validateContent(), and resolveTokens() are reused by both interfaces
Single Database: Both query the same Firestore collections, ensuring data consistency
Unified Authentication: Same Firebase auth system protects both MCP and REST endpoints
Synchronized Updates: Changes automatically apply to both interfaces without dual maintenance
Reduced Complexity: One codebase, one deployment, one test suite instead of separate projects
The alternative would require duplicating all core registry logic and keeping two projects synchronized.
3. Soft Validation Mode
// functions/src/tools/lint.ts
export async function lintArticle(
content: string,
useSoftValidation?: boolean
): Promise<LintArticleResult> {
const issues = await validateContent(content);
if (useSoftValidation) {
// Convert errors to warnings in soft mode
const softIssues = issues.map(issue => ({
...issue,
severity: issue.severity === 'error' ? 'warn' : issue.severity
}));
return {
isValid: true, // Always valid in soft mode
issues: softIssues,
registryVersion: await getRegistryVersion()
};
}
return {
isValid: issues.filter(i => i.severity === 'error').length === 0,
issues,
registryVersion: await getRegistryVersion()
};
}
Reasoning: Content creators shouldn't be blocked by minor validation issues. This allows workflows to continue while still providing feedback, trading strict validation for user experience.
Technical Evolution & Tradeoffs
1. Authentication Strategy
Dual Authentication Pattern
// functions/src/auth.ts
export const verifyFirebaseAuth = async (req: Request, res: Response, next: NextFunction) => {
const authHeader = req.headers.authorization;
if (authHeader?.startsWith('Bearer ')) {
const token = authHeader.substring(7);
// Try Firebase ID token first
try {
const decodedToken = await getAuth().verifyIdToken(token);
req.user = decodedToken;
return next();
} catch (firebaseError) {
// Fallback to API key authentication
const userContext = await verifyApiKey(token);
if (userContext) {
req.user = userContext;
return next();
}
}
}
return res.status(401).json({ error: 'Unauthorized' });
};
async function verifyApiKey(keyValue: string): Promise<DecodedIdToken | null> {
const db = getFirestore();
const apiKeysQuery = await db
.collection('api_keys')
.where('keyValue', '==', keyValue)
.where('status', '==', 'active')
.limit(1)
.get();
if (apiKeysQuery.empty) return null;
const keyDoc = apiKeysQuery.docs[0];
const keyData = keyDoc.data();
// Update usage tracking
await keyDoc.ref.update({
lastUsedAt: new Date(),
usageCount: (keyData.usageCount || 0) + 1
});
// Return user-like context
return {
uid: keyData.createdBy,
// ... other fields
} as DecodedIdToken;
}
Authentication Types:
- Firebase ID tokens for authenticated users (short-lived, secure)
- API keys for external systems (long-lived, convenient)
Reasoning: The dual authentication strategy reflects different security requirements:
Admin Features: Use Firebase ID tokens because admin users are already authenticated in the frontend, which can handle token refresh automatically. These operations modify data and require robust authentication.
Read-Only Integrations: Use API keys for WordPress and n8n because they only perform reads, don't need token refresh complexity, and benefit from simpler long-lived authentication.
This approach balances security with practicality - high-security authentication where data modification occurs, simpler authentication for read-only operations.
2. Registry Version Management
// functions/src/lib/firestoreRegistry.ts
export async function getRegistryVersion(): Promise<string> {
const cacheKey = 'registry:version';
const cached = getCache<string>(cacheKey);
if (cached) return cached;
const db = getDb();
const docSnap = await db.collection('registry').doc('version').get();
const rawVersion = docSnap.exists ? docSnap.data()?.version || '1.0.0' : '1.0.0';
// Ensure version is always a string
const version = typeof rawVersion === 'number' ? rawVersion.toString() : rawVersion;
setCache(cacheKey, version);
return version;
}
// All results include this for cache invalidation
export interface DatasetSchemaResult {
key: { fields: string[] };
fields: Array<{ path: string; type: string }>;
shortcodes: Array<{ name: string; props: any }>;
constraints?: any;
registryVersion: string; // Always included
}
Reasoning: Uses global registry version for cache invalidation rather than per-dataset versions. Simpler to manage but means any registry change invalidates all caches.
3. Configuration-Driven Linting
// functions/src/types.ts
export interface LinterConfiguration {
version?: string;
issueTypes: Record<string, IssueTypeDefinition>;
defaultSeverity?: Severity;
globalSettings?: GlobalLinterSettings;
}
export interface IssueTypeDefinition {
severity: Severity;
message: string;
suggestedFix?: string;
category?: string;
enabled?: boolean;
}
// functions/linter.config.json (example)
{
"version": "1.0.0",
"issueTypes": {
"INVALID_TOKEN_FORMAT": {
"severity": "error",
"message": "Token must follow {{ds:dataset/key.field}} format exactly",
"suggestedFix": "Use format: {{ds:dataset/key.field}}",
"category": "syntax"
},
"UNKNOWN_DATASET": {
"severity": "warning",
"message": "Dataset not found in registry",
"suggestedFix": "Check available datasets or create new dataset",
"category": "validation"
}
},
"defaultSeverity": "warning"
}
Reasoning: Teams can customize linting rules without code changes by editing JSON configuration files. This provides flexibility but requires careful documentation and testing of configuration options.
Implementation Patterns
1. MCP Tool Registration Pattern
All tools follow a consistent pattern for logging, error handling, and response formatting:
// functions/src/tools/registry.ts
export function registerRegistryTools(server: McpServer): void {
server.registerTool(
'insert_token',
{
title: 'Insert Token',
description: 'Create a token string for dataset/key/path combination',
inputSchema: {
dataset: z.string(),
key: z.string(),
path: z.string(),
defaultValue: z.string().optional(),
useSoftValidation: z.boolean().optional()
}
},
async ({ dataset, key, path, defaultValue, useSoftValidation }) => {
let requestRef;
try {
// 1. Log the request
requestRef = await logRequest({
toolId: 'insert_token',
input: { dataset, key, path, defaultValue }
});
// 2. Execute the tool logic
const result = await insertTokenResult(
dataset, key, path, defaultValue, useSoftValidation
);
// 3. Log successful response
await logResponse({
requestRef,
toolId: 'insert_token',
ok: true,
outputPreview: `Token: ${result.token}${
result.warnings?.length ? ` with ${result.warnings.length} warnings` : ''
}`,
validationMode: (useSoftValidation ?? true) ? 'soft' : 'strict',
warningCount: result.warnings?.length || 0
});
// 4. Return standardized response
return {
content: [{
type: 'text',
text: JSON.stringify(result, null, 2)
}]
};
} catch (error) {
// 5. Log error response
if (requestRef) {
await logResponse({
requestRef,
toolId: 'insert_token',
ok: false,
outputPreview: error instanceof Error ? error.message : String(error)
});
}
throw error;
}
}
);
}
Pattern Benefits:
- Consistent logging across all tools
- Standardized error handling
- Uniform response format
- Request/response tracing
2. Firestore Registry Pattern
// functions/src/lib/firestoreRegistry.ts
const cache = new Map<string, { data: unknown; expires: number }>();
function setCache<T>(key: string, data: T, ttlMs: number = 5 * 60 * 1000): void {
cache.set(key, { data, expires: Date.now() + ttlMs });
}
function getCache<T>(key: string): T | null {
const entry = cache.get(key);
if (!entry || Date.now() > entry.expires) {
cache.delete(key);
return null;
}
return entry.data as T;
}
export async function getDatasets(): Promise<Array<{ name: string; label: string; version: string }>> {
const cacheKey = 'datasets:list';
const cached = getCache<Array<{ name: string; label: string; version: string }>>(cacheKey);
if (cached) return cached;
const db = getDb();
const snap = await db.collection('registry_datasets').get();
const datasets: Array<{ name: string; label: string; version: string }> = [];
snap.forEach(doc => {
const d = doc.data() as RegistryDatasetDoc;
if (d.enabled === false) return; // filter disabled
if (!d.name) d.name = doc.id; // fallback
datasets.push({
name: d.name,
label: d.label || d.name,
version: d.version || '1.0.0'
});
});
datasets.sort((a, b) => a.name.localeCompare(b.name));
setCache(cacheKey, datasets);
return datasets;
}
Pattern Benefits:
- In-memory caching with TTL (5 minutes default)
- Firestore as source of truth
- Graceful fallbacks for missing data
- Performance optimization for repeated queries
Testing Strategy
Firebase Emulator Requirements
The constitutional requirement for Firebase emulator integration creates specific testing constraints:
// vitest.config.ts - Required configuration
export default defineConfig({
test: {
environment: 'node',
poolOptions: {
threads: {
singleThread: true, // Firebase emulator limitation
isolate: false, // Prevents test isolation issues
}
},
maxConcurrency: 1, // Sequential execution only
testTimeout: 60000, // Longer timeouts for emulator operations
}
});
// Test setup pattern
beforeAll(async () => {
// Set emulator environment before any Firebase imports
process.env.GCLOUD_PROJECT = 'test-project';
process.env.FIRESTORE_EMULATOR_HOST = 'localhost:8080';
// Seed test data
await seedTestData();
});
Test Data Seeding Pattern
// functions/test/helpers/seedData.ts
export async function seedTestData(): Promise<void> {
const db = getFirestoreInstance();
// Clear existing data
await clearCollection(db, 'registry_datasets');
await clearCollection(db, 'shortcodes');
// Seed registry version
await db.collection('registry').doc('version').set({
version: '20250905T120000Z'
});
// Seed datasets
await db.collection('registry_datasets').doc('sportsbooks').set({
name: 'sportsbooks',
label: 'Sportsbooks',
version: '1.0.0',
enabled: true
});
// Seed shortcodes with parent-child relationships
await db.collection('shortcodes').doc('faqs').set({
name: 'faqs',
description: 'FAQ section container',
parameters: {},
status: 'active'
});
await db.collection('shortcodes').doc('faq').set({
name: 'faq',
description: 'Individual FAQ item',
parentId: 'faqs', // Child references parent
parameters: {
question: { type: 'string', required: true },
answer: { type: 'string', required: true }
},
status: 'active'
});
}
Tradeoff Analysis
Summary of Key Architectural Tradeoffs
| Decision | Benefit | Cost | Reasoning |
|---|---|---|---|
| Stateless MCP Server | No session conflicts, better concurrency | Higher memory/CPU per request | AI assistant integration requires concurrent safety |
| Firebase-First | Managed infrastructure, serverless scaling | Vendor lock-in, cost at scale | Rapid development, built-in authentication |
| Dual Interfaces (MCP + REST) | Flexibility for different clients | Code duplication, maintenance overhead | AI tools need rich interfaces, external systems need HTTP |
| Soft Validation Mode | Better user experience, workflow continuity | Risk of invalid content in production | Content creators shouldn't be blocked by minor issues |
| Token Grammar Change | Clearer parsing, no ambiguity | Migration effort for existing content | Long-term maintainability over short-term convenience |
| Global Registry Versioning | Simpler cache management | Cache invalidation affects everything | Reduced complexity in version tracking |
| Comprehensive Logging | Full observability, debugging capability | Storage costs, request latency | Required for enterprise debugging and analytics |
| Sequential Testing | Reliable emulator-based tests | Slower test execution | Firebase emulator limitations force this choice |
| Parent-Child Simplification | Intuitive relationships, easier validation | Migration from existing schema | Scalability and maintainability improvements |
| Configuration-Driven Linting | Flexible rule customization | Complex configuration management | Teams need different validation rules |
Performance vs. Observability Trade-off
// Every operation includes comprehensive logging
const requestRef = await logRequest({
toolId: 'resolve_tokens',
input: { html: truncatePayload(html, 4096), metadata }
});
// This adds ~50-100ms per request but provides:
// - Full request/response audit trail
// - Usage analytics
// - Error debugging capability
// - Performance monitoring data
Decision: Prioritize observability over raw performance for enterprise requirements.
Flexibility vs. Complexity Trade-off
// Supporting both MCP and REST requires duplicate logic
// MCP Tool
server.registerTool('get_schema', { /* schema */ }, async (args) => {
return await getSchemaResult(args.dataset, args.metadata);
});
// REST API
app.get('/api/schema', async (req, res) => {
const result = await getSchemaResult(req.query.dataset, extractMetadata(req));
res.json(result);
});
Decision: Accept complexity to support both AI assistants (MCP) and traditional systems (HTTP).
Conclusion
Building this content management MCP Server required balancing competing priorities: AI-first design patterns, enterprise reliability requirements, and developer experience concerns. The "constitutional principles" framework helped maintain consistency across a complex system with multiple interfaces and strict observability requirements.
The key insight was recognizing that architectural decisions aren't just about technical optimization—they're about creating sustainable patterns that enable the team to move fast while maintaining system integrity. Sometimes that means accepting performance costs for better debugging (comprehensive logging), or complexity costs for better flexibility (dual interfaces).
These patterns and principles continue to evolve as we learn more about building AI-integrated systems at scale, but the constitutional framework provides a stable foundation for making consistent decisions as requirements change.
Brian Wight
Technical leader and entrepreneur focused on building scalable systems and high-performing teams. Passionate about ownership culture, data-driven decision making, and turning complex problems into simple solutions.