feat: Add automated PyPI and GitHub release workflow (#13)

* Add pypi release workflow

* Bump release version to 0.9.0

* Rename FHIR_SERVER_REQUEST_TIMEOUT env variable name to FHIR_MCP_REQUEST_TIMEOUT
This commit is contained in:
Vimukthi Rajapaksha
2025-07-23 00:04:30 +05:30
committed by GitHub
parent 2926ab901d
commit dec904fbbe
10 changed files with 284 additions and 14 deletions

View File

@@ -5,8 +5,8 @@ FHIR_MCP_PORT="8000"
# (Optional) If set, this value will be used as the server's base URL instead of generating it from host and port
# FHIR_MCP_SERVER_URL=""
# Timeout from MCP server to FHIR server, in seconds
# FHIR_MCP_REQUEST_TIMEOUT=60
# (Optional) Timeout from MCP server to FHIR server, in seconds
# FHIR_MCP_REQUEST_TIMEOUT=30
# Details about the FHIR server/API
FHIR_SERVER_BASE_URL=""

269
.github/workflows/release.yaml vendored Normal file
View File

@@ -0,0 +1,269 @@
name: release
on:
push:
tags:
- "[0-9]+.[0-9]+.[0-9]+"
- "[0-9]+.[0-9]+.[0-9]+a[0-9]+"
- "[0-9]+.[0-9]+.[0-9]+b[0-9]+"
- "[0-9]+.[0-9]+.[0-9]+rc[0-9]+"
# Set default permissions to read-only for security
permissions:
contents: read
env:
PACKAGE_NAME: "fhir-mcp-server"
jobs:
details:
runs-on: ubuntu-latest
outputs:
new_version: ${{ steps.release.outputs.new_version }}
suffix: ${{ steps.release.outputs.suffix }}
tag_name: ${{ steps.release.outputs.tag_name }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@v2
with:
egress-policy: audit
- uses: actions/checkout@v4
- name: Extract tag and Details
id: release
run: |
if [ "${{ github.ref_type }}" = "tag" ]; then
TAG_NAME=${GITHUB_REF#refs/tags/}
# Validate tag format
if [[ ! "$TAG_NAME" =~ ^[0-9]+\.[0-9]+\.[0-9]+([a-z]+[0-9]+)?$ ]]; then
echo "Error: Invalid tag format: $TAG_NAME"
exit 1
fi
NEW_VERSION=$(echo $TAG_NAME | awk -F'-' '{print $1}')
SUFFIX=$(echo $TAG_NAME | grep -oP '[a-z]+[0-9]+' || echo "")
echo "new_version=$NEW_VERSION" >> "$GITHUB_OUTPUT"
echo "suffix=$SUFFIX" >> "$GITHUB_OUTPUT"
echo "tag_name=$TAG_NAME" >> "$GITHUB_OUTPUT"
echo "Version is $NEW_VERSION"
echo "Suffix is $SUFFIX"
echo "Tag name is $TAG_NAME"
else
echo "Error: No tag found"
exit 1
fi
check_pypi:
needs: details
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@v2
with:
egress-policy: audit
- name: Fetch information from PyPI
run: |
set -euo pipefail # Enable strict error handling
echo "Checking package ${{ env.PACKAGE_NAME }} on PyPI..."
response=$(curl -s --fail-with-body "https://pypi.org/pypi/${{ env.PACKAGE_NAME }}/json" || echo "{}")
latest_previous_version=$(echo "$response" | jq --raw-output "select(.releases != null) | .releases | keys_unsorted | last" || echo "")
if [ -z "$latest_previous_version" ] || [ "$latest_previous_version" = "null" ]; then
echo "Package not found on PyPI or no releases available."
latest_previous_version="0.0.0"
fi
echo "Latest version on PyPI: $latest_previous_version"
echo "latest_previous_version=$latest_previous_version" >> $GITHUB_ENV
- name: Compare versions and exit if not newer
run: |
set -euo pipefail
NEW_VERSION=${{ needs.details.outputs.new_version }}
LATEST_VERSION=$latest_previous_version
echo "Comparing versions: $LATEST_VERSION (PyPI) vs $NEW_VERSION (new)"
if [ "$(printf '%s\n' "$LATEST_VERSION" "$NEW_VERSION" | sort -rV | head -n 1)" != "$NEW_VERSION" ] || [ "$NEW_VERSION" == "$LATEST_VERSION" ]; then
echo "Error: The new version $NEW_VERSION is not greater than the latest version $LATEST_VERSION on PyPI."
exit 1
else
echo "✓ The new version $NEW_VERSION is greater than the latest version $LATEST_VERSION on PyPI."
fi
setup_and_build:
needs: [details, check_pypi]
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@v2
with:
egress-policy: audit
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install uv
uses: astral-sh/setup-uv@v4
with:
enable-cache: true
cache-dependency-glob: "uv.lock"
- name: Set project version in pyproject.toml
run: |
set -euo pipefail
echo "Setting version to ${{ needs.details.outputs.new_version }}"
# Create backup
cp pyproject.toml pyproject.toml.bak
# Update version
sed -i 's/^version = ".*"/version = "${{ needs.details.outputs.new_version }}"/' pyproject.toml
# Verify the change
if ! grep -q 'version = "${{ needs.details.outputs.new_version }}"' pyproject.toml; then
echo "Error: Failed to update version in pyproject.toml"
mv pyproject.toml.bak pyproject.toml
exit 1
fi
echo "✓ Version updated successfully"
- name: Install dependencies
run: |
set -euo pipefail
uv sync --frozen
- name: Build source and wheel distribution
run: |
set -euo pipefail
uv build
# Verify build artifacts exist
if [ ! -d "dist" ] || [ -z "$(ls -A dist)" ]; then
echo "Error: Build failed - no artifacts found in dist/"
exit 1
fi
echo "✓ Build completed successfully"
ls -la dist/
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
retention-days: 30
pypi_publish:
name: Upload release to PyPI
needs: [setup_and_build, details]
runs-on: ubuntu-latest
permissions:
id-token: write # Required for OIDC
contents: read # Required for checkout
steps:
- name: Harden Runner
uses: step-security/harden-runner@v2
with:
egress-policy: audit
- name: Download artifacts
uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- name: Verify artifacts
run: |
set -euo pipefail
echo "Verifying build artifacts..."
if [ ! -d "dist" ] || [ -z "$(ls -A dist)" ]; then
echo "Error: No build artifacts found"
exit 1
fi
# Check for both wheel and source distribution
if ! ls dist/*.whl >/dev/null 2>&1; then
echo "Error: No wheel file found"
exit 1
fi
if ! ls dist/*.tar.gz >/dev/null 2>&1; then
echo "Error: No source distribution found"
exit 1
fi
echo "✓ Artifacts verified:"
ls -la dist/
- name: Publish distribution to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
print-hash: true
verbose: true
github_release:
name: Create GitHub Release
needs: [setup_and_build, details]
runs-on: ubuntu-latest
permissions:
contents: write # Required for creating releases
steps:
- name: Harden Runner
uses: step-security/harden-runner@v2
with:
egress-policy: audit
- name: Checkout Code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Download artifacts
uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- name: Verify artifacts for release
run: |
set -euo pipefail
echo "Verifying artifacts for GitHub release..."
if [ ! -d "dist" ] || [ -z "$(ls -A dist)" ]; then
echo "Error: No artifacts found for release"
exit 1
fi
echo "✓ Artifacts ready for release:"
ls -la dist/
- name: Create GitHub Release
id: create_release
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
echo "Creating GitHub release for tag ${{ needs.details.outputs.tag_name }}"
gh release create "${{ needs.details.outputs.tag_name }}" \
dist/* \
--title "${{ needs.details.outputs.tag_name }}" \
--generate-notes \
--verify-tag
echo "✓ GitHub release created successfully"

View File

@@ -370,6 +370,8 @@ uv run fhir-mcp-server --help
**MCP Server Configurations:**
- `FHIR_MCP_HOST`: The hostname or IP address the MCP server should bind to (e.g., `localhost` for local-only access, or `0.0.0.0` for all interfaces).
- `FHIR_MCP_PORT`: The port on which the MCP server will listen for incoming client requests (e.g., `8000`).
- `FHIR_MCP_SERVER_URL`: If set, this value will be used as the server's base URL instead of generating it from host and port. Useful for custom URL configurations or when behind a proxy.
- `FHIR_MCP_REQUEST_TIMEOUT`: Timeout duration in seconds for requests from the MCP server to the FHIR server (default: `30`).
**MCP Server OAuth2 with FHIR server Configuration (MCP Client ↔ MCP Server):**
These variables configure the MCP client's secure connection to the MCP server, using the OAuth2 authorization code grant flow with a FHIR server.
@@ -380,8 +382,6 @@ These variables configure the MCP client's secure connection to the MCP server,
- `FHIR_SERVER_SCOPES`: A space-separated list of OAuth2 scopes to request from the FHIR authorization server (e.g., `user/Patient.read user/Observation.read`).
- `FHIR_SERVER_ACCESS_TOKEN`: The access token to use for authenticating requests to the FHIR server. If this variable is set, the server will bypass the OAuth2 authorization flow and use this token directly for all requests.
## Tools
- `get_capabilities`: Retrieves metadata about a specified FHIR resource type, including its supported search parameters and custom operations.

View File

@@ -30,7 +30,7 @@ license-files = ["LICEN[CS]E*"]
name = "fhir-mcp-server"
readme = {file = "README.md", content-type = "text/markdown"}
requires-python = ">=3.12"
version = "0.2.0"
version = "0.9.0"
[build-system]
build-backend = "hatchling.build"

View File

@@ -33,12 +33,13 @@ class ServerConfigs(BaseSettings):
mcp_host: str = "localhost"
mcp_port: int = 8000
mcp_server_url: str | None = None
mcp_request_timeout: int = 30 # in secs
# FHIR settings
server_client_id: str = ""
server_client_secret: str = ""
server_scopes: str = ""
server_base_url: str = ""
server_request_timeout: int = 30 # in secs
server_access_token: str | None = None
def callback_url(

View File

@@ -36,7 +36,7 @@ async def create_async_fhir_client(
client_kwargs: Dict = {
"url": config.server_base_url,
"aiohttp_config": {
"timeout": aiohttp.ClientTimeout(total=config.server_request_timeout),
"timeout": aiohttp.ClientTimeout(total=config.mcp_request_timeout),
},
"extra_headers": extra_headers,
}

View File

@@ -38,7 +38,7 @@ class TestIntegration:
# Set the server config after initialization
config.server_base_url = "https://custom.fhir.org"
config.server_request_timeout = 60
config.mcp_request_timeout = 60
# Test that nested configuration works
assert config.mcp_host == "0.0.0.0"
@@ -51,7 +51,7 @@ class TestIntegration:
# Test server config integration
assert config.server_base_url == "https://custom.fhir.org"
assert config.server_request_timeout == 60
assert config.mcp_request_timeout == 60
def test_fhir_oauth_config_integration(self):
"""Test FHIR OAuth config integration with server config."""

View File

@@ -42,7 +42,7 @@ class TestServerConfigs:
assert config.server_client_secret == ""
assert config.server_scopes == ""
assert config.server_base_url == ""
assert config.server_request_timeout == 30
assert config.mcp_request_timeout == 30
assert config.server_access_token is None
def test_effective_server_url_default(self):
@@ -69,13 +69,13 @@ class TestServerConfigs:
config = ServerConfigs(
server_client_id="test_client",
server_base_url="https://example.com/fhir",
server_request_timeout=120,
mcp_request_timeout=120,
_env_file=None
)
assert config.server_client_id == "test_client"
assert config.server_base_url == "https://example.com/fhir"
assert config.server_request_timeout == 120
assert config.mcp_request_timeout == 120
def test_callback_url_basic(self):
"""Test callback URL generation."""

View File

@@ -81,7 +81,7 @@ class TestCreateAsyncFhirClient:
@pytest.mark.asyncio
async def test_create_client_with_custom_timeout(self):
"""Test creating FHIR client with custom timeout."""
config = ServerConfigs(server_base_url="https://example.fhir.org/R4", server_request_timeout=60)
config = ServerConfigs(server_base_url="https://example.fhir.org/R4", mcp_request_timeout=60)
with patch('fhir_mcp_server.utils.AsyncFHIRClient') as mock_client, \
patch('fhir_mcp_server.utils.aiohttp.ClientTimeout') as mock_timeout:

2
uv.lock generated
View File

@@ -215,7 +215,7 @@ wheels = [
[[package]]
name = "fhir-mcp-server"
version = "0.1.11"
version = "0.9.0"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },