~/blog/create-a-mcp-server-with-go
Published on

Create a MCP Server with Go and OAuth

7115 words36 min read
Authors
  • avatar

Table of Contents

Change Log

  • 2025-11-28: Figured out why Claude Code was complaining about DCR. Post updated to use Claude for the whole tutorial.

Introduction

Recently I wanted to learn more about the MCP protocol, and so decided the best way to do it was to create a MCP Server. I opted to use the official SDK and it was surprisingly easy to get it set-up and working locally.

The MCP Server itself is backed by an API, for which a user has to be authenticated to use. I opted to start simple, and use an environment variable (API_KEY) to pass in a JWT that the MCP Server uses when connecting to the API on behalf of the user:

// ~/.claude.json
{
  // ... rest of config
  "mcpServers": {
    "custommcp": {
      "type": "http",
      "url": "${API_BASE_URL:-http://localhost:8080}",
      "headers": {
        "APIKey": "${API_KEY}"
      }
    },
  }

This worked, but it was a bit finicky and it was difficult to share with others.

I had read that the MCP protocol supported OAuth authorization, and seeing as the initial MCP creation was pretty painless I thought it would be good to dive into "proper authorization" so I didn't need to use headers/environment variables.

Several headaches later and I managed to get it working with 90% cruft, and 10% luck.

In an attempt to prove to myself that I do actually understand what I've built, I decided to build it all again, from scratch, and hopefully in a much cleaner way, documenting the whole journey in a blog (which you are reading right now).

Prerequisites and Caveats

  • If you're following along I'm going to assume you:
    • Have Docker installed
    • Are running Ubuntu; although the steps may work on other systems, I can only guarantee it will work on what I use.
    • Have Go installed
    • Have at least a basic knowledge of Go
  • The steps/configurations in this post are for local development purposes only. I've purposefully taken liberties with secrets to make my life easier.
  • This blog is not intended to be a comprehensive tutorial on OAuth. While it will touch on OAuth topics, it is first and foremost a tutorial for setting up a MCP Server with OAuth authorization.
  • I am neither an authoritative source on, nor an expert on, OAuth or MCP. This post is something I wish I had when I was starting to dabble in MCP (especially the authorisation side), but it's possible I have made errors.

Project Folder Structure

The folder structure I'll be using in this directory is outlined below.

.
├── authentik
│   └── docker-compose.yml
│   └── // other authentik related files/directories
├── go.mod
└── main.go
└── // other go related files/directories

OAuth

Before we dive into anything else, here's a quick rundown of some OAuth basics.

Terminology

  • Resource: The entity granting access to resources, which is often an end-user.
  • Client (Claude Code): The entity requesting access on behalf of the resource owner
  • Resource Server (MCP Server): The server that is hosting the protected resources.
  • Authorization Server (Authentik): The trusted system that authenticates users and issues tokens
  • Access Token: A credential proving authorization to access protected resources
  • Authorization Code: A temporary credential the user receives after successful user authorization, which is then exchanged for an Access Token.
  • Scopes: Permissions defining what access is being requested (e.g. openid for minimal OIDC, or openid profile email for additional user info)

High-Level Authorization Flow

authz flow

I'll go into detail about each step of this flow as we go through the post, but a quick overview of the flow that we're implementing is:

  1. POST - /mcp

    • Client attempts MCP operation without Bearer token
    • MCP Server Returns 401 Unauthorized with WWW-Authenticate: Bearer header
    • Client then knows to begin the OAuth process
  2. GET - /.well-known/oauth-authorization-server

    • Client learns about authorization endpoints and PKCE requirements
  3. GET - /.well-known/oauth-protected-resource

    • Client learns about the protected resource and authorization servers
  4. Redirect user for login and consent

    • Client opens the users browser to the authorization URL
    • User signs in (if needed) and gives consent
    • Browser redirects to the redirect_uri with the authorization code and state
  5. Exchange authorization code for tokens with PKCE

    • Client sends a POST request to the /token endpoint, swapping the authorization code for an authorization token
  6. Client retries the MCP operation, using the authorization token in the Authorization header

OAuth Authorization Flows

OAuth 2.0 defines different "flows" (or grant types) for different scenarios. This MCP Server will support the authorization code flow, but we'll also define the device code flow as it's a popular choice and it's helpful to understand the difference.

Code Flow

Often used for web servers, service-side applications, and MCP Servers. The code flow uses a browser for user authorization, requires a redirect URI for callbacks, a Client ID, and optionally a Client Secret (if not using PKCE).

The Authorization Code Flow uses two separate HTTP requests:

  1. Authorization Request: Client redirects user's browser to authorization server

    • User authenticates and grants permissions
    • Authorization server redirects (redirect_uri) back with an authorization code
    • Code is short-lived (60-600 seconds) and single-use
  2. Token Request: Client exchanges authorization code for tokens

    • Client makes direct POST request to token endpoint (not through browser)
    • Sends authorization code + verification parameters
    • Authorization server returns access token (and optionally refresh token, ID token)

It does this in two steps as the process occurs through the browser (potentially insecure), while the token exchange happens server-to-server (secure channel). This prevents token exposure in browser history and referrer headers.

Device Flow

Often used for applications that cannot receive HTTP callbacks, such as CLI tools, smart TVs, IoT devices etc. The user will manually enter a code, often in a browser on the same or different device, and the client polls the token endpoint until the user completes authorization.

CSRF Protection

Within the OAuth authorization flow, a state parameter is used to protect against Cross-Site Request Forgery (CSRF) attacks. For example:

  1. Attacker starts OAuth flow on their device → Gets redirected to authorization server
  2. Attacker captures the callback URL (with authorization code) but doesn't complete it
  3. Attacker tricks YOUR browser into visiting that callback URL (via malicious link, XSS, etc.)
  4. YOUR browser completes the OAuth flow → Links attacker's account to your session
  5. Now when you think you're accessing your own data, you're actually accessing attacker's data

The state field prevents this using the following flow:

  1. Client generates random state value (e.g., "xqr8TbkZBv7q...") at the start of the authorization flow
  2. Client stores this value (in memory, cookie, or session)
  3. Client includes state in authorization request
  4. Authorization server echoes back the same state in the callback
  5. Client verifies: "Is the state in the callback the same one I generated?"

If the state field matches, the client knows it's legitimate, otherwise it is rejected.

PKCE

PKCE (Proof Key for Code Exchange) is a security extension for OAuth 2.0. It was originally designed for mobile apps but is now recommended for all OAuth clients, including web servers, and is mandatory for OAuth 2.1.

It solves the risk of an attacker intercepting an authorization code. For example:

This could be abused by:

  1. Attacker intercepts the redirect (malware, proxy, network sniffing)
  2. Attacker extracts the authorization code
  3. Attacker races to exchange code for token before legitimate client
  4. Attacker gains access, user's authorization fails

PKCE works by using the following authorization flow:

  1. Client generates a cryptographically random string (code verifier)
    • Length: 43-128 characters
    • Character set: [A-Z], [a-z], [0-9], -, ., _, ~
  2. Client creates a code challenge, by way of "transforming" (SHA-256 hashing) the code verifier. Note: Later you'll see code_challenge_methods_supported with values S256 and plain; S256 is the SHA-256 hashing, whereas plain is to return the the code verifier without transformation (not recommended).
  3. Client sends authorization request with the code challenge and method. The Authorization Server then:
    • Receives and validates the request
    • Stores the code_challenge and code_challenge_method
    • Generates an authorization code and binds it to this specific challenge, meaning the authorization code can be exchanged by whoever knows the original verifier only.
  4. Client exchanges the authorization code for an authorization token, sending the original code verifier without transformation (i.e. no SHA-256 hashing). The Authorization Server then:
    • Retrieves the stored challenge and method for the authorization code
    • Applies the same transformation (SHA-256 hashing) to the received verifier
    • Compares the computed challenge with the stored challenge
    • If it matches - an authorization token is issued, otherwise it is rejected.

Without PKCE, the authorization code alone is sufficient to get an access token; anyone who intercepts the code can use it. With PKCE, the code alone is useless; You need both the code AND the original secret (code verifier) that only the legitimate client knows.

Authentik

In order set-up everything needed for this MCP Server, I opted to run authentik to act as a local OAuth Authorization Server.

Initial Set-up

Create the docker-compose.yml file using Appendix A and then run docker compose up (or docker compose up -d if you want to run it in detached mode).

Once everything is running, go to http://${YOUR IP}:9000/if/flow/initial-setup/ in a browser. Note: If you see a Server is starting up. Refreshing in a few seconds message, then Authentik is still bootstrapping so just be patient.

Next, you'll be asked to provide an email/password for the default admin user (akadmin). Set these to whatever you like, as long as you remember what you set them too.

Creating an Application

Once you've set-up the default admin user, you'll be redirected to the My applications page. Click Create a new application and use the following values:

  • Application:
    • Name - MCPTest
    • Leave everything else as default
  • Choose a Provider:
    • Select OAuth2/OpenID Provider
  • Configure Provider:
    • Provider Name - Provider for MCPTest
    • Authorization flow - default-provider-authorization-explicit-consent (Authorize Application)
    • Protocol settings:
      • Client type - Public
      • Client ID - R96uZIUA82grFnOgU2LuiWmGOLrQEq0nWyzlb0bx
      • Redirect URIs/Origins - add http://localhost:8080/callback
      • Logout URI - http://localhost:8080/logout
    • Advanced flow settings:
      • Authentication flow: default-authentication-flow (Welcome to authentik!)
    • Leave everything else as default
  • Configure Bindings:
    • Just click Next (i.e. don't create any bindings)
  • Review and Submit Application
    • Check all the details are correct and then hit Submit

Once submitted, the page will redirect to the Applications page (:9000/if/admin/#/core/applications).

Getting the Provider Details

Now we need to get the details about our new provider. To do this navigate to Applications > Provider in the left navbar, where you should then see the Provider for MCPTest provider.

Click on the provider, and you should see details such as OpenID Configuration URL, Authorize URL etc. Keep this page handy, as we'll need it in a second.

MCP Server

Minimal Server With Logging

First, we're going to create a minimal MCP Server with some logging so we can see what's happening and then start adding authorization step-by-step.

Create the following main.go and middleware.go files:

// main.go

package main

import (
	"fmt"
	"log"
	"net/http"

	"github.com/modelcontextprotocol/go-sdk/mcp"
)

const (
	host = "localhost"
	port = 8080
)

const (
	// From the 'Authentik > Creating an Application' step
	clientID = "R96uZIUA82grFnOgU2LuiWmGOLrQEq0nWyzlb0bx"
	// Taken from the 'OpenID Configuration URL' under Applications > Providers
	issuer                = "http://192.168.1.200:9000/application/o/mcpt/"
	authorizationEndpoint = "http://192.168.1.200:9000/application/o/authorize/"
	tokenEndpoint         = "http://192.168.1.200:9000/application/o/token/"
	revocationEndpoint    = "http://192.168.1.200:9000/application/o/revoke/"
)

var (
	// Taken from the 'OpenID Configuration URL' under Applications > Providers
	scopesSupported              = []string{"openid", "email", "profile"}
	responseTypesSupported       = []string{"code", "id_token", "id_token token", "code token", "code id_token", "code id_token token"}
	grantTypesSupported          = []string{"authorization_code", "refresh_token", "implicit", "client_credentials", "password", "urn:ietf:params:oauth:grant-type:device_code"}
	codeChallengeMethodSupported = []string{"plain", "S256"}
)

func main() {
	server := mcp.NewServer(&mcp.Implementation{
		Name:    "gomcp-auth",
		Version: "0.0.1",
	}, nil)

	server.AddReceivingMiddleware(loggingMiddleware())

	handler := mcp.NewStreamableHTTPHandler(func(req *http.Request) *mcp.Server {
		return server
	}, nil)

	log.Printf("MCP server listening on %s", fmt.Sprintf("%s:%d", host, port))

	// Start the HTTP server
	if err := http.ListenAndServe(fmt.Sprintf("%s:%d", host, port), handler); err != nil {
		log.Fatalf("Server failed: %v", err)
	}

}
// middleware.go

package main

import (
	"context"
	"log"
	"time"

	"github.com/modelcontextprotocol/go-sdk/mcp"
)

func loggingMiddleware() mcp.Middleware {
	return func(next mcp.MethodHandler) mcp.MethodHandler {
		return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) {

			log.Printf("[REQUEST] Session: %s | Method: %s", req.GetSession().ID(), method)

			start := time.Now()
			result, err := next(ctx, method, req)
			duration := time.Since(start)

			if err != nil {
				log.Printf("[RESPONSE] Session: %s | Method: %s | Status: ERROR | Duration: %v | Error: %v",
					req.GetSession().ID(),
					method,
					duration,
					err)
			} else {
				log.Printf("[RESPONSE] Session: %s | Method: %s | Status: OK | Duration: %v",
					req.GetSession().ID(),
					method,
					duration)
			}

			return result, err
		}
	}
}

Then, add our MCP Server to ~/.claude.json so Claude Code knows about it:

{
  // rest of config
  "mcpServers": {
    "gomcp-auth": {
      "type": "http",
      "url": "http://localhost:8080/mcp"
    }
  }
}

Now, run the MCP Server (go run .) and run claude mcp list - all being well, you'll see that it's connected successfully and the MCP Server is logging the request/response details:

$ claude mcp list
Checking MCP server health...

gomcp-auth: http://localhost:8080/mcp (HTTP) - ✓ Connected
2025/11/26 14:22:52 MCP server listening on localhost:8080
2025/11/26 14:23:00 [REQUEST] Session: ME2H7LZANS3BIYRQPKIUAELHBZ | Method: initialize
2025/11/26 14:23:00 [RESPONSE] Session: ME2H7LZANS3BIYRQPKIUAELHBZ | Method: initialize | Status: OK | Duration: 6.838µs
2025/11/26 14:23:00 [REQUEST] Session: ME2H7LZANS3BIYRQPKIUAELHBZ | Method: notifications/initialized
2025/11/26 14:23:00 [RESPONSE] Session: ME2H7LZANS3BIYRQPKIUAELHBZ | Method: notifications/initialized | Status: OK | Duration: 4.994µs

Note: At this point our MCP Server doesn't do anything, but we've got a good basis to move on from.

Adding Authorization

Step 1. MCP Server returns 401

The first step in the flow is to check if the client (Claude Code) has a valid Bearer token, allowing them to access the protected routes. If not we'll return a 401 Unauthorized with a WWW-Authenticate: Bearer header, which is used to tell the client what type of authorization the MCP Server expects.

Middleware

Let's add a very basic authorization middleware which checks if a Bearer token has been sent with the request; if it has we'll let the request continue, regardless of validity, otherwise we'll return the 401 response with WWW-Authenticate header mentioned above.

// middleware.go

func authMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		authHeader := r.Header.Get("Authorization")

		if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
			w.Header().Set("WWW-Authenticate", "Bearer")
			w.WriteHeader(http.StatusUnauthorized)
			return
		}

		// TODO: Validate the token here
		// For now, we'll accept any Bearer token

		w.WriteHeader(http.StatusOK)
		next.ServeHTTP(w, r)
	})
}

