16 min readEnglish

Building a Custom MCP Server: From 53 Tools to 20 with 62% Token Reduction

How I built a Model Context Protocol server to manage 6 projects, reducing protocol maintenance from 3 hours to 5 minutes weekly with automated compliance and billing.

#MCP#Model Context Protocol#Claude Code#TypeScript#Developer Tools#Automation

The Problem: Managing 6 Projects Manually

I manage six concurrent software projects with different billing models, documentation requirements, and client workflows:

  1. gespervis-school (hourly @ $10/hr)
  2. papamin-soon (fixed-price @ $150)
  3. pabellon-fama (monthly retainer @ $75/month)
  4. nitaino-menu-system (non-billable product)
  5. jayei (pro-bono work)
  6. portfolio (personal marketing site)

Every project follows a .project/ protocol with strict requirements: STATUS.md files, billing logs, feature specifications, handoff documents, and compliance validation. Managing this manually was consuming 3 hours per week.

The repetitive tasks were:

  • Protocol compliance checks: 30 minutes per project × 6 projects = 180 minutes
  • Billing calculations: Reading through log files, summing hours, computing totals
  • Handoff generation: Copy-pasting templates, filling in project details
  • Status reporting: Parsing markdown files across all projects
  • Feature management: Tracking active vs archived features manually

This was the perfect opportunity to build a custom MCP server.

What is Model Context Protocol (MCP)?

Model Context Protocol is Anthropic's framework for connecting AI assistants to external tools, data, and systems. Think of it like an API for AI interactions.

Before MCP: Claude could only work with information provided in the chat message itself.

With MCP: Claude can call custom tools, read from databases, validate schemas, and integrate with your entire development infrastructure.

Architecture Overview

┌─────────────────┐
│   Claude Code   │
└────────┬────────┘
         │ stdio/SSE
         │
┌────────▼────────────┐
│   MCP Server        │
├─────────────────────┤
│  • Tools (30+)      │
│  • Resources        │
│  • Reusable prompts │
└────────┬────────────┘
         │
┌────────▼────────────┐
│  Your System        │
│  • Files            │
│  • Databases        │
│  • APIs             │
│  • Workflows        │
└─────────────────────┘

MCP servers export Tools (functions Claude can call) and Resources (data Claude can read). The server runs as a separate process and communicates via stdout/stdin or HTTP.

Key MCP Concepts

  • Tools: Functions that Claude can invoke (like check_protocol_compliance)
  • Resources: Data that Claude can read (like project metadata)
  • Prompts: Reusable prompt templates for consistent outputs
  • Transport: How the server communicates (stdio is simplest for CLI tools)

Designing the Solution

I needed three core capabilities:

1. Validation Tools

  • Check .project/ structure completeness
  • Validate billing model implementation
  • Score protocol compliance (0-100%)

2. Querying Tools

  • Parse STATUS.md for project state
  • List active and archived features
  • Calculate billable hours with date filtering
  • Get project health metrics

3. Generation Tools

  • Create feature specifications from templates
  • Generate handoff documents automatically
  • Archive completed features with summaries

Tool Design Examples

// Tool 1: Check Protocol Compliance
check_protocol_compliance({
  project: "gespervis-school",
  verbose: true
})

// Response:
{
  compliance_score: 85,
  status: "good",
  missing_items: [
    "/billing/time-logs/ - empty (no logs since Oct 27)"
  ],
  recommendations: [
    "Update STATUS.md (47 days stale)",
    "Begin Phase 3 with time tracking"
  ]
}

// Tool 2: Calculate Billable Hours
calculate_billable_hours({
  project: "gespervis-school",
  start_date: "2025-11-01",
  end_date: "2025-11-30"
})

// Response:
{
  total_hours: 0.0,
  billable_amount: "$0.00",
  sessions_count: 0,
  note: "No activity recorded this period"
}

// Tool 3: Generate Handoff
generate_handoff({
  project: "pabellon-fama",
  type: "supervisor_to_workagent",
  feature: "Phase A - Exaltado Member Page"
})

