はじめに
MCP Version 2025-06-18では、HTTPベースのトランスポートを使用する場合、OAuthによる保護が推奨されており、リモート環境で動作するMCPサーバをセキュアにできます。
本記事では、Go言語を使ってOAuthで保護されたMCPサーバのサンプル実装を紹介します。MCPサーバの実装には公式SDKの1つであるMCP Go SDKを、認可サーバには先日リリースされたKeycloak 26.4を使用します。KeycloakはMCPで推奨されているDynamic Client Registration (DCR) を以前からサポートしていましたが、26.4でRFC 8414 (OAuth 2.0 Authorization Server Metadata) に準拠したwell-known URIを通じてサーバメタデータを提供するようにもなり、MCPの認可サーバとして利用できるようになりました。
対象MCP仕様バージョン
本記事は、MCP Specification 2025-06-18 - Authorizationに基づいています。MCPは現在も活発に開発が進んでおり、仕様が変更される可能性があります。
シンプルなMCPサーバを作る
まずは認証なしのシンプルなMCPサーバを実装します。入力されたメッセージをそのままエコーバックするecho
ツールを提供するだけの最小構成です。
MCPのトランスポートには、stdioとStreamable HTTPがあります。本記事では、リモート環境で動作させることを想定し、Streamable HTTPを採用します。リモート環境で機密性の高いリソースにアクセスするMCPサーバを構築する場合、適切な認証・認可が必要になります。後続のステップでOAuthによる保護を追加していきます。
公式のMCP Go SDKを利用すると、MCPサーバを以下のように簡単に実装できます:
package main
import (
"context"
"log"
"net/http"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
type EchoArgs struct {
Message string `json:"message"`
}
func Echo(ctx context.Context, req *mcp.CallToolRequest, args *EchoArgs) (*mcp.CallToolResult, any, error) {
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: "Echo: " + args.Message},
},
}, nil, nil
}
func main() {
server := mcp.NewServer(&mcp.Implementation{
Name: "simple-mcp-server",
Version: "1.0.0",
}, nil)
mcp.AddTool(server, &mcp.Tool{
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 message to echo back",
},
},
"required": []string{"message"},
},
}, Echo)
handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server {
return server
}, nil)
log.Println("Starting MCP server on :8000")
log.Println("Tool available: echo")
if err := http.ListenAndServe(":8000", handler); err != nil {
log.Printf("Server failed: %v", err)
}
}
サーバーを起動します:
go run main.go
MCP Inspectorで動作確認
MCP Inspectorを使って、MCPサーバの動作を確認できます。ブラウザで接続先にhttp://localhost:8000
を指定すれば、echo
ツールが表示され、実行できます。
MCPサーバにOAuthによる保護を追加
ここからMCPサーバにOAuthよる保護を追加していきます。以下はMCP Authorization仕様に記載されているシーケンス図です。
実装のポイント
未認可時のレスポンス
OAuthで保護されたMCPサーバは、アクセストークンが提供されていない場合や検証に失敗した場合、401 Unauthorized
レスポンスを返す必要があります。この際、WWW-Authenticate
ヘッダーにresource_metadata
パラメータを含めることで、MCPクライアントがアクセストークンを取得するために必要な情報を提供します。
resource_metadata
には、OAuth 2.0 Protected Resource MetadataのエンドポイントURLを指定します(RFC 9728)。MCPクライアントはこのWWW-Authenticate
ヘッダーからresource_metadata
のURLを取得し、次に説明するProtected Resource Metadataエンドポイントにアクセスして認可サーバのURLを取得します。その後、認可サーバのメタデータ(Authorization Server Metadata)から認可エンドポイントやトークンエンドポイント、DCRエンドポイントなどの情報を取得し、DCRによるクライアント登録、認可コードフローへと進みます。
OAuth 2.0 Protected Resource Metadataの実装
RFC 9728 (OAuth 2.0 Protected Resource Metadata) では、Protected Resourceがメタデータを/.well-known/oauth-protected-resource
エンドポイントで公開することが規定されており、MCP仕様ではMCPサーバにてこの実装が必須です。
MCPクライアントは、MCPサーバのこのエンドポイントにアクセスして認可サーバのURLを取得します。
今回の実装では、メタデータに以下の情報を含めています:
-
resource
: このリソースサーバのURL(Resource URL) -
authorization_servers
: 認可サーバのURL一覧 -
scopes_supported
: サポートするスコープ一覧(今回はmcp:tools
のみ)
アクセストークンの真正性と有効性の検証
OAuthによる保護では、MCPサーバはリソースサーバとなり、アクセス制御を行います。MCPクライアントからのアクセスには、OAuthのアクセストークンが付与されます。MCPサーバは、まずこのトークンが認可サーバによって発行された有効なトークンであるかを検証する必要があります。
検証方法は、主に以下の2つがあります:
-
Token Introspection (RFC 7662): 認可サーバのエンドポイントにトークンを送信して検証する方法。トークンの形式に依存せず、認可サーバが
active: true/false
を返します。 -
ローカルでのJWT検証: JWT形式のアクセストークンの場合、公開鍵を使って署名を検証し、
iss
(発行者)やexp
(有効期限)などのクレームをチェックする方法。
今回は、KeycloakがJWT形式のアクセストークンを発行するため、ローカルでのJWT検証で実装しています。この方法には以下のメリットがあります:
- 認可サーバへのネットワーク通信が不要(レイテンシ削減)
- 認可サーバへの負荷が少ない
ただし、トークン失効の即時反映が必要な場合は、Token Introspectionの方が適しています。
JWT検証の実装には、keyfuncライブラリを使用します。このライブラリは、JWKSエンドポイントから公開鍵を自動的に取得し、JWTの署名検証を行います。
JWT検証では、署名検証に加えて以下の標準的なクレームも検証します:
-
iss
(発行者): 期待する認可サーバのURLと完全に一致するか -
exp
(有効期限): トークンが期限切れでないか(実装ではクロックスキューを考慮して60秒の猶予を許可)
アクセストークンのクレーム検証
前述のトークンの真正性と有効性の検証(JWTアクセストークンの場合は署名の検証とiss
、exp
のチェック)により、アクセストークンが認可サーバによって発行された有効なトークンであることを確認しました。次に、リソースサーバ(MCPサーバ)固有のクレームを検証する必要があります。
Audience (aud
) の検証
MCP Authorization仕様では、MCPサーバがアクセストークンを検証する際、自身が意図された受信者であることの検証がMUSTとされています。
RFC 8707 (Resource Indicators for OAuth 2.0)では、OAuthクライアントがresource
パラメータでアクセス先のリソースサーバURLを指定し、認可サーバがそれを元にトークンのaud
クレームを設定する仕組みが定義されています。MCP仕様では、この RFC 8707 のサポートが要求されています。
MCPサーバ側の責務は、アクセストークンのaud
クレームに自身のURL(今回の例ではhttp://localhost:8000
)が含まれていることを検証することです。 これにより、そのアクセストークンが自身専用に発行されたものであることを確認し、他のリソースサーバ向けのトークンの不正利用を防ぎます。
Scope (scope
) の検証
アクセストークンが必要な権限(スコープ)を持っているかを検証します。今回の実装では、mcp:tools
スコープを必須としています。スコープはOAuth 2.0標準に従い、スペース区切りの文字列としてscope
クレームに格納されます。
なお、RFC 9068 (OAuth 2.0 Access Token in JWT Format)では、JWTアクセストークンの内容が規定されていますが、認可サーバによっては完全に準拠していないケースが見られます。たとえばKeycloakでは、クライアント設定で「Use "at+jwt" as access token header type」を有効にすることでRFC 9068準拠のトークンを発行できますが、デフォルトでは無効です(過去の互換性のため)。実装時は、使用する認可サーバが発行するトークンの実際の形式を確認し、それに合わせた検証を行う必要があります。
実装
OAuth保護機能をmiddlewareとして実装します:
oauth_middleware.go
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"time"
"github.com/MicahParks/keyfunc/v3"
"github.com/golang-jwt/jwt/v5"
"github.com/modelcontextprotocol/go-sdk/oauthex"
)
// OAuthConfig holds OAuth configuration
type OAuthConfig struct {
AuthzServerURL string
JwksURL string
ResourceURL string
jwks keyfunc.Keyfunc
}
// InitJWKS initializes the JWKS client
func (c *OAuthConfig) InitJWKS() error {
jwks, err := keyfunc.NewDefault([]string{c.JwksURL})
if err != nil {
return fmt.Errorf("failed to create JWKS client: %w", err)
}
c.jwks = jwks
log.Printf("Initialized JWKS from: %s", c.JwksURL)
return nil
}
// OAuthMiddleware is a middleware that performs OAuth 2.1 authorization
func (c *OAuthConfig) OAuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Check Authorization header
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
c.sendUnauthorized(w, r)
return
}
// Extract Bearer token
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
if tokenString == authHeader {
c.sendUnauthorized(w, r)
return
}
// Validate JWT token using JWKS with algorithm validation
token, err := jwt.Parse(tokenString, c.jwks.Keyfunc, jwt.WithValidMethods([]string{"RS256"}))
if err != nil {
log.Printf("Failed to parse token: %v", err)
c.sendUnauthorized(w, r)
return
}
if !token.Valid {
log.Printf("Invalid token")
c.sendUnauthorized(w, r)
return
}
// Get claims for validation
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
log.Printf("Invalid claims type")
c.sendUnauthorized(w, r)
return
}
// Debug: Dump JWT access token before validation
log.Printf("=== JWT Access Token Debug ===")
log.Printf("Raw Token: %s", tokenString)
claimsJSON, _ := json.MarshalIndent(claims, "", " ")
log.Printf("Claims: %s", string(claimsJSON))
log.Printf("===============================")
// Validate audience (MUST): Verify this resource server is in the audience
if !c.validateAudience(claims) {
log.Printf("Invalid audience")
c.sendUnauthorized(w, r)
return
}
// Validate issuer (MUST): Verify token is issued by expected authorization server
if !c.validateIssuer(claims) {
log.Printf("Invalid issuer")
c.sendUnauthorized(w, r)
return
}
// Validate expiration (MUST): Ensure token is not expired
// Note: jwt.Parse already validates exp by default, but we explicitly check here for clarity
if !c.validateExpiration(claims) {
log.Printf("Token expired")
c.sendUnauthorized(w, r)
return
}
// Validate scope: Verify token has required scopes (optional, depends on your requirements)
if !c.validateScope(claims) {
log.Printf("Insufficient scope")
c.sendUnauthorized(w, r)
return
}
// Authorization successful - proceed to next handler
next.ServeHTTP(w, r)
})
}
// validateAudience validates that the token's audience matches this resource server
func (c *OAuthConfig) validateAudience(claims jwt.MapClaims) bool {
aud, ok := claims["aud"]
if !ok {
return false
}
// aud can be a string or array of strings
switch v := aud.(type) {
case string:
return v == c.ResourceURL
case []interface{}:
for _, a := range v {
if audStr, ok := a.(string); ok && audStr == c.ResourceURL {
return true
}
}
return false
default:
return false
}
}
// validateIssuer validates that the token's issuer matches the expected authorization server
func (c *OAuthConfig) validateIssuer(claims jwt.MapClaims) bool {
iss, ok := claims["iss"].(string)
if !ok {
return false
}
return iss == c.AuthzServerURL
}
// validateExpiration validates that the token has not expired
func (c *OAuthConfig) validateExpiration(claims jwt.MapClaims) bool {
exp, ok := claims["exp"].(float64)
if !ok {
return false
}
// Allow 60 seconds of clock skew
return time.Now().Unix() < int64(exp)+60
}
// validateScope validates that the token has required scopes
func (c *OAuthConfig) validateScope(claims jwt.MapClaims) bool {
scope, ok := claims["scope"].(string)
if !ok {
return false
}
// Scope is a space-separated string (OAuth 2.0 standard)
// Check if "mcp:tools" is present
for _, s := range strings.Split(scope, " ") {
if s == "mcp:tools" {
return true
}
}
return false
}
// sendUnauthorized sends a 401 response with WWW-Authenticate header
func (c *OAuthConfig) sendUnauthorized(w http.ResponseWriter, r *http.Request) {
metadataURL := c.ResourceURL + "/.well-known/oauth-protected-resource"
w.Header().Set("WWW-Authenticate",
fmt.Sprintf(`Bearer resource_metadata="%s", scope="openid profile email"`, metadataURL))
http.Error(w, "Unauthorized", http.StatusUnauthorized)
}
// HandleProtectedResourceMetadata handles the protected resource metadata endpoint
func (c *OAuthConfig) HandleProtectedResourceMetadata(w http.ResponseWriter, r *http.Request) {
// Set CORS headers
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
metadata := oauthex.ProtectedResourceMetadata{
Resource: c.ResourceURL,
ScopesSupported: []string{"mcp:tools"},
AuthorizationServers: []string{c.AuthzServerURL},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(metadata)
}
// LoggingMiddleware logs HTTP requests including method, path, and POST body
func LoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// Log basic request info
log.Printf("[%s] %s %s", r.Method, r.URL.Path, r.RemoteAddr)
// Log POST body if present
if r.Method == "POST" && r.Body != nil {
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
log.Printf("Error reading body: %v", err)
} else {
// Log the body
log.Printf("Body: %s", string(bodyBytes))
// Restore the body for the next handler
r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
}
}
next.ServeHTTP(w, r)
log.Printf("Request completed in %v", time.Since(start))
})
}
次に、main.goを修正してmiddlewareを組み込みます:
main.go
package main
import (
"context"
"flag"
"log"
"net/http"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
type EchoArgs struct {
Message string `json:"message"`
}
func Echo(ctx context.Context, req *mcp.CallToolRequest, args *EchoArgs) (*mcp.CallToolResult, any, error) {
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: "Echo: " + args.Message},
},
}, nil, nil
}
func main() {
// Parse command line flags
authzServerURL := flag.String("authz-server-url", "http://localhost/realms/demo", "Authorization Server URL")
jwksURL := flag.String("jwks-url", "http://localhost/realms/demo/protocol/openid-connect/certs", "JWKS URL")
resourceURL := flag.String("resource-url", "http://localhost:8000", "Resource URL for this server")
flag.Parse()
// Initialize OAuth config
oauthConfig := &OAuthConfig{
AuthzServerURL: *authzServerURL,
JwksURL: *jwksURL,
ResourceURL: *resourceURL,
}
if err := oauthConfig.InitJWKS(); err != nil {
log.Fatalf("Failed to initialize JWKS: %v", err)
}
server := mcp.NewServer(&mcp.Implementation{
Name: "simple-mcp-server",
Version: "1.0.0",
}, nil)
mcp.AddTool(server, &mcp.Tool{
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 message to echo back",
},
},
"required": []string{"message"},
},
}, Echo)
// MCP handler
mcpHandler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server {
return server
}, nil)
// Setup routing
mux := http.NewServeMux()
// OAuth 2.1 metadata endpoint (no authorization required)
mux.HandleFunc("/.well-known/oauth-protected-resource", oauthConfig.HandleProtectedResourceMetadata)
// MCP endpoint (OAuth authorization required, with logging)
mux.Handle("/", LoggingMiddleware(oauthConfig.OAuthMiddleware(mcpHandler)))
log.Println("Starting MCP server on :8000")
log.Printf("Authorization Server URL: %s", *authzServerURL)
log.Printf("JWKS URL: %s", *jwksURL)
log.Printf("Resource URL: %s", *resourceURL)
log.Println("Tool available: echo")
log.Println("OAuth2.1 endpoint:")
log.Println(" - /.well-known/oauth-protected-resource")
if err := http.ListenAndServe(":8000", mux); err != nil {
log.Printf("Server failed: %v", err)
}
}
主なポイント:
- コマンドライン引数で認可サーバURL、JWKS URL、Resource URLを受け取る
-
OAuthMiddleware
でMCPエンドポイント(/
)を保護 -
/.well-known/oauth-protected-resource
エンドポイントは保護せず、メタデータを公開 - デバッグ用に
LoggingMiddleware
を追加(リクエスト内容をログ出力)
この時点では、認可サーバがまだないため動作確認はできません。次のセクションで認可サーバを準備します。
認可サーバの準備
OAuthによる保護を実装するには、認可サーバが必要です。本記事では、MCP Authorization仕様で求められるスペックの大部分を実装しているKeycloak 26.4を使用します1。
KeycloakのCORSの問題とワークアラウンド
Keycloak 26.4には既知の問題があり、MCP InspectorなどのブラウザアプリがMCPクライアントとして接続する際、CORSエラーが発生します。
ワークアラウンドとして、nginxをリバースプロキシとして配置し、CORSヘッダーを追加します。
Docker Composeで起動
以下の構成でKeycloakとnginxを起動します:
docker-compose.yml
services:
nginx:
image: nginx:latest
ports:
- "80:80"
depends_on:
- keycloak
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
keycloak:
image: quay.io/keycloak/keycloak:26.4.0
command:
- start-dev
- --proxy-headers=xforwarded
environment:
KC_BOOTSTRAP_ADMIN_USERNAME: admin
KC_BOOTSTRAP_ADMIN_PASSWORD: admin
nginx.confでは、Dynamic Client Registration (DCR) エンドポイントに対してCORSヘッダーを追加します:
nginx.conf
events {}
http {
log_format debug_format '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" '
'host="$proxy_host" '
'body="$request_body"';
access_log /dev/stdout debug_format;
upstream keycloak_upstream {
server keycloak:8080;
}
server {
listen 80;
client_body_buffer_size 1M;
location / {
proxy_pass http://keycloak_upstream;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/realms/[^/]+/clients-registrations/openid-connect {
# preflight (OPTIONS)
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type' always;
add_header 'Access-Control-Max-Age' 3600;
return 204;
}
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type' always;
proxy_pass http://keycloak_upstream;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
}
Keycloakとnginxを起動します:
docker-compose up -d
Keycloakの管理画面はhttp://localhost/admin
(admin/admin)でアクセスできます。
Keycloakの設定
-
新しいRealmを作成(例:
demo
) -
Client scopes → Create client scope からクライアントスコープを追加
-
Realm Settings → Clients → Client registration → Client Policies → Policies タブ
-
Users → Add user でテスト用ユーザを追加
RFC 8707対応について
Keycloak 26.4はまだRFC 8707 (Resource Indicators for OAuth 2.0)に対応していません。RFC 8707では、クライアントがresource
パラメータでリソースサーバのURLを指定し、認可サーバがそれを元にトークンのaud
クレームを設定する仕組みが定義されています。
そのため、ここでは代替手段として、mcp:tools
スコープが要求された場合に、Audience Mapperを使用してトークンのaud
クレームにリソースサーバのURL(http://localhost:8000
)を設定するようにしています。これにより、MCPサーバ側でaud
クレームの検証が可能になります。
これでDCRとログインに必要な準備が完了します。
OAuth 2.1準拠について
MCP Authorization仕様では、ドラフト版であるOAuth 2.1への準拠を要求しています。OAuth 2.1ではPKCE(Proof Key for Code Exchange)が必須とされていますが、KeycloakではDCRで登録されたクライアントに対してデフォルトではPKCEが必須になりません。
準拠させるには、KeycloakのClient Policy機能を使用して、例えば以下のような設定が必要になるでしょう:
- Confidential Clientには「oauth-2-1-for-confidential-client」ポリシーを適用
MCP Inspectorで動作確認
OAuth保護を追加したMCPサーバを起動します。Keycloakの場合、JWKS URLはhttp://localhost/realms/demo/protocol/openid-connect/certs
となりますので以下のように実行します:
go run . \
-authz-server-url="http://localhost/realms/demo" \
-jwks-url="http://localhost/realms/demo/protocol/openid-connect/certs" \
-resource-url="http://localhost:8000"
MCP Inspectorから接続すると、DCR (Dynamic Client Registration) が自動的に実行され、認可コードフローが開始されます。Keycloakのログイン画面が表示されるので、認証すればMCPサーバに接続できます!
MCP InspectorのAuthentication SettingsからOAuthのフローの詳細を確認することもできます。
Keycloakの管理画面からクライアント一覧を参照すると、DCRによりMCP Inspector用のクライアントが自動登録されていることがわかります。
MCP Authorization仕様の今後について
MCP Authorization仕様(2025-06-18版)では、Dynamic Client Registration (DCR) を採用していますが、MCP公式ブログでいくつかの課題が指摘されています:
DCRの運用上の課題:
- 認可サーバのデータベースが無制限に増大する
- Client IDの有効期限管理の仕組みが不明確
- DoS攻撃の脆弱性
セキュリティリスク:
- クライアントのなりすまし攻撃
今後の改善案として、以下が提案されています:
- SEP-991: Client ID Metadata Documents (CIMD): HTTPSメタデータURLをClient IDとして使用し、認可時にメタデータを取得する方式。データベース増大問題を解決。
- SEP-1032: Software Statements: デスクトップアプリ向けに、バックエンドがJWTを発行する方式。なりすまし攻撃の難易度を上げる。
これらは相互補完的なアプローチとして検討されており、認可サーバが信頼レベルを選択できるような柔軟性を目指しているようです。
一方で、エンタープライズ環境ではDCRそのものを使わない別のアプローチも提案されています。SEP-646では、DCRを使わない Enterprise-Managed Authorization Profile が提案されています。以下の特徴があります:
- 企業のIdP(OIDCまたはSAML)を利用した認証
- エンドユーザーによるMCPサーバ接続のための手動セットアップ作業を削減
- 企業の管理者がMCPサーバへのアクセスを集中管理
- ID TokenからMCPアクセストークンへのToken Exchangeをサポート
このアプローチにより、エンタープライズ環境でのセキュアなMCP導入が容易になることが期待されています。
また、SEP-1036では、Elicitation機能に新しい URL mode を追加する提案が議論されています。
現在のMCP Authorization仕様は、MCPクライアントからMCPサーバへのアクセス認可のみをカバーしています。しかし、MCPサーバが外部API(GitHub、Slack、Google Driveなど)にアクセスする場合、その外部APIのアクセストークンをどう安全に取得するかという問題が残っています。
MCPのセキュリティベストプラクティスでも、トークンのパススルー(MCPクライアントから受け取ったトークンを、MCPサーバが検証せずにそのまま外部APIに渡すこと)は避けるべきとされています。URL modeは、この問題を解決するための仕組みです。SEP-1036を見ると以下のような流れを想定しているようです:
- MCPサーバは、外部サービス(例:GitHub)の認可リクエストのURL(
https://github.com/login/oauth/authorize?client_id=...&state=...
)を直接MCPクライアントに返す - MCPクライアントは、そのURLをユーザーに提示し、ユーザーの明示的な同意を得た上でブラウザで開く
- ユーザーは、ブラウザで外部サービスの認可画面で認可を行う
- 認可コードはMCPサーバのコールバックエンドポイントに渡され、MCPサーバが外部サービスのアクセストークンを取得する
- 外部サービスのアクセストークンは、MCPサーバのみが保持し、MCPクライアントを経由しない
これにより、外部APIのアクセストークンがMCPクライアントを経由せず、信頼境界が明確に保たれます。
しかし、このフローには重要な課題があるように思います。MCPサーバは複数のユーザーからのリクエストを処理するため、どのユーザーの外部APIトークンなのかを正しく識別し、MCPクライアントが管理するアクセストークンと紐づける必要があります。紐づけを誤ると、あるユーザーが別のユーザーの外部APIリソース(例:Google Drive)にアクセスしてしまう深刻なセキュリティ問題が発生します。
この紐づけの課題について、Elicitationのドラフト仕様では、MCPサーバはURLを開いたユーザーの識別情報を検証し、Elicitationを開始したユーザーと同一であることを確認しなければならない とされています。しかし、具体的な方法については言及されていないようです。
個人的には、認可リクエストのURLをMCPクライアントに直接渡す方法だとこの問題の解決は難しいのでは・・?と思います。通常のOAuthの認可コードフローのように、ブラウザを通じてMCPサーバにアクセスし、そこから外部サービスに対して認可リクエストを発行しないと危険な予感がします。
まとめ
本記事では、Go言語を使ってOAuth 2.1で保護されたMCPサーバを実装しました。Streamable HTTP transportでシンプルなMCPサーバを作成し、OAuthで保護するためにOAuth 2.0 Protected Resource Metadataとアクセストークンの検証を実装しました。また、Keycloakを認可サーバとして構成しました。
MCPの認証・認可の仕組みは現在も活発に議論されており、今後大きく変化していく可能性があります。本記事で紹介した実装は、MCPクライアントからMCPサーバへのアクセス保護に焦点を当てたものですが、実用的なMCPサーバではさらにMCPサーバから外部API(GitHub、Slack、Google Driveなど)へのアクセスを統合する必要があります。この外部サービス統合については、SEP-1036でURL modeが提案されています。
本記事で紹介した実装は現時点(2025-06-18版仕様)でのアプローチですが、DCRの改善やエンタープライズ環境向けの代替手法、外部サービス統合の仕組みなど、様々な提案が検討されています。実装する際は、最新の仕様と議論を確認することをお勧めします。
参考資料
- Model Context Protocol
- MCP Go SDK
- MCP Inspector
- Keycloak
- OAuth 2.1 Draft
- RFC 9728: OAuth 2.0 Protected Resource Metadata
- keyfunc - JWKS library for Go
-
残念ながらRFC 8707 (Resource Indicators for OAuth 2.0)については、Keycloakではまだ実装していません(Issue #14355でマイルストーン26.5が設定されており、次のマイナーバージョンアップ時に対応される見込みです)。 ↩