Let's also add another middleware function that logs out the request/response details, similar to loggingMiddleware, but for HTTP requests (more on this in a second).

// middleware.go

// responseWriter wraps http.ResponseWriter to capture status code
type responseWriter struct {
	http.ResponseWriter
	statusCode int
}

// WriteHeader overrides the default to capture status code
func (rw *responseWriter) WriteHeader(code int) {
	rw.statusCode = code
	rw.ResponseWriter.WriteHeader(code)
}

func loggingMiddlewareHTTP(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		log.Printf("[HTTP REQUEST] Method: %s | Path: %s | RemoteAddr: %s",
			r.Method,
			r.URL.Path,
			r.RemoteAddr)

		// Wrap the ResponseWriter to capture status code
		rw := &responseWriter{
			ResponseWriter: w,
			statusCode:     200, // Default to 200 if WriteHeader is never called
		}

		start := time.Now()
		next.ServeHTTP(rw, r)
		duration := time.Since(start)

		log.Printf("[HTTP RESPONSE] Method: %s | Path: %s | Status: %d | Duration: %v",
			r.Method,
			r.URL.Path,
			rw.statusCode,
			duration)
	})
}

Using the Middleware

Next we need to change our main func to use this new middleware:

func main() {
	server := mcp.NewServer(&mcp.Implementation{
		Name:    "gomcp-auth",
		Version: "0.0.1",
	}, nil)

	server.AddReceivingMiddleware(loggingMiddleware())

	handler := mcp.NewStreamableHTTPHandler(func(req *http.Request) *mcp.Server {
		return server
	}, nil)

	mux := http.NewServeMux()
	mux.Handle("/", loggingMiddlewareHTTP(handler))
	mux.Handle("/mcp", loggingMiddlewareHTTP(authMiddleware(handler)))

	log.Printf("MCP server listening on %s", fmt.Sprintf("%s:%d", host, port))

	// Start the HTTP server
	if err := http.ListenAndServe(fmt.Sprintf("%s:%d", host, port), mux); err != nil {
		log.Fatalf("Server failed: %v", err)
	}

}

