Mcp

MCP Server Versioning Strategies

Strategies for versioning MCP servers including tool schema evolution, capability negotiation, and backward-compatible changes.

MCP Server Versioning Strategies

Overview

MCP servers evolve. Tool schemas change, new capabilities get added, old ones get deprecated, and none of this can happen if every change breaks every client that depends on your server. Versioning an MCP server is fundamentally different from versioning a REST API because the contract is not just "what endpoints exist" -- it is "what tools, resources, and prompts does this server advertise, what input schemas do they expect, and what capabilities did client and server negotiate during initialization." This article covers practical strategies for versioning MCP servers in Node.js, from semantic versioning of tool schemas to capability negotiation and multi-version support.

Prerequisites

  • Node.js 18+ installed (LTS recommended)
  • npm for package management
  • Working knowledge of the Model Context Protocol (tools, resources, transports)
  • Experience building at least one MCP server (see my earlier guide on building production-ready MCP servers)
  • Familiarity with JSON Schema (MCP tool input schemas are JSON Schema)
  • Basic understanding of semantic versioning (semver)

Why MCP Servers Need Versioning

If you have built MCP servers and deployed them to production, you already know this pain. Day one, your server exposes a query_database tool that takes a sql string parameter. A month later, you realize you need to add a database parameter so users can target different databases. Two months after that, you rename sql to query because the tool now supports multiple query languages.

Every one of those changes can break clients. The AI model has been trained -- or at least prompted -- to call your tools with specific parameter names and shapes. MCP clients cache tool listings. Orchestration layers hardcode tool names. Unlike a REST API where you can throw a /v2/ prefix on the URL, MCP does not have a built-in versioning mechanism in the protocol itself. You have to build it.

There are three categories of change that matter:

  1. Additive changes -- new tools, new optional parameters, new resources. Generally safe.
  2. Modification changes -- renaming parameters, changing types, altering behavior. Dangerous.
  3. Removal changes -- dropping tools, removing parameters, discontinuing resources. Breaking.

The goal of a versioning strategy is to make category 1 seamless, category 2 manageable, and category 3 survivable.

Semantic Versioning for MCP Tool Schemas

Every MCP server already declares a version in its initialization:

var { McpServer } = require("@modelcontextprotocol/sdk/server/mcp.js");

var server = new McpServer({
  name: "data-tools",
  version: "2.3.1",
  description: "Database and analytics tools"
});

That version string should follow semver, but you need to apply it more granularly than most people do. Here is how I map semver to MCP changes:

MAJOR (3.0.0) -- Breaking changes to existing tool schemas. A required parameter was renamed or removed. A tool was deleted. The output format changed in a way that existing consumers cannot handle.

MINOR (2.4.0) -- New tools added. New optional parameters on existing tools. New resources or prompts. Everything that existed before still works identically.

PATCH (2.3.2) -- Bug fixes. Performance improvements. Documentation updates. No schema changes at all.

I also recommend versioning individual tools independently of the server version. This lets you evolve tools at different rates:

var TOOL_VERSIONS = {
  "query_database": "2.1.0",
  "create_record": "1.0.0",
  "analyze_metrics": "3.0.0-beta"
};

You can expose this metadata in the tool description or through a dedicated version-discovery tool (more on that later).

Versioning Tool Definitions Without Breaking Clients

The safest approach to tool evolution is the additive-only strategy. Never remove parameters. Never rename them. Only add new optional ones. This sounds limiting, but it covers 80% of real-world changes.

// Version 1.0.0 - original tool
server.tool(
  "query_database",
  "Execute a SQL query against the database",
  {
    sql: { type: "string", description: "SQL query to execute" }
  },
  function(params) {
    return executeQuery("default", params.sql);
  }
);

// Version 1.1.0 - additive change (new optional parameter)
server.tool(
  "query_database",
  "Execute a SQL query against the database",
  {
    sql: { type: "string", description: "SQL query to execute" },
    database: {
      type: "string",
      description: "Target database name (defaults to 'default')",
      default: "default"
    },
    timeout_ms: {
      type: "number",
      description: "Query timeout in milliseconds (defaults to 30000)",
      default: 30000
    }
  },
  function(params) {
    var db = params.database || "default";
    var timeout = params.timeout_ms || 30000;
    return executeQuery(db, params.sql, { timeout: timeout });
  }
);

Clients that only know about the sql parameter continue to work. Clients that know about database and timeout_ms can use them. No coordination required.

When you genuinely need a breaking change -- renaming a parameter, changing its type, or fundamentally altering behavior -- create a new tool rather than modifying the existing one:

// Keep the old tool (deprecated but functional)
server.tool(
  "query_database",
  "[DEPRECATED: Use execute_query instead] Execute a SQL query",
  {
    sql: { type: "string", description: "SQL query to execute" }
  },
  function(params) {
    console.error("[DEPRECATION] query_database is deprecated. Use execute_query.");
    return executeQuery("default", params.sql);
  }
);

// New tool with the improved schema
server.tool(
  "execute_query",
  "Execute a query against any supported database engine",
  {
    query: { type: "string", description: "Query string to execute" },
    engine: {
      type: "string",
      enum: ["postgresql", "mysql", "sqlite"],
      description: "Database engine to target"
    },
    database: { type: "string", description: "Database name" },
    timeout_ms: {
      type: "number",
      description: "Query timeout in milliseconds",
      default: 30000
    }
  },
  function(params) {
    return executeQuery(params.engine, params.database, params.query, {
      timeout: params.timeout_ms || 30000
    });
  }
);

This pattern keeps old clients working while giving new clients the improved interface. The deprecation notice in the description tells AI models (and the humans reading tool listings) to prefer the new version.

Capability Negotiation During Initialization

MCP includes a capability negotiation phase during the initialize handshake. The client sends its supported capabilities, the server responds with its own, and both sides agree on what features are available for the session. This is your primary mechanism for version-aware behavior.

Here is how to implement server-side capability negotiation that adapts based on what the client supports:

var { McpServer } = require("@modelcontextprotocol/sdk/server/mcp.js");
var { StdioServerTransport } = require("@modelcontextprotocol/sdk/server/stdio.js");

var server = new McpServer({
  name: "versioned-tools",
  version: "3.0.0"
});

// Track negotiated capabilities per session
var sessionCapabilities = {};

// Custom initialization handler
server.server.setRequestHandler("initialize", function(request) {
  var clientInfo = request.params.clientInfo || {};
  var clientVersion = clientInfo.version || "0.0.0";
  var protocolVersion = request.params.protocolVersion;

  console.error("Client: " + clientInfo.name + " v" + clientVersion);
  console.error("Protocol version: " + protocolVersion);

  // Store what this client supports
  sessionCapabilities = {
    clientName: clientInfo.name,
    clientVersion: clientVersion,
    protocolVersion: protocolVersion,
    supportsProgress: !!(request.params.capabilities && request.params.capabilities.experimental),
    supportsRoots: !!(request.params.capabilities && request.params.capabilities.roots)
  };

  return {
    protocolVersion: protocolVersion,
    capabilities: {
      tools: { listChanged: true },
      resources: { subscribe: true, listChanged: true }
    },
    serverInfo: {
      name: "versioned-tools",
      version: "3.0.0"
    }
  };
});

The listChanged capability is particularly important for versioning. When set to true, the server can notify clients that the tool or resource list has changed mid-session. This enables live migration -- you can deprecate a tool and notify connected clients in real time.

Supporting Multiple Protocol Versions Simultaneously

The MCP protocol itself has versions. As of early 2026, the primary versions in the wild are 2024-11-05 and 2025-03-26. Your server should handle both gracefully. Here is a pattern I use:

var SUPPORTED_PROTOCOL_VERSIONS = [
  "2025-03-26",
  "2024-11-05"
];

function negotiateProtocolVersion(requestedVersion) {
  // Client requests a specific version
  if (SUPPORTED_PROTOCOL_VERSIONS.indexOf(requestedVersion) !== -1) {
    return requestedVersion;
  }

  // Fall back to the newest version we support
  console.error(
    "Client requested unsupported protocol version: " + requestedVersion +
    ". Falling back to " + SUPPORTED_PROTOCOL_VERSIONS[0]
  );
  return SUPPORTED_PROTOCOL_VERSIONS[0];
}