// Response:
{
  success: true,
  file_path: "/coordination/handoff-2025-12-10.md",
  handoff_type: "supervisor_to_workagent",
  content_preview: "..."
}

Technology Stack

  • Language: TypeScript (strict mode)
  • MCP SDK: @modelcontextprotocol/sdk
  • Validation: Zod for input/output schemas
  • File System: Native Node.js fs/promises
  • Build: tsup for fast compilation
  • Runtime: Node.js 18+ with tsx for development

Implementation Deep Dive

Project Structure

protocol-manager/
├── src/
│   ├── index.ts              # MCP server entry point
│   ├── config.ts             # Project configurations
│   ├── tools/
│   │   ├── validation.ts     # Compliance tools
│   │   ├── querying.ts       # Status/billing tools
│   │   ├── generation.ts     # Document tools
│   │   └── features.ts       # Feature management
│   ├── lib/
│   │   ├── parser.ts         # Parse .project/ structure
│   │   ├── validator.ts      # Compliance scoring
│   │   ├── calculator.ts     # Billing calculations
│   │   └── templates.ts      # Handoff templates
│   └── schemas/
│       ├── inputs.ts         # Zod input schemas
│       └── outputs.ts        # Zod output schemas
├── package.json
├── tsconfig.json
└── README.md

Step 1: Setting Up the MCP Server

// src/index.ts
#!/usr/bin/env node
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
  registerValidationTools,
  registerQueryingTools,
  registerGenerationTools,
  registerFeatureTools
} from './tools/index.js';

const server = new McpServer({
  name: 'protocol-manager',
  version: '1.0.0'
});

// Register all tool categories
registerValidationTools(server);
registerQueryingTools(server);
registerGenerationTools(server);
registerFeatureTools(server);

// Start with stdio transport
const transport = new StdioServerTransport();
await server.connect(transport);

console.error('✓ Protocol Manager MCP server running on stdio');

Critical Detail: Use console.error for logging, not console.log. The stdout channel is reserved for MCP protocol messages. Any accidental stdout writes will break communication.

Step 2: Defining Input & Output Schemas

// src/schemas/inputs.ts
import { z } from 'zod';

// Define valid projects
export const projectEnum = z.enum([
  'gespervis-school',
  'papamin-soon',
  'pabellon-fama',
  'nitaino-menu-system',
  'jayei',
  'portfolio'
]);

// Reusable date schema (YYYY-MM-DD format)
const dateString = z.string()
  .regex(/^\d\d\d\d-\d\d-\d\d$/, 'Must be YYYY-MM-DD format');

// Input validation for compliance check
export const checkComplianceInput = z.object({
  project: projectEnum,
  verbose: z.boolean().optional().default(false),
  include_history: z.boolean().optional().default(false)
});

// Input validation for billing
export const calculateBillingInput = z.object({
  project: projectEnum,
  start_date: dateString.optional(),
  end_date: dateString.optional(),
  include_background_agents: z.boolean().optional().default(true)
});

// Input validation for handoff generation
export const generateHandoffInput = z.object({
  project: projectEnum,
  type: z.enum([
    'orchestrator_to_supervisor',
    'supervisor_to_workagent',
    'workagent_completion',
    'orchestrator_continuation',
    'supervisor_continuation'
  ]),
  feature: z.string(),
  context: z.record(z.string()).optional()
});

Output schemas ensure Claude receives consistently structured responses:

// src/schemas/outputs.ts
export const complianceOutput = z.object({
  project: z.string(),
  compliance_score: z.number().min(0).max(100),
  status: z.enum(['excellent', 'good', 'fair', 'poor']),
  required_items: z.array(z.object({
    name: z.string(),
    present: z.boolean(),
    last_modified: z.string().optional()
  })),
  recommendations: z.array(z.string()),
  timestamp: z.string()
});

Step 3: Implementing a Validation Tool

// src/tools/validation.ts
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { checkComplianceInput, complianceOutput } from '../schemas/index.js';
import { validateProjectCompliance } from '../lib/validator.js';