Our MCP Sever has had a new HTTP Serve Mux added, which exposes two new endpoints:

  1. The root endpoint (/), which doesn't require authorization
  2. The main MCP endpoint (/mcp), as we configured it in ~/.claude.json, which does require authorization

Now, if you restart the MCP Server and run claude mcp list again - you'll notice that it fails to connect:

$ claude mcp list
Checking MCP server health...

gomcp-auth: http://localhost:8080/mcp (HTTP) - ✗ Failed to connect

Because we added HTTP logging to our middleware, we can see exactly what happened:

  • The client first makes a POST request to /mcp, which results in a 401 because there is no bearer token
  • It then tries to make a GET request to /.well-known/oauth-authorization-server and /.well-known/oauth-protected-resource to find the details about the OAuth authorization endpoints, PKCE requirements, authorization resources, and authorization servers
  • It then falls back to making a POST request to /register, which also doesn't work so the client doesn't know how to continue
2025/11/26 16:29:58 [HTTP REQUEST] Method: POST | Path: /mcp | RemoteAddr: 127.0.0.1:33644
2025/11/26 16:29:58 [HTTP RESPONSE] Method: POST | Path: /mcp | Status: 401 | Duration: 8.601µs

2025/11/26 16:29:58 [HTTP REQUEST] Method: GET | Path: /.well-known/oauth-protected-resource/mcp | RemoteAddr: 127.0.0.1:33652
2025/11/26 16:29:58 [HTTP RESPONSE] Method: GET | Path: /.well-known/oauth-protected-resource/mcp | Status: 405 | Duration: 16.149µs
2025/11/26 16:29:58 [HTTP REQUEST] Method: GET | Path: /.well-known/oauth-protected-resource | RemoteAddr: 127.0.0.1:33644
2025/11/26 16:29:58 [HTTP RESPONSE] Method: GET | Path: /.well-known/oauth-protected-resource | Status: 405 | Duration: 17.147µs
2025/11/26 16:29:58 [HTTP REQUEST] Method: GET | Path: /.well-known/oauth-authorization-server/mcp | RemoteAddr: 127.0.0.1:33652
2025/11/26 16:29:58 [HTTP RESPONSE] Method: GET | Path: /.well-known/oauth-authorization-server/mcp | Status: 405 | Duration: 19.594µs
2025/11/26 16:29:58 [HTTP REQUEST] Method: GET | Path: /.well-known/oauth-authorization-server | RemoteAddr: 127.0.0.1:33644
2025/11/26 16:29:58 [HTTP RESPONSE] Method: GET | Path: /.well-known/oauth-authorization-server | Status: 405 | Duration: 7.066µs
2025/11/26 16:29:58 [HTTP REQUEST] Method: GET | Path: /.well-known/openid-configuration/mcp | RemoteAddr: 127.0.0.1:33652
2025/11/26 16:29:58 [HTTP RESPONSE] Method: GET | Path: /.well-known/openid-configuration/mcp | Status: 405 | Duration: 7.41µs
2025/11/26 16:29:58 [HTTP REQUEST] Method: GET | Path: /mcp/.well-known/openid-configuration | RemoteAddr: 127.0.0.1:33644
2025/11/26 16:29:58 [HTTP RESPONSE] Method: GET | Path: /mcp/.well-known/openid-configuration | Status: 405 | Duration: 19.044µs

