mirror of
https://github.com/wso2/fhir-mcp-server.git
synced 2025-11-14 22:18:14 +03:00
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:
committed by
GitHub
parent
2926ab901d
commit
dec904fbbe
@@ -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
269
.github/workflows/release.yaml
vendored
Normal 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"
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user