export function registerValidationTools(server: McpServer) {
  server.tool(
    'check_protocol_compliance',
    'Validate .project/ directory structure and protocol compliance for a project',
    {
      project: {
        type: 'string',
        description: 'Project name'
      },
      verbose: {
        type: 'boolean',
        description: 'Include detailed breakdown of all checks'
      }
    },
    async (params) => {
      // 1. Validate input
      const validated = checkComplianceInput.parse(params);

      // 2. Get project configuration
      const config = PROJECTS[validated.project];
      if (!config) {
        throw new Error(`Project not found: ${validated.project}`);
      }

      // 3. Run compliance validation
      const result = await validateProjectCompliance(
        config.path,
        validated.verbose
      );

      // 4. Verify output schema
      const output = complianceOutput.parse(result);

      // 5. Return formatted response
      return {
        content: [{
          type: 'text',
          text: `Compliance Report for ${validated.project}\n\n` +
                `Score: ${output.compliance_score}% (${output.status})\n\n` +
                `Missing Items:\n` +
                output.required_items
                  .filter(item => !item.present)
                  .map(item => `• ${item.name}`)
                  .join('\n') +
                `\n\nRecommendations:\n` +
                output.recommendations
                  .map((rec, i) => `${i + 1}. ${rec}`)
                  .join('\n')
        }]
      };
    }
  );
}

Step 4: Building the Compliance Validator

// src/lib/validator.ts
import fs from 'fs/promises';
import path from 'path';

interface ComplianceItem {
  name: string;
  present: boolean;
  last_modified?: string;
  severity: 'critical' | 'high' | 'medium' | 'low';
}

export async function validateProjectCompliance(
  projectPath: string,
  verbose: boolean = false
): Promise<ComplianceResult> {
  const projectDir = path.join(projectPath, '.project');
  const items: ComplianceItem[] = [];
  let score = 0;

  // Check 1: Core structure (30 points)
  const hasCoreDir = await checkExists(projectDir);
  items.push({
    name: '.project/ directory exists',
    present: hasCoreDir,
    severity: 'critical'
  });
  if (hasCoreDir) score += 30;

  // Check 2: Required files (25 points)
  const requiredFiles = ['STATUS.md', 'BACKLOG.md'];
  for (const file of requiredFiles) {
    const filePath = path.join(projectDir, file);
    const exists = await checkExists(filePath);
    const stat = exists ? await fs.stat(filePath) : null;

    items.push({
      name: `${file} exists and up-to-date`,
      present: exists && daysSinceUpdate(stat) < 7,
      last_modified: stat?.mtime.toISOString(),
      severity: 'critical'
    });
  }
  if (items.filter(i => i.present).length >= 2) score += 25;

  // Check 3: Billing directory (20 points)
  const billingDir = path.join(projectDir, 'billing');
  const hasBillingDir = await checkExists(billingDir);
  items.push({
    name: 'billing/ directory exists',
    present: hasBillingDir,
    severity: 'high'
  });
  if (hasBillingDir) score += 20;

  // Check 4: Features directory (15 points)
  const featuresDir = path.join(projectDir, 'features');
  const hasFeaturesDir = await checkExists(featuresDir);
  items.push({
    name: 'features/ directory exists',
    present: hasFeaturesDir,
    severity: 'medium'
  });
  if (hasFeaturesDir) score += 15;

  // Check 5: Coordination directory (10 points)
  const coordDir = path.join(projectDir, 'coordination');
  const hasCoordDir = await checkExists(coordDir);
  items.push({
    name: 'coordination/ directory exists',
    present: hasCoordDir,
    severity: 'medium'
  });
  if (hasCoordDir) score += 10;

  // Generate recommendations
  const recommendations: string[] = [];
  const missingCritical = items.filter(
    i => i.severity === 'critical' && !i.present
  );
  if (missingCritical.length > 0) {
    recommendations.push(
      `Create missing critical files: ${missingCritical.map(i => i.name).join(', ')}`
    );
  }

  const staleStatus = items.find(i => i.name.includes('STATUS.md'));
  if (staleStatus?.present === false) {
    recommendations.push('Update STATUS.md - project appears inactive');
  }

  return {
    project: path.basename(projectPath),
    compliance_score: Math.min(100, score),
    status: score >= 95 ? 'excellent' : score >= 85 ? 'good' : 'fair',
    required_items: items,
    recommendations,
    timestamp: new Date().toISOString()
  };
}

