Update PyPi packaging (#3984)

* Update PyProject TOML template handling

* Small cleanup on dev status

* Support dev zero versioning

* Also support other MCP servers in Pack-PyPi
This commit is contained in:
Alan Zimmer authored and GitHub committed 2026-10-19 12:34:06 -04:00
1 parent adb9de1074
commit 5ecde31916
6 files changed
+185 -16

No files matched your search

+17 -4
View File
@@ -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
+5 -6
View File
@@ -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 subprocess
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
+3 -1
View File
@@ -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"
+122
View File
@@ -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 <CliName> on <System> (native: <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)
}
}
+35 -5
View File
@@ -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:
@@ -131,6 +131,31 @@ function ConvertTo-PythonPackageVersion {
return $parsedVersion.ToString()
}
function Get-DevStatus {
param ([string] $version)
$parsedVersion = [AzureEngSemanticVersion]::ParseVersionString($version)
if (!$parsedVersion) {
return $null
}
if (!$parsedVersion.IsPrerelease) {
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' }
'rc' { return '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 +213,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,16 +286,20 @@ 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
# 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
@@ -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."