Content Management MCP Server: Design Decisions & Architectural Reasoning

Content Management MCP Server: Design Decisions & Architectural Reasoning

Content Management MCP Server: Design Decisions & Architectural Reasoning

Technology Stack

ComponentTechnologyPurpose
RuntimeNode.js 20JavaScript runtime for serverless functions
FrameworkTypeScriptType safety and development experience
ProtocolModel Context Protocol (MCP)AI agent communication standard
DatabaseFirebase FirestoreDocument-based NoSQL database
AuthenticationFirebase AuthUser authentication and authorization
Cloud PlatformGoogle Cloud FunctionsServerless request handling
TransportHTTP/HTTPSMCP message transport layer
Build Systemnpm + tscPackage management and compilation
TestingJest + SupertestUnit and integration testing
ValidationZodRuntime schema validation

Table of Contents

  1. Constitutional Principles Framework
  2. Core Architectural Decisions
  3. Technical Evolution & Tradeoffs
  4. Implementation Patterns
  5. Testing Strategy
  6. 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:

  1. Write test first - Defines expected behavior precisely
  2. AI implements - Uses test as specification
  3. Run test - Immediate verification of AI implementation
  4. 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

DecisionBenefitCostReasoning
Stateless MCP ServerNo session conflicts, better concurrencyHigher memory/CPU per requestAI assistant integration requires concurrent safety
Firebase-FirstManaged infrastructure, serverless scalingVendor lock-in, cost at scaleRapid development, built-in authentication
Dual Interfaces (MCP + REST)Flexibility for different clientsCode duplication, maintenance overheadAI tools need rich interfaces, external systems need HTTP
Soft Validation ModeBetter user experience, workflow continuityRisk of invalid content in productionContent creators shouldn't be blocked by minor issues
Token Grammar ChangeClearer parsing, no ambiguityMigration effort for existing contentLong-term maintainability over short-term convenience
Global Registry VersioningSimpler cache managementCache invalidation affects everythingReduced complexity in version tracking
Comprehensive LoggingFull observability, debugging capabilityStorage costs, request latencyRequired for enterprise debugging and analytics
Sequential TestingReliable emulator-based testsSlower test executionFirebase emulator limitations force this choice
Parent-Child SimplificationIntuitive relationships, easier validationMigration from existing schemaScalability and maintainability improvements
Configuration-Driven LintingFlexible rule customizationComplex configuration managementTeams 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

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.

Content Management MCP Server: Design Decisions & Architectural Reasoning - Brian Wight