// Use in initialization
server.server.setRequestHandler("initialize", function(request) {
  var negotiatedVersion = negotiateProtocolVersion(request.params.protocolVersion);

  return {
    protocolVersion: negotiatedVersion,
    capabilities: getCapabilitiesForVersion(negotiatedVersion),
    serverInfo: { name: "versioned-tools", version: "3.0.0" }
  };
});

function getCapabilitiesForVersion(protocolVersion) {
  var capabilities = {
    tools: { listChanged: true },
    resources: {}
  };

  // The 2025-03-26 spec added resource subscription support
  if (protocolVersion === "2025-03-26") {
    capabilities.resources.subscribe = true;
    capabilities.resources.listChanged = true;
  }

  return capabilities;
}

The key principle: never reject a client because it speaks an older protocol version. Degrade gracefully. If a client connects with 2024-11-05 and you have features that require 2025-03-26, simply do not advertise those features.

Migrating Clients Between Server Versions

When you release a major version of your MCP server, you need a migration path. Cold-cutting every client over simultaneously is rarely practical, especially when your server is consumed by multiple teams or embedded in different AI applications.

Here is a migration strategy I have used successfully:

Phase 1: Shadow period (2-4 weeks). Deploy the new version alongside the old one. Both versions run simultaneously. New tools exist but old tools still work.

// migration-manager.js
var semver = require("semver");

function MigrationManager() {
  this.migrations = [];
}

MigrationManager.prototype.register = function(migration) {
  this.migrations.push(migration);
  this.migrations.sort(function(a, b) {
    return semver.compare(a.fromVersion, b.fromVersion);
  });
};

MigrationManager.prototype.getMigrationsFor = function(currentVersion, targetVersion) {
  return this.migrations.filter(function(m) {
    return semver.gte(m.fromVersion, currentVersion) &&
           semver.lte(m.toVersion, targetVersion);
  });
};

MigrationManager.prototype.getDeprecatedTools = function(version) {
  var deprecated = [];
  this.migrations.forEach(function(m) {
    if (semver.lte(m.fromVersion, version)) {
      deprecated = deprecated.concat(m.deprecatedTools || []);
    }
  });
  return deprecated;
};

var manager = new MigrationManager();

manager.register({
  fromVersion: "2.0.0",
  toVersion: "3.0.0",
  deprecatedTools: ["query_database", "list_tables"],
  replacements: {
    "query_database": "execute_query",
    "list_tables": "describe_schema"
  },
  notes: "SQL tools rewritten to support multiple database engines"
});

module.exports = manager;

Phase 2: Deprecation warnings (2-4 weeks). Old tools emit warnings in their responses. Log usage of deprecated tools to understand adoption of the new versions.

var deprecationLog = require("./deprecation-log");

function wrapDeprecatedTool(toolName, replacementName, originalHandler) {
  return function(params) {
    // Log the deprecation hit
    deprecationLog.record({
      tool: toolName,
      replacement: replacementName,
      timestamp: new Date().toISOString(),
      params: Object.keys(params)
    });

    // Execute the original handler
    var result = originalHandler(params);

    // Inject deprecation notice into the response
    return {
      content: [
        {
          type: "text",
          text: "WARNING: '" + toolName + "' is deprecated and will be " +
                "removed in v4.0.0. Use '" + replacementName + "' instead.\n\n"
        },
        result.content[0]
      ]
    };
  };
}

Phase 3: Removal. After sufficient migration time and usage metrics showing low traffic to deprecated tools, remove them in the next major version.

Deprecation Workflows for Tools and Resources

A formal deprecation workflow prevents surprises. Here is the lifecycle I follow:

ACTIVE --> DEPRECATED --> SUNSET --> REMOVED
  • ACTIVE: Tool works normally. No warnings.
  • DEPRECATED: Tool still works but emits warnings. Description is prefixed with [DEPRECATED]. A replacement is documented.
  • SUNSET: Tool returns an error directing users to the replacement. It no longer executes the original logic.
  • REMOVED: Tool is no longer registered on the server. Calls to it return a standard "unknown tool" error.

Implementing this as a state machine:

var TOOL_LIFECYCLE = {
  ACTIVE: "active",
  DEPRECATED: "deprecated",
  SUNSET: "sunset",
  REMOVED: "removed"
};