2025/11/26 16:29:58 [HTTP REQUEST] Method: POST | Path: /register | RemoteAddr: 127.0.0.1:33652
2025/11/26 16:29:58 [HTTP RESPONSE] Method: POST | Path: /register | Status: 400 | Duration: 343.596µs

If this sounds familiar, it's because we're starting to build out the flow detailed in OAuth Terminology and Authorization Flow > Flow.

Steps 2 & 3. Exposing Authorization Details

The next step is to expose two new endpoints in our HTTP handler for GET /.well-known/oauth-protected-resource and GET /.well-known/oauth-protected-resource.

First, we're going to amend our authorization middleware to include the resource_metadata and realm details in our 401 response as recommended in the MCP Authorization tutorial:

// middleware.go

func authMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		authHeader := r.Header.Get("Authorization")

		if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
			resourceMetadataURL := "http://localhost:8080/.well-known/oauth-protected-resource"
			w.Header().Set("WWW-Authenticate", `Bearer realm="mcp", resource_metadata="`+resourceMetadataURL+`"`)
			w.WriteHeader(http.StatusUnauthorized)
			return
		}

		// TODO: Validate the token here
		// For now, we'll accept any Bearer token

		next.ServeHTTP(w, r)
	})
}

Then we'll create two new handlers, for our new endpoints:

