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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ Table of Contents:
| **[Terraform NGINX hello world](containers/terraform-nginx-hello-world/README.md)** <br/> A minimal example running the base NGINX image in a serverless container deployed with Terraform. | N/A | [Terraform] |
| **[Triggers with Terraform](containers/terraform-triggers/README.md)** <br/> Configuring two SQS triggers, used to trigger two containers, one public, one private. | N/A | [Terraform] |
| **[gRPC HTTP2 in Go](containers/grpc-http2-go/README.md)** <br/> A Go gRPC Container using http2 | Go/Protobuf | [CLI] |
| **[Remote MCP Server](containers/mcp-go-server/README.md)** <br/> A Go-based Model Context Protocol server exposing tools over HTTP on Serverless Containers. | Go | [Terraform]-[CLI] |
| **[Rust hello world](containers/rust-hello-world)** <br/> A simple Rust hello world Container | Rust | [CLI] |
| **[.NET C#](containers/csharp-hello-world)** <br/> A .NET C# Container hello world | C# .NET | [CLI] |
| **[Ruby Hello World](containers/ruby-hello-world/)** <br/> A simple Ruby Hello world Container | Ruby | [CLI] |
Expand Down
9 changes: 9 additions & 0 deletions containers/mcp-go-server/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Scaleway credentials (IAM API key) - never commit real values
SCW_ACCESS_KEY=SCWXXXXXXXXXXXXXXXXX
SCW_SECRET_KEY=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx

# Optional: Scaleway project ID (uses default project if omitted)
# SCW_DEFAULT_PROJECT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx

# Value returned by the get_app_name MCP tool
SCW_APPLICATION_NAME=my-mcp-server
16 changes: 16 additions & 0 deletions containers/mcp-go-server/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Terraform
.terraform/
terraform.tfstate*
*.tfvars
*.tfplan

# Serverless Framework / OSLS
.serverless/
node_modules/

# Go build artifacts
mcp-server
*.exe

# Local env files
.env
31 changes: 31 additions & 0 deletions containers/mcp-go-server/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# syntax=docker/dockerfile:1

# Step 1: Build binary using modern Go toolchain
FROM golang:1.27-alpine AS builder

WORKDIR /app

COPY go.mod go.sum ./

RUN go mod download

COPY main.go .

RUN CGO_ENABLED=0 GOOS=linux go build \
-trimpath \
-ldflags="-w -s" \
-o mcp-server main.go

# Step 2: Minimal non-root distroless runtime (~5MB final image)
FROM gcr.io/distroless/static-debian13:nonroot

WORKDIR /

COPY --from=builder /app/mcp-server /mcp-server

EXPOSE 8080
ENV PORT=8080

USER nonroot:nonroot

ENTRYPOINT ["/mcp-server"]
155 changes: 155 additions & 0 deletions containers/mcp-go-server/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
# Deploying Remote MCP Servers on Scaleway Serverless Containers

A lightweight Go implementation of the **Model Context Protocol (MCP)** deployed as a Scaleway Serverless Container, enabling AI agents (like OpenCode, Claude, and Cursor) to securely invoke tools over HTTP.

The server is built on the official [MCP Go SDK](https://github.com/modelcontextprotocol/go-sdk) (`github.com/modelcontextprotocol/go-sdk/mcp`), which handles the JSON-RPC protocol lifecycle, tool schemas/validation, and the Streamable HTTP transport.

The server exposes three tools:

| Tool | Description |
| :--- | :--- |
| `get_server_time` | Returns the current UTC time from the container. |
| `echo` | Echoes back the input message. |
| `get_app_name` | Returns the `SCW_APPLICATION_NAME` environment variable. |

---

## Why Serverless Fits the Model Context Protocol

The Model Context Protocol (MCP) standardizes how LLMs interact with external tools and data sources. Hosting MCP servers on serverless platforms like Scaleway Serverless Containers offers key operational advantages:

| Advantage | Why It Matters for MCP |
| :--- | :--- |
| **Scale-to-Zero Cost** | AI agents invoke tools in intermittent bursts. Serverless ensures you pay only for active execution time, eliminating costs when idle. |
| **Stateless Architecture** | Remote MCP uses HTTP/SSE transports where requests carry their own execution context. Serverless containers scale horizontally without sticky sessions. |
| **Sandbox Security** | Tool execution happens inside isolated, microVM-level container sandboxes, protecting infrastructure from unexpected agent actions. |
| **Zero Maintenance** | Automated OS patching, SSL termination, and horizontal scaling are handled entirely by the cloud provider. |

### Why Scaleway Serverless Containers?

- **Native Container Support:** Run any Docker image (Go, Rust, Python, Node.js) with zero runtime restrictions.
- **Built-in Private Access Control:** Native API Gateway enforcement of `X-Auth-Token` blocks unauthorized traffic before it hits your container — saving compute costs.
- **European Sovereignty & GDPR:** Full data processing compliance in European regions (Paris, Amsterdam, Warsaw).
- **Automated TLS & Custom Domains:** Out-of-the-box HTTPS endpoint provisioning with support for custom domain mapping.

---

## Technical Architecture & Transports

Local MCP servers communicate over standard input/output (`stdio`). Remote serverless MCP deployments require network-based transports over HTTP:

- **Streamable HTTP / SSE:** The MCP server exposes a `/mcp` endpoint accepting POST requests for JSON-RPC 2.0 tool execution and streaming responses back over Server-Sent Events (SSE). This transport is provided by the SDK's `mcp.StreamableHTTPHandler` (deployed in `Stateless` mode, ideal for scale-to-zero containers).
- **Isolated Routing:** This example uses Go's `http.NewServeMux` (Go 1.22+ method routing) rather than the global default router, preventing route hijacking and allowing clean middleware integration.

A `/health` endpoint is also exposed for container health checks.

---

## Prerequisites

- [Terraform](https://www.terraform.io/downloads) `>= 1.5`
- [Scaleway CLI (`scw`)](https://github.com/scaleway/scaleway-cli) (latest)
- [Docker](https://docs.docker.com/get-docker/) (for building and pushing the image)
- [Go](https://go.dev/dl/) `>= 1.22` (only needed for local development/testing)
- A Scaleway account with an [IAM API key](https://www.scaleway.com/en/docs/identity-and-access-management/iam/how-to/create-api-keys/)

---

## Environment Variables

Copy `.env.example` to `.env` and fill in your values:

| Variable | Required | Description |
| :--- | :--- | :--- |
| `SCW_ACCESS_KEY` | Yes | Scaleway IAM access key. |
| `SCW_SECRET_KEY` | Yes | Scaleway IAM secret key. |
| `SCW_DEFAULT_PROJECT_ID` | No | Target project ID. Uses your default project if omitted. |

```bash
cp .env.example .env
# Edit .env with your real values
```

---

## Deployment

This example offers two deployment methods. **Choose one.**

### Option 1: Terraform

The `main.tf` file provisions the registry namespace, builds and pushes the Docker image, and creates/deployes the container.

```bash
# Export your Scaleway credentials (or set them in a .env file sourced beforehand)
export SCW_ACCESS_KEY="SCWXXXXXXXXXXXXXXXXX"
export SCW_SECRET_KEY="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"

terraform init
terraform apply
```

When prompted, type `yes` to confirm. The apply output will print the container URL, the MCP endpoint, and the health endpoint.

### Option 2: Scaleway CLI (`deploy.sh`)

The `deploy.sh` script builds the Docker image, pushes it, and creates/deployes the container using only `scw` commands.

```bash
export SCW_SECRET_KEY="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"

chmod +x deploy.sh
./deploy.sh
```

The script prints the container URL, MCP endpoint, and health endpoint on success.

---

## Connecting Clients (e.g. OpenCode)

Add the deployed remote endpoint to your client configuration (such as `opencode.json` or `~/.config/opencode/opencode.json`). Pass your Scaleway IAM secret key via the `X-Auth-Token` header:

```json
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"scaleway-tools": {
"type": "remote",
"url": "https://<container-name>.<namespace-id>.functions.fnc.fr-par.scw.cloud/mcp",
"headers": {
"X-Auth-Token": "YOUR_SCALEWAY_IAM_SECRET_KEY"
}
}
}
}
```

---

## Cleanup

### Terraform

```bash
terraform destroy
```

### Scaleway CLI

The deletion commands are commented out at the bottom of `deploy.sh`. Uncomment and run them, or execute directly:

```bash
scw container container delete region=fr-par <container-id>
scw container namespace delete region=fr-par <namespace-id>
# Optionally, remove the pushed image from the registry:
scw registry namespace delete region=fr-par <registry-namespace-id>
```

---

## Production Best Practices

- **Cold Start Optimization:** Keep container images ultra-minimal (e.g., Go/Rust static binaries inside `distroless` or `alpine`) to ensure cold boots remain under 1 second. For latency-critical agents, set `min_scale = 1`.
- **Database Connection Management:** When connecting tools to databases (like PostgreSQL), use HTTP-based query clients or a connection pooler (e.g., PgBouncer) to prevent connection exhaustion during rapid container scaling.
- **Least-Privilege Tool Execution:** Restrict database user roles to read-only permissions and sanitize all inputs inside tool implementations to prevent SQL injection or accidental data manipulation.
131 changes: 131 additions & 0 deletions containers/mcp-go-server/deploy.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
#!/usr/bin/env bash
set -euo pipefail

# =============================================================================
# Scaleway CLI deployment script for the mcp-go-server container example.
# Builds the Docker image, pushes it to the Scaleway Container Registry, then
# creates/deployes a Serverless Container exposing the MCP server.
# =============================================================================

# --- Configuration -----------------------------------------------------------
NAMESPACE_NAME="mcp-go-server-ns"
CONTAINER_NAME="mcp-go-server"
IMAGE_NAME="mcp-server"
IMAGE_TAG="latest"
PORT=8080
MIN_SCALE=0
MAX_SCALE=5
PRIVACY="private"
REGION="fr-par"
REGISTRY="rg.${REGION}.scw.cloud"

# --- Pre-flight checks -------------------------------------------------------
if ! command -v scw >/dev/null 2>&1; then
echo "Error: Scaleway CLI ('scw') is not installed or not in PATH."
echo "Install it from: https://github.com/scaleway/scaleway-cli"
exit 1
fi

if ! command -v docker >/dev/null 2>&1; then
echo "Error: 'docker' is required but was not found in PATH."
exit 1
fi

if ! command -v jq >/dev/null 2>&1; then
echo "Error: 'jq' is required but was not found in PATH."
exit 1
fi

if [[ -z "${SCW_SECRET_KEY:-}" ]]; then
echo "Error: SCW_SECRET_KEY environment variable is not set."
echo "Export your Scaleway IAM secret key before running this script."
exit 1
fi

# Use a per-run unique registry namespace name to avoid collisions.
# Scaleway registry namespace names must be unique per region.
REGISTRY_NAMESPACE="mcp-go-server-$(date +%s | tail -c 5)"

# --- Docker registry login ---------------------------------------------------
echo "Logging in to Scaleway Container Registry..."
echo "${SCW_SECRET_KEY}" | docker login "${REGISTRY}" -u nologin --password-stdin

# --- Registry namespace ------------------------------------------------------
# The registry namespace must exist before the image can be pushed.
echo "Ensuring the registry namespace '${REGISTRY_NAMESPACE}' exists..."
REGISTRY_NAMESPACE_ID="$(scw registry namespace list \
region="${REGION}" \
name="${REGISTRY_NAMESPACE}" \
-o json | jq -r '.[0].id // empty')"

if [[ -z "${REGISTRY_NAMESPACE_ID}" ]]; then
echo "Creating registry namespace '${REGISTRY_NAMESPACE}'..."
REGISTRY_NAMESPACE_ID="$(scw registry namespace create \
region="${REGION}" \
name="${REGISTRY_NAMESPACE}" \
-o json | jq -r '.id')"
fi

# --- Build and push the image ------------------------------------------------
FULL_IMAGE="${REGISTRY}/${REGISTRY_NAMESPACE}/${IMAGE_NAME}:${IMAGE_TAG}"

echo "Building Docker image: ${FULL_IMAGE}"
docker build -t "${FULL_IMAGE}" .

echo "Pushing Docker image..."
docker push "${FULL_IMAGE}"

# --- Container namespace -----------------------------------------------------
NAMESPACE_ID="$(scw container namespace list \
region="${REGION}" \
name="${NAMESPACE_NAME}" \
-o json | jq -r '.[0].id // empty')"

if [[ -z "${NAMESPACE_ID}" ]]; then
echo "Creating container namespace '${NAMESPACE_NAME}'..."
NAMESPACE_ID="$(scw container namespace create \
region="${REGION}" \
name="${NAMESPACE_NAME}" \
--wait \
-o json | jq -r '.id')"
else
echo "Reusing existing namespace '${NAMESPACE_NAME}' (${NAMESPACE_ID})."
fi

# --- Container ---------------------------------------------------------------
echo "Creating container '${CONTAINER_NAME}'..."
CONTAINER_ID="$(scw container container create \
region="${REGION}" \
namespace-id="${NAMESPACE_ID}" \
name="${CONTAINER_NAME}" \
image="${FULL_IMAGE}" \
port="${PORT}" \
min-scale="${MIN_SCALE}" \
max-scale="${MAX_SCALE}" \
privacy="${PRIVACY}" \
--wait \
-o json | jq -r '.id')"

# --- Output ------------------------------------------------------------------
DOMAIN="$(scw container container get \
region="${REGION}" \
"${CONTAINER_ID}" \
-o json | jq -r '.public_endpoint')"

echo ""
echo "Container URL: ${DOMAIN}"
echo "MCP endpoint: ${DOMAIN}/mcp"
echo "Health endpoint: ${DOMAIN}/health"

# =============================================================================
# Cleanup / Remove
# -----------------------------------------------------------------------------
# To delete the container and namespace created above, uncomment and run:
#
# scw container container delete region="${REGION}" "${CONTAINER_ID}"
# scw container namespace delete region="${REGION}" "${NAMESPACE_ID}"
#
# To also remove the pushed Docker image from the registry:
#
# scw registry namespace delete region="${REGION}" "${REGISTRY_NAMESPACE_ID}"
# =============================================================================
16 changes: 16 additions & 0 deletions containers/mcp-go-server/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
module github.com/scaleway/serverless-examples/containers/mcp-go-server

go 1.27.0

require github.com/modelcontextprotocol/go-sdk v1.7.0

require (
github.com/google/jsonschema-go v0.4.3 // indirect
github.com/segmentio/asm v1.1.3 // indirect
github.com/segmentio/encoding v0.5.4 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/time v0.15.0 // indirect
)
24 changes: 24 additions & 0 deletions containers/mcp-go-server/go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/modelcontextprotocol/go-sdk v1.7.0 h1:yqjY2dsbKAC0LSuWZVBMrHgiG8ukXv6NRo0JiALay44=
github.com/modelcontextprotocol/go-sdk v1.7.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
Loading
Loading