var toolRegistry = {
  "query_database": {
    status: TOOL_LIFECYCLE.DEPRECATED,
    deprecatedSince: "2025-12-01",
    sunsetDate: "2026-03-01",
    removalDate: "2026-06-01",
    replacement: "execute_query",
    migrationGuide: "https://docs.example.com/migrate/query-to-execute"
  },
  "execute_query": {
    status: TOOL_LIFECYCLE.ACTIVE,
    since: "2025-12-01"
  },
  "list_tables": {
    status: TOOL_LIFECYCLE.SUNSET,
    deprecatedSince: "2025-06-01",
    sunsetDate: "2025-12-01",
    removalDate: "2026-03-01",
    replacement: "describe_schema"
  }
};

function registerToolWithLifecycle(server, name, description, schema, handler) {
  var lifecycle = toolRegistry[name];

  if (!lifecycle || lifecycle.status === TOOL_LIFECYCLE.REMOVED) {
    return; // Do not register removed tools
  }

  if (lifecycle.status === TOOL_LIFECYCLE.SUNSET) {
    // Register a stub that returns migration instructions
    server.tool(name, "[SUNSET] " + description, schema, function(params) {
      return {
        content: [{
          type: "text",
          text: "ERROR: '" + name + "' has been sunset and no longer executes. " +
                "Please use '" + lifecycle.replacement + "' instead. " +
                "Migration guide: " + lifecycle.migrationGuide
        }],
        isError: true
      };
    });
    return;
  }

  var finalDescription = description;
  var finalHandler = handler;

  if (lifecycle.status === TOOL_LIFECYCLE.DEPRECATED) {
    finalDescription = "[DEPRECATED: Use " + lifecycle.replacement +
                       " instead. Sunset: " + lifecycle.sunsetDate + "] " + description;
    finalHandler = function(params) {
      console.error(
        "[DEPRECATION] " + name + " called. Sunset date: " + lifecycle.sunsetDate
      );
      return handler(params);
    };
  }

  server.tool(name, finalDescription, schema, finalHandler);
}

Version Discovery and Advertisement

Clients need a way to discover what version of your server they are talking to and what tools are available at what versions. I solve this with a dedicated server_info tool:

server.tool(
  "server_info",
  "Returns server version, supported protocol versions, and tool version manifest",
  {},
  function() {
    var toolManifest = Object.keys(toolRegistry).map(function(name) {
      var info = toolRegistry[name];
      return {
        name: name,
        status: info.status,
        replacement: info.replacement || null,
        sunsetDate: info.sunsetDate || null
      };
    });

    return {
      content: [{
        type: "text",
        text: JSON.stringify({
          server: {
            name: "data-tools",
            version: "3.0.0",
            supportedProtocolVersions: SUPPORTED_PROTOCOL_VERSIONS
          },
          tools: toolManifest,
          changelog: "https://docs.example.com/changelog",
          deprecationPolicy: "https://docs.example.com/deprecation-policy"
        }, null, 2)
      }]
    };
  }
);

When an AI model first connects to your server, it can call server_info to understand the current state of the API surface. This is especially valuable in multi-server architectures where an orchestrator needs to route tool calls to the appropriate server version.

Changelog Management for MCP Servers

Every version of your MCP server should have a machine-readable changelog. I keep mine as a JSON file alongside the server code:

{
  "versions": [
    {
      "version": "3.0.0",
      "date": "2026-01-15",
      "breaking": true,
      "changes": [
        {
          "type": "added",
          "tool": "execute_query",
          "description": "Multi-engine query execution replacing query_database"
        },
        {
          "type": "deprecated",
          "tool": "query_database",
          "replacement": "execute_query",
          "sunsetDate": "2026-06-15"
        },
        {
          "type": "added",
          "tool": "describe_schema",
          "description": "Schema introspection replacing list_tables"
        }
      ]
    },
    {
      "version": "2.5.0",
      "date": "2025-11-01",
      "breaking": false,
      "changes": [
        {
          "type": "added",
          "tool": "analyze_metrics",
          "description": "Statistical analysis on query results"
        },
        {
          "type": "modified",
          "tool": "query_database",
          "description": "Added optional timeout_ms parameter"
        }
      ]
    }
  ]
}

Expose this through a resource so clients can read it programmatically:

var fs = require("fs");
var path = require("path");

var changelogPath = path.join(__dirname, "changelog.json");
var changelog = JSON.parse(fs.readFileSync(changelogPath, "utf8"));

server.resource(
  "changelog",
  "changelog://current",
  { mimeType: "application/json", description: "Server version changelog" },
  function() {
    return {
      contents: [{
        uri: "changelog://current",
        mimeType: "application/json",
        text: JSON.stringify(changelog, null, 2)
      }]
    };
  }
);

Complete Working Example

Here is a complete MCP server that implements multi-version tool support with deprecation management, capability negotiation, and version discovery. Save this as server.js:

// server.js - Versioned MCP Server
var { McpServer } = require("@modelcontextprotocol/sdk/server/mcp.js");
var { StdioServerTransport } = require("@modelcontextprotocol/sdk/server/stdio.js");
var fs = require("fs");
var path = require("path");

// --- Configuration ---
var SERVER_NAME = "versioned-data-tools";
var SERVER_VERSION = "3.0.0";
var SUPPORTED_PROTOCOL_VERSIONS = ["2025-03-26", "2024-11-05"];

// --- Tool Lifecycle States ---
var LIFECYCLE = {
  ACTIVE: "active",
  DEPRECATED: "deprecated",
  SUNSET: "sunset",
  REMOVED: "removed"
};

// --- Tool Registry ---
var toolRegistry = {
  "query_database": {
    status: LIFECYCLE.DEPRECATED,
    version: "1.0.0",
    deprecatedSince: "2026-01-15",
    sunsetDate: "2026-06-15",
    replacement: "execute_query"
  },
  "execute_query": {
    status: LIFECYCLE.ACTIVE,
    version: "3.0.0",
    since: "2026-01-15"
  },
  "list_tables": {
    status: LIFECYCLE.SUNSET,
    version: "1.0.0",
    deprecatedSince: "2025-06-01",
    sunsetDate: "2026-01-15",
    replacement: "describe_schema"
  },
  "describe_schema": {
    status: LIFECYCLE.ACTIVE,
    version: "3.0.0",
    since: "2026-01-15"
  },
  "server_info": {
    status: LIFECYCLE.ACTIVE,
    version: "1.0.0",
    since: "2025-01-01"
  }
};

// --- Deprecation Logger ---
var deprecationCounts = {};

function logDeprecation(toolName) {
  if (!deprecationCounts[toolName]) {
    deprecationCounts[toolName] = 0;
  }
  deprecationCounts[toolName]++;
  console.error(
    "[DEPRECATION] " + toolName + " called (" +
    deprecationCounts[toolName] + " total calls this session). " +
    "Replacement: " + toolRegistry[toolName].replacement
  );
}

// --- Server Setup ---
var server = new McpServer({
  name: SERVER_NAME,
  version: SERVER_VERSION
});

// --- Register Tools Based on Lifecycle ---

// DEPRECATED: query_database (still works, emits warnings)
(function() {
  var meta = toolRegistry["query_database"];
  if (meta.status === LIFECYCLE.DEPRECATED) {
    server.tool(
      "query_database",
      "[DEPRECATED: Use execute_query. Sunset: " + meta.sunsetDate +
      "] Execute a SQL query against the default database",
      {
        sql: { type: "string", description: "SQL query to execute" }
      },
      function(params) {
        logDeprecation("query_database");

        // Simulate query execution
        var results = [
          { id: 1, name: "Alice", email: "[email protected]" },
          { id: 2, name: "Bob", email: "[email protected]" }
        ];

        return {
          content: [
            {
              type: "text",
              text: "WARNING: 'query_database' is deprecated. " +
                    "Use 'execute_query' instead (sunset: " + meta.sunsetDate + ").\n\n" +
                    "Results:\n" + JSON.stringify(results, null, 2)
            }
          ]
        };
      }
    );
  }
})();

// SUNSET: list_tables (returns error, does not execute)
(function() {
  var meta = toolRegistry["list_tables"];
  if (meta.status === LIFECYCLE.SUNSET) {
    server.tool(
      "list_tables",
      "[SUNSET] This tool no longer functions. Use describe_schema instead.",
      {
        database: { type: "string", description: "Database name" }
      },
      function(params) {
        return {
          content: [{
            type: "text",
            text: "ERROR: 'list_tables' was sunset on " + meta.sunsetDate +
                  " and no longer executes. Use 'describe_schema' instead."
          }],
          isError: true
        };
      }
    );
  }
})();