// main.go

// handleOauthAuthorizationServer handles /.well-known/oauth-authorization-server
func handleOauthAuthorizationServer() http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		metadata := map[string]any{
			"issuer":                                issuer,
			"authorization_endpoint":                authorizationEndpoint,
			"token_endpoint":                        tokenEndpoint,
			"revocation_endpoint":                   revocationEndpoint,
			"scopes_supported":                      scopesSupported,
			"response_types_supported":              responseTypesSupported,
			"grant_types_supported":                 grantTypesSupported,
			"code_challenge_methods_supported":      codeChallengeMethodSupported,
			"token_endpoint_auth_methods_supported": []string{"none"}, // Public client, no client secret
		}

		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusOK)
		json.NewEncoder(w).Encode(metadata)

	}
}

// handleOauthProtectedResource handles /.well-known/oauth-protected-resource
func handleOauthProtectedResource() http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		metadata := map[string]any{
			"resource":                fmt.Sprintf("http://%s:%d/mcp", host, port),
			"authorization_servers":   []string{issuer},
			"scopes_supported":        scopesSupported,
			"bearer_methods_supported": []string{"header"},
		}

		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusOK)
		json.NewEncoder(w).Encode(metadata)
	}
}

Finally, we'll use these new handlers and refactor the mux logic slightly to make our lives easier:

// main.go

func main() {
	server := mcp.NewServer(&mcp.Implementation{
		Name:    "gomcp-auth",
		Version: "0.0.1",
	}, nil)

	server.AddReceivingMiddleware(loggingMiddleware())

	handler := mcp.NewStreamableHTTPHandler(func(req *http.Request) *mcp.Server {
		return server
	}, nil)

	rootRouter := http.NewServeMux() // => /
	rootRouter.HandleFunc("GET /.well-known/oauth-authorization-server", handleOauthAuthorizationServer())
	rootRouter.HandleFunc("GET /.well-known/oauth-protected-resource", handleOauthProtectedResource())

	mux := http.NewServeMux()
	mux.Handle(
		"/",
		loggingMiddlewareHTTP(rootRouter),
	)
	mux.Handle(
		"/mcp",
		loggingMiddlewareHTTP(authMiddleware(handler)),
	)

	log.Printf("MCP server listening on %s", fmt.Sprintf("%s:%d", host, port))

	// Start the HTTP server
	if err := http.ListenAndServe(fmt.Sprintf("%s:%d", host, port), mux); err != nil {
		log.Fatalf("Server failed: %v", err)
	}

}

Now re-running the server and claude mcp list, we can see the requests are working from our server logs:

$ go run .
2025/11/26 17:39:56 MCP server listening on localhost:8080
2025/11/26 17:40:16 [HTTP REQUEST] Method: POST | Path: /mcp | RemoteAddr: 127.0.0.1:42086
2025/11/26 17:40:16 [HTTP RESPONSE] Method: POST | Path: /mcp | Status: 401 | Duration: 13.98µs
2025/11/26 17:40:16 [HTTP REQUEST] Method: GET | Path: /.well-known/oauth-protected-resource | RemoteAddr: 127.0.0.1:42088
2025/11/26 17:40:16 [HTTP RESPONSE] Method: GET | Path: /.well-known/oauth-protected-resource | Status: 200 | Duration: 161.511µs

Claude Code and DCR

For reasons I don't fully understand, Claude Code seems to have a strict requirement that MCP Servers supports Dynamic Client Registration (DCR) even though it is specifically marked as a MAY/SHOULD in the MCP specification (ref).

Authorization servers and MCP clients MAY support the OAuth 2.0 Dynamic Client Registration Protocol (RFC7591).

$ claude mcp list
Checking MCP server health...

gomcp-auth: http://localhost:8080/mcp (HTTP) - ✗ Failed to connect
> /mcp
╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Gomcp-auth MCP Server                                                                                                                                 │
│                                                                                                                                                       │
│ Status: ✘ failed                                                                                                                                      │
│ URL: http://localhost:8080/mcp                                                                                                                        │
│                                                                                                                                                       │
│ Error: Incompatible auth server: does not support dynamic client registration                                                                         │
│                                                                                                                                                       │
│ ❯ 1. Authenticate                                                                                                                                     │
2. Reconnect                                                                                                                                        │
3. Disable                                                                                                                                          │
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯

As far as I can tell, this error is occuring because Authentik doesn't support DCR via a /register endpoint. To fix it, I had to change the issuer from http://192.168.1.200:9000/application/o/mcpt/ to the MCP Server (http://localhost:8080) and implement a /register endpoint, which is covered in the next section.

Attempting to add the MCP Server to Codex, as Claude Code is blocked on the DCR issue from above, we can see that the client is trying to automatically redirect to Authentik to handle the authorization and consent:

$ codex mcp add --url "http://localhost:8080/mcp" gomcp-auth
Added global MCP server 'gomcp-auth'.
Detected OAuth support. Starting OAuth flow…
Authorize `gomcp-auth` by opening this URL in your browser:
http://192.168.1.200:9000/application/o/authorize/?response_type=code&client_id=mcp-client&state=n9uwVx4gpBSoUtHxYZexpA&code_challenge=cbsq3AWtw0wdpArtJMNuMGtVrarekhXNO8oLeqgZscg&code_challenge_method=S256&redirect_uri=http%3A%2F%2F127.0.0.1%3A46075%2Fcallback

Unfortunately, when it opens the browser, it results in a The client identifier (client_id) is missing or invalid error because it's trying to use a Client ID of mcp-client and not R96uZIUA82grFnOgU2LuiWmGOLrQEq0nWyzlb0bx

For this we need to add in another new endpoint (/register), which is usually used for DCR but seems to be the only way of setting the Client ID.

First create the handler:

// main.go

// handleRegister handles the /register endpoint
func handleRegister() http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		// Stub implementation: always return the pre-configured client
		// This satisfies DCR expectations without actually doing dynamic registration
		response := map[string]any{
			"client_id":                  clientID,
			"token_endpoint_auth_method": "none",
			"grant_types":                []string{"authorization_code", "refresh_token"},
			"response_types":             []string{"code"},
			"redirect_uris":              []string{fmt.Sprintf("http://%s:%d/callback", host, port)},
		}

		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusCreated) // 201 Created per OAuth 2.0 DCR spec
		json.NewEncoder(w).Encode(response)
	}
}

Then use it for the /register endpoint:

// main.go

	rootRouter := http.NewServeMux() // => /
	rootRouter.HandleFunc("GET /.well-known/oauth-authorization-server", handleOauthAuthorizationServer())
	rootRouter.HandleFunc("GET /.well-known/oauth-protected-resource", handleOauthProtectedResource())
	rootRouter.HandleFunc("POST /register", handleRegister())

If we then retry the /mcp command, we can see that Claude Code automatically recognises that it needs authentication:

> /mcp

MCP Config Diagnostics

For help configuring MCP servers, see: https://docs.claude.com/en/docs/claude-code/mcp

╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Manage MCP servers                                                                                                                                    │
│                                                                                                                                                       │
│ ❯ 1. gomcp-auth            △ needs authentication · Enter to login                                                                                    │
│                                                                                                                                                       │
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯

If we follow the authentication process the Client ID is now correct (client_id=R96uZIUA82grFnOgU2LuiWmGOLrQEq0nWyzlb0bx), but we've got a new error - The request fails due to a missing, invalid, or mismatching redirection URI (redirect_uri).

Authenticating with gomcp-auth…

*  A browser window will open for authentication

If your browser doesn't open automatically, copy this URL manually:
http://192.168.1.200:9000/application/o/authorize/?response_type=code&client_id=R96uZIUA82grFnOgU2LuiWmGOLrQEq0nWyzlb0bx&code_challenge=_zzNZZB4uu6eBc0gA
GPpBON9R9ks85twBMS2MY95y_M&code_challenge_method=S256&redirect_uri=http%3A%2F%2Flocalhost%3A64078%2Fcallback&state=NRTXUVr0yQiDnvpKfk08hdZ_auznAn-xhWxCjO
NZ-d4&scope=openid+email+profile&resource=http%3A%2F%2Flocalhost%3A8080%2Fmcp

