Adios
← MCP Server Hosting

Language walkthrough

MCP server in Go

Build one authenticated MCP service, connect it to the Orders API, test a real tool call, and deploy it on Adios.

Go 1.25 · Streamable HTTP · JWT · Adios

Start with a running example

Launch it, then make it yours

Create the Orders API, a PostgreSQL database and your chosen MCP server in Adios. We upload the source, configure separate JWT credentials and start development previews.

Launch to Adios →

No account yet? Create one and return to this example. Select a team and review the resources before launching. The full example needs a paid plan with room for two workspaces and a database.

Build it step by step

Follow the manual guide

Design the API and schema, add JWT authentication, write the MCP tools and run them in your own development workspaces. Each step includes source and commands.

Follow the manual walkthrough →

Use the running example to explore first. OAuth for user sign-in and production deployment are later steps.

Start with the API and database

Build an MCP server in Go that looks up orders through a REST API. You’ll add JWT authentication, test the server in an Adios workspace, and deploy it.

Before you start, you’ll need a running API and its JWT signing key. The main guide provides an example Orders API using Node.js 24 and PostgreSQL, with instructions for starting its development workspace.

The API process listens on port 8081 and MCP on port 8080 inside their workspaces. Clients use the generated HTTPS preview URLs. Keep API_JWT_SECRET and MCP_JWT_SECRET separate.

Create an MCP server in Go

In go/, the official SDK derives schemas from input and output structs. Register the tool with mcp.AddTool, then mount its Streamable HTTP handler behind authentication. The API request uses the tool’s context, a timeout and a bounded response body.

Go module and pinned SDK
go/go.modDownload file
module example.com/orders-mcp

go 1.25.0

require (
	github.com/golang-jwt/jwt/v5 v5.3.1
	github.com/modelcontextprotocol/go-sdk v1.8.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
)
go/main.goDownload file
package main

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"log"
	"net/http"
	"net/url"
	"os"
	"regexp"
	"strings"
	"time"

	"github.com/golang-jwt/jwt/v5"
	"github.com/modelcontextprotocol/go-sdk/mcp"
)

type OrderInput struct {
	OrderID string `json:"order_id" jsonschema:"Order ID, using letters, numbers, or hyphens"`
}
type Order struct {
	ID     string `json:"id"`
	Status string `json:"status"`
}

func envOr(key, fallback string) string {
	if value := os.Getenv(key); value != "" {
		return value
	}
	return fallback
}
func main() {
	apiSecret, mcpSecret := os.Getenv("API_JWT_SECRET"), os.Getenv("MCP_JWT_SECRET")
	if len(apiSecret) < 32 || len(mcpSecret) < 32 || apiSecret == mcpSecret {
		log.Fatal("Use separate API and MCP signing secrets of at least 32 characters")
	}
	apiBase, err := url.Parse(envOr("API_BASE_URL", "http://127.0.0.1:8081"))
	if err != nil || apiBase.Host == "" {
		log.Fatal("Set a valid API_BASE_URL")
	}
	origin, err := url.Parse(envOr("PUBLIC_ORIGIN", "http://127.0.0.1:8080"))
	if err != nil || origin.Host == "" {
		log.Fatal("Set a valid PUBLIC_ORIGIN")
	}
	client := &http.Client{Timeout: 5 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return errors.New("redirects are not allowed") }}
	validID := regexp.MustCompile(`^[a-zA-Z0-9-]{1,64}$`)
	type principalKey struct{}
	createServer := func(subject string) *mcp.Server {
		server := mcp.NewServer(&mcp.Implementation{Name: "orders-mcp", Version: "1.0.0"}, nil)
		mcp.AddTool(server, &mcp.Tool{Name: "get_order", Description: "Read an order's shipping status from the Orders API.", Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true}},
			func(ctx context.Context, _ *mcp.CallToolRequest, input OrderInput) (*mcp.CallToolResult, Order, error) {
				if !validID.MatchString(input.OrderID) {
					return nil, Order{}, errors.New("invalid order ID")
				}
				// Carry the verified caller in a new JWT intended for the API.
				now := time.Now()
				apiToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
					"iss": "orders-demo", "aud": "orders-api", "sub": subject,
					"scope": "orders:read", "iat": now.Unix(), "exp": now.Add(5 * time.Minute).Unix(),
				}).SignedString([]byte(apiSecret))
				if err != nil {
					return nil, Order{}, errors.New("could not authorize API request")
				}
				endpoint := apiBase.ResolveReference(&url.URL{Path: "/orders/" + input.OrderID})
				req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
				if err != nil {
					return nil, Order{}, errors.New("could not prepare API request")
				}
				req.Header.Set("Authorization", "Bearer "+apiToken)
				res, err := client.Do(req)
				if err != nil {
					return nil, Order{}, errors.New("could not read order")
				}
				defer res.Body.Close()
				var order Order
				if res.StatusCode != http.StatusOK || json.NewDecoder(io.LimitReader(res.Body, 65536)).Decode(&order) != nil || order.ID == "" || order.Status == "" {
					return nil, Order{}, errors.New("could not read order; check its ID and API access")
				}
				return nil, order, nil
			})
		return server
	}
	handler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server {
		subject, _ := r.Context().Value(principalKey{}).(string)
		return createServer(subject)
	}, &mcp.StreamableHTTPOptions{Stateless: true, JSONResponse: true})
	mux := http.NewServeMux()
	mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		fmt.Fprint(w, `{"ok":true}`)
	})
	mux.HandleFunc("/mcp", func(w http.ResponseWriter, r *http.Request) {
		if (r.Host != origin.Host && r.Host != "127.0.0.1:8080" && r.Host != "localhost:8080") || (r.Header.Get("Origin") != "" && r.Header.Get("Origin") != origin.Scheme+"://"+origin.Host) {
			http.Error(w, "Invalid host or origin", http.StatusForbidden)
			return
		}
		header := r.Header.Get("Authorization")
		if !strings.HasPrefix(header, "Bearer ") {
			http.Error(w, "Missing access token", http.StatusUnauthorized)
			return
		}
		token, err := jwt.Parse(strings.TrimPrefix(header, "Bearer "), func(*jwt.Token) (any, error) { return []byte(mcpSecret), nil },
			jwt.WithValidMethods([]string{"HS256"}), jwt.WithIssuer("orders-demo"), jwt.WithAudience("orders-mcp"), jwt.WithExpirationRequired())
		if err != nil || !token.Valid {
			http.Error(w, "Invalid or expired access token", http.StatusUnauthorized)
			return
		}
		claims, ok := token.Claims.(jwt.MapClaims)
		subject, subjectErr := claims.GetSubject()
		if !ok || subjectErr != nil || subject == "" {
			http.Error(w, "Missing subject", http.StatusUnauthorized)
			return
		}
		scopes, _ := claims["scope"].(string)
		permitted := false
		for _, scope := range strings.Fields(scopes) {
			if scope == "orders:read" {
				permitted = true
			}
		}
		if !permitted {
			http.Error(w, "orders:read permission required", http.StatusForbidden)
			return
		}
		r.Body = http.MaxBytesReader(w, r.Body, 65536)
		handler.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), principalKey{}, subject)))
	})
	httpServer := &http.Server{Addr: envOr("HOST", "127.0.0.1") + ":" + envOr("PORT", "8080"), Handler: mux, ReadHeaderTimeout: 5 * time.Second, IdleTimeout: 60 * time.Second}
	log.Fatal(httpServer.ListenAndServe())
}

Each request creates a stateless server for the verified caller and rejects invalid IDs before calling the API. readOnlyHint describes the tool to clients; it is a hint, so the API still needs to enforce permissions.

Official reference: MCP Go server SDK.

Test in an Adios workspace

The MCP workspace verifies the caller before calling the shared API.

First start the API workspace from the main guide. In this language’s manifest, set API_BASE_URL to the API preview origin and use the same development team’s signing secrets.

Terminal
adios ws create --name orders-mcp-dev --json
export MCP_WORKSPACE_ID=YOUR_MCP_WORKSPACE_ID
(cd go && adios sync "$MCP_WORKSPACE_ID")
adios ws run start "$MCP_WORKSPACE_ID" --wait --json
adios ws get "$MCP_WORKSPACE_ID"

Copy the generated MCP preview origin into PUBLIC_ORIGIN in adios.yaml. Sync and restart before testing: the host check must match the preview’s actual hostname. Health probes can run before this update.

