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
1 change: 1 addition & 0 deletions src/aws-healthomics-mcp-server/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

- **Output path layout for runs**: `StartAHORun` accepts an optional `output_uri_prefix_mode` (`SERVICE_DEFINED` or `NONE_WITH_OVERWRITE`). `SERVICE_DEFINED`, the HealthOmics default, writes outputs under `<output_uri>/<runId>/out/`; `NONE_WITH_OVERWRITE` writes them directly under `output_uri`, with logs still under `logs/<runId>/`, and overwrites same-named files from earlier runs without warning. The value is checked locally and sent only when provided, and `GetAHORun` now returns `outputUriPrefixMode`. Requires boto3/botocore >= 1.43.123
- **Session policies for runs**: `StartAHORun` and `StartAHORunBatch` accept an optional `session_policy` (inline IAM policy as a JSON string or object, max 2048 characters) that scopes down the run role; its shape and length are checked locally and it is sent minified, and `GetAHORun` now returns `sessionPolicy`. Requires boto3/botocore >= 1.43.90
- **StartAHORun tags**: `StartAHORun` now accepts an optional `tags` map that is applied to the run at start
- **ListAHORuns date filters**: `created_after` and `created_before` values without a UTC offset, such as `2024-01-01` or `2024-01-01T10:00`, are now treated as UTC instead of failing with "can't compare offset-naive and offset-aware datetimes"
Expand Down
4 changes: 2 additions & 2 deletions src/aws-healthomics-mcp-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,9 @@ This MCP server provides tools for:

### Workflow Execution Tools

1. **StartAHORun** - Start workflow runs with custom parameters, resource configuration, optional VPC networking mode with a named configuration, optional tags, and an optional inline IAM session policy to scope down the run role
1. **StartAHORun** - Start workflow runs with custom parameters, resource configuration, optional VPC networking mode with a named configuration, optional tags, an optional inline IAM session policy to scope down the run role, and an optional output path layout: `SERVICE_DEFINED` (the default) writes outputs under a run ID folder, while `NONE_WITH_OVERWRITE` writes them directly under the output URI and overwrites same-named files from earlier runs without warning, so use a unique output URI per run unless overwriting is intended
2. **ListAHORuns** - List workflow runs with filtering by status and date ranges
3. **GetAHORun** - Retrieve detailed run information including status and metadata
3. **GetAHORun** - Retrieve detailed run information including status, metadata, and the output path layout when the service returns it
4. **ListAHORunTasks** - List tasks for specific runs with status filtering
5. **GetAHORunTask** - Get detailed information about specific workflow tasks

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,14 @@
RUN_LOG_LEVEL_ALL,
]

# Run output URI prefix modes (controls the output path layout under outputUri)
OUTPUT_URI_PREFIX_MODE_SERVICE_DEFINED = 'SERVICE_DEFINED'
OUTPUT_URI_PREFIX_MODE_NONE_WITH_OVERWRITE = 'NONE_WITH_OVERWRITE'
OUTPUT_URI_PREFIX_MODES = [
OUTPUT_URI_PREFIX_MODE_SERVICE_DEFINED,
OUTPUT_URI_PREFIX_MODE_NONE_WITH_OVERWRITE,
]