If we decode the URL we can see:

  • It's redirecting the user to http://192.168.1.200:9000/application/o/authorize/, which is what we configured in the MCP Server (authorizationEndpoint which gets returned in the /.well-known/oauth-authorization-server endpoint)
  • response_type=code - tells the authorization server that a successful response should include an authorization code, which the client can exchange for an access token.
  • client_id=R96uZIUA82grFnOgU2LuiWmGOLrQEq0nWyzlb0bx - the Client ID we configured via the /register endpoint
  • state=NRTXUVr0yQiDnvpKfk08hdZ_auznAn-xhWxCjONZ-d4 - see CSRF section for more details
  • code_challenge=_zzNZZB4uu6eBc0gAGPpBON9R9ks85twBMS2MY95y_M& - see PKCE section for more details
  • code_challenge_method=S256 - see PKCE section for more details
  • redirect_uri=http://localhost:64078/callback - the redirect URI that should be used on successful authorization

The missing, invalid, or mismatching redirection URI error is happening because Authentik is rejecting the OAuth request due to the redirect URI not matching what was configured for the provider; When we added the MCP server with Codex, it uses a dynamically generated callback URI with a random port (e.g. http://127.0.0.1:46157/callback), but we configured http://localhost:8080/callback.

If we look at RFC8252, which outlines the best practices for OAuth 2.0 for Native Apps, section 7.3 specifically states:

Native apps that are able to open a port on the loopback network interface without needing special permissions (typically, those on desktop operating systems) can use the loopback interface to receive the OAuth redirect.

Loopback redirect URIs use the "http" scheme and are constructed with the loopback IP literal and whatever port the client is listening on.

That is, "http://127.0.0.1:\{port\}/\{path\}" for IPv4, and "http://[::1]:{port}/{path}" for IPv6. An example redirect using the IPv4 loopback interface with a randomly assigned port:

http://127.0.0.1:51004/oauth2redirect/example-provider

An example redirect using the IPv6 loopback interface with a randomly assigned port:

http://[::1]:61023/oauth2redirect/example-provider

The authorization server MUST allow any port to be specified at the time of the request for loopback IP redirect URIs, to accommodate clients that obtain an available ephemeral port from the operating system at the time of the request.

Clients SHOULD NOT assume that the device supports a particular version of the Internet Protocol. It is RECOMMENDED that clients attempt to bind to the loopback interface using both IPv4 and IPv6 and use whichever is available.

The important part being the second to last paragraph, which states that the authorization server must allow any port to be specified, to accommodate clients that obtain an available ephemeral port.

To fix this, we'll change the Redirect URI from http://localhost:8080/callback to http://localhost:.*/callback; making sure to change the type from Strict to Regex. The Redirect URIs section of the provider should now show one entry - regex: http://localhost:.*/callback.

Run the authentication process again, and it should automatically redirect the browser to Authentik where it will ask you to approve the sign-in request.

Assuming you approve the request, the browser should show Authentication complete. You may close this window., and the Claude Code command should have completed successfully:

> /mcp
  ⎿  Authentication successful. Connected to gomcp-auth.

Step 5. Exchange Authorization Code for Authorization Token

Behind the scenes Claude Code will have exchanged the authorization code for an authorization token, via the token endpoint (http://192.168.1.200:9000/application/o/token/) that we configured in the MCP Server and exposed via the /.well-known/oauth-authorization-server endpoint.

At this point, Claude Code now has a valid authorization token.

Step 6. Retry MCP Operation with Authorization Token

Running claude mcp list, we'll see that our MCP Server is connected:

$ claude mcp list
Checking MCP server health...

gomcp-auth: http://localhost:8080/mcp (HTTP) - ✓ Connected

If we open Claude Code, we should see the MCP logs show that we have a valid Bearer Token by returning a non-401 response on the /mcp endpoint, proving that we have successfully authenticated and exchanged the authorization code for an authorization token:

2025/11/27 09:33:59 [HTTP REQUEST] Method: GET | Path: /.well-known/oauth-authorization-server/mcp | RemoteAddr: 127.0.0.1:46160
2025/11/27 09:33:59 [HTTP RESPONSE] Method: GET | Path: /.well-known/oauth-authorization-server/mcp | Status: 404 | Duration: 22.442µs
2025/11/27 09:33:59 [HTTP REQUEST] Method: GET | Path: /mcp/.well-known/oauth-authorization-server | RemoteAddr: 127.0.0.1:46160
2025/11/27 09:33:59 [HTTP RESPONSE] Method: GET | Path: /mcp/.well-known/oauth-authorization-server | Status: 404 | Duration: 24.044µs
2025/11/27 09:33:59 [HTTP REQUEST] Method: GET | Path: /.well-known/oauth-authorization-server | RemoteAddr: 127.0.0.1:46160
2025/11/27 09:33:59 [HTTP RESPONSE] Method: GET | Path: /.well-known/oauth-authorization-server | Status: 200 | Duration: 21.258µs
2025/11/27 09:33:59 [HTTP REQUEST] Method: POST | Path: /mcp | RemoteAddr: 127.0.0.1:46160
2025/11/27 09:33:59 [REQUEST] Session: UTO6SH2HMMHWU4NZT74YDILSL4 | Method: initialize
2025/11/27 09:33:59 [RESPONSE] Session: UTO6SH2HMMHWU4NZT74YDILSL4 | Method: initialize | Status: OK | Duration: 3.68µs
2025/11/27 09:33:59 [HTTP RESPONSE] Method: POST | Path: /mcp | Status: 200 | Duration: 458.581µs
2025/11/27 09:33:59 [HTTP REQUEST] Method: POST | Path: /mcp | RemoteAddr: 127.0.0.1:46160
2025/11/27 09:33:59 [HTTP RESPONSE] Method: POST | Path: /mcp | Status: 202 | Duration: 14.449µs
2025/11/27 09:33:59 [REQUEST] Session: UTO6SH2HMMHWU4NZT74YDILSL4 | Method: notifications/initialized
2025/11/27 09:33:59 [RESPONSE] Session: UTO6SH2HMMHWU4NZT74YDILSL4 | Method: notifications/initialized | Status: OK | Duration: 1.503µs
2025/11/27 09:33:59 [HTTP REQUEST] Method: GET | Path: /mcp | RemoteAddr: 127.0.0.1:46160

Troubleshooting

Codex

If you're following along with codex, running the /mcp slash command may not recognise our MCP Server:

/mcp

🔌  MCP Tools

  • No MCP tools available.

I'm not 100% sure why at this stage, but the responseWriter wrapper seems to be the culprit and seems to stop the MCP client from making requests to the MCP specific endpoints (e.g. tools/list).

Not wanting to spend too much time trying to figure out why, at this stage, I managed to fix it by getting rid of the wrapper:

func loggingMiddlewareHTTP(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		log.Printf("[HTTP REQUEST] Method: %s | Path: %s | Query: %s | RemoteAddr: %s",
			r.Method,
			r.URL.Path,
			r.URL.RawQuery,
			r.RemoteAddr)

		start := time.Now()
		next.ServeHTTP(w, r)
		duration := time.Since(start)

		log.Printf("[HTTP RESPONSE] Method: %s | Path: %s | Duration: %v",
			r.Method,
			r.URL.Path,
			duration)
	})
}

