From a9fa251af6ba2a2922b2f65d695f94bde61bcfa1 Mon Sep 17 00:00:00 2001 From: alzimmermsft <48699787+alzimmermsft@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:18:25 -0400 Subject: [PATCH 1/4] Update PyProject TOML template handling --- eng/pypi/pyproject.toml.template | 4 +++- eng/scripts/Pack-Pypi.ps1 | 25 ++++++++++++++++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/eng/pypi/pyproject.toml.template b/eng/pypi/pyproject.toml.template index 7f56401a70..2c0eca8ada 100644 --- a/eng/pypi/pyproject.toml.template +++ b/eng/pypi/pyproject.toml.template @@ -13,7 +13,7 @@ authors = [ ] keywords = [{{KEYWORDS}}] classifiers = [ - "Development Status :: 4 - Beta", + "Development Status :: {{DEV_STATUS}}", "Environment :: Console", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", @@ -23,6 +23,8 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.15", "Topic :: Software Development :: Libraries :: Python Modules", ] requires-python = ">=3.10" diff --git a/eng/scripts/Pack-Pypi.ps1 b/eng/scripts/Pack-Pypi.ps1 index 41a91d703f..23b180e7ff 100644 --- a/eng/scripts/Pack-Pypi.ps1 +++ b/eng/scripts/Pack-Pypi.ps1 @@ -131,6 +131,26 @@ function ConvertTo-PythonPackageVersion { return $parsedVersion.ToString() } +function Get-DevStatus { + param ([string] $version) + + $parsedVersion = [AzureEngSemanticVersion]::ParseVersionString($version) + if (!$parsedVersion) { + return $null + } + + if (!$parsedVersion.IsPrerelease) { + return 'Development Status :: 5 - Production/Stable' + } + + switch ($parsedVersion.PrereleaseLabel) { + 'alpha' { return 'Development Status :: 3 - Alpha' } + 'beta' { return 'Development Status :: 4 - Beta' } + 'rc' { return 'Development Status :: 4 - Beta' } + default { throw "Unknown prerelease label '$($parsedVersion.PrereleaseLabel)' for version '$version'." } + } +} + function Get-PythonCommand { # Try python3 first (common on Linux/macOS), but verify it works # On Windows, "python3" may be a Store alias that doesn't work @@ -188,6 +208,7 @@ function BuildServerPackages([hashtable] $server, [bool] $native) { return } $pythonVersion = $pythonVersion.ToString() + $devStatus = Get-DevStatus $server.version $description = if ([string]::IsNullOrWhiteSpace($server.pypiDescription)) { $server.description } else { $server.pypiDescription } $cliName = $server.cliName @@ -260,10 +281,12 @@ function BuildServerPackages([hashtable] $server, [bool] $native) { -replace '{{OS_CLASSIFIER}}', $osClassifier ` -replace '{{CLI_NAME}}', $cliName ` -replace '{{MODULE_NAME}}', $moduleName ` - -replace '{{HOMEPAGE}}', $server.readmeUrl + -replace '{{HOMEPAGE}}', $server.readmeUrl ` + -replace '{{DEV_STATUS}}', $devStatus $pyprojectPath = "$tempFolder/pyproject.toml" Write-Host " Writing pyproject.toml" + Write-Host $pyprojectContent $pyprojectContent | Out-File -FilePath $pyprojectPath -Encoding utf8 -Force # Update version in __init__.py From 876acad2a59042af6a2a39f163a142be9fd08e62 Mon Sep 17 00:00:00 2001 From: alzimmermsft <48699787+alzimmermsft@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:23:00 -0400 Subject: [PATCH 2/4] Small cleanup on dev status --- eng/scripts/Pack-Pypi.ps1 | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/eng/scripts/Pack-Pypi.ps1 b/eng/scripts/Pack-Pypi.ps1 index 23b180e7ff..73ea279d4d 100644 --- a/eng/scripts/Pack-Pypi.ps1 +++ b/eng/scripts/Pack-Pypi.ps1 @@ -140,13 +140,13 @@ function Get-DevStatus { } if (!$parsedVersion.IsPrerelease) { - return 'Development Status :: 5 - Production/Stable' + return '5 - Production/Stable' } switch ($parsedVersion.PrereleaseLabel) { - 'alpha' { return 'Development Status :: 3 - Alpha' } - 'beta' { return 'Development Status :: 4 - Beta' } - 'rc' { return 'Development Status :: 4 - Beta' } + 'alpha' { return '3 - Alpha' } + 'beta' { return '4 - Beta' } + 'rc' { return '4 - Beta' } default { throw "Unknown prerelease label '$($parsedVersion.PrereleaseLabel)' for version '$version'." } } } From b41a779e13ed2f69d67e78f9c4511996fc3cc200 Mon Sep 17 00:00:00 2001 From: alzimmermsft <48699787+alzimmermsft@users.noreply.github.com> Date: Mon, 19 Oct 2026 10:14:18 -0400 Subject: [PATCH 3/4] Support dev zero versioning --- eng/scripts/Pack-Pypi.ps1 | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/eng/scripts/Pack-Pypi.ps1 b/eng/scripts/Pack-Pypi.ps1 index 73ea279d4d..4795240f75 100644 --- a/eng/scripts/Pack-Pypi.ps1 +++ b/eng/scripts/Pack-Pypi.ps1 @@ -143,6 +143,11 @@ function Get-DevStatus { return '5 - Production/Stable' } + # The parser treats unlabeled major-zero versions as prerelease beta versions. + if ($parsedVersion.Major -eq 0 -and [string]::IsNullOrEmpty($parsedVersion.PrereleaseLabel)) { + return '4 - Beta' + } + switch ($parsedVersion.PrereleaseLabel) { 'alpha' { return '3 - Alpha' } 'beta' { return '4 - Beta' } From b36c70d61655ee19ccc7fa1f2feee7957d11a74c Mon Sep 17 00:00:00 2001 From: alzimmermsft <48699787+alzimmermsft@users.noreply.github.com> Date: Mon, 19 Oct 2026 10:47:54 -0400 Subject: [PATCH 4/4] Also support other MCP servers in Pack-PyPi --- eng/pypi/README.md | 21 ++- eng/pypi/__init__.py | 11 +- eng/scripts/Pack-Pypi.Tests.ps1 | 122 ++++++++++++++++++ eng/scripts/Pack-Pypi.ps1 | 10 +- ...zimmermsft-pypi-configured-executable.yaml | 3 + 5 files changed, 153 insertions(+), 14 deletions(-) create mode 100644 eng/scripts/Pack-Pypi.Tests.ps1 create mode 100644 servers/Azure.Mcp.Server/changelog-entries/alzimmermsft-pypi-configured-executable.yaml diff --git a/eng/pypi/README.md b/eng/pypi/README.md index f59e44e537..2ddb60563a 100644 --- a/eng/pypi/README.md +++ b/eng/pypi/README.md @@ -1,10 +1,10 @@ -# PyPI Packaging for Azure MCP Server +# PyPI Packaging for MCP Servers -This document describes how the Azure MCP Server is packaged for distribution on [PyPI](https://pypi.org), enabling installation via `pip`, `pipx`, or execution via `uvx`. +This document describes how MCP servers are packaged for distribution on [PyPI](https://pypi.org), enabling installation via `pip`, `pipx`, or execution via `uvx`. The examples below use the Azure MCP Server. ## Package Architecture -The Azure MCP Server is published as a single package (`msmcp-azure`) with platform-specific wheels. Each wheel contains the pre-compiled binary for a specific OS and architecture combination. +Each configured MCP server is published as a single package (for example, `msmcp-azure`) with platform-specific wheels. Each wheel contains the pre-compiled binary for a specific OS and architecture combination. ### Wheel Naming Convention @@ -78,6 +78,8 @@ azmcp server start ## Building PyPI Packages +[Pack-Pypi.ps1](../scripts/Pack-Pypi.ps1) uses each server's `cliName` from the generated `build_info.json` to configure both its console entry point and the executable name in the shared [Python launcher template](./__init__.py). The launcher adds `.exe` on Windows and uses the configured name without a suffix on Linux and macOS. Native packages use the same configured executable name. + ### Prerequisites 1. Python 3.10+ with `pip` and `build` package @@ -114,6 +116,16 @@ For quick local testing: pip install .work/packages_pypi/Azure.Mcp.Server/*.whl ``` +### Testing + +Run the packaging regression tests with: + +```powershell +Invoke-Pester -Path .\eng\scripts\Pack-Pypi.Tests.ps1 +``` + +The tests check configured executable names on Windows, Linux, and macOS for both standard and native packages, including argument forwarding and exit codes. Wheel building and package installation are stubbed out. + ## Publishing to PyPI ### Test PyPI (recommended for testing) @@ -142,7 +154,8 @@ eng/ │ ├── pyproject.toml.template # Template for pyproject.toml │ └── README.md # This file └── scripts/ - └── Pack-Pypi.ps1 # Packaging script + ├── Pack-Pypi.ps1 # Packaging script + └── Pack-Pypi.Tests.ps1 # Packaging regression tests ``` ## Debugging diff --git a/eng/pypi/__init__.py b/eng/pypi/__init__.py index 35838553bb..2ab564e6b6 100644 --- a/eng/pypi/__init__.py +++ b/eng/pypi/__init__.py @@ -1,8 +1,8 @@ #!/usr/bin/env python3 """ -Azure MCP Server - PyPI package. +MCP Server - PyPI package. -This module provides the entry point for the Azure MCP Server CLI. +This module provides the entry point for the packaged MCP server CLI. The binary is bundled directly in the wheel for the target platform. """ @@ -12,7 +12,7 @@ import sys from pathlib import Path -__version__ = "0.0.0" # Will be replaced during packaging +__version__ = "{{VERSION}}" # Will be replaced during packaging # Debug mode check DEBUG = os.environ.get("DEBUG", "").lower() in ("true", "1", "*") or "mcp" in os.environ.get("DEBUG", "") @@ -32,10 +32,9 @@ def get_executable_path(): # Determine the executable name based on platform system = platform.system().lower() + executable_name = "{{CLI_NAME}}" if system == "windows": - executable_name = "azmcp.exe" - else: - executable_name = "azmcp" + executable_name += ".exe" executable_path = bin_dir / executable_name diff --git a/eng/scripts/Pack-Pypi.Tests.ps1 b/eng/scripts/Pack-Pypi.Tests.ps1 new file mode 100644 index 0000000000..414c010a77 --- /dev/null +++ b/eng/scripts/Pack-Pypi.Tests.ps1 @@ -0,0 +1,122 @@ +# cspell:ignore runpy pathlib returncode + +BeforeAll { + $repoRoot = (Resolve-Path "$PSScriptRoot\..\..").Path + $pythonCommand = (Get-Command python -CommandType Application -ErrorAction Stop | Select-Object -First 1).Source +} + +Describe "Pack-Pypi executable configuration" { + It "launches on (native: )" -ForEach @( + foreach ($cliName in @("azmcp", "fabmcp", "mcptmp")) { + foreach ($system in @("Windows", "Linux", "Darwin")) { + foreach ($native in @($false, $true)) { + @{ CliName = $cliName; System = $system; Native = $native } + } + } + } + ) { + $fixtureRoot = Join-Path $TestDrive ([guid]::NewGuid().ToString("N")) + $scriptsDirectory = Join-Path $fixtureRoot "eng\scripts" + $commonDirectory = Join-Path $fixtureRoot "eng\common\scripts" + $pypiDirectory = Join-Path $fixtureRoot "eng\pypi" + $platformDirectory = Join-Path $fixtureRoot "artifacts\test-server\platform" + New-Item -ItemType Directory -Path $scriptsDirectory, $commonDirectory, $pypiDirectory, $platformDirectory -Force | Out-Null + Copy-Item -LiteralPath "$repoRoot\eng\scripts\Pack-Pypi.ps1" -Destination $scriptsDirectory + Copy-Item -LiteralPath "$repoRoot\eng\common\scripts\SemVer.ps1" -Destination $commonDirectory + Copy-Item -LiteralPath "$repoRoot\eng\pypi\__init__.py", "$repoRoot\eng\pypi\pyproject.toml.template" -Destination $pypiDirectory + + @' +$RepoRoot = Resolve-Path (Join-Path $PSScriptRoot "..\..\..") +. "$PSScriptRoot\SemVer.ps1" +function LogError { param([string]$Message) throw $Message } +function Invoke-LoggedCommand { + param([string]$Command) + if ($Command -like "* -m build --wheel") { + $stagedDirectory = Join-Path $RepoRoot "staged" + New-Item -ItemType Directory -Path $stagedDirectory -Force | Out-Null + Copy-Item -Path (Join-Path (Get-Location) "src\*") -Destination $stagedDirectory -Recurse -Force + Copy-Item -LiteralPath (Join-Path (Get-Location) "pyproject.toml") -Destination $stagedDirectory + } +} +'@ | Set-Content -LiteralPath (Join-Path $commonDirectory "common.ps1") + @' +param($Command, $InputReadMePath, $PackageType, $InsertPayload, $OutputDirectory) +'@ | Set-Content -LiteralPath (Join-Path $scriptsDirectory "Process-PackageReadMe.ps1") + "Fixture license" | Set-Content -LiteralPath (Join-Path $fixtureRoot "LICENSE") + "Fixture notices" | Set-Content -LiteralPath (Join-Path $fixtureRoot "NOTICE.txt") + + $extension = if ($System -eq "Windows") { ".exe" } else { "" } + $nodeOs = switch ($System) { + "Windows" { "win32" } + "Linux" { "linux" } + "Darwin" { "darwin" } + } + $executableName = "$CliName$extension" + "Fixture executable" | Set-Content -LiteralPath (Join-Path $platformDirectory $executableName) + $buildInfo = @{ + servers = @( + @{ + name = "Test.Mcp.Server" + artifactPath = "test-server" + version = "1.2.3" + description = "Test MCP server" + cliName = $CliName + pypiPackageName = "msmcp-test" + pypiPackageKeywords = @("mcp") + readmeUrl = "https://github.com/microsoft/mcp" + readmePath = "README.md" + mcpRepositoryName = "io.github.microsoft/test" + platforms = @( + @{ + artifactPath = "test-server\platform" + native = $Native + specialPurpose = $false + nodeOs = $nodeOs + architecture = "x64" + extension = $extension + } + ) + } + ) + } + $buildInfoPath = Join-Path $fixtureRoot "build_info.json" + $buildInfo | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $buildInfoPath + + & (Join-Path $scriptsDirectory "Pack-Pypi.ps1") ` + -ArtifactsPath (Join-Path $fixtureRoot "artifacts") ` + -BuildInfoPath $buildInfoPath ` + -OutputPath (Join-Path $fixtureRoot "packages") 6>$null 3>$null + + $LASTEXITCODE | Should -Be 0 + $moduleName = if ($Native) { "msmcp_test_native" } else { "msmcp_test" } + $initPyPath = Join-Path $fixtureRoot "staged\$moduleName\__init__.py" + (Get-Content -LiteralPath $initPyPath -Raw).Contains("{{CLI_NAME}}") | Should -BeFalse + Get-Content -LiteralPath (Join-Path $fixtureRoot "staged\pyproject.toml") -Raw | + Should -Match ([regex]::Escape("$CliName = `"${moduleName}:main`"")) + + $pythonTest = @' +import runpy +import sys +from pathlib import Path +from unittest.mock import patch + +launcher = runpy.run_path(sys.argv[1]) +expected = Path(sys.argv[1]).parent / "bin" / sys.argv[2] +with patch("platform.system", return_value=sys.argv[3]): + actual = launcher["get_executable_path"]() + assert actual == expected, f"Expected {expected}, got {actual}" + assert actual.is_file(), f"Packaged executable missing: {actual}" + with patch("subprocess.run") as run: + run.return_value.returncode = 17 + assert launcher["run_executable"](["server", "start"]) == 17 + run.assert_called_once_with( + [str(expected), "server", "start"], + stdin=sys.stdin, + stdout=sys.stdout, + stderr=sys.stderr, + ) +'@ + $pythonOutput = & $pythonCommand -c $pythonTest $initPyPath $executableName $System 2>&1 + $LASTEXITCODE | Should -Be 0 -Because ($pythonOutput -join [Environment]::NewLine) + } +} diff --git a/eng/scripts/Pack-Pypi.ps1 b/eng/scripts/Pack-Pypi.ps1 index 4795240f75..ecdb8f44aa 100644 --- a/eng/scripts/Pack-Pypi.ps1 +++ b/eng/scripts/Pack-Pypi.ps1 @@ -3,10 +3,10 @@ <# .SYNOPSIS - Packs Azure MCP Server binaries into PyPI wheels for distribution. + Packs MCP server binaries into PyPI wheels for distribution. .DESCRIPTION - This script creates platform-specific PyPI wheels for the Azure MCP Server. + This script creates platform-specific PyPI wheels for configured MCP servers. Each wheel contains the binary for a specific platform (OS + architecture). The wheels are named according to PyPI conventions: @@ -294,10 +294,12 @@ function BuildServerPackages([hashtable] $server, [bool] $native) { Write-Host $pyprojectContent $pyprojectContent | Out-File -FilePath $pyprojectPath -Encoding utf8 -Force - # Update version in __init__.py + # Configure version and CLI name in __init__.py $initPyPath = "$tempFolder/src/$moduleName/__init__.py" $initPyContent = Get-Content $initPyPath -Raw - $initPyContent = $initPyContent -replace '__version__ = "0\.0\.0"', "__version__ = `"$pythonVersion`"" + $initPyContent = $initPyContent ` + -replace '{{VERSION}}', $pythonVersion ` + -replace '{{CLI_NAME}}', $cliName $initPyContent | Out-File -FilePath $initPyPath -Encoding utf8 -Force # Set executable permissions on non-Windows diff --git a/servers/Azure.Mcp.Server/changelog-entries/alzimmermsft-pypi-configured-executable.yaml b/servers/Azure.Mcp.Server/changelog-entries/alzimmermsft-pypi-configured-executable.yaml new file mode 100644 index 0000000000..9b2ea90621 --- /dev/null +++ b/servers/Azure.Mcp.Server/changelog-entries/alzimmermsft-pypi-configured-executable.yaml @@ -0,0 +1,3 @@ +changes: + - section: "Bugs Fixed" + description: "Fixed PyPI packages to launch each MCP server's configured executable instead of always using azmcp."