// ACTIVE: execute_query (current version)
server.tool(
  "execute_query",
  "Execute a query against a supported database engine",
  {
    query: { type: "string", description: "Query string to execute" },
    engine: {
      type: "string",
      enum: ["postgresql", "mysql", "sqlite"],
      description: "Database engine"
    },
    database: { type: "string", description: "Database name" },
    timeout_ms: {
      type: "number",
      description: "Timeout in milliseconds (default: 30000)",
      default: 30000
    }
  },
  function(params) {
    var timeout = params.timeout_ms || 30000;
    var startTime = Date.now();

    // Simulate query execution
    var results = [
      { id: 1, name: "Alice", department: "Engineering" },
      { id: 2, name: "Bob", department: "Product" },
      { id: 3, name: "Carol", department: "Engineering" }
    ];

    var elapsed = Date.now() - startTime;

    return {
      content: [{
        type: "text",
        text: JSON.stringify({
          engine: params.engine,
          database: params.database,
          query: params.query,
          rowCount: results.length,
          elapsed_ms: elapsed,
          results: results
        }, null, 2)
      }]
    };
  }
);

// ACTIVE: describe_schema
server.tool(
  "describe_schema",
  "Describe the schema of a database including tables, columns, and indexes",
  {
    engine: {
      type: "string",
      enum: ["postgresql", "mysql", "sqlite"],
      description: "Database engine"
    },
    database: { type: "string", description: "Database name" },
    table: {
      type: "string",
      description: "Specific table name (omit for full schema)"
    }
  },
  function(params) {
    // Simulate schema response
    var schema = {
      database: params.database,
      engine: params.engine,
      tables: [
        {
          name: "users",
          columns: [
            { name: "id", type: "SERIAL", primaryKey: true },
            { name: "name", type: "VARCHAR(255)", nullable: false },
            { name: "email", type: "VARCHAR(255)", nullable: false },
            { name: "created_at", type: "TIMESTAMP", nullable: false }
          ],
          indexes: ["idx_users_email"]
        }
      ]
    };

    return {
      content: [{
        type: "text",
        text: JSON.stringify(schema, null, 2)
      }]
    };
  }
);

// ACTIVE: server_info (version discovery)
server.tool(
  "server_info",
  "Returns server version, protocol support, tool lifecycle status, and deprecation schedule",
  {},
  function() {
    var manifest = Object.keys(toolRegistry).map(function(name) {
      var info = toolRegistry[name];
      return {
        tool: name,
        status: info.status,
        version: info.version,
        replacement: info.replacement || null,
        sunsetDate: info.sunsetDate || null
      };
    });

    return {
      content: [{
        type: "text",
        text: JSON.stringify({
          server: {
            name: SERVER_NAME,
            version: SERVER_VERSION,
            supportedProtocolVersions: SUPPORTED_PROTOCOL_VERSIONS
          },
          tools: manifest,
          deprecationCounts: deprecationCounts,
          uptime: process.uptime().toFixed(1) + "s"
        }, null, 2)
      }]
    };
  }
);

// --- Changelog Resource ---
var changelog = {
  versions: [
    {
      version: "3.0.0",
      date: "2026-01-15",
      breaking: true,
      changes: [
        { type: "added", tool: "execute_query", description: "Multi-engine query tool" },
        { type: "added", tool: "describe_schema", description: "Schema introspection" },
        { type: "deprecated", tool: "query_database", replacement: "execute_query" },
        { type: "sunset", tool: "list_tables", replacement: "describe_schema" }
      ]
    },
    {
      version: "2.0.0",
      date: "2025-06-01",
      breaking: true,
      changes: [
        { type: "deprecated", tool: "list_tables", replacement: "describe_schema" },
        { type: "modified", tool: "query_database", description: "Added timeout_ms param" }
      ]
    },
    {
      version: "1.0.0",
      date: "2025-01-01",
      breaking: false,
      changes: [
        { type: "added", tool: "query_database", description: "Initial SQL query tool" },
        { type: "added", tool: "list_tables", description: "Table listing tool" },
        { type: "added", tool: "server_info", description: "Version discovery" }
      ]
    }
  ]
};