async function checkExists(filePath: string): Promise<boolean> {
  try {
    await fs.stat(filePath);
    return true;
  } catch {
    return false;
  }
}

function daysSinceUpdate(stat: fs.Stats | null): number {
  if (!stat) return 999;
  return Math.floor((Date.now() - stat.mtime.getTime()) / (1000 * 60 * 60 * 24));
}

Step 5: Implementing Billing Calculations

// src/lib/calculator.ts
import fs from 'fs/promises';
import path from 'path';

interface BillingSession {
  timestamp: string;
  duration_minutes: number;
  hourly_rate: number;
}

export async function calculateBillingHours(
  projectPath: string,
  startDate?: string,
  endDate?: string
): Promise<BillingResult> {
  const billingDir = path.join(projectPath, '.project', 'billing');

  // Parse date range
  const start = startDate ? new Date(startDate) : getMonthStart();
  const end = endDate ? new Date(endDate) : new Date();

  // Read time logs
  const timeLogs = await readBillingLogs(
    path.join(billingDir, 'time-logs'),
    start,
    end
  );

  // Calculate totals
  let totalMinutes = 0;
  let sessionCount = 0;

  for (const log of timeLogs) {
    totalMinutes += log.duration_minutes;
    sessionCount++;
  }

  const totalHours = totalMinutes / 60;
  const billingModel = getBillingModel(projectPath);
  const billableAmount = calculateAmount(totalHours, billingModel);

  return {
    period: {
      start: start.toISOString().split('T')[0],
      end: end.toISOString().split('T')[0]
    },
    total_hours: Math.round(totalHours * 10) / 10,
    total_minutes: totalMinutes,
    sessions_count: sessionCount,
    billable_amount: billableAmount,
    billing_model: billingModel
  };
}

async function readBillingLogs(
  logsDir: string,
  start: Date,
  end: Date
): Promise<BillingSession[]> {
  try {
    const files = await fs.readdir(logsDir);
    const sessions: BillingSession[] = [];

    for (const file of files) {
      if (!file.endsWith('.jsonl')) continue;

      const content = await fs.readFile(path.join(logsDir, file), 'utf-8');
      const lines = content.trim().split('\n');

      for (const line of lines) {
        try {
          const entry = JSON.parse(line);
          const entryDate = new Date(entry.timestamp);

          if (entryDate >= start && entryDate <= end) {
            sessions.push({
              timestamp: entry.timestamp,
              duration_minutes: entry.duration_minutes || 0,
              hourly_rate: entry.hourly_rate || 0
            });
          }
        } catch {
          // Skip malformed lines
        }
      }
    }

    return sessions;
  } catch {
    return [];
  }
}

Step 6: Registering with Claude Code

# Navigate to project
cd ~/claude-mcp/protocol-manager

# Install dependencies
pnpm install

# Build TypeScript
pnpm run build

# Register MCP server with Claude Code
claude mcp add protocol-manager \
  /home/temiban/claude-mcp/protocol-manager/dist/index.js

Check that it loaded:

claude mcp list
# Output should show: protocol-manager (ready)

Now Claude Code can call any tools defined in your MCP server.

Results & Impact

Before vs After Metrics

TaskManual TimeMCP TimeSaved
Check compliance (all 6 projects)180 min3 sec99.7%
Calculate monthly billing15 min2 sec99.9%
Generate handoff document15 min5 sec99.7%
List active features10 min1 sec99.9%
Get project health snapshot20 min1 sec99.9%
Total weekly maintenance~3 hours~5 minutes97% reduction

