Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions eng/pypi/README.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
11 changes: 5 additions & 6 deletions eng/pypi/__init__.py
Original file line number Diff line number Diff line change
@@ -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.
"""

Expand All @@ -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", "")
Expand All @@ -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

Expand Down
4 changes: 3 additions & 1 deletion eng/pypi/pyproject.toml.template
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ authors = [
]
keywords = [{{KEYWORDS}}]
classifiers = [
"Development Status :: 4 - Beta",
"Development Status :: {{DEV_STATUS}}",
Comment thread
alzimmermsft marked this conversation as resolved.
"Environment :: Console",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
Expand All @@ -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"
Expand Down
122 changes: 122 additions & 0 deletions eng/scripts/Pack-Pypi.Tests.ps1
Original file line number Diff line number Diff line change
@@ -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)
}
}
40 changes: 35 additions & 5 deletions eng/scripts/Pack-Pypi.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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."
Loading