Skip to content

Version Management: Multiple Sources of Truth Across MCP Servers #16

@shwetank-dev

Description

@shwetank-dev

Problem

Every NimbleBrain MCP server has version numbers scattered across multiple files, each serving a different consumer. There is no single source of truth — make bump is the only mechanism keeping them in sync, and it is a manual local step with no enforcement.

Node.js servers

File Consumer How version is stored
manifest.json mpak registry / bundle tooling { "version": "0.1.0" }
package.json npm package manager { "version": "0.1.0" }
src/constants.ts MCP SDK handshake, runtime logs export const VERSION = '0.1.0'
server.json MCP registry discovery { "version": "0.1.0" } (synced by mcpb-pack at release)

Python servers

File Consumer How version is stored
manifest.json mpak registry / bundle tooling { "version": "0.1.0" }
pyproject.toml pip / uv package manager version = "0.1.0"
src/mcp_<name>/__init__.py FastMCP handshake, runtime logs __version__ = "0.1.0"
server.json MCP registry discovery { "version": "0.1.0" } (synced by mcpb-pack at release)

Current pain points

  • A developer can forget to run make bump and commit mismatched versions — nothing catches it
  • CI has no version consistency check
  • mcpb-pack only syncs server.json from manifest.json — it does not validate the rest
  • No template-level enforcement means every new server repo inherits this gap silently
  • make bump currently requires the developer to pass VERSION=x.y.z as an argument — the version has no persistent home in the repo

Proposed Solution

1. Introduce .PACKAGE_VERSION as the single source of truth

Add a .PACKAGE_VERSION file in the repo root containing only the version string:

0.1.0

This is the one file a developer ever edits when bumping a version. It is a flat file — trivially readable by any script, in any language, with no parsing.

2. make bump reads from .PACKAGE_VERSION — no argument needed

Instead of passing VERSION=x.y.z on the command line, the developer edits .PACKAGE_VERSION directly and then runs make bump with no arguments:

bump:
	$(eval VERSION := $(shell cat .PACKAGE_VERSION))
	@if [ -z "$(VERSION)" ]; then \
		echo "Error: .PACKAGE_VERSION is empty"; exit 1; \
	fi
	@echo "Bumping to version $(VERSION)..."
	@jq --arg v "$(VERSION)" '.version = $$v' manifest.json > manifest.tmp.json && mv manifest.tmp.json manifest.json
	# Node.js
	@if [ -f package.json ]; then \
		jq --arg v "$(VERSION)" '.version = $$v' package.json > package.tmp.json && mv package.tmp.json package.json; \
	fi
	@if [ -f src/constants.ts ]; then \
		sed -i '' "s/export const VERSION = '.*'/export const VERSION = '$(VERSION)'/" src/constants.ts; \
	fi
	# Python
	@if [ -f pyproject.toml ]; then \
		sed -i '' "s/^version = \".*\"/version = \"$(VERSION)\"/" pyproject.toml; \
	fi
	@if [ -f src/mcp_*/\_\_init\_\_.py ]; then \
		sed -i '' "s/__version__ = \".*\"/__version__ = \"$(VERSION)\"/" src/mcp_*/__init__.py; \
	fi
	@echo "Version bumped to $(VERSION) in all files."

The full developer workflow becomes:

1. Edit .PACKAGE_VERSION → "1.2.0"
2. make bump                          (propagates to all files)
3. git commit
4. gh release create v$(cat .PACKAGE_VERSION)   (tag always matches)

3. Remove Update manifest version step from build-bundle.yml

Since the git tag is derived from .PACKAGE_VERSION, and make bump has already stamped all files before the commit, the CI step that overwrites manifest.json and server.json from the git tag is unnecessary and should be removed. mcpb-pack already handles syncing server.json version from manifest.json internally.

Optionally, add a guard in build-bundle.yml to verify the tag matches .PACKAGE_VERSION:

EXPECTED="v$(cat .PACKAGE_VERSION)"
ACTUAL="${{ github.event.release.tag_name }}"

if [ "$EXPECTED" != "$ACTUAL" ]; then
  echo "Tag $ACTUAL does not match .PACKAGE_VERSION ($EXPECTED)"
  echo "Did you forget to run make bump?"
  exit 1
fi

4. Add a version consistency check to CI

This script reads the runtime type from manifest.json and validates all relevant files match .PACKAGE_VERSION:

#!/bin/bash
set -e

EXPECTED=$(cat .PACKAGE_VERSION)
MANIFEST=$(jq -r '.version' manifest.json)
RUNTIME=$(jq -r '.server.type' manifest.json)
FAILED=0

check() {
  local label=$1
  local actual=$2
  if [ "$actual" != "$EXPECTED" ]; then
    echo "MISMATCH: $label has '$actual', expected '$EXPECTED'"
    FAILED=1
  else
    echo "OK: $label = $actual"
  fi
}

check "manifest.json" "$MANIFEST"

if [ "$RUNTIME" = "node" ]; then
  check "package.json" "$(jq -r '.version' package.json)"
  check "src/constants.ts" "$(grep -o "'[0-9.]*'" src/constants.ts | tr -d "'")"
elif [ "$RUNTIME" = "python" ]; then
  check "pyproject.toml" "$(grep '^version' pyproject.toml | sed 's/version = "//;s/"//')"
  check "src/__init__.py" "$(grep '__version__' src/mcp_*/__init__.py | grep -o '"[0-9.]*"' | tr -d '"')"
fi

if [ "$FAILED" -eq 1 ]; then
  echo ""
  echo "Version mismatch detected. Edit .PACKAGE_VERSION and run: make bump"
  exit 1
fi

echo "All version files in sync."

Add this as a step in ci.yml:

- name: Check version consistency
  run: bash scripts/check-versions.sh

5. Add the same check inside mcpb-pack

The version consistency check should also run inside the mcpb-pack action so it is enforced at release time as a hard gate — not just on PRs. This means even if a PR slips through, a release cannot be published with mismatched versions.


Affected repos

  • NimbleBrainInc/mcp-server-template — add .PACKAGE_VERSION, updated Makefile, scripts/check-versions.sh, and remove Update manifest version step from build-bundle.yml so all new servers get this for free
  • NimbleBrainInc/mcpb-pack — add version consistency check as a build step
  • All existing server repos — migrate on next version bump

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions