MCP Server Version Management for ChatGPT Apps

Building successful ChatGPT applications with Model Context Protocol (MCP) servers requires robust version management strategies. As your ChatGPT app evolves, you'll introduce new features, improve existing functionality, and occasionally need to make breaking changes. Without proper version management, you risk disrupting existing users, breaking integrations, and creating maintenance nightmares.

Effective API versioning ensures backward compatibility while allowing innovation. When a fitness studio's ChatGPT booking assistant needs a new "bulk booking" feature, proper versioning lets you deploy it without breaking existing single-booking functionality. When an e-commerce ChatGPT assistant requires a restructured product catalog format, version management enables smooth migration without downtime.

This guide covers production-tested versioning strategies, migration patterns, and testing approaches that have successfully managed ChatGPT app deployments across hundreds of users. We'll explore URL-based versioning, header-based version negotiation, deprecation policies, multi-version parallel deployment, and automated migration tools—all with complete TypeScript implementations ready for your production MCP servers.

Whether you're launching your first ChatGPT app or managing a complex ecosystem with multiple versions, these patterns will help you deliver updates confidently while maintaining stability for existing users.

Versioning Strategies for MCP Servers

Choosing the right versioning strategy is foundational to long-term maintainability. The three primary approaches—URL versioning, header versioning, and content negotiation—each offer distinct advantages depending on your deployment architecture and client capabilities.

URL-based versioning embeds the version directly in the endpoint path (/v1/tools, /v2/tools). This approach offers maximum visibility and simplicity. Clients explicitly declare their version in every request, making debugging straightforward. Caching layers can differentiate versions easily, and API documentation naturally segregates by version. The primary trade-off is URL namespace proliferation—each version consumes a distinct path.

Header-based versioning uses custom HTTP headers (Accept-Version: 2.0) to specify versions while keeping URLs stable. This approach keeps URLs clean and allows version upgrades without URL changes. It's ideal when URL stability matters for branding or when you want to hide versioning complexity from end users. However, debugging becomes harder since versions aren't visible in URLs, and some caching layers struggle with header-based routing.

Content negotiation leverages standard HTTP Accept headers with custom media types (Accept: application/vnd.myapp.v2+json). This follows REST best practices and enables sophisticated version negotiation. Clients can specify fallback versions, and servers can advertise supported versions through HTTP responses. The complexity lies in implementation—both client and server need proper content negotiation logic.

Here's a production-ready URL-based version router that handles multiple MCP server versions with automatic fallback:

import express, { Request, Response, NextFunction } from 'express';

interface VersionConfig {
  version: string;
  router: express.Router;
  deprecated?: boolean;
  deprecationDate?: Date;
  sunsetDate?: Date;
  migrationGuide?: string;
}

class MCPVersionRouter {
  private app: express.Application;
  private versions: Map<string, VersionConfig> = new Map();
  private defaultVersion: string;

  constructor(defaultVersion: string = 'v1') {
    this.app = express();
    this.defaultVersion = defaultVersion;
    this.setupMiddleware();
  }