server.resource(
  "changelog",
  "changelog://current",
  { mimeType: "application/json", description: "Server version changelog" },
  function() {
    return {
      contents: [{
        uri: "changelog://current",
        mimeType: "application/json",
        text: JSON.stringify(changelog, null, 2)
      }]
    };
  }
);

// --- Start Server ---
var transport = new StdioServerTransport();
server.connect(transport).then(function() {
  console.error(SERVER_NAME + " v" + SERVER_VERSION + " running on stdio");
  console.error("Supported protocol versions: " + SUPPORTED_PROTOCOL_VERSIONS.join(", "));
});

Set up the project:

mkdir versioned-mcp-server
cd versioned-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk
{
  "name": "versioned-mcp-server",
  "version": "3.0.0",
  "main": "server.js",
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "@modelcontextprotocol/sdk": "^1.0.0"
  }
}

Configure it in Claude Desktop's claude_desktop_config.json:

{
  "mcpServers": {
    "versioned-data-tools": {
      "command": "node",
      "args": ["C:/projects/versioned-mcp-server/server.js"]
    }
  }
}

When a client connects and calls server_info, it gets back:

{
  "server": {
    "name": "versioned-data-tools",
    "version": "3.0.0",
    "supportedProtocolVersions": ["2025-03-26", "2024-11-05"]
  },
  "tools": [
    { "tool": "query_database", "status": "deprecated", "version": "1.0.0", "replacement": "execute_query", "sunsetDate": "2026-06-15" },
    { "tool": "execute_query", "status": "active", "version": "3.0.0", "replacement": null, "sunsetDate": null },
    { "tool": "list_tables", "status": "sunset", "version": "1.0.0", "replacement": "describe_schema", "sunsetDate": "2026-01-15" },
    { "tool": "describe_schema", "status": "active", "version": "3.0.0", "replacement": null, "sunsetDate": null },
    { "tool": "server_info", "status": "active", "version": "1.0.0", "replacement": null, "sunsetDate": null }
  ],
  "deprecationCounts": {},
  "uptime": "0.3s"
}

If a client calls the deprecated query_database tool, it still gets results but with a clear warning:

WARNING: 'query_database' is deprecated. Use 'execute_query' instead (sunset: 2026-06-15).

Results:
[
  { "id": 1, "name": "Alice", "email": "[email protected]" },
  { "id": 2, "name": "Bob", "email": "[email protected]" }
]

If a client calls the sunset list_tables tool, it gets an error:

ERROR: 'list_tables' was sunset on 2026-01-15 and no longer executes. Use 'describe_schema' instead.

This gives you a complete, graduated deprecation pipeline. Old clients degrade gracefully through clear lifecycle stages rather than breaking without explanation.

Common Issues and Troubleshooting

1. Client Caches Stale Tool Listings

Error: The AI model calls a tool with the old parameter schema even after the server was updated.

Error: Invalid params for tool "execute_query": missing required property "engine"

Cause: MCP clients cache the tools/list response. If your client does not honor listChanged notifications, it will keep using the stale schema.

Fix: Ensure your server advertises listChanged: true in capabilities and sends a notifications/tools/list_changed notification when tools are modified. On the client side, verify it re-fetches the tool list upon receiving this notification. As a workaround, restart the client to force a fresh tools/list call.

2. Protocol Version Mismatch on Initialization

Error:

{
  "jsonrpc": "2.0",
  "error": {
    "code": -32600,
    "message": "Unsupported protocol version: 2025-03-26"
  }
}

Cause: The client and server do not share a common protocol version. This typically happens when a newer client connects to an older server or vice versa.

Fix: Always implement protocol version negotiation in your server. Accept multiple versions and fall back to the newest version you share with the client. Never hard-reject a version without attempting negotiation. The code in the "Supporting Multiple Protocol Versions" section above shows the pattern.

3. Deprecated Tool Returns Unexpected Response Shape

Error: Client code that parses tool responses breaks because the deprecation wrapper added an extra content element.

TypeError: Cannot read property 'results' of undefined
    at parseQueryResponse (client.js:142:28)

Cause: When you wrap a deprecated tool's response with a warning message, you change the content array. Clients that expect content[0] to be the data now find the warning there instead.

Fix: Put the warning at the end of the content array, not the beginning. Or better yet, use a separate content block with a distinct type or annotation so clients can filter it:

return {
  content: [
    // Original data first (clients that index by position still work)
    { type: "text", text: JSON.stringify(results, null, 2) },
    // Warning second
    { type: "text", text: "[DEPRECATION NOTICE] Use execute_query instead." }
  ]
};

4. Sunset Tool Called in Automated Pipeline

Error:

MCP tool call failed: list_tables returned isError=true
Pipeline step "schema_discovery" failed after 0 retries

Cause: Automated pipelines that call MCP tools often hardcode tool names. When a tool is sunset and starts returning isError: true, the pipeline breaks without a clear path to recovery.

Fix: Automated consumers should call server_info on startup and check for sunset or deprecated tools before executing their pipeline. Build a pre-flight check:

function preflightCheck(mcpClient, requiredTools) {
  return mcpClient.callTool("server_info", {}).then(function(result) {
    var info = JSON.parse(result.content[0].text);
    var issues = [];

    requiredTools.forEach(function(toolName) {
      var tool = info.tools.find(function(t) { return t.tool === toolName; });
      if (!tool) {
        issues.push("Tool '" + toolName + "' not found on server");
      } else if (tool.status === "sunset" || tool.status === "removed") {
        issues.push(
          "Tool '" + toolName + "' is " + tool.status +
          ". Use '" + tool.replacement + "' instead."
        );
      } else if (tool.status === "deprecated") {
        console.warn(
          "Tool '" + toolName + "' is deprecated. " +
          "Consider migrating to '" + tool.replacement + "' before " + tool.sunsetDate
        );
      }
    });

    if (issues.length > 0) {
      throw new Error("Pre-flight check failed:\n" + issues.join("\n"));
    }
  });
}

5. Version Drift Across Multiple Server Instances

Error: Intermittent failures where the same tool call succeeds sometimes and fails other times.

Error: Unknown tool "execute_query"
  (subsequent request succeeds)

Cause: In load-balanced deployments, different instances may be running different server versions. One instance has execute_query (v3), another only has query_database (v2).

Fix: Enforce version consistency across all instances during deployment. Use health checks that verify the server version before routing traffic. In your health endpoint, include the version:

server.tool("health_check", "Server health and version", {}, function() {
  return {
    content: [{
      type: "text",
      text: JSON.stringify({
        status: "healthy",
        version: SERVER_VERSION,
        uptime: process.uptime()
      })
    }]
  };
});

Best Practices

  • Never remove a tool without a sunset period. The minimum lifecycle should be ACTIVE -> DEPRECATED (4+ weeks) -> SUNSET (4+ weeks) -> REMOVED. This gives every consumer time to migrate, even slow-moving enterprise teams.

  • Version individual tools, not just the server. Each tool should carry its own version number. This lets consumers understand exactly which tools changed between server releases without reading the full changelog.

  • Use additive changes by default. New optional parameters with sensible defaults are always backward-compatible. Reserve new tool names for genuinely different interfaces, not incremental improvements.

  • Expose a server_info tool from day one. Even if your first version has no deprecated tools, establishing the pattern early means consumers build version-aware logic from the start rather than retrofitting it later.

  • Log deprecation usage metrics. Track how many times deprecated tools are called per session and per day. This data tells you when it is safe to sunset a tool -- when usage drops below a threshold, you have your green light.

  • Include migration guides in deprecation notices. A tool description that says "[DEPRECATED]" is not enough. Include the replacement tool name, the sunset date, and ideally a URL to a migration guide that shows exact parameter mappings.

  • Test both old and new schemas in CI. Your test suite should include calls to deprecated tools to ensure they still work correctly. Only remove those tests when you remove the tools.

  • Pin protocol versions in production clients. If your client works with protocol version 2024-11-05, do not blindly upgrade. Test against the new version, verify behavior, then update the pin. Protocol version changes can subtly alter capability negotiation.

  • Maintain a machine-readable changelog. JSON or YAML changelogs can be consumed by automation. Expose them as MCP resources so clients can programmatically check for breaking changes before upgrading.

References

Powered by Contentful