Terminal
(cd go && adios sync "$MCP_WORKSPACE_ID")
adios ws run restart "$MCP_WORKSPACE_ID" --wait --json
export MCP_URL=https://YOUR-MCP-PREVIEW-HOST/mcp
curl --fail https://YOUR-MCP-PREVIEW-HOST/healthz
curl -i "$MCP_URL"
# Expected: 401 without a JWT.
export MCP_ACCESS_TOKEN="$(node issue-token.mjs)"
python3.13 -m venv .client-venv
.client-venv/bin/pip install -r python/requirements.txt
.client-venv/bin/python check.py
# Expected result: {'id': 'demo-1001', 'status': 'shipped'}
Inspect the client check
check.pyDownload file
import asyncio
import os

from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client


async def main():
    url = os.getenv("MCP_URL", "http://127.0.0.1:8080/mcp")
    headers = {"Authorization": "Bearer " + os.environ["MCP_ACCESS_TOKEN"]}
    async with streamablehttp_client(url, headers=headers) as (read, write, _):
        async with ClientSession(read, write) as session:
            await session.initialize()
            tools = await session.list_tools()
            assert any(tool.name == "get_order" for tool in tools.tools)
            result = await session.call_tool("get_order", {"order_id": "demo-1001"})
            assert not result.isError, result
            assert result.structuredContent == {"id": "demo-1001", "status": "shipped"}, result
            print("MCP initialize, tools/list, and get_order passed:", result.structuredContent)


asyncio.run(main())

Test an expired JWT, a wrong audience, and a missing orders:read scope. A demo-client token must not read other-1002. The API returns 404 for that other tenant’s order; MCP returns a tool error.

Review build and runtime logs in the workspace. After source changes, sync and restart the preview; this guide does not assume automatic hot reload.

Terminal
adios ws run stop "$MCP_WORKSPACE_ID"

Keep JWT verification and plan OAuth separately

This implementation verifies operator-issued JWTs. It does not implement user sign-in, consent, or refresh. Keep issuer, audience, signature, expiry, scope, and tenant checks when adding an OAuth provider.

For a user-facing integration, follow the main guide’s resource metadata, token verification, and client onboarding requirements. The included JavaScript resource-server gate is a separate implementation; it is not a drop-in OAuth switch for this server.

Verify provider tokens in middleware before the MCP handler and carry the principal in the request context. Map issuer and subject to the application’s canonical identity.

Review the shared OAuth architecture

Deploy this MCP server on Adios

After verifying the workspace preview, follow the main guide to deploy the release database and API. Use the release team’s signing secrets and the release API origin for this MCP service.

Deploy the shared database and API
go/adios.yamlDownload file
name: orders-mcp
region: de
replicas: 1
build_cmd: go build -o orders-mcp .
start_cmd: ./orders-mcp
runtime:
  name: go@1.25
  port: 8080
  health_path: /healthz
env:
  HOST: 0.0.0.0
  PORT: "8080"
  PUBLIC_ORIGIN: https://mcp.example.com
  API_BASE_URL: https://api.example.com
  API_JWT_SECRET: secret://ORDERS_API_JWT_SECRET
  MCP_JWT_SECRET: secret://ORDERS_MCP_JWT_SECRET

In this manifest, replace API_BASE_URL with the deployed API’s HTTPS origin and PUBLIC_ORIGIN with the MCP service’s actual origin. Keep the secret references, port 8080, and public health path.

If the default MCP hostname is not known yet, obtain it from the first deployment, update PUBLIC_ORIGIN, and deploy again before connecting a client. The host check must match the actual route.

Deploy the MCP service
(cd go && adios up)
adios apps get orders-mcp

adios up promotes a healthy release. It is a deployment command, not a local preview; verify the selected team and target before running it.

Check the hosted MCP tool
export MCP_ACCESS_TOKEN="$(node issue-token.mjs)"
export MCP_URL=https://YOUR-MCP-HOST/mcp
.client-venv/bin/python check.py

Regenerate the demo token after 15 minutes. Confirm the hosted tool call and permission checks before sharing the endpoint.

Continue the shared walkthrough

Once the hosted tool call succeeds, follow the main guide to connect an AI client, check permissions, inspect runtime logs, and operate the service.

Connect and operate the hosted server →

Explore another implementation