# Run statuses
RUN_STATUS_PENDING = 'PENDING'
RUN_STATUS_STARTING = 'STARTING'
Expand Down Expand Up @@ -232,6 +240,7 @@
ERROR_INVALID_CACHE_BEHAVIOR = 'Invalid cache behavior. Must be one of: {}'
ERROR_INVALID_SCRATCH_STORAGE_MODE = "Invalid scratch storage mode '{}'. Must be one of: {}"
ERROR_INVALID_RUN_LOG_LEVEL = 'Invalid log level. Must be one of: {}'
ERROR_INVALID_OUTPUT_URI_PREFIX_MODE = "'{}' is not a valid value. Must be one of: {}"
ERROR_INVALID_SESSION_POLICY = 'Invalid session policy: {}'
ERROR_INVALID_RUN_STATUS = 'Invalid run status. Must be one of: {}'
ERROR_STATIC_STORAGE_REQUIRES_CAPACITY = (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,11 @@ async def start_run_batch(
) -> Dict[str, Any]:
"""Start a new HealthOmics run batch.

Run batches do not support an output path layout option (outputUriPrefixMode).
Batch runs always use the HealthOmics default layout, output_uri/<runId>/out/.
To write outputs directly under an output URI, start individual runs with
StartAHORun and output_uri_prefix_mode instead.

Args:
ctx: MCP context for error reporting
workflow_id: ID of the workflow to run
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
ERROR_CONFIGURATION_NAME_REQUIRES_VPC_MODE,
ERROR_INVALID_CACHE_BEHAVIOR,
ERROR_INVALID_NETWORKING_MODE,
ERROR_INVALID_OUTPUT_URI_PREFIX_MODE,
ERROR_INVALID_RUN_LOG_LEVEL,
ERROR_INVALID_RUN_STATUS,
ERROR_INVALID_SCRATCH_STORAGE_MODE,
Expand All @@ -30,6 +31,8 @@
NETWORKING_MODE_RESTRICTED,
NETWORKING_MODE_VPC,
NETWORKING_MODES,
OUTPUT_URI_PREFIX_MODE_SERVICE_DEFINED,
OUTPUT_URI_PREFIX_MODES,
RUN_LOG_LEVELS,
RUN_STATUSES,
SCRATCH_STORAGE_MODES,
Expand Down Expand Up @@ -202,6 +205,19 @@ async def start_run(
'Set to ALL to capture full engine logs for debugging.'
),
),
output_uri_prefix_mode: Optional[str] = Field(
None,
description=(
'Output path layout for the run. Allowed values: SERVICE_DEFINED, '
'NONE_WITH_OVERWRITE. SERVICE_DEFINED (the HealthOmics API default) writes '
'outputs under output_uri/<runId>/out/ and logs under output_uri/<runId>/logs/. '
'NONE_WITH_OVERWRITE writes outputs directly under output_uri with no run ID '
'folder, and logs under output_uri/logs/<runId>/. With NONE_WITH_OVERWRITE, a run into a URI that '
'already holds outputs overwrites files with the same name without warning. '
'Use a unique output_uri per run unless overwriting is intended. When omitted, '
'the HealthOmics API default applies.'
),
),
tags: Optional[Dict[str, str]] = Field(
None,
description='Optional tags (key-value pairs) to apply to the run',
Expand Down Expand Up @@ -249,6 +265,10 @@ async def start_run(
log_level: Optional log level for the run (OFF, FATAL, ERROR, or ALL).
Controls engine log capture to CloudWatch. Defaults to the HealthOmics API
default when omitted.
output_uri_prefix_mode: Optional output path layout (SERVICE_DEFINED or
NONE_WITH_OVERWRITE). NONE_WITH_OVERWRITE writes outputs directly under
output_uri and overwrites same-named files from earlier runs. Defaults to the
HealthOmics API default when omitted.
tags: Optional tags (key-value pairs) to apply to the run
session_policy: Optional inline IAM session policy (JSON string or dict) that
scopes down the permissions of role_arn for this run
Expand Down Expand Up @@ -343,6 +363,25 @@ async def start_run(
'Invalid log level',
)

# Normalize output_uri_prefix_mode the same way (guards against an unresolved Field default)
effective_output_uri_prefix_mode = (
output_uri_prefix_mode if isinstance(output_uri_prefix_mode, str) else None
)

if (
effective_output_uri_prefix_mode is not None
and effective_output_uri_prefix_mode not in OUTPUT_URI_PREFIX_MODES
):
return await handle_tool_error(
ctx,
ValueError(
ERROR_INVALID_OUTPUT_URI_PREFIX_MODE.format(
effective_output_uri_prefix_mode, OUTPUT_URI_PREFIX_MODES
)
),
'Invalid output URI prefix mode',
)

# Normalize tags the same way (guards against an unresolved Field default)
effective_tags = tags if isinstance(tags, dict) else None

Expand Down Expand Up @@ -393,6 +432,10 @@ async def start_run(
if effective_log_level is not None:
params['logLevel'] = effective_log_level

# Only set outputUriPrefixMode when provided, to preserve the HealthOmics API default otherwise
if effective_output_uri_prefix_mode is not None:
params['outputUriPrefixMode'] = effective_output_uri_prefix_mode

if effective_tags is not None:
params['tags'] = effective_tags

Expand All @@ -418,6 +461,8 @@ async def start_run(
else NETWORKING_MODE_RESTRICTED,
'scratchStorageMode': effective_scratch_storage_mode,
'logLevel': effective_log_level,
'outputUriPrefixMode': effective_output_uri_prefix_mode
or OUTPUT_URI_PREFIX_MODE_SERVICE_DEFINED,
}
except Exception as e:
return await handle_tool_error(ctx, e, 'Error starting run')
Expand Down Expand Up @@ -646,7 +691,8 @@ async def get_run(
- Basic run information (id, arn, name, status)
- Workflow information (workflowId, workflowType, workflowVersionName)
- Timing information (creationTime, startTime, stopTime)
- Output locations (outputUri, runOutputUri)
- Output locations (outputUri, runOutputUri) and the output path layout
(outputUriPrefixMode), when the service returns it
- IAM role (roleArn)
- Run parameters and metadata
- Status messages and failure reasons (if applicable)
Expand Down Expand Up @@ -689,6 +735,7 @@ async def get_run(
'workflowVersionName',
'scratchStorageMode',
'sessionPolicy',
'outputUriPrefixMode',
]:
if field in response:
result[field] = response[field]
Expand Down
4 changes: 2 additions & 2 deletions src/aws-healthomics-mcp-server/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ description = "An AWS Labs Model Context Protocol (MCP) server for AWS HealthOmi
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"boto3>=1.43.90",
"botocore>=1.43.90",
"boto3>=1.43.123",
"botocore>=1.43.123",
"loguru>=0.7.0",
"mcp[cli]>=2.0.0,<3.0.0",
"pydantic>=2.10.6",
Expand Down
14 changes: 14 additions & 0 deletions src/aws-healthomics-mcp-server/tests/test_consts.py
Original file line number Diff line number Diff line change
Expand Up @@ -315,3 +315,17 @@ def test_run_statuses_completeness(self):
def test_default_region_in_supported_regions(self):
"""Test that DEFAULT_REGION is included in HEALTHOMICS_SUPPORTED_REGIONS."""
assert consts.DEFAULT_REGION in consts.HEALTHOMICS_SUPPORTED_REGIONS


class TestOutputUriPrefixModes:
"""Test cases for the run output URI prefix mode constants and error template."""

def test_output_uri_prefix_modes_list(self):
"""Test OUTPUT_URI_PREFIX_MODES contains exactly the two API values in order."""
assert consts.OUTPUT_URI_PREFIX_MODE_SERVICE_DEFINED == 'SERVICE_DEFINED'
assert consts.OUTPUT_URI_PREFIX_MODE_NONE_WITH_OVERWRITE == 'NONE_WITH_OVERWRITE'
assert consts.OUTPUT_URI_PREFIX_MODES == ['SERVICE_DEFINED', 'NONE_WITH_OVERWRITE']

def test_error_template_has_two_placeholders(self):
"""Test the template exposes placeholders for the value and allowed list."""
assert consts.ERROR_INVALID_OUTPUT_URI_PREFIX_MODE.count('{}') == 2
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Smoke tests for AWS SDK output URI prefix mode support.

These are low-iteration smoke tests (not property tests) that verify the
external AWS SDK behavior required by this feature:

- The installed boto3 / botocore versions meet the minimum that introduced the
``outputUriPrefixMode`` field, and pyproject.toml declares that minimum.
- A real ``omics`` boto3 client's StartRun input shape accepts
``outputUriPrefixMode`` with the values the tool validates against, and the
GetRun output shape returns it (no network calls are made; the fields are
verified against the client's service model).
"""

import boto3
import botocore
import pathlib
import re
from awslabs.aws_healthomics_mcp_server.consts import OUTPUT_URI_PREFIX_MODES
from packaging.version import Version


# Minimum SDK version whose omics model includes outputUriPrefixMode on StartRun and GetRun.
MINIMUM_SDK_VERSION = Version('1.43.123')

# Repo root is the parent of the tests/ directory.
REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent
PYPROJECT_PATH = REPO_ROOT / 'pyproject.toml'


def test_installed_boto3_version_meets_minimum():
"""The installed boto3 version is >= 1.43.123."""
assert Version(boto3.__version__) >= MINIMUM_SDK_VERSION, (
f'Installed boto3 {boto3.__version__} is below the required minimum {MINIMUM_SDK_VERSION}'
)


def test_installed_botocore_version_meets_minimum():
"""The installed botocore version is >= 1.43.123."""
assert Version(botocore.__version__) >= MINIMUM_SDK_VERSION, (
f'Installed botocore {botocore.__version__} is below the required '
f'minimum {MINIMUM_SDK_VERSION}'
)


def _declared_minimum(package):
"""Return the declared minimum Version for a package from pyproject.toml."""
text = PYPROJECT_PATH.read_text(encoding='utf-8')
match = re.search(rf'["\']{package}\s*>=\s*([0-9][0-9A-Za-z.\-]*)["\']', text)
if match:
return Version(match.group(1))
return None


def test_pyproject_declares_boto3_minimum():
"""pyproject.toml declares a boto3 minimum of at least 1.43.123."""
declared = _declared_minimum('boto3')

assert declared is not None, 'boto3 dependency constraint not found in pyproject.toml'
assert declared >= MINIMUM_SDK_VERSION, (
f'pyproject.toml declares boto3>={declared}, below required {MINIMUM_SDK_VERSION}'
)


def test_pyproject_declares_botocore_minimum():
"""pyproject.toml declares an explicit botocore minimum of at least 1.43.123."""
declared = _declared_minimum('botocore')

assert declared is not None, 'botocore dependency constraint not found in pyproject.toml'
assert declared >= MINIMUM_SDK_VERSION, (
f'pyproject.toml declares botocore>={declared}, below required {MINIMUM_SDK_VERSION}'
)


def _omics_operation_model(operation_name):
"""Return the omics operation model (no network calls)."""
client = boto3.client('omics', region_name='us-east-1')
return client.meta.service_model.operation_model(operation_name)


def test_start_run_accepts_output_uri_prefix_mode():
"""StartRun accepts outputUriPrefixMode, and its enum matches the tool's allowed values."""
members = _omics_operation_model('StartRun').input_shape.members
assert 'outputUriPrefixMode' in members, (
'StartRun input shape does not declare outputUriPrefixMode; '
'the installed SDK does not support output URI prefix mode'
)
assert sorted(members['outputUriPrefixMode'].enum) == sorted(OUTPUT_URI_PREFIX_MODES)


def test_get_run_returns_output_uri_prefix_mode():
"""The GetRun output shape declares outputUriPrefixMode."""
members = _omics_operation_model('GetRun').output_shape.members
assert 'outputUriPrefixMode' in members, (
'GetRun output shape does not declare outputUriPrefixMode; '
'the installed SDK does not support output URI prefix mode'
)
Loading
Loading