Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
59 changes: 59 additions & 0 deletions BEDROCK_AGENTCORE_DEPLOYMENT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Purple AI MCP - Deployment on Amazon Bedrock Agent Core

This guide will take you through deploying the Purple AI MCP Server to Amazon Bedrock AgentCore.

## Prerequisites

**Obtain a Sentinelone Singularity Operations Center console token**

This can be found in Policy & Settings → User Management → Service Users in your console. Currently, this server only supports tokens that have access to a single Account or Site. If you need to access multiple sites, you will need to run multiple MCP servers with Account-specific or Site-specific tokens.

**Prepare Environment Variables**

```bash
PURPLEMCP_CONSOLE_BASE_URL=https://your-console.sentinelone.net
PURPLEMCP_CONSOLE_TOKEN=your-token
MCP_MODE=streamable-http
PURPLEMCP_STATELESS_HTTP=True
```

**Prepare AWS Environment**

### VPC Configuration

**Outbound internet access** - Outbound internet access should be permitted through an Internet Gateway or NAT Gateway. \
**Security Groups** - Allow outbound HTTPS on port 443 to connect to the Sentinelone service.

It is important to note that Purple AI MCP does not include built-in authentication. For network exposed AWS deployments, ensure the MCP Server is placed behind an Application Load Balancer (ALB) with the appropriate timeout settings. Detailed information on production setups can be found [here](PRODUCTION_SETUP.md#cloud-load-balancer-setup).

### IAM Configuration

When deploying Purple AI MCP via AWS Marketplace a 'default' service role will be automatically created. To use a customer-managed service role reference the IAM Policy below.

[Trust Policy](bedrock-agentcore-trust-policy.json)
[IAM Policy](bedrock-agentcore-iam-policy.json)

## Agent Core Configuration Settings

When configuring the MCP in Bedrock Agent core, set the following.

1. **Name** - Give the deployment a suitable name and description.
2. **Agent Source** - Default (ECR) as this is automatically populated via AWS Marketplace.
3. **IAM Permissions** - Default service role or create a custom service role based on the IAM Configuration step [above](#iam-configuration).
4. **Protocol** - Set to MCP.
5. **Inbound Identity Type** - Select JWT (JSON Web Tokens) or IAM.
- **JWT** - Requires IdP bearer tokens to invoke the agent.
- **IAM** - Requires relevant IAM Permissions to invoke the agent.
6. **Security Type** - Select VPC and set the relevant VPC, Subnets & Security Groups.
7. **Advanced Settings** - Add environment variables as noted in the [prerequisites](#prerequisites).
8. Click **Host Agent** to complete the setup.

The Agent will take around ~1-2 minutes to become active.

## Useful Links

[Production VPC Setup for Purple AI MCP](PRODUCTION_SETUP.md) \
[AWS Bedrock AgentCore Overview](https://aws.amazon.com/bedrock/agentcore/) \
[Granting a VPC Internet Access via Internet Gateway](https://docs.aws.amazon.com/vpc/latest/userguide/VPC_Internet_Gateway.html) \
[Creating custom IAM Roles](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_create_for-service.html)

100 changes: 100 additions & 0 deletions bedrock-agentcore-iam-policy.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ECRImageAccess",
"Effect": "Allow",
"Action": [
"ecr:BatchGetImage",
"ecr:GetDownloadUrlForLayer"
],
"Resource": [
"arn:aws:ecr:us-east-1:709825985650:repository/sentinelone/purple-ai-mcp-server"
]
},
{
"Effect": "Allow",
"Action": [
"logs:DescribeLogStreams",
"logs:CreateLogGroup"
],
"Resource": [
"arn:aws:logs:{{region}}:{{accountId}}:log-group:/aws/bedrock-agentcore/runtimes/*"
]
},
{
"Effect": "Allow",
"Action": [
"logs:DescribeLogGroups"
],
"Resource": [
"arn:aws:logs:{{region}}:{{accountId}}:log-group:*"
]
},
{
"Effect": "Allow",
"Action": [
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": [
"arn:aws:logs:{{region}}:{{accountId}}:log-group:/aws/bedrock-agentcore/runtimes/*:log-stream:*"
]
},
{
"Sid": "ECRTokenAccess",
"Effect": "Allow",
"Action": [
"ecr:GetAuthorizationToken"
],
"Resource": "*"
},
{
"Effect": "Allow",
"Action": [
"xray:PutTraceSegments",
"xray:PutTelemetryRecords",
"xray:GetSamplingRules",
"xray:GetSamplingTargets"
],
"Resource": [
"*"
]
},
{
"Effect": "Allow",
"Resource": "*",
"Action": "cloudwatch:PutMetricData",
"Condition": {
"StringEquals": {
"cloudwatch:namespace": "bedrock-agentcore"
}
}
},
{
"Sid": "GetAgentAccessToken",
"Effect": "Allow",
"Action": [
"bedrock-agentcore:GetWorkloadAccessToken",
"bedrock-agentcore:GetWorkloadAccessTokenForJWT",
"bedrock-agentcore:GetWorkloadAccessTokenForUserId"
],
"Resource": [
"arn:aws:bedrock-agentcore:{{region}}:{{accountId}}:workload-identity-directory/default",
"arn:aws:bedrock-agentcore:{{region}}:{{accountId}}:workload-identity-directory/default/workload-identity/hosted_agent_s26ce-*"
]
},
{
"Sid": "BedrockModelInvocation",
"Effect": "Allow",
"Action": [
"bedrock:InvokeModel",
"bedrock:InvokeModelWithResponseStream"
],
"Resource": [
"arn:aws:bedrock:*::foundation-model/*",
"arn:aws:bedrock:{{region}}:{{accountId}}:*"
]
}
]
}
21 changes: 21 additions & 0 deletions bedrock-agentcore-trust-policy.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AssumeRolePolicy",
"Effect": "Allow",
"Principal": {
"Service": "bedrock-agentcore.amazonaws.com"
},
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": {
"aws:SourceAccount": "{{accountId}}"
},
"ArnLike": {
"aws:SourceArn": "arn:aws:bedrock-agentcore:{{region}}:{{accountId}}:*"
}
}
}
]
}
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ services:
MCP_MODE: streamable-http
MCP_HOST: 0.0.0.0
MCP_PORT: 8000
PURPLEMCP_STATELESS_HTTP: ${PURPLEMCP_STATELESS_HTTP}
# Required: SentinelOne Console configuration
PURPLEMCP_CONSOLE_BASE_URL: ${PURPLEMCP_CONSOLE_BASE_URL}
PURPLEMCP_CONSOLE_TOKEN: ${PURPLEMCP_CONSOLE_TOKEN}
Expand Down
19 changes: 17 additions & 2 deletions src/purple_mcp/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ def _apply_environment_overrides(
console_base_url: str | None,
graphql_endpoint: str | None,
alerts_graphql_endpoint: str | None,
stateless_http: bool
) -> None:
"""Apply CLI argument values to environment variables.

Expand All @@ -102,6 +103,8 @@ def _apply_environment_overrides(
and alerts_graphql_endpoint != "/web/api/v2.1/unifiedalerts/graphql"
):
os.environ[f"{ENV_PREFIX}ALERTS_GRAPHQL_ENDPOINT"] = alerts_graphql_endpoint
if stateless_http:
os.environ[f"{ENV_PREFIX}STATELESS_HTTP"] = str(stateless_http)


def _check_unsupported_config() -> None:
Expand Down Expand Up @@ -240,6 +243,7 @@ def _run_uvicorn(
port: int,
verbose: bool,
allow_remote_access: bool,
stateless_http: bool
) -> None: # pragma: no cover - uvicorn is mocked in unit-tests
"""Run the HTTP/SSE transport using *uvicorn*."""
# Validate host binding for security
Expand All @@ -258,7 +262,7 @@ def _run_uvicorn(
from purple_mcp.server import app

# Create the HTTP app and instrument it if Logfire is enabled
http_app = app.http_app(transport=transport)
http_app = app.http_app(transport=transport, stateless_http=stateless_http)
instrument_starlette_app(http_app)

uvicorn.run(
Expand All @@ -280,21 +284,23 @@ def _run_mode(
verbose: bool,
no_banner: bool = False,
allow_remote_access: bool = False,
stateless_http: bool = False
) -> None:
"""Dispatch to the appropriate server runner for *mode*."""
mode_normalised = mode.lower()

runners: Mapping[str, Callable[[], None]] = {
"stdio": lambda: _run_stdio(verbose, no_banner),
"sse": lambda: _run_uvicorn(
"sse", host=host, port=port, verbose=verbose, allow_remote_access=allow_remote_access
"sse", host=host, port=port, verbose=verbose, allow_remote_access=allow_remote_access, stateless_http=stateless_http
),
"streamable-http": lambda: _run_uvicorn(
"streamable-http",
host=host,
port=port,
verbose=verbose,
allow_remote_access=allow_remote_access,
stateless_http=stateless_http
),
}

Expand Down Expand Up @@ -365,6 +371,12 @@ def _run_mode(
is_flag=True,
help="Allow binding to non-loopback interfaces (SECURITY RISK: exposes unauthenticated tools)",
)
@click.option(
"--stateless-http",
is_flag=True,
envvar=f"{ENV_PREFIX}STATELESS_HTTP",
help="uses true stateless mode (new transport per request)",
)
def main(
mode: str,
host: str,
Expand All @@ -377,6 +389,7 @@ def main(
verbose: bool,
banner: bool,
allow_remote_access: bool,
stateless_http: bool
) -> None:
"""Purple MCP Server - AI monitoring and analysis tool."""
_setup_logging(verbose)
Expand All @@ -390,6 +403,7 @@ def main(
console_base_url=console_base_url,
graphql_endpoint=graphql_endpoint,
alerts_graphql_endpoint=alerts_graphql_endpoint,
stateless_http=stateless_http
)

settings = _create_settings()
Expand All @@ -406,6 +420,7 @@ def main(
verbose=verbose,
no_banner=not banner,
allow_remote_access=allow_remote_access,
stateless_http=stateless_http
)


Expand Down
20 changes: 11 additions & 9 deletions tests/unit/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ def test_sse_mode_options(self) -> None:
assert call_args[1]["port"] == 9000
assert call_args[1]["log_level"] == "warning"

mock_app.http_app.assert_called_once_with(transport="sse")
mock_app.http_app.assert_called_once_with(transport="sse", stateless_http=False)
assert result.exit_code == 0

def test_streamable_http_mode_options(self) -> None:
Expand Down Expand Up @@ -144,7 +144,7 @@ def test_streamable_http_mode_options(self) -> None:
assert call_args[1]["port"] == 8080
assert call_args[1]["log_level"] == "info" # verbose mode

mock_app.http_app.assert_called_once_with(transport="streamable-http")
mock_app.http_app.assert_called_once_with(transport="streamable-http", stateless_http=False)
assert result.exit_code == 0

def test_verbose_logging_setup(self) -> None:
Expand Down Expand Up @@ -353,7 +353,7 @@ def test_sse_mode(self) -> None:
main, ["--mode", "sse", "--host", "127.0.0.1", "--port", "8001"]
)

mock_app.http_app.assert_called_once_with(transport="sse")
mock_app.http_app.assert_called_once_with(transport="sse", stateless_http=False)
mock_uvicorn.assert_called_once()

# Check uvicorn call arguments
Expand All @@ -379,7 +379,7 @@ def test_streamable_http_mode(self) -> None:

result = runner.invoke(main, ["--mode", "streamable-http"])

mock_app.http_app.assert_called_once_with(transport="streamable-http")
mock_app.http_app.assert_called_once_with(transport="streamable-http", stateless_http=False)
mock_uvicorn.assert_called_once()

assert (
Expand Down Expand Up @@ -432,7 +432,7 @@ def test_sse_mode_dispatch(self) -> None:
_run_mode("sse", host="127.0.0.1", port=8001, verbose=False)

mock_run_uvicorn.assert_called_once_with(
"sse", host="127.0.0.1", port=8001, verbose=False, allow_remote_access=False
"sse", host="127.0.0.1", port=8001, verbose=False, allow_remote_access=False, stateless_http=False
)

def test_streamable_http_mode_dispatch(self) -> None:
Expand All @@ -444,6 +444,7 @@ def test_streamable_http_mode_dispatch(self) -> None:
port=9000,
verbose=True,
allow_remote_access=True,
stateless_http=False
)

mock_run_uvicorn.assert_called_once_with(
Expand All @@ -452,6 +453,7 @@ def test_streamable_http_mode_dispatch(self) -> None:
port=9000,
verbose=True,
allow_remote_access=True,
stateless_http=False
)

def test_case_insensitive_mode_dispatch(self) -> None:
Expand All @@ -465,7 +467,7 @@ def test_case_insensitive_mode_dispatch(self) -> None:
_run_mode("SSE", host="localhost", port=8000, verbose=False)

mock_run_uvicorn.assert_called_once_with(
"sse", host="localhost", port=8000, verbose=False, allow_remote_access=False
"sse", host="localhost", port=8000, verbose=False, allow_remote_access=False, stateless_http=False
)


Expand Down Expand Up @@ -589,7 +591,7 @@ def test_full_workflow_with_all_options(self) -> None:
mock_settings.assert_called_once()

# Should start server
mock_app.http_app.assert_called_once_with(transport="sse")
mock_app.http_app.assert_called_once_with(transport="sse", stateless_http=False)
mock_uvicorn.assert_called_once()

assert result.exit_code == 0
Expand Down Expand Up @@ -628,9 +630,9 @@ def test_all_modes_with_valid_config(self, mode: str) -> None:
else:
mock_uvicorn.assert_called_once()
if mode == "sse":
mock_app.http_app.assert_called_once_with(transport="sse")
mock_app.http_app.assert_called_once_with(transport="sse", stateless_http=False)
else: # streamable-http
mock_app.http_app.assert_called_once_with(transport="streamable-http")
mock_app.http_app.assert_called_once_with(transport="streamable-http", stateless_http=False)

assert result.exit_code == 0

Expand Down
Loading
Loading