Token Efficiency

The MCP server consolidates 53 separate tool calls across projects into 20 high-level, reusable tools. This reduces token usage for context and instructions by 62%.

Before MCP: Each project needed separate instructions for parsing its files, calculating its billing model, generating its specific handoff format.

After MCP: One MCP server encapsulates all that logic. Claude just calls check_protocol_compliance(project="gespervis-school").

Real Usage Examples

User: "Check protocol compliance for all 6 projects"

Claude: *Calls check_protocol_compliance 6 times in parallel*
Result (3 seconds): Portfolio 100%, gespervis-school 85%,
papamin-soon 92%, pabellon-fama 88%, nitaino-menu-system 95%, jayei 100%

---

User: "How many billable hours on gespervis this month?"

Claude: *Calls calculate_billable_hours with date range*
Result: 0 hours (23-day activity gap - recommend updating STATUS.md)

---

User: "Generate handoff for the authentication feature on papamin"

Claude: *Calls generate_handoff with context*
Result: Created /coordination/handoff-supervisor_to_workagent-2025-12-10.md
Ready for handoff to work agent.

Compliance Improvements

  • Starting compliance: 70% average across projects
  • Current compliance: 89% average (with MCP enforcement)
  • Target: 100% (achievable through continuous MCP automation)

The server now catches:

  • Stale STATUS.md files (>7 days old)
  • Missing billing directories
  • Incomplete feature specifications
  • Orphaned handoff documents

Unexpected Benefits

  1. Consistency: All handoff documents follow the same format now
  2. Auditability: Every tool call is logged and traceable
  3. Onboarding: New projects can be added in minutes (just add config)
  4. Client reporting: Easy to generate accurate billing reports
  5. Context switching: Claude instantly understands any project's state

Lessons Learned & Best Practices

Technical Lessons

1. Schema Validation is Critical

Use Zod for both inputs and outputs. This prevents runtime errors and provides excellent TypeScript inference:

// Input validation
const params = checkComplianceInput.parse(userInput);

// Output validation
const response = complianceOutput.parse(result);

// Now TypeScript knows exactly what properties exist
console.log(response.compliance_score); // ✓ No errors
console.log(response.invalid); // ✗ TypeScript error

2. Error Handling is Essential

File system operations can fail. Always provide graceful fallbacks:

async function readBillingLogs(logsDir: string): Promise<BillingSession[]> {
  try {
    const files = await fs.readdir(logsDir);
    // ... process files
  } catch (error) {
    console.error(`Failed to read billing logs: ${error.message}`);
    return []; // Return empty array, not error
  }
}

3. Performance Matters

File I/O is slow. For large projects, consider:

  • Caching: Cache compliance scores for 5 minutes
  • Lazy loading: Only read files when needed
  • Parallel operations: Use Promise.all() for independent checks

Most tool calls should return in less than 1 second for good UX.

4. Logging Strategy

Always use console.error for debug logs:

// ✓ GOOD: Logs to stderr (doesn't break MCP protocol)
console.error('Processing compliance check for gespervis-school');

// ✗ BAD: Logs to stdout (breaks MCP protocol)
console.log('Processing compliance check for gespervis-school');

Design Best Practices

1. Modular Architecture

Separate concerns into different files:

  • tools/ - Tool definitions and registration
  • lib/ - Business logic (validation, calculation)
  • schemas/ - Zod input/output definitions
  • config.ts - Project metadata (single source of truth)

This makes testing easier and code more maintainable.

2. Configuration as Code

Store project metadata in a single configuration file:

// src/config.ts
export const PROJECTS = {
  'gespervis-school': {
    path: '/home/temiban/Development/gespervis-school',
    billingModel: 'hourly',
    hourlyRate: 10,
    description: 'School management system'
  },
  'papamin-soon': {
    path: '/home/temiban/Development/papamin-soon',
    billingModel: 'fixed-price',
    fixedPrice: 150,
    description: 'Restaurant menu system'
  }
  // ... other projects
};

3. Template-Based Generation

Store templates separately from code:

// src/lib/templates.ts
export const HANDOFF_TEMPLATE = `---
handoff_type: $type
project: $project
date: $date
---

## Work Assignment

$work_assignment

## Files to Modify

$files_to_modify

## Objective

$objective
`;

This allows non-developers to update templates without code changes.

MCP-Specific Tips

  1. Tool Naming: Use clear, action-oriented names

    • check_protocol_compliance (verb + noun)
    • compliance (ambiguous)
  2. Descriptions Matter: Claude uses tool descriptions to decide when to use them

    • ✓ "Validate .project/ directory structure and return compliance score (0-100%)"
    • ✗ "Check compliance"
  3. Output Format: Return both text and structured data

    • Text for readability in chat
    • JSON for programmatic use by Claude
  4. Testing: Use the MCP Inspector during development

    npm install -g @modelcontextprotocol/inspector
    npx @modelcontextprotocol/inspector
  5. Error Messages: Return helpful errors that guide users

    • ✓ "Project 'unknown-project' not found. Available: [list]"
    • ✗ "Project not found"

Future Enhancements

Phase 2: Auto-Fix Mode

Automatically remediate protocol violations:

// Create missing directories
server.tool('auto_fix_protocol', 'Automatically create missing directories and files', ...);

// Generate boilerplate STATUS.md
// Initialize billing directory structure
// Create missing feature templates

Phase 3: Dashboard & Reporting

Generate HTML reports showing:

  • Compliance score across all projects
  • Billing trends and invoicing status
  • Feature progress and completion rates
  • Time spent per project per week

Phase 4: Notifications & Alerts

Proactive notifications:

  • Alert when STATUS.md becomes stale (>7 days)
  • Remind about pending invoices
  • Notify when projects fall below 85% compliance
  • Weekly summary report

Phase 5: Integration Opportunities

Connect MCP server to external systems:

  • Slack: Send compliance reports to team channel
  • GitHub: Create issues for protocol gaps
  • Pinecone: Store handoffs for semantic search
  • Email: Auto-send compliance reports to clients

Conclusion: From Manual to Automated

Building this MCP server transformed project management from a 3-hour weekly chore into a 5-minute automated process. But the real value isn't just the time savings—it's the consistency, reliability, and clarity that automation brings.

Key Takeaways

  1. MCP is powerful: It's straightforward to extend Claude with domain-specific tools
  2. TypeScript + Zod is excellent DX: Type safety and validation without boilerplate
  3. Domain-specific automation delivers outsized ROI: Targeted MCP servers solve real problems
  4. Protocol-driven development scales: Standardized processes protect quality across projects

When Should You Build an MCP Server?

  • You have repetitive tasks that follow patterns (like this project management workflow)
  • You need Claude to access your systems, files, or data
  • You want to enforce workflows or standards automatically
  • You manage multiple projects with similar structures

Getting Started

The complete source code is available in my GitHub MCP servers repository. The MCP specification and examples are at modelcontextprotocol.io.

If you're managing multiple projects or have repetitive development workflows, I encourage you to build your own MCP server. The initial investment—a few hours of TypeScript code—pays back in weeks through automation and consistency.

The future of developer tools isn't generic frameworks. It's specialized, domain-specific tools that understand your unique workflow. MCP makes building those tools practical and fast.


Stack: TypeScript, MCP SDK, Zod, Node.js 18+ Tools: 20 MCP tools across validation, querying, and generation Impact: 97% time reduction (3 hours → 5 minutes), 62% token efficiency gain Next: v2 with auto-fix mode and dashboard reporting

Ready to automate your development workflows with MCP? Start small, solve a real problem, and scale from there.

MA

Mario Rafael Ayala

Full-Stack AI Engineer with 25+ years of experience. Specialist in AI agent development, multi-agent orchestration, and full-stack web development. Currently focused on AI-assisted development with Claude Code, Next.js, and TypeScript.

Related Articles