And then it worked after restarting the server:

/mcp

🔌  MCP Tools

  • gomcp-auth
    • Status: enabled
    • Auth: OAuth
    • URL: http://localhost:8080/mcp
    • Tools: sayHello
    • Resources: (none)
    • Resource templates: (none)

And the logout/login functionality still works:

$ codex mcp logout gomcp-auth
Removed OAuth credentials for 'gomcp-auth'.

$ codex mcp login gomcp-auth
Authorize `gomcp-auth` by opening this URL in your browser:
http://192.168.1.200:9000/application/o/authorize/?response_type=code&client_id=R96uZIUA82grFnOgU2LuiWmGOLrQEq0nWyzlb0bx&state=n6sf_xLfOgm2Ybg7ZY6-sQ&code_challenge=J5y1eRMFcxodY-mt9zVl01JWoNBo5vARBar0-NjUdzA&code_challenge_method=S256&redirect_uri=http%3A%2F%2F127.0.0.1%3A34511%2Fcallback

Successfully logged in to MCP server 'gomcp-auth'.

/mcp

🔌  MCP Tools

  • gomcp-auth
    • Status: enabled
    • Auth: OAuth
    • URL: http://localhost:8080/mcp
    • Tools: sayHello
    • Resources: (none)
    • Resource templates: (none)

Next Steps

These won't be covered in this post, but things I plan on doing next include:

  • Adding proper token validation to authMiddleware
  • Create some MCP Server tools

Appendix

Appendix A

services:
  authentik_postgresql:
    environment:
      POSTGRES_DB: authentik
      POSTGRES_PASSWORD: password
      POSTGRES_USER: authentik
    healthcheck:
      interval: 30s
      retries: 5
      start_period: 20s
      test:
        - CMD-SHELL
        - pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}
      timeout: 5s
    image: docker.io/library/postgres:16-alpine
    restart: unless-stopped
    volumes:
      - database:/var/lib/postgresql/data
  authentik_server:
    command: server
    depends_on:
      authentik_postgresql:
        condition: service_healthy
    environment:
      AUTHENTIK_POSTGRESQL__HOST: authentik_postgresql
      AUTHENTIK_POSTGRESQL__NAME: authentik
      AUTHENTIK_POSTGRESQL__PASSWORD: password
      AUTHENTIK_POSTGRESQL__USER: authentik
      AUTHENTIK_SECRET_KEY: sososecret
    image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.10.2}
    ports:
      - ${COMPOSE_PORT_HTTP:-9000}:9000
      - ${COMPOSE_PORT_HTTPS:-9443}:9443
    restart: unless-stopped
    volumes:
      - ./media:/media
      - ./custom-templates:/templates
  authentik_worker:
    command: worker
    depends_on:
      authentik_postgresql:
        condition: service_healthy
    environment:
      AUTHENTIK_POSTGRESQL__HOST: authentik_postgresql
      AUTHENTIK_POSTGRESQL__NAME: authentik
      AUTHENTIK_POSTGRESQL__PASSWORD: password
      AUTHENTIK_POSTGRESQL__USER: authentik
      AUTHENTIK_SECRET_KEY: sososecret
    image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.10.2}
    restart: unless-stopped
    user: root
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - ./media:/media
      - ./certs:/certs
      - ./custom-templates:/templates
volumes:
  database:
    driver: local

References