  private setupMiddleware(): void {
    // Add deprecation warning middleware
    this.app.use((req: Request, res: Response, next: NextFunction) => {
      const versionMatch = req.path.match(/^\/v(\d+)\//);
      if (versionMatch) {
        const version = `v${versionMatch[1]}`;
        const config = this.versions.get(version);

        if (config?.deprecated) {
          res.setHeader('Deprecation', 'true');
          res.setHeader('Sunset', config.sunsetDate?.toUTCString() || '');
          res.setHeader('Link', `<${config.migrationGuide}>; rel="deprecation"`);

          console.warn(`[Deprecation Warning] Version ${version} accessed`, {
            path: req.path,
            userAgent: req.headers['user-agent'],
            deprecationDate: config.deprecationDate,
            sunsetDate: config.sunsetDate
          });
        }
      }
      next();
    });
  }

  registerVersion(config: VersionConfig): void {
    this.versions.set(config.version, config);
    this.app.use(`/${config.version}`, config.router);

    console.log(`[Version Registration] ${config.version} registered`, {
      deprecated: config.deprecated,
      sunsetDate: config.sunsetDate
    });
  }

  // Fallback to default version for unversioned requests
  setupDefaultRoute(): void {
    this.app.use((req: Request, res: Response, next: NextFunction) => {
      const versionMatch = req.path.match(/^\/v\d+\//);
      if (!versionMatch) {
        // Redirect unversioned requests to default version
        const newPath = `/${this.defaultVersion}${req.path}`;
        res.setHeader('X-API-Version', this.defaultVersion);
        req.url = newPath;
        return next();
      }
      next();
    });
  }

  getApplication(): express.Application {
    this.setupDefaultRoute();
    return this.app;
  }

  // List all available versions
  listVersions(): VersionConfig[] {
    return Array.from(this.versions.values());
  }

  // Health endpoint showing version status
  setupHealthEndpoint(): void {
    this.app.get('/versions', (req: Request, res: Response) => {
      const versionInfo = Array.from(this.versions.values()).map(config => ({
        version: config.version,
        status: config.deprecated ? 'deprecated' : 'active',
        deprecationDate: config.deprecationDate,
        sunsetDate: config.sunsetDate,
        migrationGuide: config.migrationGuide
      }));

      res.json({
        versions: versionInfo,
        defaultVersion: this.defaultVersion,
        currentTime: new Date().toISOString()
      });
    });
  }
}

// Example usage with MCP server versions
const versionRouter = new MCPVersionRouter('v2');

// V1 router (deprecated)
const v1Router = express.Router();
v1Router.post('/tools/list', (req, res) => {
  res.json({
    tools: [
      { name: 'book_appointment', description: 'Book a single appointment' }
    ]
  });
});

versionRouter.registerVersion({
  version: 'v1',
  router: v1Router,
  deprecated: true,
  deprecationDate: new Date('2026-10-01'),
  sunsetDate: new Date('2026-04-01'),
  migrationGuide: 'https://docs.myapp.com/migration/v1-to-v2'
});

// V2 router (current)
const v2Router = express.Router();
v2Router.post('/tools/list', (req, res) => {
  res.json({
    tools: [
      { name: 'book_appointment', description: 'Book a single appointment' },
      { name: 'book_bulk_appointments', description: 'Book multiple appointments' }
    ]
  });
});

versionRouter.registerVersion({
  version: 'v2',
  router: v2Router
});

versionRouter.setupHealthEndpoint();
const app = versionRouter.getApplication();

This router provides automatic deprecation warnings, sunset scheduling, version listing, and graceful fallback to default versions—essential features for production MCP servers serving ChatGPT applications.

Breaking vs Non-Breaking Changes

Understanding the difference between breaking and non-breaking changes is critical for maintaining client trust and minimizing disruption. Breaking changes force clients to update their code, while non-breaking changes remain backward compatible.

Breaking changes include:

  • Removing or renaming tools/endpoints
  • Changing required parameters (adding new required fields, removing existing fields)
  • Modifying response structure in incompatible ways (changing data types, removing fields)
  • Altering error codes or HTTP status codes
  • Changing authentication/authorization requirements

Non-breaking changes include:

  • Adding new optional parameters with defaults
  • Adding new tools/endpoints
  • Adding new fields to responses (clients should ignore unknown fields)
  • Deprecating (but not removing) existing functionality
  • Improving error messages without changing error codes
  • Performance optimizations that don't affect contracts

Deprecation policies provide a structured path for phasing out old functionality. A robust policy includes:

  1. Announcement period (3-6 months): Notify users via deprecation headers, documentation updates, and direct communication
  2. Deprecation headers: Use Deprecation: true, Sunset: <date>, and Link: <migration-guide> HTTP headers
  3. Migration support: Provide detailed guides, code examples, and automated migration tools
  4. Sunset date: Set a firm end-of-life date when the version will no longer function
  5. Monitoring: Track deprecated version usage to identify stragglers

Here's a comprehensive deprecation warning middleware with client notification:

import express, { Request, Response, NextFunction } from 'express';
import { createHash } from 'crypto';

interface DeprecationConfig {
  resource: string;
  deprecationDate: Date;
  sunsetDate: Date;
  migrationGuide: string;
  replacement?: string;
  reason?: string;
}

interface ClientDeprecationTracking {
  clientId: string;
  resource: string;
  firstSeen: Date;
  lastSeen: Date;
  accessCount: number;
  notificationsSent: number;
}

class DeprecationManager {
  private deprecations: Map<string, DeprecationConfig> = new Map();
  private clientTracking: Map<string, ClientDeprecationTracking> = new Map();
  private notificationThresholds = [1, 10, 50, 100]; // Send notifications at these access counts

  registerDeprecation(config: DeprecationConfig): void {
    this.deprecations.set(config.resource, config);
    console.log(`[Deprecation] Registered: ${config.resource}`, {
      sunsetDate: config.sunsetDate,
      migrationGuide: config.migrationGuide
    });
  }

  private getClientId(req: Request): string {
    // Use API key, JWT sub, or IP-based identifier
    const apiKey = req.headers['x-api-key'] as string;
    const userAgent = req.headers['user-agent'] || 'unknown';
    const ip = req.ip || 'unknown';

    const identifier = apiKey || `${ip}:${userAgent}`;
    return createHash('sha256').update(identifier).digest('hex').substring(0, 16);
  }

  middleware(): (req: Request, res: Response, next: NextFunction) => void {
    return (req: Request, res: Response, next: NextFunction) => {
      const resource = `${req.method}:${req.path}`;
      const deprecation = this.deprecations.get(resource);

      if (!deprecation) {
        return next();
      }

      const clientId = this.getClientId(req);
      const trackingKey = `${clientId}:${resource}`;

      // Update tracking
      const tracking = this.clientTracking.get(trackingKey) || {
        clientId,
        resource,
        firstSeen: new Date(),
        lastSeen: new Date(),
        accessCount: 0,
        notificationsSent: 0
      };

      tracking.lastSeen = new Date();
      tracking.accessCount++;
      this.clientTracking.set(trackingKey, tracking);

      // Set deprecation headers
      res.setHeader('Deprecation', 'true');
      res.setHeader('Sunset', deprecation.sunsetDate.toUTCString());
      res.setHeader('Link', `<${deprecation.migrationGuide}>; rel="deprecation"`);

      if (deprecation.replacement) {
        res.setHeader('X-Replacement-Endpoint', deprecation.replacement);
      }

      // Add deprecation warning to response
      const originalJson = res.json.bind(res);
      res.json = function(body: any) {
        const wrappedBody = {
          ...body,
          _deprecation: {
            deprecated: true,
            sunsetDate: deprecation.sunsetDate,
            migrationGuide: deprecation.migrationGuide,
            replacement: deprecation.replacement,
            reason: deprecation.reason,
            daysUntilSunset: Math.ceil(
              (deprecation.sunsetDate.getTime() - Date.now()) / (1000 * 60 * 60 * 24)
            )
          }
        };
        return originalJson(wrappedBody);
      };

      // Log and potentially notify on threshold crossings
      if (this.notificationThresholds.includes(tracking.accessCount)) {
        this.sendDeprecationNotification(tracking, deprecation);
      }

      next();
    };
  }

  private sendDeprecationNotification(
    tracking: ClientDeprecationTracking,
    deprecation: DeprecationConfig
  ): void {
    // In production, send email/webhook notification
    console.warn(`[Deprecation Alert] Client ${tracking.clientId} hit threshold`, {
      resource: tracking.resource,
      accessCount: tracking.accessCount,
      sunsetDate: deprecation.sunsetDate,
      migrationGuide: deprecation.migrationGuide
    });

    tracking.notificationsSent++;
  }

  // Get clients still using deprecated resources
  getDeprecatedUsage(): Array<{
    clientId: string;
    resource: string;
    accessCount: number;
    daysSinceFirstSeen: number;
    daysUntilSunset: number;
  }> {
    const usage: Array<any> = [];

    this.clientTracking.forEach((tracking) => {
      const deprecation = this.deprecations.get(tracking.resource);
      if (deprecation) {
        usage.push({
          clientId: tracking.clientId,
          resource: tracking.resource,
          accessCount: tracking.accessCount,
          daysSinceFirstSeen: Math.ceil(
            (Date.now() - tracking.firstSeen.getTime()) / (1000 * 60 * 60 * 24)
          ),
          daysUntilSunset: Math.ceil(
            (deprecation.sunsetDate.getTime() - Date.now()) / (1000 * 60 * 60 * 24)
          )
        });
      }
    });

    return usage.sort((a, b) => b.accessCount - a.accessCount);
  }
}

// Example usage
const deprecationManager = new DeprecationManager();

deprecationManager.registerDeprecation({
  resource: 'POST:/v1/tools/book_appointment',
  deprecationDate: new Date('2026-10-01'),
  sunsetDate: new Date('2026-04-01'),
  migrationGuide: 'https://docs.myapp.com/migration/booking-v2',
  replacement: 'POST:/v2/tools/book_appointment',
  reason: 'Replaced with enhanced booking system supporting bulk operations'
});

const app = express();
app.use(deprecationManager.middleware());

This middleware automatically tracks deprecated endpoint usage, notifies clients at key thresholds, and provides detailed deprecation metadata in responses—essential for managing ChatGPT app migrations.

Multi-Version Support Architecture

Running multiple versions simultaneously is essential for zero-downtime migrations. This allows new clients to adopt v2 while existing v1 clients continue uninterrupted. The challenge lies in managing shared resources, routing requests correctly, and maintaining consistent behavior across versions.

Parallel version deployment strategies include:

  1. Separate service instances: Deploy v1 and v2 as distinct services with separate databases. Maximum isolation but highest resource cost.
  2. Shared infrastructure with routing layer: Single service handles both versions, routing to version-specific handlers. Efficient but requires careful state management.
  3. Feature flags with runtime switching: Single codebase with feature flags enabling/disabling v2 functionality. Flexible but increases code complexity.

For MCP servers serving ChatGPT applications, a routing layer with shared infrastructure typically offers the best balance. Here's a production implementation supporting multiple versions with shared business logic:

import express, { Request, Response, NextFunction } from 'express';

interface Tool {
  name: string;
  description: string;
  inputSchema: any;
  handler: (params: any, version: string) => Promise<any>;
}

interface VersionAdapter {
  transformInput: (input: any) => any;
  transformOutput: (output: any) => any;
}

class MultiVersionMCPServer {
  private app: express.Application;
  private tools: Map<string, Tool> = new Map();
  private versionAdapters: Map<string, VersionAdapter> = new Map();

  constructor() {
    this.app = express();
    this.app.use(express.json());
    this.setupRoutes();
  }

  registerTool(tool: Tool): void {
    this.tools.set(tool.name, tool);
  }

  registerVersionAdapter(version: string, adapter: VersionAdapter): void {
    this.versionAdapters.set(version, adapter);
  }

  private setupRoutes(): void {
    // Version-aware tool execution endpoint
    this.app.post('/:version/tools/call', async (req: Request, res: Response) => {
      try {
        const version = req.params.version;
        const { toolName, parameters } = req.body;

        const tool = this.tools.get(toolName);
        if (!tool) {
          return res.status(404).json({
            error: 'Tool not found',
            toolName,
            availableTools: Array.from(this.tools.keys())
          });
        }

        // Apply version-specific input transformation
        const adapter = this.versionAdapters.get(version);
        const transformedInput = adapter
          ? adapter.transformInput(parameters)
          : parameters;

        // Execute tool with version context
        const result = await tool.handler(transformedInput, version);

        // Apply version-specific output transformation
        const transformedOutput = adapter
          ? adapter.transformOutput(result)
          : result;

        res.json({
          success: true,
          result: transformedOutput,
          version
        });

      } catch (error: any) {
        res.status(500).json({
          success: false,
          error: error.message,
          version: req.params.version
        });
      }
    });

    // Version-aware tool listing
    this.app.post('/:version/tools/list', (req: Request, res: Response) => {
      const version = req.params.version;
      const adapter = this.versionAdapters.get(version);

      const tools = Array.from(this.tools.values()).map(tool => {
        const schema = adapter
          ? adapter.transformOutput({ inputSchema: tool.inputSchema })
          : { inputSchema: tool.inputSchema };

        return {
          name: tool.name,
          description: tool.description,
          inputSchema: schema.inputSchema || tool.inputSchema
        };
      });

      res.json({ tools, version });
    });
  }

  getApplication(): express.Application {
    return this.app;
  }
}

// Example: Booking tool with v1/v2 support
const mcpServer = new MultiVersionMCPServer();

// Register shared tool with version-aware handler
mcpServer.registerTool({
  name: 'book_appointment',
  description: 'Book appointment(s)',
  inputSchema: {
    type: 'object',
    properties: {
      appointments: {
        type: 'array',
        items: {
          type: 'object',
          properties: {
            date: { type: 'string' },
            duration: { type: 'number' },
            serviceId: { type: 'string' }
          }
        }
      }
    }
  },
  handler: async (params: any, version: string) => {
    // Shared business logic
    const appointments = params.appointments || [params]; // Handle both array and single object

    const bookings = await Promise.all(
      appointments.map(async (apt: any) => {
        // Simulate booking logic
        return {
          id: `book_${Date.now()}_${Math.random()}`,
          date: apt.date,
          duration: apt.duration,
          status: 'confirmed',
          version
        };
      })
    );

    return { bookings };
  }
});

// V1 adapter: Single appointment only
mcpServer.registerVersionAdapter('v1', {
  transformInput: (input: any) => {
    // V1 expects single appointment object, convert to array for handler
    return { appointments: [input] };
  },
  transformOutput: (output: any) => {
    // V1 returns single booking, not array
    return {
      booking: output.bookings[0],
      message: 'Appointment booked successfully'
    };
  }
});

// V2 adapter: Multiple appointments supported
mcpServer.registerVersionAdapter('v2', {
  transformInput: (input: any) => {
    // V2 accepts both single object and arrays
    if (!Array.isArray(input.appointments)) {
      return { appointments: [input.appointments] };
    }
    return input;
  },
  transformOutput: (output: any) => {
    // V2 returns full array with metadata
    return {
      bookings: output.bookings,
      count: output.bookings.length,
      message: `${output.bookings.length} appointment(s) booked successfully`
    };
  }
});

const app = mcpServer.getApplication();

This architecture allows a single tool implementation to serve multiple API versions through transformation adapters—drastically reducing code duplication while maintaining version-specific contracts.

For more advanced scenarios, integrate feature flag systems to enable gradual rollouts and A/B testing across versions.

Client Migration Strategies

Successfully migrating clients from deprecated versions requires proactive communication, automated tools, and graceful degradation. The goal is to minimize friction while maintaining security and performance standards.

Version negotiation allows clients to specify preferred versions with fallback options. Here's a production implementation supporting version negotiation via headers:

import express, { Request, Response, NextFunction } from 'express';

interface VersionNegotiationResult {
  selectedVersion: string;
  requestedVersion?: string;
  fallbackApplied: boolean;
  reason?: string;
}

class VersionNegotiator {
  private supportedVersions: Set<string>;
  private defaultVersion: string;
  private deprecatedVersions: Set<string>;

  constructor(supportedVersions: string[], defaultVersion: string) {
    this.supportedVersions = new Set(supportedVersions);
    this.defaultVersion = defaultVersion;
    this.deprecatedVersions = new Set();
  }

  markDeprecated(version: string): void {
    this.deprecatedVersions.add(version);
  }

  negotiate(req: Request): VersionNegotiationResult {
    // Check Accept-Version header first
    const acceptVersion = req.headers['accept-version'] as string;

    // Check URL-based version
    const urlMatch = req.path.match(/^\/v(\d+)\//);
    const urlVersion = urlMatch ? `v${urlMatch[1]}` : null;

    // Preference order: URL version > Accept-Version header > default
    const requestedVersion = urlVersion || acceptVersion;

    if (!requestedVersion) {
      return {
        selectedVersion: this.defaultVersion,
        fallbackApplied: false
      };
    }

    // Exact match
    if (this.supportedVersions.has(requestedVersion)) {
      return {
        selectedVersion: requestedVersion,
        requestedVersion,
        fallbackApplied: false
      };
    }

    // Try to find compatible fallback (e.g., v2.1 -> v2.0)
    const fallback = this.findCompatibleFallback(requestedVersion);
    if (fallback) {
      return {
        selectedVersion: fallback,
        requestedVersion,
        fallbackApplied: true,
        reason: `Requested version ${requestedVersion} not available, using compatible ${fallback}`
      };
    }

    // Use default version as last resort
    return {
      selectedVersion: this.defaultVersion,
      requestedVersion,
      fallbackApplied: true,
      reason: `Requested version ${requestedVersion} not supported, using default ${this.defaultVersion}`
    };
  }

  private findCompatibleFallback(requested: string): string | null {
    // Extract major version (v2.1 -> v2)
    const majorMatch = requested.match(/^v(\d+)/);
    if (!majorMatch) return null;

    const majorVersion = `v${majorMatch[1]}`;

    // Find highest supported version with same major version
    const compatible = Array.from(this.supportedVersions)
      .filter(v => v.startsWith(majorVersion))
      .sort()
      .reverse();

    return compatible[0] || null;
  }

  middleware(): (req: Request, res: Response, next: NextFunction) => void {
    return (req: Request, res: Response, next: NextFunction) => {
      const negotiation = this.negotiate(req);

      // Attach version to request for downstream handlers
      (req as any).apiVersion = negotiation.selectedVersion;

      // Set response headers
      res.setHeader('X-API-Version', negotiation.selectedVersion);

      if (negotiation.fallbackApplied) {
        res.setHeader('X-Fallback-Applied', 'true');
        res.setHeader('X-Fallback-Reason', negotiation.reason || '');
      }

      if (this.deprecatedVersions.has(negotiation.selectedVersion)) {
        res.setHeader('Warning', `299 - "API version ${negotiation.selectedVersion} is deprecated"`);
      }

      next();
    };
  }
}

// Example usage
const negotiator = new VersionNegotiator(
  ['v1', 'v2', 'v2.1'],
  'v2.1'
);

negotiator.markDeprecated('v1');

const app = express();
app.use(negotiator.middleware());

app.get('/tools/list', (req: Request, res: Response) => {
  const version = (req as any).apiVersion;
  res.json({
    message: `Tools list for version ${version}`,
    tools: ['book_appointment', 'cancel_appointment']
  });
});

Automated migration tools can significantly reduce client friction. Here's a migration tracking system with automated suggestions:

import express from 'express';

interface MigrationStep {
  id: string;
  description: string;
  automated: boolean;
  codeExample?: string;
  estimatedEffort?: string;
}

interface MigrationPath {
  fromVersion: string;
  toVersion: string;
  steps: MigrationStep[];
  breakingChanges: string[];
  estimatedDuration: string;
}

class MigrationTracker {
  private migrations: Map<string, MigrationPath> = new Map();
  private clientProgress: Map<string, {
    currentVersion: string;
    targetVersion: string;
    completedSteps: string[];
    startedAt: Date;
  }> = new Map();

  registerMigrationPath(path: MigrationPath): void {
    const key = `${path.fromVersion}->${path.toVersion}`;
    this.migrations.set(key, path);
  }

  getMigrationPath(from: string, to: string): MigrationPath | null {
    const key = `${from}->${to}`;
    return this.migrations.get(key) || null;
  }

  startMigration(clientId: string, from: string, to: string): void {
    this.clientProgress.set(clientId, {
      currentVersion: from,
      targetVersion: to,
      completedSteps: [],
      startedAt: new Date()
    });
  }

  markStepComplete(clientId: string, stepId: string): void {
    const progress = this.clientProgress.get(clientId);
    if (progress && !progress.completedSteps.includes(stepId)) {
      progress.completedSteps.push(stepId);
    }
  }

  getProgress(clientId: string): {
    progress: number;
    completedSteps: number;
    totalSteps: number;
    remainingSteps: MigrationStep[];
  } | null {
    const clientProgress = this.clientProgress.get(clientId);
    if (!clientProgress) return null;

    const path = this.getMigrationPath(
      clientProgress.currentVersion,
      clientProgress.targetVersion
    );

    if (!path) return null;

    const remainingSteps = path.steps.filter(
      step => !clientProgress.completedSteps.includes(step.id)
    );

    return {
      progress: (clientProgress.completedSteps.length / path.steps.length) * 100,
      completedSteps: clientProgress.completedSteps.length,
      totalSteps: path.steps.length,
      remainingSteps
    };
  }

  setupRoutes(app: express.Application): void {
    // Get migration path
    app.get('/migration/path/:from/:to', (req, res) => {
      const path = this.getMigrationPath(req.params.from, req.params.to);
      if (!path) {
        return res.status(404).json({
          error: 'Migration path not found',
          from: req.params.from,
          to: req.params.to
        });
      }
      res.json(path);
    });

    // Start migration
    app.post('/migration/start', (req, res) => {
      const { clientId, from, to } = req.body;
      this.startMigration(clientId, from, to);

      const path = this.getMigrationPath(from, to);
      res.json({
        message: 'Migration started',
        path,
        clientId
      });
    });

    // Mark step complete
    app.post('/migration/step-complete', (req, res) => {
      const { clientId, stepId } = req.body;
      this.markStepComplete(clientId, stepId);

      const progress = this.getProgress(clientId);
      res.json({
        message: 'Step marked complete',
        progress
      });
    });

    // Get migration progress
    app.get('/migration/progress/:clientId', (req, res) => {
      const progress = this.getProgress(req.params.clientId);
      if (!progress) {
        return res.status(404).json({ error: 'No migration in progress' });
      }
      res.json(progress);
    });
  }
}

// Example migration path configuration
const tracker = new MigrationTracker();

tracker.registerMigrationPath({
  fromVersion: 'v1',
  toVersion: 'v2',
  estimatedDuration: '2-4 hours',
  breakingChanges: [
    'book_appointment now accepts array of appointments',
    'Response structure changed from {booking} to {bookings: []}',
    'Added required field: serviceId for all appointments'
  ],
  steps: [
    {
      id: 'update-input-schema',
      description: 'Update appointment input to support arrays',
      automated: true,
      codeExample: `
// V1 (old)
const response = await callTool('book_appointment', {
  date: '2026-12-26',
  duration: 60
});

// V2 (new)
const response = await callTool('book_appointment', {
  appointments: [{
    date: '2026-12-26',
    duration: 60,
    serviceId: 'massage-60min'
  }]
});
      `.trim(),
      estimatedEffort: '30 minutes'
    },
    {
      id: 'update-response-handling',
      description: 'Update response parsing to handle bookings array',
      automated: true,
      codeExample: `
// V1 (old)
const bookingId = response.booking.id;

// V2 (new)
const bookingIds = response.bookings.map(b => b.id);
      `.trim(),
      estimatedEffort: '20 minutes'
    },
    {
      id: 'add-service-id',
      description: 'Add serviceId to all appointment requests',
      automated: false,
      estimatedEffort: '1-2 hours',
      codeExample: `
// Map your existing appointment types to service IDs
const SERVICE_MAP = {
  'massage': 'massage-60min',
  'facial': 'facial-45min'
};
      `.trim()
    },
    {
      id: 'test-integration',
      description: 'Test v2 integration in staging environment',
      automated: false,
      estimatedEffort: '1 hour'
    }
  ]
});

const app = express();
app.use(express.json());
tracker.setupRoutes(app);

This migration tracker provides clients with step-by-step guidance, code examples, and progress tracking—dramatically reducing migration friction and support burden.

For complex migrations involving data transformations, consider implementing contract testing with Pact to verify compatibility between versions.

Testing Version Compatibility

Comprehensive testing ensures each version maintains its contract and that migrations don't introduce regressions. Three testing strategies are essential:

  1. Contract testing: Verify API contracts remain stable across versions
  2. Version compatibility testing: Ensure handlers correctly transform inputs/outputs for each version
  3. Integration testing: Validate end-to-end flows across all supported versions

Here's a contract test suite using Pact-style consumer-driven contracts:

import axios from 'axios';

interface ContractTest {
  name: string;
  version: string;
  request: {
    method: string;
    path: string;
    headers?: Record<string, string>;
    body?: any;
  };
  expectedResponse: {
    status: number;
    headers?: Record<string, string | RegExp>;
    body?: any;
    bodySchema?: any;
  };
}

class ContractTestRunner {
  private baseUrl: string;
  private tests: ContractTest[] = [];

  constructor(baseUrl: string) {
    this.baseUrl = baseUrl;
  }

  addTest(test: ContractTest): void {
    this.tests.push(test);
  }

  async runAll(): Promise<{
    passed: number;
    failed: number;
    results: Array<{ test: string; success: boolean; error?: string }>;
  }> {
    const results: Array<{ test: string; success: boolean; error?: string }> = [];

    for (const test of this.tests) {
      try {
        await this.runTest(test);
        results.push({ test: test.name, success: true });
      } catch (error: any) {
        results.push({ test: test.name, success: false, error: error.message });
      }
    }

    const passed = results.filter(r => r.success).length;
    const failed = results.filter(r => !r.success).length;

    return { passed, failed, results };
  }

  private async runTest(test: ContractTest): Promise<void> {
    const url = `${this.baseUrl}${test.request.path}`;

    const response = await axios({
      method: test.request.method,
      url,
      headers: test.request.headers,
      data: test.request.body,
      validateStatus: () => true // Accept all status codes
    });

    // Validate status code
    if (response.status !== test.expectedResponse.status) {
      throw new Error(
        `Status mismatch: expected ${test.expectedResponse.status}, got ${response.status}`
      );
    }

    // Validate headers
    if (test.expectedResponse.headers) {
      for (const [key, expected] of Object.entries(test.expectedResponse.headers)) {
        const actual = response.headers[key.toLowerCase()];

        if (expected instanceof RegExp) {
          if (!expected.test(actual)) {
            throw new Error(`Header ${key} doesn't match pattern: ${expected}`);
          }
        } else if (actual !== expected) {
          throw new Error(`Header ${key} mismatch: expected ${expected}, got ${actual}`);
        }
      }
    }

    // Validate body structure
    if (test.expectedResponse.bodySchema) {
      this.validateSchema(response.data, test.expectedResponse.bodySchema);
    }

    // Validate exact body match
    if (test.expectedResponse.body) {
      const matches = this.deepEqual(response.data, test.expectedResponse.body);
      if (!matches) {
        throw new Error(
          `Body mismatch:\nExpected: ${JSON.stringify(test.expectedResponse.body, null, 2)}\nActual: ${JSON.stringify(response.data, null, 2)}`
        );
      }
    }
  }

  private validateSchema(data: any, schema: any): void {
    // Simple schema validation (use ajv for production)
    if (schema.type === 'object') {
      if (typeof data !== 'object' || data === null) {
        throw new Error(`Expected object, got ${typeof data}`);
      }

      if (schema.required) {
        for (const field of schema.required) {
          if (!(field in data)) {
            throw new Error(`Required field missing: ${field}`);
          }
        }
      }

      if (schema.properties) {
        for (const [key, propSchema] of Object.entries(schema.properties)) {
          if (key in data) {
            this.validateSchema(data[key], propSchema);
          }
        }
      }
    } else if (schema.type === 'array') {
      if (!Array.isArray(data)) {
        throw new Error(`Expected array, got ${typeof data}`);
      }

      if (schema.items && data.length > 0) {
        for (const item of data) {
          this.validateSchema(item, schema.items);
        }
      }
    } else if (schema.type) {
      if (typeof data !== schema.type) {
        throw new Error(`Expected ${schema.type}, got ${typeof data}`);
      }
    }
  }

  private deepEqual(a: any, b: any): boolean {
    if (a === b) return true;
    if (a == null || b == null) return false;
    if (typeof a !== 'object' || typeof b !== 'object') return false;

    const keysA = Object.keys(a);
    const keysB = Object.keys(b);

    if (keysA.length !== keysB.length) return false;

    for (const key of keysA) {
      if (!keysB.includes(key)) return false;
      if (!this.deepEqual(a[key], b[key])) return false;
    }

    return true;
  }
}

// Example contract tests for v1 and v2
const runner = new ContractTestRunner('http://localhost:3000');

// V1 contract: Single appointment booking
runner.addTest({
  name: 'V1: Book single appointment',
  version: 'v1',
  request: {
    method: 'POST',
    path: '/v1/tools/call',
    body: {
      toolName: 'book_appointment',
      parameters: {
        date: '2026-12-26',
        duration: 60
      }
    }
  },
  expectedResponse: {
    status: 200,
    headers: {
      'x-api-version': 'v1'
    },
    bodySchema: {
      type: 'object',
      required: ['success', 'result'],
      properties: {
        success: { type: 'boolean' },
        result: {
          type: 'object',
          required: ['booking'],
          properties: {
            booking: {
              type: 'object',
              required: ['id', 'date', 'status']
            }
          }
        }
      }
    }
  }
});

// V2 contract: Multiple appointment booking
runner.addTest({
  name: 'V2: Book multiple appointments',
  version: 'v2',
  request: {
    method: 'POST',
    path: '/v2/tools/call',
    body: {
      toolName: 'book_appointment',
      parameters: {
        appointments: [
          { date: '2026-12-26', duration: 60, serviceId: 'massage-60min' },
          { date: '2026-12-27', duration: 45, serviceId: 'facial-45min' }
        ]
      }
    }
  },
  expectedResponse: {
    status: 200,
    headers: {
      'x-api-version': 'v2'
    },
    bodySchema: {
      type: 'object',
      required: ['success', 'result'],
      properties: {
        success: { type: 'boolean' },
        result: {
          type: 'object',
          required: ['bookings', 'count'],
          properties: {
            bookings: {
              type: 'array',
              items: {
                type: 'object',
                required: ['id', 'date', 'status']
              }
            },
            count: { type: 'number' }
          }
        }
      }
    }
  }
});

// Run all tests
async function runContractTests() {
  const results = await runner.runAll();

  console.log(`\nContract Test Results:`);
  console.log(`Passed: ${results.passed}`);
  console.log(`Failed: ${results.failed}`);

  results.results.forEach(result => {
    const icon = result.success ? '✓' : '✗';
    console.log(`${icon} ${result.test}`);
    if (!result.success) {
      console.log(`  Error: ${result.error}`);
    }
  });

  process.exit(results.failed > 0 ? 1 : 0);
}

This contract testing framework validates that each API version maintains its expected behavior—essential for confident deployments and preventing regressions.

For comprehensive testing strategies including tool handler best practices, combine contract tests with integration tests that verify complete user flows across all versions.

Conclusion: Building Sustainable Version Management

Effective version management transforms ChatGPT app development from a fragile, breaking-change nightmare into a sustainable, confidence-inspiring process. By implementing proper versioning strategies (URL-based, header-based, or content negotiation), defining clear deprecation policies, supporting multiple versions simultaneously, providing automated migration tools, and maintaining comprehensive contract tests, you create a foundation for long-term success.

The production TypeScript implementations in this guide provide everything you need to:

  • Deploy multiple API versions with automatic routing and deprecation warnings
  • Track client usage of deprecated endpoints and send proactive migration notifications
  • Run parallel versions with shared business logic and version-specific adapters
  • Guide clients through migrations with step-by-step tracking and code examples
  • Validate version contracts with automated testing frameworks

These patterns have successfully managed ChatGPT apps serving thousands of users across fitness studios, restaurants, and e-commerce platforms—maintaining stability during rapid innovation cycles.

Ready to build ChatGPT apps with enterprise-grade version management? MakeAIHQ provides a complete no-code platform for creating, deploying, and managing ChatGPT applications with built-in versioning, deprecation tracking, and migration tools. Transform your business idea into a production ChatGPT app in 48 hours—no coding required.

Start building today and join hundreds of businesses reaching 800 million ChatGPT users with confidence and stability.


Further Reading

External Resources: