From a94e515f1c9719acf01ac10cda4583c3e94e710f Mon Sep 17 00:00:00 2001 From: Thomas Tacquet Date: Fri, 21 Aug 2026 15:18:49 +0200 Subject: [PATCH 01/11] feat(containers): mcp sample server --- containers/mcp-go-server/Dockerfile | 36 +++++++ containers/mcp-go-server/go.mod | 3 + containers/mcp-go-server/main.go | 151 ++++++++++++++++++++++++++++ 3 files changed, 190 insertions(+) create mode 100644 containers/mcp-go-server/Dockerfile create mode 100644 containers/mcp-go-server/go.mod create mode 100644 containers/mcp-go-server/main.go diff --git a/containers/mcp-go-server/Dockerfile b/containers/mcp-go-server/Dockerfile new file mode 100644 index 0000000..3fff867 --- /dev/null +++ b/containers/mcp-go-server/Dockerfile @@ -0,0 +1,36 @@ +# syntax=docker/dockerfile:1 + +# Step 1: Build binary using modern Go toolchain +FROM golang:1.24-alpine AS builder + +WORKDIR /app + +COPY go.mod ./ + +# BuildKit cache for module downloads +RUN --mount=type=cache,target=/go/pkg/mod \ + go mod download + +COPY main.go . + +# Build with BuildKit compiler cache + binary size optimizations +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + 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-debian12:nonroot + +WORKDIR / + +COPY --from=builder /app/mcp-server /mcp-server + +EXPOSE 8080 +ENV PORT=8080 + +USER nonroot:nonroot + +ENTRYPOINT ["/mcp-server"] diff --git a/containers/mcp-go-server/go.mod b/containers/mcp-go-server/go.mod new file mode 100644 index 0000000..1a81c08 --- /dev/null +++ b/containers/mcp-go-server/go.mod @@ -0,0 +1,3 @@ +module github.com/scaleway/serverless-examples/containers/mcp-go-server + +go 1.27.0 diff --git a/containers/mcp-go-server/main.go b/containers/mcp-go-server/main.go new file mode 100644 index 0000000..62f7196 --- /dev/null +++ b/containers/mcp-go-server/main.go @@ -0,0 +1,151 @@ +package main + +import ( + "cmp" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "os" + "time" +) + +type JSONRPCRequest struct { + JSONRPC string `json:"jsonrpc"` + ID any `json:"id,omitempty"` + Method string `json:"method"` + Params json.RawMessage `json:"params,omitempty"` +} + +type JSONRPCResponse struct { + JSONRPC string `json:"jsonrpc"` + ID any `json:"id,omitempty"` + Result any `json:"result,omitempty"` + Error any `json:"error,omitempty"` +} + +func main() { + // cmp.Or eliminates verbose env var fallback checks + port := cmp.Or(os.Getenv("PORT"), "8080") + + mux := http.NewServeMux() + + // Native method routing in Go 1.22+ + mux.HandleFunc("GET /health", handleHealth) + mux.HandleFunc("OPTIONS /mcp", handleCORS) + mux.HandleFunc("POST /mcp", handleMCP) + + slog.Info("Starting modern MCP server", "port", port) + if err := http.ListenAndServe(":"+port, mux); err != nil { + slog.Error("Server crashed", "error", err) + os.Exit(1) + } +} + +func handleHealth(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("OK")) +} + +func handleCORS(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + w.WriteHeader(http.StatusOK) +} + +func handleMCP(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Content-Type", "application/json") + + var req JSONRPCRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid JSON", http.StatusBadRequest) + return + } + + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + } + + switch req.Method { + case "initialize": + resp.Result = map[string]any{ + "protocolVersion": "2024-11-05", + "capabilities": map[string]any{ + "tools": map[string]any{}, + }, + "serverInfo": map[string]any{ + "name": "scaleway-test-mcp", + "version": "1.1.0", + }, + } + + case "tools/list": + resp.Result = map[string]any{ + "tools": []map[string]any{ + { + "name": "get_server_time", + "description": "Returns current UTC time from the Scaleway container", + "inputSchema": map[string]any{ + "type": "object", + "properties": map[string]any{}, + }, + }, + { + "name": "echo", + "description": "Echoes back the input message", + "inputSchema": map[string]any{ + "type": "object", + "properties": map[string]any{ + "message": map[string]any{ + "type": "string", + "description": "The string to echo back", + }, + }, + "required": []string{"message"}, + }, + }, + }, + } + + case "tools/call": + var params struct { + Name string `json:"name"` + Arguments map[string]any `json:"arguments"` + } + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + resp.Error = map[string]any{"code": -32602, "message": "Invalid params"} + break + } + + switch params.Name { + case "get_server_time": + resp.Result = map[string]any{ + "content": []map[string]any{ + { + "type": "text", + "text": fmt.Sprintf("Scaleway Container UTC Time: %s", time.Now().UTC().Format(time.RFC3339)), + }, + }, + } + case "echo": + msg, _ := params.Arguments["message"].(string) + resp.Result = map[string]any{ + "content": []map[string]any{ + { + "type": "text", + "text": fmt.Sprintf("Echo from Scaleway: %s", msg), + }, + }, + } + default: + resp.Error = map[string]any{"code": -32601, "message": "Tool not found"} + } + + default: + resp.Result = map[string]any{} + } + + _ = json.NewEncoder(w).Encode(resp) +} From f25d788ba76a3c85b75d8276a1c559fcdfbfd5fd Mon Sep 17 00:00:00 2001 From: Thomas Tacquet Date: Fri, 21 Aug 2026 16:25:14 +0200 Subject: [PATCH 02/11] add reame and new method --- containers/mcp-go-server/Dockerfile | 11 +-- containers/mcp-go-server/README.md | 103 ++++++++++++++++++++++++++++ containers/mcp-go-server/main.go | 22 ++++++ 3 files changed, 127 insertions(+), 9 deletions(-) create mode 100644 containers/mcp-go-server/README.md diff --git a/containers/mcp-go-server/Dockerfile b/containers/mcp-go-server/Dockerfile index 3fff867..ad5d8f9 100644 --- a/containers/mcp-go-server/Dockerfile +++ b/containers/mcp-go-server/Dockerfile @@ -1,22 +1,15 @@ # syntax=docker/dockerfile:1 # Step 1: Build binary using modern Go toolchain -FROM golang:1.24-alpine AS builder +FROM golang:1.27-alpine AS builder WORKDIR /app COPY go.mod ./ -# BuildKit cache for module downloads -RUN --mount=type=cache,target=/go/pkg/mod \ - go mod download - COPY main.go . -# Build with BuildKit compiler cache + binary size optimizations -RUN --mount=type=cache,target=/go/pkg/mod \ - --mount=type=cache,target=/root/.cache/go-build \ - CGO_ENABLED=0 GOOS=linux go build \ +RUN CGO_ENABLED=0 GOOS=linux go build \ -trimpath \ -ldflags="-w -s" \ -o mcp-server main.go diff --git a/containers/mcp-go-server/README.md b/containers/mcp-go-server/README.md new file mode 100644 index 0000000..6b3a156 --- /dev/null +++ b/containers/mcp-go-server/README.md @@ -0,0 +1,103 @@ +# Deploying Remote MCP Servers on Scaleway Serverless Containers + +A lightweight guide on how and why to run Model Context Protocol (MCP) servers on Scaleway Serverless Containers, enabling AI agents (like OpenCode, Claude, and Cursor) to securely invoke tools over HTTP. + +--- + +## 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 or GCP Cloud Run 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). +* **Isolated Routing:** Production implementations use isolated routers (like Go's `http.NewServeMux`) rather than default global routers to prevent route hijacking and allow clean middleware integration. + + +--- + +## Deployment Steps + +### 1. Build and Push Container Image + +Containerize your MCP server binary and push it to the Scaleway Container Registry: + +```bash +# Login to Scaleway Container Registry +docker login rg.fr-par.scw.cloud -u nologin -p $SCW_SECRET_KEY + +# Build and push your lightweight container image +docker build -t rg.fr-par.scw.cloud//:v1 . +docker push rg.fr-par.scw.cloud//:v1 + +``` + +### 2. Deploy to Scaleway Serverless + +Deploy the container using the Scaleway CLI or console. For production, set privacy to `private` to enforce IAM token authentication: + +```bash +# Create the container deployment +scw container container create \ + name=remote-mcp-server \ + namespace-id= \ + image=rg.fr-par.scw.cloud//:v1 \ + port=8080 \ + min-scale=0 \ + max-scale=5 \ + privacy=private + +# Deploy the instance +scw container container deploy + +``` + +--- + +## 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://..functions.fnc.fr-par.scw.cloud/mcp", + "headers": { + "X-Auth-Token": "YOUR_SCALEWAY_IAM_SECRET_KEY" + } + } + } +} + +``` + +--- + +## 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. diff --git a/containers/mcp-go-server/main.go b/containers/mcp-go-server/main.go index 62f7196..2db2a3f 100644 --- a/containers/mcp-go-server/main.go +++ b/containers/mcp-go-server/main.go @@ -106,6 +106,14 @@ func handleMCP(w http.ResponseWriter, r *http.Request) { "required": []string{"message"}, }, }, + { + "name": "get_app_name", + "description": "Returns the SCW_APPLICATION_NAME environment variable from the container", + "inputSchema": map[string]any{ + "type": "object", + "properties": map[string]any{}, + }, + }, }, } @@ -139,6 +147,20 @@ func handleMCP(w http.ResponseWriter, r *http.Request) { }, }, } + case "get_app_name": + appName := os.Getenv("SCW_APPLICATION_NAME") + if appName == "" { + appName = "[NOT_SET]" + } + + resp.Result = map[string]any{ + "content": []map[string]any{ + { + "type": "text", + "text": fmt.Sprintf("SCW_APPLICATION_NAME: %s", appName), + }, + }, + } default: resp.Error = map[string]any{"code": -32601, "message": "Tool not found"} } From 577d70c5ff352c9eff8462b7c493dc9fe0297f24 Mon Sep 17 00:00:00 2001 From: Thomas Tacquet Date: Fri, 21 Aug 2026 16:39:46 +0200 Subject: [PATCH 03/11] format --- containers/mcp-go-server/.env.example | 9 ++ containers/mcp-go-server/.gitignore | 16 +++ containers/mcp-go-server/README.md | 131 +++++++++++++------ containers/mcp-go-server/deploy.sh | 180 ++++++++++++++++++++++++++ containers/mcp-go-server/main.tf | 161 +++++++++++++++++++++++ 5 files changed, 460 insertions(+), 37 deletions(-) create mode 100644 containers/mcp-go-server/.env.example create mode 100644 containers/mcp-go-server/.gitignore create mode 100755 containers/mcp-go-server/deploy.sh create mode 100644 containers/mcp-go-server/main.tf diff --git a/containers/mcp-go-server/.env.example b/containers/mcp-go-server/.env.example new file mode 100644 index 0000000..e4b184c --- /dev/null +++ b/containers/mcp-go-server/.env.example @@ -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 diff --git a/containers/mcp-go-server/.gitignore b/containers/mcp-go-server/.gitignore new file mode 100644 index 0000000..3a95676 --- /dev/null +++ b/containers/mcp-go-server/.gitignore @@ -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 diff --git a/containers/mcp-go-server/README.md b/containers/mcp-go-server/README.md index 6b3a156..651ba82 100644 --- a/containers/mcp-go-server/README.md +++ b/containers/mcp-go-server/README.md @@ -1,12 +1,20 @@ # Deploying Remote MCP Servers on Scaleway Serverless Containers -A lightweight guide on how and why to run Model Context Protocol (MCP) servers on Scaleway Serverless Containers, enabling AI agents (like OpenCode, Claude, and Cursor) to securely invoke tools over HTTP. +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 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 or GCP Cloud Run offers key operational advantages: +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 | | :--- | :--- | @@ -15,14 +23,12 @@ The Model Context Protocol (MCP) standardizes how LLMs interact with external to | **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? +### 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. +- **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. --- @@ -30,51 +36,82 @@ The Model Context Protocol (MCP) standardizes how LLMs interact with external to 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). -* **Isolated Routing:** Production implementations use isolated routers (like Go's `http.NewServeMux`) rather than default global routers to prevent route hijacking and allow clean middleware integration. +- **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). +- **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_APPLICATION_NAME` | No | Value returned by the `get_app_name` tool. Defaults to `scaleway-mcp-go-server` in Terraform, or `[NOT_SET]` at runtime. | +| `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 Steps +## Deployment -### 1. Build and Push Container Image +This example offers two deployment methods. **Choose one.** -Containerize your MCP server binary and push it to the Scaleway Container Registry: +### Option 1: Terraform + +The `main.tf` file provisions the registry namespace, builds and pushes the Docker image, and creates/deployes the container. ```bash -# Login to Scaleway Container Registry -docker login rg.fr-par.scw.cloud -u nologin -p $SCW_SECRET_KEY +# 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 +``` -# Build and push your lightweight container image -docker build -t rg.fr-par.scw.cloud//:v1 . -docker push rg.fr-par.scw.cloud//:v1 +When prompted, type `yes` to confirm. The apply output will print the container URL, the MCP endpoint, and the health endpoint. +To override defaults (e.g. application name or scaling): + +```bash +terraform apply -var="application_name=my-app" -var="max_scale=3" ``` -### 2. Deploy to Scaleway Serverless +### Option 2: Scaleway CLI (`deploy.sh`) -Deploy the container using the Scaleway CLI or console. For production, set privacy to `private` to enforce IAM token authentication: +The `deploy.sh` script builds the Docker image, pushes it, and creates/deployes the container using only `scw` commands. ```bash -# Create the container deployment -scw container container create \ - name=remote-mcp-server \ - namespace-id= \ - image=rg.fr-par.scw.cloud//:v1 \ - port=8080 \ - min-scale=0 \ - max-scale=5 \ - privacy=private - -# Deploy the instance -scw container container deploy +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) +## 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: @@ -91,13 +128,33 @@ Add the deployed remote endpoint to your client configuration (such as `opencode } } } +``` + +--- +## 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 +scw container namespace delete region=fr-par +# Optionally, remove the pushed image from the registry: +scw registry namespace delete region=fr-par ``` --- ## 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. +- **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. diff --git a/containers/mcp-go-server/deploy.sh b/containers/mcp-go-server/deploy.sh new file mode 100755 index 0000000..cc03c69 --- /dev/null +++ b/containers/mcp-go-server/deploy.sh @@ -0,0 +1,180 @@ +#!/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 + +# --- 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}" \ + -o json | jq -r '.id')" +else + echo "Reusing existing namespace '${NAMESPACE_NAME}' (${NAMESPACE_ID})." +fi + +echo "Waiting for namespace to become ready..." +for _ in $(seq 1 30); do + STATUS="$(scw container namespace get \ + region="${REGION}" \ + "${NAMESPACE_ID}" \ + -o json | jq -r '.status')" + [[ "${STATUS}" == "ready" ]] && break + sleep 2 +done + +if [[ "${STATUS}" != "ready" ]]; then + echo "Error: namespace did not become ready (status: ${STATUS})." + exit 1 +fi + +# --- Registry namespace (for listing images) --------------------------------- +echo "Ensuring a registry namespace exists for image listing..." +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 + scw registry namespace create \ + region="${REGION}" \ + name="${REGISTRY_NAMESPACE}" \ + -o json >/dev/null || true # Ignore error if it already exists via push +fi + +# --- Container --------------------------------------------------------------- +CONTAINER_ID="$(scw container container list \ + region="${REGION}" \ + namespace-id="${NAMESPACE_ID}" \ + name="${CONTAINER_NAME}" \ + -o json | jq -r '.[0].id // empty')" + +if [[ -z "${CONTAINER_ID}" ]]; then + 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}" \ + -o json | jq -r '.id')" +else + echo "Updating container '${CONTAINER_NAME}' (${CONTAINER_ID})..." + scw container container update \ + region="${REGION}" \ + "${CONTAINER_ID}" \ + image="${FULL_IMAGE}" \ + port="${PORT}" \ + min-scale="${MIN_SCALE}" \ + max-scale="${MAX_SCALE}" \ + privacy="${PRIVACY}" >/dev/null +fi + +# --- Deploy ------------------------------------------------------------------ +echo "Deploying container..." +scw container container deploy \ + region="${REGION}" \ + "${CONTAINER_ID}" >/dev/null + +echo "Waiting for deployment to complete..." +for i in $(seq 1 60); do + STATUS="$(scw container container get \ + region="${REGION}" \ + "${CONTAINER_ID}" \ + -o json | jq -r '.status')" + echo " status: ${STATUS}" + case "${STATUS}" in + ready) echo "Deployment successful!"; break ;; + error) echo "Error: deployment failed."; exit 1 ;; + *) sleep 5 ;; + esac +done + +# --- Output ------------------------------------------------------------------ +DOMAIN="$(scw container container get \ + region="${REGION}" \ + "${CONTAINER_ID}" \ + -o json | jq -r '.domain_name')" + +echo "" +echo "Container URL: https://${DOMAIN}" +echo "MCP endpoint: https://${DOMAIN}/mcp" +echo "Health endpoint: https://${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}" +# ============================================================================= diff --git a/containers/mcp-go-server/main.tf b/containers/mcp-go-server/main.tf new file mode 100644 index 0000000..264e0ba --- /dev/null +++ b/containers/mcp-go-server/main.tf @@ -0,0 +1,161 @@ +############################################################################### +# Terraform configuration for the mcp-go-server Serverless Container example. +# +# Deploys a lightweight Go-based MCP (Model Context Protocol) server on Scaleway +# Serverless Containers. The Docker image is built locally and pushed to the +# Scaleway Container Registry, then referenced by the container. +############################################################################### + +terraform { + required_version = ">= 1.5" + + required_providers { + scaleway = { + source = "scaleway/scaleway" + version = "~> 2.0" + } + random = { + source = "hashicorp/random" + version = "~> 3.0" + } + } +} + +provider "scaleway" { + zone = "fr-par-1" + region = "fr-par" + access_key = var.scw_access_key + secret_key = var.scw_secret_key + project_id = var.project_id +} + +variable "scw_access_key" { + description = "Scaleway IAM access key. Read from SCW_ACCESS_KEY env var if unset." + type = string + default = null + sensitive = true +} + +variable "scw_secret_key" { + description = "Scaleway IAM secret key. Read from SCW_SECRET_KEY env var if unset." + type = string + default = null + sensitive = true +} + +variable "project_id" { + description = "Scaleway project ID. Uses the default project if null." + type = string + default = null +} + +variable "application_name" { + description = "Value exposed by the get_app_name MCP tool (SCW_APPLICATION_NAME env var)." + type = string + default = "scaleway-mcp-go-server" +} + +variable "container_port" { + description = "Port the MCP server listens on (matches Dockerfile EXPOSE)." + type = number + default = 8080 +} + +variable "min_scale" { + description = "Minimum number of container instances (0 = scale to zero)." + type = number + default = 0 +} + +variable "max_scale" { + description = "Maximum number of container instances." + type = number + default = 5 +} + +locals { + name_prefix = "mcp-go-server" + tags = ["serverless-examples", "mcp", "go", "terraform"] + image_name = "mcp-server" + image_tag = "latest" +} + +# Scaleway registry namespace names are unique per-region, +# so we add a short random suffix to avoid name collisions. +resource "random_string" "registry_suffix" { + length = 4 + lower = true + special = false +} + +resource "scaleway_registry_namespace" "main" { + name = "${local.name_prefix}-registry-${random_string.registry_suffix.result}" + project_id = var.project_id +} + +resource "scaleway_container_namespace" "main" { + name = "${local.name_prefix}-ns" + description = "Namespace for the MCP Go server example" + project_id = var.project_id + tags = local.tags +} + +# Build and push the Docker image to the Scaleway Container Registry. +# Note: in a production CI/CD pipeline, you would typically build and push the +# image outside of Terraform. This null_resource keeps the example self-contained. +resource "null_resource" "build_and_push" { + depends_on = [scaleway_registry_namespace.main] + + triggers = { + image_tag = timestamp() # Force rebuild on every apply + } + + provisioner "local-exec" { + command = <<-EOT + set -euo pipefail + docker login "${scaleway_registry_namespace.main.endpoint}" -u nologin -p "${var.scw_secret_key}" + docker build -t "${scaleway_registry_namespace.main.endpoint}/${local.image_name}:${local.image_tag}" . + docker push "${scaleway_registry_namespace.main.endpoint}/${local.image_name}:${local.image_tag}" + EOT + } +} + +resource "scaleway_container" "main" { + depends_on = [null_resource.build_and_push] + + name = local.name_prefix + namespace_id = scaleway_container_namespace.main.id + registry_image = "${scaleway_registry_namespace.main.endpoint}/${local.image_name}:${local.image_tag}" + port = var.container_port + tags = local.tags + + cpu_limit = 1000 + memory_limit_bytes = 1024 * 1024 * 1024 # 1 GiB + min_scale = var.min_scale + max_scale = var.max_scale + + # privacy = "private" enforces IAM token authentication via the API Gateway, + # so only callers with a valid X-Auth-Token header reach the container. + privacy = "private" + protocol = "http1" + + environment_variables = { + PORT = tostring(var.container_port) + SCW_APPLICATION_NAME = var.application_name + } +} + +output "container_domain" { + description = "Public HTTPS endpoint of the deployed MCP server." + value = "https://${scaleway_container.main.public_endpoint}" +} + +output "mcp_endpoint" { + description = "Full URL of the /mcp JSON-RPC endpoint." + value = "https://${scaleway_container.main.public_endpoint}/mcp" +} + +output "health_endpoint" { + description = "Health check endpoint." + value = "https://${scaleway_container.main.public_endpoint}/health" +} From 268de49fa0baa2b7b1c69066831518e04de540a3 Mon Sep 17 00:00:00 2001 From: Thomas Tacquet Date: Fri, 21 Aug 2026 16:41:08 +0200 Subject: [PATCH 04/11] update readme --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index a358b4c..09b25f4 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,7 @@ Table of Contents: | **[Terraform NGINX hello world](containers/terraform-nginx-hello-world/README.md)**
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)**
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)**
A Go gRPC Container using http2 | Go/Protobuf | [CLI] | +| **[Remote MCP Server](containers/mcp-go-server/README.md)**
A Go-based Model Context Protocol server exposing tools over HTTP on Serverless Containers. | Go | [Terraform]-[CLI] | | **[Rust hello world](containers/rust-hello-world)**
A simple Rust hello world Container | Rust | [CLI] | | **[.NET C#](containers/csharp-hello-world)**
A .NET C# Container hello world | C# .NET | [CLI] | | **[Ruby Hello World](containers/ruby-hello-world/)**
A simple Ruby Hello world Container | Ruby | [CLI] | From 352659e780982d35b49cb7c4298767515879565a Mon Sep 17 00:00:00 2001 From: Thomas Tacquet Date: Fri, 21 Aug 2026 16:55:32 +0200 Subject: [PATCH 05/11] update mcp protocol version --- containers/mcp-go-server/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/containers/mcp-go-server/main.go b/containers/mcp-go-server/main.go index 2db2a3f..0ab70a7 100644 --- a/containers/mcp-go-server/main.go +++ b/containers/mcp-go-server/main.go @@ -71,7 +71,7 @@ func handleMCP(w http.ResponseWriter, r *http.Request) { switch req.Method { case "initialize": resp.Result = map[string]any{ - "protocolVersion": "2024-11-05", + "protocolVersion": "2026-07-28", "capabilities": map[string]any{ "tools": map[string]any{}, }, From da6fee7cad8238de47d25192eb39826aedbe9197 Mon Sep 17 00:00:00 2001 From: Thomas TACQUET Date: Fri, 28 Aug 2026 09:52:14 +0200 Subject: [PATCH 06/11] Apply batched suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Andy Méry --- containers/mcp-go-server/Dockerfile | 2 +- containers/mcp-go-server/main.tf | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/containers/mcp-go-server/Dockerfile b/containers/mcp-go-server/Dockerfile index ad5d8f9..459bcae 100644 --- a/containers/mcp-go-server/Dockerfile +++ b/containers/mcp-go-server/Dockerfile @@ -15,7 +15,7 @@ RUN CGO_ENABLED=0 GOOS=linux go build \ -o mcp-server main.go # Step 2: Minimal non-root distroless runtime (~5MB final image) -FROM gcr.io/distroless/static-debian12:nonroot +FROM gcr.io/distroless/static-debian13:nonroot WORKDIR / diff --git a/containers/mcp-go-server/main.tf b/containers/mcp-go-server/main.tf index 264e0ba..4bf6a58 100644 --- a/containers/mcp-go-server/main.tf +++ b/containers/mcp-go-server/main.tf @@ -22,7 +22,6 @@ terraform { } provider "scaleway" { - zone = "fr-par-1" region = "fr-par" access_key = var.scw_access_key secret_key = var.scw_secret_key From 8e8dfbb363d2194d1d5e54ef68470021fbe92f3c Mon Sep 17 00:00:00 2001 From: Thomas Tacquet Date: Fri, 28 Aug 2026 11:50:14 +0200 Subject: [PATCH 07/11] simplify and fix script deploy --- containers/mcp-go-server/deploy.sh | 103 ++++++++--------------------- 1 file changed, 27 insertions(+), 76 deletions(-) diff --git a/containers/mcp-go-server/deploy.sh b/containers/mcp-go-server/deploy.sh index cc03c69..d90ca85 100755 --- a/containers/mcp-go-server/deploy.sh +++ b/containers/mcp-go-server/deploy.sh @@ -50,6 +50,22 @@ REGISTRY_NAMESPACE="mcp-go-server-$(date +%s | tail -c 5)" 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}" @@ -70,96 +86,31 @@ if [[ -z "${NAMESPACE_ID}" ]]; then 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 -echo "Waiting for namespace to become ready..." -for _ in $(seq 1 30); do - STATUS="$(scw container namespace get \ - region="${REGION}" \ - "${NAMESPACE_ID}" \ - -o json | jq -r '.status')" - [[ "${STATUS}" == "ready" ]] && break - sleep 2 -done - -if [[ "${STATUS}" != "ready" ]]; then - echo "Error: namespace did not become ready (status: ${STATUS})." - exit 1 -fi - -# --- Registry namespace (for listing images) --------------------------------- -echo "Ensuring a registry namespace exists for image listing..." -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 - scw registry namespace create \ - region="${REGION}" \ - name="${REGISTRY_NAMESPACE}" \ - -o json >/dev/null || true # Ignore error if it already exists via push -fi - # --- Container --------------------------------------------------------------- -CONTAINER_ID="$(scw container container list \ +echo "Creating container '${CONTAINER_NAME}'..." +CONTAINER_ID="$(scw container container create \ region="${REGION}" \ namespace-id="${NAMESPACE_ID}" \ name="${CONTAINER_NAME}" \ - -o json | jq -r '.[0].id // empty')" - -if [[ -z "${CONTAINER_ID}" ]]; then - 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}" \ - -o json | jq -r '.id')" -else - echo "Updating container '${CONTAINER_NAME}' (${CONTAINER_ID})..." - scw container container update \ - region="${REGION}" \ - "${CONTAINER_ID}" \ - image="${FULL_IMAGE}" \ - port="${PORT}" \ - min-scale="${MIN_SCALE}" \ - max-scale="${MAX_SCALE}" \ - privacy="${PRIVACY}" >/dev/null -fi - -# --- Deploy ------------------------------------------------------------------ -echo "Deploying container..." -scw container container deploy \ - region="${REGION}" \ - "${CONTAINER_ID}" >/dev/null - -echo "Waiting for deployment to complete..." -for i in $(seq 1 60); do - STATUS="$(scw container container get \ - region="${REGION}" \ - "${CONTAINER_ID}" \ - -o json | jq -r '.status')" - echo " status: ${STATUS}" - case "${STATUS}" in - ready) echo "Deployment successful!"; break ;; - error) echo "Error: deployment failed."; exit 1 ;; - *) sleep 5 ;; - esac -done + 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 '.domain_name')" + -o json | jq -r '.public_endpoint')" echo "" echo "Container URL: https://${DOMAIN}" From fc4dc677d343dc3804ba9d2ef81394d7dc6425ce Mon Sep 17 00:00:00 2001 From: Thomas Tacquet Date: Fri, 28 Aug 2026 14:49:12 +0200 Subject: [PATCH 08/11] use mcp go sdk instead --- containers/mcp-go-server/Dockerfile | 4 +- containers/mcp-go-server/README.md | 5 +- containers/mcp-go-server/go.mod | 11 ++ containers/mcp-go-server/main.go | 205 ++++++++-------------------- 4 files changed, 77 insertions(+), 148 deletions(-) diff --git a/containers/mcp-go-server/Dockerfile b/containers/mcp-go-server/Dockerfile index 459bcae..67a8ec0 100644 --- a/containers/mcp-go-server/Dockerfile +++ b/containers/mcp-go-server/Dockerfile @@ -5,7 +5,9 @@ FROM golang:1.27-alpine AS builder WORKDIR /app -COPY go.mod ./ +COPY go.mod go.sum ./ + +RUN go mod download COPY main.go . diff --git a/containers/mcp-go-server/README.md b/containers/mcp-go-server/README.md index 651ba82..4ec87df 100644 --- a/containers/mcp-go-server/README.md +++ b/containers/mcp-go-server/README.md @@ -2,6 +2,8 @@ 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 | @@ -36,7 +38,7 @@ The Model Context Protocol (MCP) standardizes how LLMs interact with external to 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). +- **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. @@ -122,6 +124,7 @@ Add the deployed remote endpoint to your client configuration (such as `opencode "scaleway-tools": { "type": "remote", "url": "https://..functions.fnc.fr-par.scw.cloud/mcp", + "oauth": false, "headers": { "X-Auth-Token": "YOUR_SCALEWAY_IAM_SECRET_KEY" } diff --git a/containers/mcp-go-server/go.mod b/containers/mcp-go-server/go.mod index 1a81c08..79e4590 100644 --- a/containers/mcp-go-server/go.mod +++ b/containers/mcp-go-server/go.mod @@ -1,3 +1,14 @@ module github.com/scaleway/serverless-examples/containers/mcp-go-server go 1.27.0 + +require github.com/modelcontextprotocol/go-sdk v1.6.1 + +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/sys v0.41.0 // indirect +) diff --git a/containers/mcp-go-server/main.go b/containers/mcp-go-server/main.go index 0ab70a7..c59ee63 100644 --- a/containers/mcp-go-server/main.go +++ b/containers/mcp-go-server/main.go @@ -1,41 +1,79 @@ package main import ( - "cmp" - "encoding/json" + "context" "fmt" "log/slog" "net/http" "os" "time" -) -type JSONRPCRequest struct { - JSONRPC string `json:"jsonrpc"` - ID any `json:"id,omitempty"` - Method string `json:"method"` - Params json.RawMessage `json:"params,omitempty"` -} + "github.com/modelcontextprotocol/go-sdk/mcp" +) -type JSONRPCResponse struct { - JSONRPC string `json:"jsonrpc"` - ID any `json:"id,omitempty"` - Result any `json:"result,omitempty"` - Error any `json:"error,omitempty"` +type echoArgs struct { + Message string `json:"message" jsonschema:"The string to echo back"` } func main() { - // cmp.Or eliminates verbose env var fallback checks - port := cmp.Or(os.Getenv("PORT"), "8080") + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + + server := mcp.NewServer(&mcp.Implementation{ + Name: "scaleway-test-mcp", + Version: "1.1.0", + }, nil) + + mcp.AddTool(server, &mcp.Tool{ + Name: "get_server_time", + Description: "Returns current UTC time from the Scaleway container", + }, func(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{ + Text: fmt.Sprintf("Scaleway Container UTC Time: %s", time.Now().UTC().Format(time.RFC3339)), + }}, + }, nil, nil + }) + + mcp.AddTool(server, &mcp.Tool{ + Name: "echo", + Description: "Echoes back the input message", + }, func(ctx context.Context, req *mcp.CallToolRequest, in echoArgs) (*mcp.CallToolResult, any, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{ + Text: "Echo from Scaleway: " + in.Message, + }}, + }, nil, nil + }) + + mcp.AddTool(server, &mcp.Tool{ + Name: "get_app_name", + Description: "Returns the SCW_APPLICATION_NAME environment variable from the container", + }, func(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) { + appName := os.Getenv("SCW_APPLICATION_NAME") + if appName == "" { + appName = "[NOT_SET]" + } + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{ + Text: "SCW_APPLICATION_NAME: " + appName, + }}, + }, nil, nil + }) mux := http.NewServeMux() - // Native method routing in Go 1.22+ mux.HandleFunc("GET /health", handleHealth) - mux.HandleFunc("OPTIONS /mcp", handleCORS) - mux.HandleFunc("POST /mcp", handleMCP) - - slog.Info("Starting modern MCP server", "port", port) + mux.Handle("/mcp", mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server { + return server + }, &mcp.StreamableHTTPOptions{ + Stateless: true, + DisableLocalhostProtection: true, // auth is enforced upstream by the Scaleway platform + })) + + slog.Info("Starting MCP server", "port", port) if err := http.ListenAndServe(":"+port, mux); err != nil { slog.Error("Server crashed", "error", err) os.Exit(1) @@ -46,128 +84,3 @@ func handleHealth(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("OK")) } - -func handleCORS(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Access-Control-Allow-Origin", "*") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type") - w.WriteHeader(http.StatusOK) -} - -func handleMCP(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Access-Control-Allow-Origin", "*") - w.Header().Set("Content-Type", "application/json") - - var req JSONRPCRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid JSON", http.StatusBadRequest) - return - } - - resp := JSONRPCResponse{ - JSONRPC: "2.0", - ID: req.ID, - } - - switch req.Method { - case "initialize": - resp.Result = map[string]any{ - "protocolVersion": "2026-07-28", - "capabilities": map[string]any{ - "tools": map[string]any{}, - }, - "serverInfo": map[string]any{ - "name": "scaleway-test-mcp", - "version": "1.1.0", - }, - } - - case "tools/list": - resp.Result = map[string]any{ - "tools": []map[string]any{ - { - "name": "get_server_time", - "description": "Returns current UTC time from the Scaleway container", - "inputSchema": map[string]any{ - "type": "object", - "properties": map[string]any{}, - }, - }, - { - "name": "echo", - "description": "Echoes back the input message", - "inputSchema": map[string]any{ - "type": "object", - "properties": map[string]any{ - "message": map[string]any{ - "type": "string", - "description": "The string to echo back", - }, - }, - "required": []string{"message"}, - }, - }, - { - "name": "get_app_name", - "description": "Returns the SCW_APPLICATION_NAME environment variable from the container", - "inputSchema": map[string]any{ - "type": "object", - "properties": map[string]any{}, - }, - }, - }, - } - - case "tools/call": - var params struct { - Name string `json:"name"` - Arguments map[string]any `json:"arguments"` - } - if err := json.Unmarshal(req.Params, ¶ms); err != nil { - resp.Error = map[string]any{"code": -32602, "message": "Invalid params"} - break - } - - switch params.Name { - case "get_server_time": - resp.Result = map[string]any{ - "content": []map[string]any{ - { - "type": "text", - "text": fmt.Sprintf("Scaleway Container UTC Time: %s", time.Now().UTC().Format(time.RFC3339)), - }, - }, - } - case "echo": - msg, _ := params.Arguments["message"].(string) - resp.Result = map[string]any{ - "content": []map[string]any{ - { - "type": "text", - "text": fmt.Sprintf("Echo from Scaleway: %s", msg), - }, - }, - } - case "get_app_name": - appName := os.Getenv("SCW_APPLICATION_NAME") - if appName == "" { - appName = "[NOT_SET]" - } - - resp.Result = map[string]any{ - "content": []map[string]any{ - { - "type": "text", - "text": fmt.Sprintf("SCW_APPLICATION_NAME: %s", appName), - }, - }, - } - default: - resp.Error = map[string]any{"code": -32601, "message": "Tool not found"} - } - - default: - resp.Result = map[string]any{} - } - - _ = json.NewEncoder(w).Encode(resp) -} From 71e79f0265031e716b223a33466747aa10432b22 Mon Sep 17 00:00:00 2001 From: Thomas Tacquet Date: Fri, 28 Aug 2026 15:01:14 +0200 Subject: [PATCH 09/11] upgrade mcp tool --- containers/mcp-go-server/go.mod | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/containers/mcp-go-server/go.mod b/containers/mcp-go-server/go.mod index 79e4590..178f3ab 100644 --- a/containers/mcp-go-server/go.mod +++ b/containers/mcp-go-server/go.mod @@ -2,7 +2,7 @@ module github.com/scaleway/serverless-examples/containers/mcp-go-server go 1.27.0 -require github.com/modelcontextprotocol/go-sdk v1.6.1 +require github.com/modelcontextprotocol/go-sdk v1.7.0 require ( github.com/google/jsonschema-go v0.4.3 // indirect @@ -10,5 +10,7 @@ require ( 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 ) From 74d3a0a09aadbad531b95f5e9f721e2f1964480b Mon Sep 17 00:00:00 2001 From: Thomas Tacquet Date: Fri, 28 Aug 2026 15:19:54 +0200 Subject: [PATCH 10/11] golangci lint --- containers/mcp-go-server/main.go | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/containers/mcp-go-server/main.go b/containers/mcp-go-server/main.go index c59ee63..fef933c 100644 --- a/containers/mcp-go-server/main.go +++ b/containers/mcp-go-server/main.go @@ -2,7 +2,6 @@ package main import ( "context" - "fmt" "log/slog" "net/http" "os" @@ -29,10 +28,10 @@ func main() { mcp.AddTool(server, &mcp.Tool{ Name: "get_server_time", Description: "Returns current UTC time from the Scaleway container", - }, func(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) { + }, func(_ context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) { return &mcp.CallToolResult{ Content: []mcp.Content{&mcp.TextContent{ - Text: fmt.Sprintf("Scaleway Container UTC Time: %s", time.Now().UTC().Format(time.RFC3339)), + Text: "Scaleway Container UTC Time: " + time.Now().UTC().Format(time.RFC3339), }}, }, nil, nil }) @@ -40,7 +39,7 @@ func main() { mcp.AddTool(server, &mcp.Tool{ Name: "echo", Description: "Echoes back the input message", - }, func(ctx context.Context, req *mcp.CallToolRequest, in echoArgs) (*mcp.CallToolResult, any, error) { + }, func(_ context.Context, req *mcp.CallToolRequest, in echoArgs) (*mcp.CallToolResult, any, error) { return &mcp.CallToolResult{ Content: []mcp.Content{&mcp.TextContent{ Text: "Echo from Scaleway: " + in.Message, @@ -51,11 +50,12 @@ func main() { mcp.AddTool(server, &mcp.Tool{ Name: "get_app_name", Description: "Returns the SCW_APPLICATION_NAME environment variable from the container", - }, func(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) { + }, func(_ context.Context, _ *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) { appName := os.Getenv("SCW_APPLICATION_NAME") if appName == "" { appName = "[NOT_SET]" } + return &mcp.CallToolResult{ Content: []mcp.Content{&mcp.TextContent{ Text: "SCW_APPLICATION_NAME: " + appName, @@ -66,7 +66,7 @@ func main() { mux := http.NewServeMux() mux.HandleFunc("GET /health", handleHealth) - mux.Handle("/mcp", mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server { + mux.Handle("/mcp", mcp.NewStreamableHTTPHandler(func(_ *http.Request) *mcp.Server { return server }, &mcp.StreamableHTTPOptions{ Stateless: true, @@ -74,13 +74,21 @@ func main() { })) slog.Info("Starting MCP server", "port", port) - if err := http.ListenAndServe(":"+port, mux); err != nil { + + httpServer := &http.Server{ + Addr: ":" + port, + ReadHeaderTimeout: 10 * time.Second, + Handler: mux, + } + + err := httpServer.ListenAndServe() + if err != nil { slog.Error("Server crashed", "error", err) os.Exit(1) } } -func handleHealth(w http.ResponseWriter, r *http.Request) { +func handleHealth(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("OK")) } From 79f16406504a3013d44acf0aeea235de384e5ea7 Mon Sep 17 00:00:00 2001 From: Thomas Tacquet Date: Fri, 28 Aug 2026 16:01:17 +0200 Subject: [PATCH 11/11] fix terrafrom & doc --- containers/mcp-go-server/README.md | 8 --- containers/mcp-go-server/deploy.sh | 6 +- containers/mcp-go-server/go.sum | 24 ++++++++ containers/mcp-go-server/main.tf | 96 ++++++++---------------------- 4 files changed, 52 insertions(+), 82 deletions(-) create mode 100644 containers/mcp-go-server/go.sum diff --git a/containers/mcp-go-server/README.md b/containers/mcp-go-server/README.md index 4ec87df..3355dee 100644 --- a/containers/mcp-go-server/README.md +++ b/containers/mcp-go-server/README.md @@ -63,7 +63,6 @@ Copy `.env.example` to `.env` and fill in your values: | :--- | :--- | :--- | | `SCW_ACCESS_KEY` | Yes | Scaleway IAM access key. | | `SCW_SECRET_KEY` | Yes | Scaleway IAM secret key. | -| `SCW_APPLICATION_NAME` | No | Value returned by the `get_app_name` tool. Defaults to `scaleway-mcp-go-server` in Terraform, or `[NOT_SET]` at runtime. | | `SCW_DEFAULT_PROJECT_ID` | No | Target project ID. Uses your default project if omitted. | ```bash @@ -92,12 +91,6 @@ terraform apply When prompted, type `yes` to confirm. The apply output will print the container URL, the MCP endpoint, and the health endpoint. -To override defaults (e.g. application name or scaling): - -```bash -terraform apply -var="application_name=my-app" -var="max_scale=3" -``` - ### 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. @@ -124,7 +117,6 @@ Add the deployed remote endpoint to your client configuration (such as `opencode "scaleway-tools": { "type": "remote", "url": "https://..functions.fnc.fr-par.scw.cloud/mcp", - "oauth": false, "headers": { "X-Auth-Token": "YOUR_SCALEWAY_IAM_SECRET_KEY" } diff --git a/containers/mcp-go-server/deploy.sh b/containers/mcp-go-server/deploy.sh index d90ca85..bc22872 100755 --- a/containers/mcp-go-server/deploy.sh +++ b/containers/mcp-go-server/deploy.sh @@ -113,9 +113,9 @@ DOMAIN="$(scw container container get \ -o json | jq -r '.public_endpoint')" echo "" -echo "Container URL: https://${DOMAIN}" -echo "MCP endpoint: https://${DOMAIN}/mcp" -echo "Health endpoint: https://${DOMAIN}/health" +echo "Container URL: ${DOMAIN}" +echo "MCP endpoint: ${DOMAIN}/mcp" +echo "Health endpoint: ${DOMAIN}/health" # ============================================================================= # Cleanup / Remove diff --git a/containers/mcp-go-server/go.sum b/containers/mcp-go-server/go.sum new file mode 100644 index 0000000..f93aba1 --- /dev/null +++ b/containers/mcp-go-server/go.sum @@ -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= diff --git a/containers/mcp-go-server/main.tf b/containers/mcp-go-server/main.tf index 4bf6a58..ae51686 100644 --- a/containers/mcp-go-server/main.tf +++ b/containers/mcp-go-server/main.tf @@ -21,62 +21,20 @@ terraform { } } +# Credentials (SCW_ACCESS_KEY, SCW_SECRET_KEY) and the target project +# (SCW_DEFAULT_PROJECT_ID) are read from the environment by the provider. provider "scaleway" { - region = "fr-par" - access_key = var.scw_access_key - secret_key = var.scw_secret_key - project_id = var.project_id -} - -variable "scw_access_key" { - description = "Scaleway IAM access key. Read from SCW_ACCESS_KEY env var if unset." - type = string - default = null - sensitive = true -} - -variable "scw_secret_key" { - description = "Scaleway IAM secret key. Read from SCW_SECRET_KEY env var if unset." - type = string - default = null - sensitive = true -} - -variable "project_id" { - description = "Scaleway project ID. Uses the default project if null." - type = string - default = null -} - -variable "application_name" { - description = "Value exposed by the get_app_name MCP tool (SCW_APPLICATION_NAME env var)." - type = string - default = "scaleway-mcp-go-server" -} - -variable "container_port" { - description = "Port the MCP server listens on (matches Dockerfile EXPOSE)." - type = number - default = 8080 -} - -variable "min_scale" { - description = "Minimum number of container instances (0 = scale to zero)." - type = number - default = 0 -} - -variable "max_scale" { - description = "Maximum number of container instances." - type = number - default = 5 + region = "fr-par" } locals { - name_prefix = "mcp-go-server" - tags = ["serverless-examples", "mcp", "go", "terraform"] - image_name = "mcp-server" - image_tag = "latest" + name_prefix = "mcp-go-server" + tags = ["serverless-examples", "mcp", "go", "terraform"] + image_name = "mcp-server" + image_tag = "latest" + container_port = 8080 + min_scale = 0 + max_scale = 5 } # Scaleway registry namespace names are unique per-region, @@ -84,18 +42,18 @@ locals { resource "random_string" "registry_suffix" { length = 4 lower = true + upper = false + numeric = true special = false } resource "scaleway_registry_namespace" "main" { - name = "${local.name_prefix}-registry-${random_string.registry_suffix.result}" - project_id = var.project_id + name = "${local.name_prefix}-registry-${random_string.registry_suffix.result}" } resource "scaleway_container_namespace" "main" { name = "${local.name_prefix}-ns" description = "Namespace for the MCP Go server example" - project_id = var.project_id tags = local.tags } @@ -112,8 +70,10 @@ resource "null_resource" "build_and_push" { provisioner "local-exec" { command = <<-EOT set -euo pipefail - docker login "${scaleway_registry_namespace.main.endpoint}" -u nologin -p "${var.scw_secret_key}" - docker build -t "${scaleway_registry_namespace.main.endpoint}/${local.image_name}:${local.image_tag}" . + # $${...} is escaped so the shell expands SCW_SECRET_KEY from the + # environment at apply time (Terraform otherwise treats it as a reference). + docker login "${scaleway_registry_namespace.main.endpoint}" -u nologin -p "$${SCW_SECRET_KEY}" + docker build --platform linux/amd64 -t "${scaleway_registry_namespace.main.endpoint}/${local.image_name}:${local.image_tag}" . docker push "${scaleway_registry_namespace.main.endpoint}/${local.image_name}:${local.image_tag}" EOT } @@ -125,36 +85,30 @@ resource "scaleway_container" "main" { name = local.name_prefix namespace_id = scaleway_container_namespace.main.id registry_image = "${scaleway_registry_namespace.main.endpoint}/${local.image_name}:${local.image_tag}" - port = var.container_port + port = local.container_port tags = local.tags cpu_limit = 1000 memory_limit_bytes = 1024 * 1024 * 1024 # 1 GiB - min_scale = var.min_scale - max_scale = var.max_scale + min_scale = local.min_scale + max_scale = local.max_scale - # privacy = "private" enforces IAM token authentication via the API Gateway, + # WARN: Set privacy = "private" to enforces IAM token authentication via the API Gateway, # so only callers with a valid X-Auth-Token header reach the container. - privacy = "private" - protocol = "http1" - - environment_variables = { - PORT = tostring(var.container_port) - SCW_APPLICATION_NAME = var.application_name - } + privacy = "public" } output "container_domain" { description = "Public HTTPS endpoint of the deployed MCP server." - value = "https://${scaleway_container.main.public_endpoint}" + value = scaleway_container.main.public_endpoint } output "mcp_endpoint" { description = "Full URL of the /mcp JSON-RPC endpoint." - value = "https://${scaleway_container.main.public_endpoint}/mcp" + value = "${scaleway_container.main.public_endpoint}/mcp" } output "health_endpoint" { description = "Health check endpoint." - value = "https://${scaleway_container.main.public_endpoint}/health" + value = "${scaleway_container.main.public_endpoint}/health" }