From fcf1d7a21fcb7ba9de3e47ef3f5cb36012f0d1d2 Mon Sep 17 00:00:00 2001 From: Manuel Ganter Date: Mon, 5 Jan 2026 16:03:32 +0100 Subject: [PATCH 01/16] chore: added gitleaks pre commit hook --- .gitleaksignore | 4 ++++ Makefile | 9 ++++++++- go.mod | 2 +- scripts/hooks/pre-commit | 4 ++++ 4 files changed, 17 insertions(+), 2 deletions(-) create mode 100644 .gitleaksignore diff --git a/.gitleaksignore b/.gitleaksignore new file mode 100644 index 0000000..b20dfb8 --- /dev/null +++ b/.gitleaksignore @@ -0,0 +1,4 @@ +# False positives - documentation examples with placeholder credentials +053e909940b7b5370e855b9bf5236f04d8bdd451:QUICKSTART.md:curl-auth-user:197 +b8b8da13d3c62c597d4029c23ed1a0ae7073e561:REMOTE_SERVER.md:curl-auth-header:61 +b8b8da13d3c62c597d4029c23ed1a0ae7073e561:REMOTE_SERVER.md:curl-auth-header:358 diff --git a/Makefile b/Makefile index 3b3aaec..55a1c84 100644 --- a/Makefile +++ b/Makefile @@ -4,12 +4,13 @@ BINARY_NAME := edge-connect-mcp GO := go GOLANGCI_LINT := $(GO) run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.6.2 +GITLEAKS := $(GO) run github.com/zricethezav/gitleaks/v8@v8.30.0 # Build flags LDFLAGS := -s -w BUILD_FLAGS := -ldflags "$(LDFLAGS)" -.PHONY: all build clean fmt format lint test run help vet tidy install-hooks +.PHONY: all build clean fmt format lint gitleaks test run help vet tidy install-hooks # Default target all: fmt vet lint build @@ -36,6 +37,12 @@ vet: ## Run go vet lint: ## Run golangci-lint $(GOLANGCI_LINT) run ./... +gitleaks: ## Check for secrets in git history + $(GITLEAKS) git --staged + +gitleaks-all: ## Check for secrets in git history + $(GITLEAKS) git . + ## Dependency management tidy: ## Tidy go modules diff --git a/go.mod b/go.mod index 02dfea9..e6d6a60 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module edp.buildth.ing/DevFW-CICD/edge-connect-mcp -go 1.25.3 +go 1.25.5 require ( edp.buildth.ing/DevFW-CICD/edge-connect-client/v2 v2.1.2 diff --git a/scripts/hooks/pre-commit b/scripts/hooks/pre-commit index 4beba3f..a522e31 100755 --- a/scripts/hooks/pre-commit +++ b/scripts/hooks/pre-commit @@ -21,4 +21,8 @@ fi echo "Running linter..." make lint +# Check for secrets with gitleaks +echo "Checking for secrets..." +make gitleaks --staged + echo "Pre-commit checks passed!" From 101bd6f5e39d45a0c8ad9a616265f28d51ec0160 Mon Sep 17 00:00:00 2001 From: Manuel Ganter Date: Mon, 5 Jan 2026 16:59:11 +0100 Subject: [PATCH 02/16] added OAuth2.1 flow --- .env.example | 100 +++++++- README.md | 82 +++++- config.go | 136 +++++++++- docs/OAUTH_SECURITY.md | 458 ++++++++++++++++++++++++++++++++++ docs/OAUTH_SETUP.md | 315 +++++++++++++++++++++++ go.mod | 2 + go.sum | 6 +- main.go | 106 +++++++- oauth/authz_server.go | 253 +++++++++++++++++++ oauth/authz_server_test.go | 403 ++++++++++++++++++++++++++++++ oauth/jwks.go | 75 ++++++ oauth/jwks_test.go | 150 +++++++++++ oauth/middleware.go | 82 ++++++ oauth/middleware_test.go | 269 ++++++++++++++++++++ oauth/oauth.go | 92 +++++++ oauth/pkce.go | 48 ++++ oauth/pkce_test.go | 115 +++++++++ oauth/resource_server.go | 89 +++++++ oauth/resource_server_test.go | 209 ++++++++++++++++ oauth/storage.go | 145 +++++++++++ oauth/storage_test.go | 193 ++++++++++++++ oauth/token_validator.go | 231 +++++++++++++++++ 22 files changed, 3519 insertions(+), 40 deletions(-) create mode 100644 docs/OAUTH_SECURITY.md create mode 100644 docs/OAUTH_SETUP.md create mode 100644 oauth/authz_server.go create mode 100644 oauth/authz_server_test.go create mode 100644 oauth/jwks.go create mode 100644 oauth/jwks_test.go create mode 100644 oauth/middleware.go create mode 100644 oauth/middleware_test.go create mode 100644 oauth/oauth.go create mode 100644 oauth/pkce.go create mode 100644 oauth/pkce_test.go create mode 100644 oauth/resource_server.go create mode 100644 oauth/resource_server_test.go create mode 100644 oauth/storage.go create mode 100644 oauth/storage_test.go create mode 100644 oauth/token_validator.go diff --git a/.env.example b/.env.example index 2f9f783..73eee34 100644 --- a/.env.example +++ b/.env.example @@ -1,27 +1,101 @@ +# Edge Connect MCP Server Configuration +# Copy this file to .env and update with your values + +# =================================== # Edge Connect API Configuration +# =================================== + +# Base URL of the Edge Connect API (required) EDGE_CONNECT_BASE_URL=https://hub.apps.edge.platform.mg3.mdb.osc.live + +# Authentication type: token, credentials, or none (required) EDGE_CONNECT_AUTH_TYPE=credentials -# Authentication - Token based (when auth_type=token) -# EDGE_CONNECT_TOKEN=your-bearer-token-here +# For token-based authentication (if auth_type=token) +#EDGE_CONNECT_TOKEN=your-token-here -# Authentication - Credentials based (when auth_type=credentials) +# For credentials-based authentication (if auth_type=credentials) EDGE_CONNECT_USERNAME=your-username EDGE_CONNECT_PASSWORD=your-password -# Optional Configuration +# Default region (optional, default: EU) EDGE_CONNECT_DEFAULT_REGION=EU -EDGE_CONNECT_DEBUG=false -# MCP Server Mode Configuration -# Options: "stdio" (local) or "remote" (HTTP/SSE) -MCP_SERVER_MODE=stdio +# Enable debug logging (optional) +#EDGE_CONNECT_DEBUG=true -# Remote Server Configuration (when MCP_SERVER_MODE=remote) +# =================================== +# MCP Server Configuration +# =================================== + +# Server mode: stdio or remote (default: stdio) +MCP_SERVER_MODE=remote + +# Remote server host (default: 0.0.0.0) MCP_REMOTE_HOST=0.0.0.0 + +# Remote server port (default: 8080) MCP_REMOTE_PORT=8080 -# Remote Server Authentication (optional but recommended for production) -MCP_REMOTE_AUTH_REQUIRED=false -# Comma-separated list of valid Bearer tokens -# MCP_REMOTE_AUTH_TOKENS=token1,token2,token3 +# =================================== +# Simple Bearer Token Authentication +# (Used when OAuth is disabled) +# =================================== + +# Enable bearer token authentication for remote access (optional) +#MCP_REMOTE_AUTH_REQUIRED=true + +# Comma-separated list of valid bearer tokens (optional) +#MCP_REMOTE_AUTH_TOKENS=token1,token2,token3 + +# =================================== +# OAuth 2.1 Configuration +# (Recommended for production) +# =================================== + +# Enable OAuth 2.1 authorization (optional, default: false) +OAUTH_ENABLED=true + +# OAuth mode (default: resource_server) +OAUTH_MODE=resource_server + +# Resource URI - the canonical URI of this MCP server (required if OAuth enabled) +OAUTH_RESOURCE_URI=http://localhost:8080 + +# Comma-separated list of authorization server URLs (required if OAuth enabled) +OAUTH_AUTH_SERVERS=http://localhost:8081 + +# Expected issuer in JWT tokens (required if OAuth enabled) +OAUTH_ISSUER=http://localhost:8081 + +# JWKS endpoint URL for token validation (required if OAuth enabled) +OAUTH_JWKS_URL=http://localhost:8081/.well-known/jwks.json + +# =================================== +# Basic Authorization Server +# (For development/testing only) +# =================================== + +# Enable built-in basic authorization server (optional, default: false) +OAUTH_AUTH_SERVER_ENABLED=true + +# Port for the authorization server (default: 8081) +OAUTH_AUTH_SERVER_PORT=8081 + +# OAuth client ID to register (required if auth server enabled) +OAUTH_CLIENT_ID=test-client + +# OAuth redirect URI for the client (required if auth server enabled) +OAUTH_REDIRECT_URI=http://localhost:3000/callback + +# =================================== +# Production OAuth Configuration +# =================================== +# For production, use an external authorization server: +# +# OAUTH_ENABLED=true +# OAUTH_RESOURCE_URI=https://mcp.example.com +# OAUTH_AUTH_SERVERS=https://auth.example.com +# OAUTH_ISSUER=https://auth.example.com +# OAUTH_JWKS_URL=https://auth.example.com/.well-known/jwks.json +# OAUTH_AUTH_SERVER_ENABLED=false # Don't use basic auth server in production diff --git a/README.md b/README.md index 80a50f7..4ef688b 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,67 @@ Command-line flags override environment variables: - `-host`: Host to bind to (remote mode only) - `-port`: Port to bind to (remote mode only) +#### 3. Remote Mode with OAuth 2.1 (Recommended for Production) + +For production deployments, use OAuth 2.1 authorization: + +```bash +# Edge Connect API configuration (unchanged) +export EDGE_CONNECT_BASE_URL="https://hub.apps.edge.platform.mg3.mdb.osc.live" +export EDGE_CONNECT_AUTH_TYPE="credentials" +export EDGE_CONNECT_USERNAME="your-username" +export EDGE_CONNECT_PASSWORD="your-password" + +# MCP Server configuration +export MCP_SERVER_MODE="remote" +export MCP_REMOTE_HOST="0.0.0.0" +export MCP_REMOTE_PORT="8080" + +# OAuth 2.1 configuration +export OAUTH_ENABLED="true" +export OAUTH_MODE="resource_server" +export OAUTH_RESOURCE_URI="https://mcp.example.com" +export OAUTH_AUTH_SERVERS="https://auth.example.com" +export OAUTH_ISSUER="https://auth.example.com" +export OAUTH_JWKS_URL="https://auth.example.com/.well-known/jwks.json" + +# Run the server +./edge-connect-mcp -mode remote +``` + +For local development/testing with the built-in basic authorization server: + +```bash +# Enable built-in authorization server +export OAUTH_AUTH_SERVER_ENABLED="true" +export OAUTH_AUTH_SERVER_PORT="8081" +export OAUTH_CLIENT_ID="test-client" +export OAUTH_REDIRECT_URI="http://localhost:5173/callback" + +# Use localhost URIs +export OAUTH_RESOURCE_URI="http://localhost:8080" +export OAUTH_AUTH_SERVERS="http://localhost:8081" +export OAUTH_ISSUER="http://localhost:8081" +export OAUTH_JWKS_URL="http://localhost:8081/.well-known/jwks.json" + +./edge-connect-mcp -mode remote +``` + +The server provides these OAuth endpoints: + +**MCP Server (Protected Resource)**: +- `GET /.well-known/oauth-protected-resource` - Protected Resource Metadata (RFC 9728) + +**Basic Authorization Server** (if enabled): +- `GET /authorize` - Authorization endpoint +- `POST /token` - Token endpoint +- `GET /.well-known/jwks.json` - JWKS endpoint +- `GET /.well-known/oauth-authorization-server` - Authorization server metadata (RFC 8414) + +For detailed OAuth setup and security best practices, see: +- [OAuth Setup Guide](docs/OAUTH_SETUP.md) +- [OAuth Security Best Practices](docs/OAUTH_SECURITY.md) + ### Integrating with Claude Code (CLI) To add this MCP server to Claude Code, use the `mcp add` command: @@ -269,20 +330,25 @@ This implementation follows the security guidelines from CLAUDE.md: ### Remote Server Security When running in remote mode: -1. **Bearer Token Authentication**: Optional Bearer token validation for remote access -2. **Rate Limiting**: Basic rate limiting to prevent DoS attacks -3. **CORS**: Configurable CORS headers for web client access -4. **Timeouts**: Request/response timeouts to prevent resource exhaustion -5. **Graceful Shutdown**: Proper shutdown handling for safe termination +1. **OAuth 2.1 Authorization** (Recommended): Full OAuth 2.1 support with JWT validation, PKCE, and RFC 8707 token audience binding +2. **Simple Bearer Token Authentication**: Optional Bearer token validation for remote access (fallback) +3. **Rate Limiting**: Basic rate limiting to prevent DoS attacks +4. **CORS**: Configurable CORS headers for web client access +5. **Timeouts**: Request/response timeouts to prevent resource exhaustion +6. **Graceful Shutdown**: Proper shutdown handling for safe termination **Production Recommendations**: -- Always enable `MCP_REMOTE_AUTH_REQUIRED=true` for remote deployments -- Use strong, randomly generated tokens (min 32 characters) +- **Use OAuth 2.1** with a production authorization server (Auth0, Cognito, Keycloak) - Deploy behind a reverse proxy with HTTPS/TLS termination -- Implement proper OAuth 2.1 with PKCE for production use - Use firewall rules to restrict access to trusted networks - Enable rate limiting at the reverse proxy level - Monitor and log all access attempts +- Follow security best practices in [docs/OAUTH_SECURITY.md](docs/OAUTH_SECURITY.md) + +**Development/Testing**: +- For simple testing, use `MCP_REMOTE_AUTH_REQUIRED=true` with bearer tokens +- For OAuth testing, use the built-in basic authorization server +- Never use the basic authorization server in production ## Dependencies diff --git a/config.go b/config.go index 08cd126..e5d2be3 100644 --- a/config.go +++ b/config.go @@ -2,10 +2,13 @@ package main import ( "fmt" + "log" "os" "strconv" "strings" "time" + + "github.com/joho/godotenv" ) type Config struct { @@ -40,21 +43,51 @@ type Config struct { // Debug Debug bool `json:"debug"` + + // OAuth Configuration + OAuthEnabled bool `json:"oauth_enabled"` + OAuthMode string `json:"oauth_mode"` // "resource_server" + OAuthResourceURI string `json:"oauth_resource_uri"` + OAuthAuthServers []string `json:"oauth_auth_servers"` + OAuthIssuer string `json:"oauth_issuer"` + OAuthJWKSURL string `json:"oauth_jwks_url"` + + // Basic Auth Server (for testing) + OAuthAuthServerEnabled bool `json:"oauth_auth_server_enabled"` + OAuthAuthServerPort int `json:"oauth_auth_server_port"` + OAuthClientID string `json:"oauth_client_id"` + OAuthRedirectURI string `json:"oauth_redirect_uri"` } func LoadConfig() (*Config, error) { + // Load .env file if it exists (optional) + // Silently ignore if .env doesn't exist - environment variables will be used + if err := godotenv.Load(); err != nil { + // Try .env in current directory first + if err := godotenv.Load(".env"); err != nil { + // Not an error - just means we'll use environment variables only + log.Printf("No .env file found, using environment variables only") + } + } else { + log.Printf("Loaded configuration from .env file") + } + cfg := &Config{ // Default values - DefaultRegion: "EU", - RetryMaxRetries: 3, - RetryInitialDelay: 1 * time.Second, - RetryMaxDelay: 30 * time.Second, - RetryMultiplier: 2.0, - Debug: false, - ServerMode: "stdio", - RemoteHost: "0.0.0.0", - RemotePort: 8080, - RemoteAuthRequired: false, + DefaultRegion: "EU", + RetryMaxRetries: 3, + RetryInitialDelay: 1 * time.Second, + RetryMaxDelay: 30 * time.Second, + RetryMultiplier: 2.0, + Debug: false, + ServerMode: "stdio", + RemoteHost: "0.0.0.0", + RemotePort: 8080, + RemoteAuthRequired: false, + OAuthEnabled: false, + OAuthMode: "resource_server", + OAuthAuthServerEnabled: false, + OAuthAuthServerPort: 8081, } // Load Edge Connect API configuration @@ -114,6 +147,55 @@ func LoadConfig() (*Config, error) { } } + // Load OAuth configuration + if oauthEnabled := os.Getenv("OAUTH_ENABLED"); oauthEnabled == "true" || oauthEnabled == "1" { + cfg.OAuthEnabled = true + } + + if oauthMode := os.Getenv("OAUTH_MODE"); oauthMode != "" { + cfg.OAuthMode = oauthMode + } + + if oauthResourceURI := os.Getenv("OAUTH_RESOURCE_URI"); oauthResourceURI != "" { + cfg.OAuthResourceURI = oauthResourceURI + } + + if oauthAuthServers := os.Getenv("OAUTH_AUTH_SERVERS"); oauthAuthServers != "" { + // Support comma-separated list of authorization servers + cfg.OAuthAuthServers = strings.Split(oauthAuthServers, ",") + // Trim whitespace from each server + for i, server := range cfg.OAuthAuthServers { + cfg.OAuthAuthServers[i] = strings.TrimSpace(server) + } + } + + if oauthIssuer := os.Getenv("OAUTH_ISSUER"); oauthIssuer != "" { + cfg.OAuthIssuer = oauthIssuer + } + + if oauthJWKSURL := os.Getenv("OAUTH_JWKS_URL"); oauthJWKSURL != "" { + cfg.OAuthJWKSURL = oauthJWKSURL + } + + // Load Basic Auth Server configuration + if authServerEnabled := os.Getenv("OAUTH_AUTH_SERVER_ENABLED"); authServerEnabled == "true" || authServerEnabled == "1" { + cfg.OAuthAuthServerEnabled = true + } + + if authServerPortStr := os.Getenv("OAUTH_AUTH_SERVER_PORT"); authServerPortStr != "" { + if port, err := strconv.Atoi(authServerPortStr); err == nil { + cfg.OAuthAuthServerPort = port + } + } + + if oauthClientID := os.Getenv("OAUTH_CLIENT_ID"); oauthClientID != "" { + cfg.OAuthClientID = oauthClientID + } + + if oauthRedirectURI := os.Getenv("OAUTH_REDIRECT_URI"); oauthRedirectURI != "" { + cfg.OAuthRedirectURI = oauthRedirectURI + } + return cfg, nil } @@ -154,5 +236,39 @@ func (c *Config) Validate() error { } } + // Validate OAuth configuration + if c.OAuthEnabled { + if c.OAuthResourceURI == "" { + return fmt.Errorf("oauth_resource_uri is required when oauth is enabled (set OAUTH_RESOURCE_URI)") + } + + if len(c.OAuthAuthServers) == 0 { + return fmt.Errorf("oauth_auth_servers is required when oauth is enabled (set OAUTH_AUTH_SERVERS)") + } + + if c.OAuthIssuer == "" { + return fmt.Errorf("oauth_issuer is required when oauth is enabled (set OAUTH_ISSUER)") + } + + if c.OAuthJWKSURL == "" { + return fmt.Errorf("oauth_jwks_url is required when oauth is enabled (set OAUTH_JWKS_URL)") + } + } + + // Validate Basic Auth Server configuration + if c.OAuthAuthServerEnabled { + if c.OAuthAuthServerPort <= 0 || c.OAuthAuthServerPort > 65535 { + return fmt.Errorf("oauth_auth_server_port must be between 1 and 65535") + } + + if c.OAuthClientID == "" { + return fmt.Errorf("oauth_client_id is required when auth server is enabled (set OAUTH_CLIENT_ID)") + } + + if c.OAuthRedirectURI == "" { + return fmt.Errorf("oauth_redirect_uri is required when auth server is enabled (set OAUTH_REDIRECT_URI)") + } + } + return nil } diff --git a/docs/OAUTH_SECURITY.md b/docs/OAUTH_SECURITY.md new file mode 100644 index 0000000..0478867 --- /dev/null +++ b/docs/OAUTH_SECURITY.md @@ -0,0 +1,458 @@ +# OAuth 2.1 Security Best Practices + +This document outlines security best practices for implementing and deploying OAuth 2.1 authorization with the Edge Connect MCP server. + +## Table of Contents + +1. [Token Security](#token-security) +2. [HTTPS Requirements](#https-requirements) +3. [Token Audience Validation (RFC 8707)](#token-audience-validation-rfc-8707) +4. [PKCE Requirements](#pkce-requirements) +5. [Token Lifetime](#token-lifetime) +6. [Key Management](#key-management) +7. [Rate Limiting](#rate-limiting) +8. [Logging and Monitoring](#logging-and-monitoring) +9. [Production Deployment](#production-deployment) + +## Token Security + +### Never Log Tokens + +**Critical**: Never log access tokens, refresh tokens, or authorization codes in application logs. + +```bash +# BAD - logs contain tokens +log.Printf("Token received: %s", token) + +# GOOD - log without sensitive data +log.Printf("Token validation successful for client: %s", clientID) +``` + +### Secure Token Storage + +- **MCP Clients**: Store tokens in secure storage (OS keychain, encrypted file) +- **MCP Server**: Tokens should only be held in memory during validation +- **Authorization Server**: Use encrypted database or secure key-value store + +### Token Theft Prevention + +If tokens are stolen, attackers can impersonate legitimate users. Mitigate this risk: + +1. **Short-lived access tokens** (1 hour or less) +2. **Token audience binding** (RFC 8707) - ensures tokens can only be used with intended resource +3. **HTTPS only** - prevents token interception in transit +4. **Monitor for anomalies** - detect unusual token usage patterns + +## HTTPS Requirements + +### Mandatory HTTPS in Production + +All OAuth 2.1 endpoints **MUST** use HTTPS in production: + +- Authorization server endpoints +- Token endpoint +- JWKS endpoint +- MCP server endpoints + +### Development Exceptions + +For local development, HTTP is acceptable for localhost: + +```bash +# Development - OK +export OAUTH_RESOURCE_URI=http://localhost:8080 +export OAUTH_ISSUER=http://localhost:8081 + +# Production - MUST use HTTPS +export OAUTH_RESOURCE_URI=https://mcp.example.com +export OAUTH_ISSUER=https://auth.example.com +``` + +### TLS Configuration + +For production deployments: + +1. **Use TLS 1.2 or higher** +2. **Strong cipher suites only** (ECDHE-RSA-AES256-GCM-SHA384, etc.) +3. **Valid certificates** from trusted CA (Let's Encrypt, etc.) +4. **Certificate pinning** (optional, for enhanced security) + +### Mutual TLS (mTLS) + +For high-security environments, consider implementing mTLS: + +- Client presents certificate to server +- Server validates client certificate +- Additional layer of authentication beyond OAuth tokens + +## Token Audience Validation (RFC 8707) + +### Critical Security Requirement + +Token audience validation is **MANDATORY** to prevent confused deputy attacks. + +**RFC 8707 Resource Indicators** bind tokens to their intended resource: + +```json +{ + "iss": "https://auth.example.com", + "sub": "client-123", + "aud": "https://mcp.example.com", // ← MUST match MCP server URI + "exp": 1735689600, + "scope": "mcp" +} +``` + +### Confused Deputy Attack + +Without audience validation: + +1. Attacker obtains token for `https://api-a.example.com` +2. Attacker uses token to access `https://api-b.example.com` +3. If `api-b` doesn't validate audience, attack succeeds + +### Implementation + +The MCP server **automatically validates** audience: + +```go +// In token_validator.go - validates aud claim +audienceValid := false +for _, a := range aud { + if a == v.expectedAudience { + audienceValid = true + break + } +} +if !audienceValid { + return nil, fmt.Errorf("token audience does not match expected audience") +} +``` + +### Configuration + +Ensure `OAUTH_RESOURCE_URI` matches exactly: + +```bash +# MCP server URI +export OAUTH_RESOURCE_URI=https://mcp.example.com + +# Client must request token with matching resource +# Authorization request: +# ?resource=https://mcp.example.com +``` + +## PKCE Requirements + +### Mandatory PKCE + +OAuth 2.1 **requires** PKCE (Proof Key for Code Exchange) for all clients. + +### S256 Challenge Method + +The MCP server **only supports S256** (SHA256): + +```bash +# Generate code verifier (43-128 characters) +CODE_VERIFIER=$(openssl rand -base64 32 | tr -d '=' | tr '+/' '-_') + +# Generate code challenge (SHA256 of verifier, base64url encoded) +CODE_CHALLENGE=$(echo -n "$CODE_VERIFIER" | openssl dgst -sha256 -binary | base64 | tr -d '=' | tr '+/' '-_') + +# Authorization request +curl "http://localhost:8081/authorize?...&code_challenge=$CODE_CHALLENGE&code_challenge_method=S256" +``` + +### Why PKCE? + +PKCE prevents authorization code interception attacks: + +1. Attacker intercepts authorization code +2. Attacker tries to exchange code for token +3. Server requires code verifier (only original client has it) +4. Attack fails + +### Plain Method Not Supported + +The `plain` challenge method is **not supported** per OAuth 2.1 requirements. + +## Token Lifetime + +### Access Token Lifetime + +**Default**: 1 hour + +For higher security environments, use shorter lifetimes: + +```go +// In authz_server.go +tokenLifetime: 15 * time.Minute, // 15 minutes instead of 1 hour +``` + +### Authorization Code Lifetime + +**Default**: 10 minutes + +Authorization codes should be short-lived and single-use: + +```go +// In storage.go +ExpiresAt: time.Now().Add(10 * time.Minute), +``` + +### Refresh Tokens + +Phase 1 does not implement refresh tokens. For Phase 2: + +- Issue refresh tokens with long lifetime (days/weeks) +- Rotate refresh tokens on each use (OAuth 2.1 requirement) +- Bind refresh tokens to specific clients + +## Key Management + +### RSA Key Pair + +The basic authorization server generates a 2048-bit RSA key pair on startup. + +### Key Rotation + +**Production Recommendation**: Rotate signing keys regularly (e.g., every 90 days). + +For production authorization servers: + +1. Generate new key pair +2. Add new public key to JWKS with new `kid` +3. Sign new tokens with new key +4. Keep old public key in JWKS for existing token validation +5. Remove old key after all tokens signed with it expire + +### Key Storage + +**Basic Authorization Server** (development): +- Key stored in memory +- Lost on restart +- Not suitable for production + +**Production Authorization Server**: +- Store private key in Hardware Security Module (HSM) +- Or use Key Management Service (AWS KMS, Azure Key Vault) +- Never store private keys in code or version control + +### JWKS Caching + +The MCP server caches JWKS for 15 minutes: + +```go +// In token_validator.go +cacheDuration: 15 * time.Minute, +``` + +This reduces load on the authorization server while allowing for key rotation. + +## Rate Limiting + +### Protect Authorization Endpoints + +Rate limit these endpoints to prevent abuse: + +- `/authorize` - prevent authorization request floods +- `/token` - prevent token exchange brute force +- `/sse` - prevent resource access floods + +### Implementation + +Use a reverse proxy (nginx, HAProxy) for rate limiting: + +```nginx +# nginx example +limit_req_zone $binary_remote_addr zone=oauth_token:10m rate=10r/m; +limit_req_zone $binary_remote_addr zone=oauth_authorize:10m rate=30r/m; +limit_req_zone $binary_remote_addr zone=mcp_sse:10m rate=100r/m; + +server { + location /token { + limit_req zone=oauth_token burst=5; + proxy_pass http://localhost:8081; + } + + location /authorize { + limit_req zone=oauth_authorize burst=10; + proxy_pass http://localhost:8081; + } + + location /sse { + limit_req zone=mcp_sse burst=20; + proxy_pass http://localhost:8080; + } +} +``` + +### Application-Level Rate Limiting + +Consider implementing rate limiting in the application: + +- Per client ID +- Per IP address +- Per user (if user context is available) + +## Logging and Monitoring + +### What to Log + +**DO log**: +- Authentication failures (with reason) +- Token validation failures +- Authorization code generation +- Token issuance (without token value) +- Client ID and user ID +- IP address and user agent +- Timestamp + +**DO NOT log**: +- Access tokens +- Refresh tokens +- Authorization codes +- Code verifiers +- Client secrets + +### Example Logging + +```go +// GOOD +log.Printf("Token validation successful: client_id=%s, subject=%s, scopes=%v", + claims.ClientID, claims.Subject, claims.Scopes) + +// BAD - includes token +log.Printf("Validating token: %s", tokenString) +``` + +### Security Monitoring + +Monitor for suspicious patterns: + +1. **High failure rates** - potential brute force attack +2. **Token reuse** - potential token theft +3. **Unusual geographic locations** - potential account compromise +4. **High token request rate** - potential abuse + +### SIEM Integration + +For production, integrate with a Security Information and Event Management (SIEM) system: + +- Splunk +- ELK Stack (Elasticsearch, Logstash, Kibana) +- AWS CloudWatch Insights +- Google Cloud Logging + +## Production Deployment + +### Checklist + +Before deploying to production: + +- [ ] HTTPS enabled for all endpoints +- [ ] Valid TLS certificates installed +- [ ] Token audience validation configured correctly +- [ ] PKCE enforcement enabled (automatic) +- [ ] Appropriate token lifetimes configured +- [ ] Rate limiting configured +- [ ] Logging configured (without sensitive data) +- [ ] Monitoring and alerting set up +- [ ] Key management strategy implemented +- [ ] Backup and recovery procedures documented +- [ ] Security testing completed + +### Architecture Recommendations + +**For Production**, do not use the basic authorization server. Instead: + +1. **Use a production-grade OAuth server**: + - Auth0 + - AWS Cognito + - Azure AD / Entra ID + - Keycloak + - Okta + +2. **Or build a production authorization server** with: + - Database-backed storage (PostgreSQL, MongoDB) + - HSM or KMS for key management + - Horizontal scalability (multiple instances) + - High availability (failover, load balancing) + - Audit logging + - Admin interface for client management + +3. **Deploy behind reverse proxy**: + - nginx or HAProxy for rate limiting + - TLS termination at proxy + - Web Application Firewall (WAF) + +4. **Use infrastructure as code**: + - Terraform or CloudFormation + - Version control all infrastructure + - Automated deployments + +### Network Security + +1. **Firewall rules**: + - Only expose HTTPS ports (443) publicly + - Restrict database access to application servers only + - Use VPC/VNET for internal communication + +2. **DDoS protection**: + - CloudFlare + - AWS Shield + - Azure DDoS Protection + +3. **Intrusion detection**: + - AWS GuardDuty + - Azure Security Center + - Suricata / Snort + +### Compliance + +Ensure compliance with relevant standards: + +- **GDPR** - if processing EU user data +- **SOC 2** - for security controls +- **PCI DSS** - if processing payment data +- **HIPAA** - if processing health data + +## Incident Response + +### Token Compromise + +If tokens are compromised: + +1. **Revoke affected tokens** (requires token revocation endpoint - Phase 2 feature) +2. **Rotate signing keys** immediately +3. **Force re-authentication** for affected users +4. **Investigate breach** to understand how tokens were compromised +5. **Update security controls** to prevent future compromises + +### Key Compromise + +If signing keys are compromised: + +1. **Immediately generate new keys** +2. **Revoke all existing tokens** +3. **Update JWKS** with new public key +4. **Remove old key** from JWKS +5. **Force re-authentication** for all users +6. **Conduct security audit** + +## Security Contacts + +For security vulnerabilities or concerns: + +- Review the [MCP Security Best Practices](https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices) +- Report security issues to your security team +- Follow responsible disclosure practices + +## References + +- [OAuth 2.1 IETF Draft](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-13) +- [RFC 8707: Resource Indicators for OAuth 2.0](https://www.rfc-editor.org/rfc/rfc8707.html) +- [RFC 9728: OAuth 2.0 Protected Resource Metadata](https://datatracker.ietf.org/doc/html/rfc9728) +- [RFC 8414: OAuth 2.0 Authorization Server Metadata](https://datatracker.ietf.org/doc/html/rfc8414) +- [MCP Authorization Specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization) +- [OWASP OAuth 2.0 Security Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/OAuth_2_0_Security_Cheat_Sheet.html) diff --git a/docs/OAUTH_SETUP.md b/docs/OAUTH_SETUP.md new file mode 100644 index 0000000..50bb3f5 --- /dev/null +++ b/docs/OAUTH_SETUP.md @@ -0,0 +1,315 @@ +# OAuth 2.1 Setup Guide + +This guide explains how to configure and use OAuth 2.1 authorization with the Edge Connect MCP server. + +## Overview + +The Edge Connect MCP server implements OAuth 2.1 authorization following the [Model Context Protocol authorization specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization). The server acts as an **OAuth 2.1 Protected Resource Server**, validating JWT tokens from MCP clients. + +## Architecture + +``` +MCP Client → OAuth Token → MCP Server (Resource Server) → Edge Connect API + ↓ validates against + Authorization Server +``` + +### Phase 1 (Current Implementation) +- MCP server validates OAuth tokens from MCP clients +- MCP server uses its own credentials (token/username/password) to access Edge Connect API + +### Phase 2 (Future) +- MCP server will also use OAuth to access Edge Connect API +- End-to-end OAuth flow + +## Quick Start (Development) + +### Option 1: Using the Built-in Basic Authorization Server + +The easiest way to test OAuth locally is to use the built-in basic authorization server: + +```bash +# MCP Server (Resource Server) +export OAUTH_ENABLED=true +export OAUTH_MODE=resource_server +export OAUTH_RESOURCE_URI=http://localhost:8080 +export OAUTH_AUTH_SERVERS=http://localhost:8081 +export OAUTH_ISSUER=http://localhost:8081 +export OAUTH_JWKS_URL=http://localhost:8081/.well-known/jwks.json + +# Basic Auth Server (for testing) +export OAUTH_AUTH_SERVER_ENABLED=true +export OAUTH_AUTH_SERVER_PORT=8081 +export OAUTH_CLIENT_ID=test-client +export OAUTH_REDIRECT_URI=http://localhost:5173/callback + +# Edge Connect (unchanged - uses server's own credentials) +export EDGE_CONNECT_BASE_URL=https://api.edge-connect.example.com +export EDGE_CONNECT_AUTH_TYPE=token +export EDGE_CONNECT_TOKEN=your-edge-connect-token + +# MCP Server +export MCP_SERVER_MODE=remote +export MCP_REMOTE_HOST=0.0.0.0 +export MCP_REMOTE_PORT=8080 + +# Start the server +./edge-connect-mcp +``` + +The server will start both the MCP server (port 8080) and the basic authorization server (port 8081). + +### Option 2: Using an External Authorization Server + +For production or testing with a real authorization server (Auth0, Cognito, Keycloak): + +```bash +# MCP Server (Resource Server) +export OAUTH_ENABLED=true +export OAUTH_MODE=resource_server +export OAUTH_RESOURCE_URI=https://mcp.example.com +export OAUTH_AUTH_SERVERS=https://auth.example.com +export OAUTH_ISSUER=https://auth.example.com +export OAUTH_JWKS_URL=https://auth.example.com/.well-known/jwks.json + +# Don't enable basic auth server +export OAUTH_AUTH_SERVER_ENABLED=false + +# Edge Connect (unchanged) +export EDGE_CONNECT_BASE_URL=https://api.edge-connect.example.com +export EDGE_CONNECT_AUTH_TYPE=token +export EDGE_CONNECT_TOKEN=your-edge-connect-token + +# MCP Server +export MCP_SERVER_MODE=remote +export MCP_REMOTE_HOST=0.0.0.0 +export MCP_REMOTE_PORT=8080 + +# Start the server +./edge-connect-mcp +``` + +## Configuration Reference + +### OAuth Resource Server Configuration + +| Environment Variable | Required | Default | Description | +|---------------------|----------|---------|-------------| +| `OAUTH_ENABLED` | No | `false` | Enable OAuth 2.1 authorization | +| `OAUTH_MODE` | No | `resource_server` | OAuth mode (currently only `resource_server` supported) | +| `OAUTH_RESOURCE_URI` | Yes (if OAuth enabled) | - | The canonical URI of this MCP server (RFC 8707) | +| `OAUTH_AUTH_SERVERS` | Yes (if OAuth enabled) | - | Comma-separated list of authorization server URLs | +| `OAUTH_ISSUER` | Yes (if OAuth enabled) | - | Expected issuer in JWT tokens | +| `OAUTH_JWKS_URL` | Yes (if OAuth enabled) | - | JWKS endpoint URL for token validation | + +### Basic Authorization Server Configuration + +| Environment Variable | Required | Default | Description | +|---------------------|----------|---------|-------------| +| `OAUTH_AUTH_SERVER_ENABLED` | No | `false` | Enable built-in basic authorization server | +| `OAUTH_AUTH_SERVER_PORT` | No | `8081` | Port for the authorization server | +| `OAUTH_CLIENT_ID` | Yes (if auth server enabled) | - | OAuth client ID to register | +| `OAUTH_REDIRECT_URI` | Yes (if auth server enabled) | - | OAuth redirect URI for the client | + +## OAuth Flow + +### 1. Discovery + +MCP clients discover the OAuth configuration through Protected Resource Metadata (RFC 9728): + +```bash +# Request without token +curl http://localhost:8080/sse + +# Response: 401 Unauthorized with WWW-Authenticate header +# WWW-Authenticate: Bearer realm="edge-connect-mcp", resource_metadata="http://localhost:8080/.well-known/oauth-protected-resource" +``` + +Fetch Protected Resource Metadata: + +```bash +curl http://localhost:8080/.well-known/oauth-protected-resource + +# Response: +{ + "resource": "http://localhost:8080", + "authorization_servers": ["http://localhost:8081"], + "bearer_methods_supported": ["header"], + "resource_signing_alg_values_supported": ["RS256"] +} +``` + +### 2. Authorization Server Metadata + +Fetch authorization server metadata: + +```bash +curl http://localhost:8081/.well-known/oauth-authorization-server + +# Response: +{ + "issuer": "http://localhost:8081", + "authorization_endpoint": "http://localhost:8081/authorize", + "token_endpoint": "http://localhost:8081/token", + "jwks_uri": "http://localhost:8081/.well-known/jwks.json", + "response_types_supported": ["code"], + "grant_types_supported": ["authorization_code"], + "code_challenge_methods_supported": ["S256"], + "token_endpoint_auth_methods_supported": ["none"], + "scopes_supported": ["mcp"] +} +``` + +### 3. Authorization Request + +Request an authorization code with PKCE: + +```bash +# Generate PKCE code verifier and challenge (using your OAuth client) +CODE_VERIFIER="dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" +CODE_CHALLENGE="E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" + +# Authorization request +curl "http://localhost:8081/authorize?client_id=test-client&redirect_uri=http://localhost:5173/callback&response_type=code&scope=mcp&code_challenge=$CODE_CHALLENGE&code_challenge_method=S256&resource=http://localhost:8080" + +# The server redirects to: http://localhost:5173/callback?code=AUTHORIZATION_CODE +``` + +### 4. Token Request + +Exchange the authorization code for an access token: + +```bash +curl -X POST http://localhost:8081/token \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=authorization_code" \ + -d "code=AUTHORIZATION_CODE" \ + -d "client_id=test-client" \ + -d "redirect_uri=http://localhost:5173/callback" \ + -d "code_verifier=$CODE_VERIFIER" \ + -d "resource=http://localhost:8080" + +# Response: +{ + "access_token": "eyJhbGciOiJSUzI1NiIs...", + "token_type": "Bearer", + "expires_in": 3600, + "scope": "mcp" +} +``` + +### 5. Access Protected Resource + +Use the access token to access the MCP server: + +```bash +curl -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIs..." \ + http://localhost:8080/sse +``` + +## Integration with External Authorization Servers + +### Auth0 + +```bash +export OAUTH_ENABLED=true +export OAUTH_RESOURCE_URI=https://mcp.example.com +export OAUTH_AUTH_SERVERS=https://YOUR_DOMAIN.auth0.com +export OAUTH_ISSUER=https://YOUR_DOMAIN.auth0.com/ +export OAUTH_JWKS_URL=https://YOUR_DOMAIN.auth0.com/.well-known/jwks.json +``` + +Configure your Auth0 application: +- Application Type: Regular Web Application +- Allowed Callback URLs: Your MCP client callback URL +- Enable PKCE + +### AWS Cognito + +```bash +export OAUTH_ENABLED=true +export OAUTH_RESOURCE_URI=https://mcp.example.com +export OAUTH_AUTH_SERVERS=https://cognito-idp.REGION.amazonaws.com/USER_POOL_ID +export OAUTH_ISSUER=https://cognito-idp.REGION.amazonaws.com/USER_POOL_ID +export OAUTH_JWKS_URL=https://cognito-idp.REGION.amazonaws.com/USER_POOL_ID/.well-known/jwks.json +``` + +Configure your Cognito User Pool: +- Enable OAuth 2.0 flows +- Configure App Client with Authorization Code Grant +- Enable PKCE + +### Keycloak + +```bash +export OAUTH_ENABLED=true +export OAUTH_RESOURCE_URI=https://mcp.example.com +export OAUTH_AUTH_SERVERS=https://keycloak.example.com/realms/YOUR_REALM +export OAUTH_ISSUER=https://keycloak.example.com/realms/YOUR_REALM +export OAUTH_JWKS_URL=https://keycloak.example.com/realms/YOUR_REALM/protocol/openid-connect/certs +``` + +Configure your Keycloak client: +- Client Protocol: openid-connect +- Access Type: public (for PKCE) or confidential +- Valid Redirect URIs: Your MCP client callback URLs + +## Troubleshooting + +### Token Validation Fails + +**Symptom**: 401 Unauthorized responses even with valid token + +**Solutions**: +1. Check token audience (`aud` claim) matches `OAUTH_RESOURCE_URI` +2. Verify token issuer (`iss` claim) matches `OAUTH_ISSUER` +3. Ensure token is not expired +4. Verify JWKS URL is accessible and returns valid keys +5. Check that the authorization server's public key is available in JWKS + +### PKCE Validation Fails + +**Symptom**: Token endpoint returns `invalid_grant` error + +**Solutions**: +1. Ensure code challenge method is `S256` (plain is not supported) +2. Verify code verifier matches the original challenge +3. Check that the authorization code hasn't expired (10 minute lifetime) + +### Authorization Server Not Reachable + +**Symptom**: Cannot fetch JWKS or metadata + +**Solutions**: +1. Check network connectivity to authorization server +2. Verify authorization server URL is correct +3. Check firewall rules allow outbound HTTPS connections +4. Ensure authorization server is running and accessible + +### Token Audience Mismatch + +**Symptom**: 401 Unauthorized with "token audience does not match expected audience" error + +**Solutions**: +1. Ensure `resource` parameter in authorization and token requests matches `OAUTH_RESOURCE_URI` +2. Check that authorization server issues tokens with correct `aud` claim +3. Verify the MCP server's resource URI configuration + +## Security Best Practices + +See [OAUTH_SECURITY.md](./OAUTH_SECURITY.md) for detailed security guidance. + +## Migration from Simple Bearer Token Auth + +If you're currently using simple bearer token authentication, follow these steps: + +1. **Deploy with OAuth disabled** (current state) +2. **Configure OAuth settings** as environment variables +3. **Enable OAuth** by setting `OAUTH_ENABLED=true` +4. **Test OAuth flow** with a test client +5. **Migrate clients** to use OAuth tokens +6. **Disable simple bearer token auth** by setting `MCP_REMOTE_AUTH_REQUIRED=false` + +Both authentication methods can coexist during migration: +- OAuth takes precedence if enabled +- Simple bearer token auth is used as fallback if OAuth is disabled diff --git a/go.mod b/go.mod index e6d6a60..3c0750c 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.25.5 require ( edp.buildth.ing/DevFW-CICD/edge-connect-client/v2 v2.1.2 + github.com/golang-jwt/jwt/v5 v5.3.0 github.com/modelcontextprotocol/go-sdk v1.2.0 ) @@ -11,6 +12,7 @@ require ( github.com/google/jsonschema-go v0.3.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-retryablehttp v0.7.8 // indirect + github.com/joho/godotenv v1.5.1 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/tools v0.35.0 // indirect diff --git a/go.sum b/go.sum index 4fb7fc6..82db90d 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= -github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= -github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/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.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q= @@ -16,6 +16,8 @@ github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB1 github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= diff --git a/main.go b/main.go index f11908e..4d3b127 100644 --- a/main.go +++ b/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/json" "flag" "fmt" "log" @@ -13,6 +14,8 @@ import ( v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2" "github.com/modelcontextprotocol/go-sdk/mcp" + + "edp.buildth.ing/DevFW-CICD/edge-connect-mcp/oauth" ) var ( @@ -92,8 +95,8 @@ func startRemoteServer(mcpServer *mcp.Server, cfg *Config) error { // Create SSE handler that returns our MCP server sseHandler := mcp.NewSSEHandler(func(r *http.Request) *mcp.Server { - // Authentication middleware - if cfg.RemoteAuthRequired { + // Simple bearer token auth - only if OAuth is disabled and auth is required + if !cfg.OAuthEnabled && cfg.RemoteAuthRequired { if !authenticateRequest(r, cfg) { return nil } @@ -101,15 +104,45 @@ func startRemoteServer(mcpServer *mcp.Server, cfg *Config) error { return mcpServer }, nil) - // Health check endpoint + // Health check endpoint (no auth required) mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"status":"healthy"}`)) }) - // SSE endpoint - mux.Handle("/sse", sseHandler) + // Configure OAuth if enabled + if cfg.OAuthEnabled { + // Create JWT validator + validator := oauth.NewJWTValidator( + cfg.OAuthResourceURI, + cfg.OAuthIssuer, + cfg.OAuthJWKSURL, + ) + + // Create resource server + resourceServer := oauth.NewResourceServer( + cfg.OAuthResourceURI, + cfg.OAuthAuthServers, + validator, + ) + + // Register Protected Resource Metadata endpoint (RFC 9728) + mux.HandleFunc("/.well-known/oauth-protected-resource", resourceServer.ServeMetadata) + + // Create OAuth middleware + authMiddleware := oauth.AuthMiddleware(resourceServer, validator) + + // Wrap SSE handler with OAuth middleware + mux.Handle("/sse", authMiddleware(sseHandler)) + + log.Printf("OAuth 2.1 enabled") + log.Printf("Protected Resource URI: %s", cfg.OAuthResourceURI) + log.Printf("Authorization Servers: %v", cfg.OAuthAuthServers) + } else { + // SSE endpoint without OAuth middleware + mux.Handle("/sse", sseHandler) + } // Create HTTP server addr := fmt.Sprintf("%s:%d", cfg.RemoteHost, cfg.RemotePort) @@ -127,13 +160,21 @@ func startRemoteServer(mcpServer *mcp.Server, cfg *Config) error { errChan := make(chan error, 1) + // Start basic auth server if enabled + if cfg.OAuthAuthServerEnabled { + go startBasicAuthServer(cfg) + } + // Start HTTP server go func() { log.Printf("HTTP server listening on %s", addr) log.Printf("SSE endpoint: http://%s/sse", addr) log.Printf("Health check: http://%s/health", addr) - if cfg.RemoteAuthRequired { - log.Printf("Authentication: ENABLED (Bearer token required)") + if cfg.OAuthEnabled { + log.Printf("Authentication: OAuth 2.1 (JWT Bearer tokens required)") + log.Printf("Protected Resource Metadata: http://%s/.well-known/oauth-protected-resource", addr) + } else if cfg.RemoteAuthRequired { + log.Printf("Authentication: Simple Bearer token") } else { log.Printf("Authentication: DISABLED (anyone can connect)") } @@ -226,3 +267,54 @@ func registerTools(s *mcp.Server) { log.Printf("Registered 11 tools") } + +func startBasicAuthServer(cfg *Config) { + // Create basic authorization server + authServer, err := oauth.NewBasicAuthServer( + cfg.OAuthIssuer, + fmt.Sprintf("http://localhost:%d", cfg.OAuthAuthServerPort), + ) + if err != nil { + log.Printf("Failed to create basic auth server: %v", err) + return + } + + // Register client from configuration + authServer.RegisterClient(cfg.OAuthClientID, []string{cfg.OAuthRedirectURI}) + + // Create HTTP mux for auth server + mux := http.NewServeMux() + + // Authorization endpoint (GET /authorize) + mux.HandleFunc("/authorize", authServer.HandleAuthorize) + + // Token endpoint (POST /token) + mux.HandleFunc("/token", authServer.HandleToken) + + // JWKS endpoint (GET /.well-known/jwks.json) + mux.HandleFunc("/.well-known/jwks.json", authServer.HandleJWKS) + + // Authorization server metadata endpoint (GET /.well-known/oauth-authorization-server) + mux.HandleFunc("/.well-known/oauth-authorization-server", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "public, max-age=3600") + json.NewEncoder(w).Encode(authServer.GetMetadata()) + }) + + // Start auth server + addr := fmt.Sprintf(":%d", cfg.OAuthAuthServerPort) + log.Printf("Basic OAuth 2.1 Authorization Server starting on http://localhost%s", addr) + log.Printf(" Authorization endpoint: http://localhost%s/authorize", addr) + log.Printf(" Token endpoint: http://localhost%s/token", addr) + log.Printf(" JWKS endpoint: http://localhost%s/.well-known/jwks.json", addr) + log.Printf(" Metadata endpoint: http://localhost%s/.well-known/oauth-authorization-server", addr) + log.Printf(" Registered client: %s", cfg.OAuthClientID) + + if err := http.ListenAndServe(addr, mux); err != nil { + log.Printf("Basic auth server error: %v", err) + } +} diff --git a/oauth/authz_server.go b/oauth/authz_server.go new file mode 100644 index 0000000..2f6daed --- /dev/null +++ b/oauth/authz_server.go @@ -0,0 +1,253 @@ +package oauth + +import ( + "crypto/rand" + "crypto/rsa" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// BasicAuthServer implements a simple OAuth 2.1 Authorization Server +// for development and testing purposes +type BasicAuthServer struct { + issuer string + baseURL string + privateKey *rsa.PrivateKey + publicKey *rsa.PublicKey + keyID string + storage *AuthStorage + tokenLifetime time.Duration + codeLifetime time.Duration +} + +// NewBasicAuthServer creates a new basic authorization server +func NewBasicAuthServer(issuer, baseURL string) (*BasicAuthServer, error) { + // Generate RSA key pair (2048-bit) + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, fmt.Errorf("failed to generate RSA key: %w", err) + } + + // Generate key ID + keyID, err := GenerateKeyID() + if err != nil { + return nil, fmt.Errorf("failed to generate key ID: %w", err) + } + + return &BasicAuthServer{ + issuer: issuer, + baseURL: baseURL, + privateKey: privateKey, + publicKey: &privateKey.PublicKey, + keyID: keyID, + storage: NewAuthStorage(), + tokenLifetime: 1 * time.Hour, + codeLifetime: 10 * time.Minute, + }, nil +} + +// GetMetadata returns authorization server metadata (RFC 8414) +func (s *BasicAuthServer) GetMetadata() *AuthServerMetadata { + return &AuthServerMetadata{ + Issuer: s.issuer, + AuthorizationEndpoint: s.baseURL + "/authorize", + TokenEndpoint: s.baseURL + "/token", + JWKSURI: s.baseURL + "/.well-known/jwks.json", + ResponseTypesSupported: []string{"code"}, + GrantTypesSupported: []string{"authorization_code"}, + CodeChallengeMethodsSupported: []string{"S256"}, + TokenEndpointAuthMethodsSupported: []string{"none"}, + ScopesSupported: []string{"mcp"}, + } +} + +// HandleAuthorize handles authorization requests (GET /authorize) +// Implements OAuth 2.1 Authorization Code Flow with PKCE +func (s *BasicAuthServer) HandleAuthorize(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Parse query parameters + query := r.URL.Query() + clientID := query.Get("client_id") + redirectURI := query.Get("redirect_uri") + responseType := query.Get("response_type") + scope := query.Get("scope") + state := query.Get("state") + codeChallenge := query.Get("code_challenge") + codeChallengeMethod := query.Get("code_challenge_method") + resource := query.Get("resource") // RFC 8707 + + // Validate required parameters + if clientID == "" || redirectURI == "" || responseType != "code" { + http.Error(w, "Invalid request: missing required parameters", http.StatusBadRequest) + return + } + + // Validate PKCE (MANDATORY in OAuth 2.1) + if codeChallenge == "" || codeChallengeMethod != "S256" { + http.Error(w, "PKCE required (code_challenge with S256)", http.StatusBadRequest) + return + } + + // Validate client + if !s.storage.ValidateClient(clientID, redirectURI) { + http.Error(w, "Invalid client or redirect_uri", http.StatusBadRequest) + return + } + + // For basic auth server: auto-approve (no user interaction) + // Production would show consent screen here + + // Generate authorization code + code, err := s.storage.CreateAuthorizationCode(clientID, redirectURI, scope, codeChallenge, resource) + if err != nil { + http.Error(w, "Failed to create authorization code", http.StatusInternalServerError) + return + } + + // Redirect with authorization code + redirectURL := fmt.Sprintf("%s?code=%s", redirectURI, code) + if state != "" { + redirectURL += fmt.Sprintf("&state=%s", state) + } + + http.Redirect(w, r, redirectURL, http.StatusFound) +} + +// HandleToken handles token requests (POST /token) +func (s *BasicAuthServer) HandleToken(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + if err := r.ParseForm(); err != nil { + sendTokenError(w, "invalid_request", "Invalid form data") + return + } + + grantType := r.FormValue("grant_type") + if grantType != "authorization_code" { + sendTokenError(w, "unsupported_grant_type", "Only authorization_code grant is supported") + return + } + + code := r.FormValue("code") + clientID := r.FormValue("client_id") + redirectURI := r.FormValue("redirect_uri") + codeVerifier := r.FormValue("code_verifier") + resource := r.FormValue("resource") + + // Validate authorization code + authCode, err := s.storage.GetAuthorizationCode(code) + if err != nil { + sendTokenError(w, "invalid_grant", "Invalid authorization code") + return + } + + // Validate PKCE + if !ValidatePKCE(codeVerifier, authCode.CodeChallenge) { + sendTokenError(w, "invalid_grant", "Invalid PKCE verifier") + return + } + + // Validate client and redirect URI + if authCode.ClientID != clientID || authCode.RedirectURI != redirectURI { + sendTokenError(w, "invalid_grant", "Client or redirect URI mismatch") + return + } + + // Use resource from auth code if not provided in token request + if resource == "" { + resource = authCode.Resource + } + + // Issue access token + token, err := s.issueAccessToken(clientID, authCode.Scope, resource) + if err != nil { + sendTokenError(w, "server_error", "Failed to issue token") + return + } + + // Mark code as used + s.storage.RevokeAuthorizationCode(code) + + // Send token response + sendTokenResponse(w, token, int(s.tokenLifetime.Seconds()), authCode.Scope) +} + +// HandleJWKS returns the JWKS document (GET /.well-known/jwks.json) +func (s *BasicAuthServer) HandleJWKS(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + jwks := CreateJWKS(s.publicKey, s.keyID) + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "public, max-age=3600") // Cache for 1 hour + json.NewEncoder(w).Encode(jwks) +} + +// RegisterClient registers a new OAuth client +func (s *BasicAuthServer) RegisterClient(clientID string, redirectURIs []string) { + s.storage.RegisterClient(clientID, redirectURIs) +} + +// issueAccessToken creates a signed JWT access token +func (s *BasicAuthServer) issueAccessToken(clientID, scope, audience string) (string, error) { + now := time.Now() + + claims := jwt.MapClaims{ + "iss": s.issuer, + "sub": clientID, + "aud": audience, + "exp": now.Add(s.tokenLifetime).Unix(), + "iat": now.Unix(), + "client_id": clientID, + "scope": scope, + } + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + token.Header["kid"] = s.keyID + + return token.SignedString(s.privateKey) +} + +// sendTokenResponse sends a successful token response +func sendTokenResponse(w http.ResponseWriter, accessToken string, expiresIn int, scope string) { + response := TokenResponse{ + AccessToken: accessToken, + TokenType: "Bearer", + ExpiresIn: expiresIn, + Scope: scope, + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Pragma", "no-cache") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(response) +} + +// sendTokenError sends an OAuth 2.1 token error response +func sendTokenError(w http.ResponseWriter, error, errorDescription string) { + response := TokenErrorResponse{ + Error: error, + ErrorDescription: errorDescription, + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Pragma", "no-cache") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(response) +} diff --git a/oauth/authz_server_test.go b/oauth/authz_server_test.go new file mode 100644 index 0000000..5d7de05 --- /dev/null +++ b/oauth/authz_server_test.go @@ -0,0 +1,403 @@ +package oauth + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" +) + +func TestNewBasicAuthServer(t *testing.T) { + server, err := NewBasicAuthServer("http://localhost:8081", "http://localhost:8081") + if err != nil { + t.Fatalf("NewBasicAuthServer() error = %v", err) + } + + if server.issuer != "http://localhost:8081" { + t.Errorf("server.issuer = %v, want http://localhost:8081", server.issuer) + } + if server.privateKey == nil { + t.Error("server.privateKey is nil") + } + if server.publicKey == nil { + t.Error("server.publicKey is nil") + } + if server.keyID == "" { + t.Error("server.keyID is empty") + } +} + +func TestBasicAuthServerGetMetadata(t *testing.T) { + server, _ := NewBasicAuthServer("http://localhost:8081", "http://localhost:8081") + metadata := server.GetMetadata() + + if metadata.Issuer != "http://localhost:8081" { + t.Errorf("metadata.Issuer = %v, want http://localhost:8081", metadata.Issuer) + } + if metadata.AuthorizationEndpoint != "http://localhost:8081/authorize" { + t.Errorf("metadata.AuthorizationEndpoint = %v", metadata.AuthorizationEndpoint) + } + if metadata.TokenEndpoint != "http://localhost:8081/token" { + t.Errorf("metadata.TokenEndpoint = %v", metadata.TokenEndpoint) + } + if metadata.JWKSURI != "http://localhost:8081/.well-known/jwks.json" { + t.Errorf("metadata.JWKSURI = %v", metadata.JWKSURI) + } + + // Check required fields + if len(metadata.ResponseTypesSupported) == 0 { + t.Error("metadata.ResponseTypesSupported is empty") + } + if len(metadata.GrantTypesSupported) == 0 { + t.Error("metadata.GrantTypesSupported is empty") + } + if len(metadata.CodeChallengeMethodsSupported) == 0 { + t.Error("metadata.CodeChallengeMethodsSupported is empty") + } + if metadata.CodeChallengeMethodsSupported[0] != "S256" { + t.Errorf("metadata.CodeChallengeMethodsSupported[0] = %v, want S256", metadata.CodeChallengeMethodsSupported[0]) + } +} + +func TestBasicAuthServerHandleJWKS(t *testing.T) { + server, _ := NewBasicAuthServer("http://localhost:8081", "http://localhost:8081") + + req := httptest.NewRequest(http.MethodGet, "/.well-known/jwks.json", nil) + w := httptest.NewRecorder() + + server.HandleJWKS(w, req) + + if w.Code != http.StatusOK { + t.Errorf("HandleJWKS() status = %d, want %d", w.Code, http.StatusOK) + } + + var jwks JWKS + if err := json.NewDecoder(w.Body).Decode(&jwks); err != nil { + t.Fatalf("Failed to decode JWKS: %v", err) + } + + if len(jwks.Keys) != 1 { + t.Errorf("JWKS Keys length = %d, want 1", len(jwks.Keys)) + } + if jwks.Keys[0].KeyType != "RSA" { + t.Errorf("JWKS Keys[0].KeyType = %v, want RSA", jwks.Keys[0].KeyType) + } +} + +func TestBasicAuthServerHandleAuthorize(t *testing.T) { + server, _ := NewBasicAuthServer("http://localhost:8081", "http://localhost:8081") + server.RegisterClient("test-client", []string{"http://localhost:3000/callback"}) + + // Generate PKCE challenge + verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + challenge := GenerateCodeChallenge(verifier) + + tests := []struct { + name string + params map[string]string + wantStatus int + }{ + { + name: "valid authorization request", + params: map[string]string{ + "client_id": "test-client", + "redirect_uri": "http://localhost:3000/callback", + "response_type": "code", + "scope": "mcp", + "state": "random-state", + "code_challenge": challenge, + "code_challenge_method": "S256", + "resource": "http://localhost:8080", + }, + wantStatus: http.StatusFound, // Redirect + }, + { + name: "missing client_id", + params: map[string]string{ + "redirect_uri": "http://localhost:3000/callback", + "response_type": "code", + "code_challenge": challenge, + "code_challenge_method": "S256", + }, + wantStatus: http.StatusBadRequest, + }, + { + name: "missing code_challenge", + params: map[string]string{ + "client_id": "test-client", + "redirect_uri": "http://localhost:3000/callback", + "response_type": "code", + }, + wantStatus: http.StatusBadRequest, + }, + { + name: "invalid code_challenge_method", + params: map[string]string{ + "client_id": "test-client", + "redirect_uri": "http://localhost:3000/callback", + "response_type": "code", + "code_challenge": challenge, + "code_challenge_method": "plain", + }, + wantStatus: http.StatusBadRequest, + }, + { + name: "invalid client", + params: map[string]string{ + "client_id": "invalid-client", + "redirect_uri": "http://localhost:3000/callback", + "response_type": "code", + "code_challenge": challenge, + "code_challenge_method": "S256", + }, + wantStatus: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Build query string + query := url.Values{} + for key, value := range tt.params { + query.Set(key, value) + } + + req := httptest.NewRequest(http.MethodGet, "/authorize?"+query.Encode(), nil) + w := httptest.NewRecorder() + + server.HandleAuthorize(w, req) + + if w.Code != tt.wantStatus { + t.Errorf("HandleAuthorize() status = %d, want %d", w.Code, tt.wantStatus) + } + + // For successful authorization, check redirect location + if tt.wantStatus == http.StatusFound { + location := w.Header().Get("Location") + if location == "" { + t.Error("HandleAuthorize() missing Location header") + } + + // Parse redirect URL + redirectURL, err := url.Parse(location) + if err != nil { + t.Fatalf("Failed to parse redirect URL: %v", err) + } + + // Check for authorization code + code := redirectURL.Query().Get("code") + if code == "" { + t.Error("HandleAuthorize() missing code in redirect") + } + + // Check state parameter if provided + if tt.params["state"] != "" { + state := redirectURL.Query().Get("state") + if state != tt.params["state"] { + t.Errorf("HandleAuthorize() state = %v, want %v", state, tt.params["state"]) + } + } + } + }) + } +} + +func TestBasicAuthServerHandleToken(t *testing.T) { + server, _ := NewBasicAuthServer("http://localhost:8081", "http://localhost:8081") + server.RegisterClient("test-client", []string{"http://localhost:3000/callback"}) + + // Generate PKCE pair + verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + challenge := GenerateCodeChallenge(verifier) + + // Create authorization code + code, _ := server.storage.CreateAuthorizationCode( + "test-client", + "http://localhost:3000/callback", + "mcp", + challenge, + "http://localhost:8080", + ) + + tests := []struct { + name string + formData map[string]string + wantStatus int + wantError string + }{ + { + name: "valid token request", + formData: map[string]string{ + "grant_type": "authorization_code", + "code": code, + "client_id": "test-client", + "redirect_uri": "http://localhost:3000/callback", + "code_verifier": verifier, + "resource": "http://localhost:8080", + }, + wantStatus: http.StatusOK, + }, + { + name: "invalid grant_type", + formData: map[string]string{ + "grant_type": "client_credentials", + }, + wantStatus: http.StatusBadRequest, + wantError: "unsupported_grant_type", + }, + { + name: "invalid code", + formData: map[string]string{ + "grant_type": "authorization_code", + "code": "invalid-code", + "client_id": "test-client", + "redirect_uri": "http://localhost:3000/callback", + "code_verifier": verifier, + }, + wantStatus: http.StatusBadRequest, + wantError: "invalid_grant", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Build form data + formData := url.Values{} + for key, value := range tt.formData { + formData.Set(key, value) + } + + req := httptest.NewRequest(http.MethodPost, "/token", strings.NewReader(formData.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + + server.HandleToken(w, req) + + if w.Code != tt.wantStatus { + t.Errorf("HandleToken() status = %d, want %d", w.Code, tt.wantStatus) + } + + if tt.wantStatus == http.StatusOK { + var tokenResp TokenResponse + if err := json.NewDecoder(w.Body).Decode(&tokenResp); err != nil { + t.Fatalf("Failed to decode token response: %v", err) + } + + if tokenResp.AccessToken == "" { + t.Error("HandleToken() missing access_token") + } + if tokenResp.TokenType != "Bearer" { + t.Errorf("HandleToken() token_type = %v, want Bearer", tokenResp.TokenType) + } + if tokenResp.ExpiresIn <= 0 { + t.Errorf("HandleToken() expires_in = %d, want > 0", tokenResp.ExpiresIn) + } + } else if tt.wantError != "" { + var errorResp TokenErrorResponse + if err := json.NewDecoder(w.Body).Decode(&errorResp); err != nil { + t.Fatalf("Failed to decode error response: %v", err) + } + + if errorResp.Error != tt.wantError { + t.Errorf("HandleToken() error = %v, want %v", errorResp.Error, tt.wantError) + } + } + }) + } +} + +func TestBasicAuthServerFullFlow(t *testing.T) { + // Create server + server, _ := NewBasicAuthServer("http://localhost:8081", "http://localhost:8081") + server.RegisterClient("test-client", []string{"http://localhost:3000/callback"}) + + // Step 1: Generate PKCE pair + verifier, err := GenerateCodeVerifier() + if err != nil { + t.Fatalf("GenerateCodeVerifier() error = %v", err) + } + challenge := GenerateCodeChallenge(verifier) + + // Step 2: Authorization request + query := url.Values{} + query.Set("client_id", "test-client") + query.Set("redirect_uri", "http://localhost:3000/callback") + query.Set("response_type", "code") + query.Set("scope", "mcp") + query.Set("state", "random-state") + query.Set("code_challenge", challenge) + query.Set("code_challenge_method", "S256") + query.Set("resource", "http://localhost:8080") + + authReq := httptest.NewRequest(http.MethodGet, "/authorize?"+query.Encode(), nil) + authW := httptest.NewRecorder() + + server.HandleAuthorize(authW, authReq) + + if authW.Code != http.StatusFound { + t.Fatalf("Authorization request failed with status %d", authW.Code) + } + + // Extract authorization code from redirect + location, _ := url.Parse(authW.Header().Get("Location")) + code := location.Query().Get("code") + if code == "" { + t.Fatal("No authorization code in redirect") + } + + // Step 3: Token request + formData := url.Values{} + formData.Set("grant_type", "authorization_code") + formData.Set("code", code) + formData.Set("client_id", "test-client") + formData.Set("redirect_uri", "http://localhost:3000/callback") + formData.Set("code_verifier", verifier) + formData.Set("resource", "http://localhost:8080") + + tokenReq := httptest.NewRequest(http.MethodPost, "/token", strings.NewReader(formData.Encode())) + tokenReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + tokenW := httptest.NewRecorder() + + server.HandleToken(tokenW, tokenReq) + + if tokenW.Code != http.StatusOK { + t.Fatalf("Token request failed with status %d: %s", tokenW.Code, tokenW.Body.String()) + } + + var tokenResp TokenResponse + if err := json.NewDecoder(tokenW.Body).Decode(&tokenResp); err != nil { + t.Fatalf("Failed to decode token response: %v", err) + } + + // Step 4: Validate token using JWT validator + // Create test JWKS server + jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server.HandleJWKS(w, r) + })) + defer jwksServer.Close() + + validator := NewJWTValidator("http://localhost:8080", "http://localhost:8081", jwksServer.URL) + + // Wait a moment for JWKS to be ready + time.Sleep(100 * time.Millisecond) + + claims, err := validator.ValidateToken(context.Background(), tokenResp.AccessToken) + if err != nil { + t.Fatalf("ValidateToken() error = %v", err) + } + + // Verify claims + if claims.Issuer != "http://localhost:8081" { + t.Errorf("claims.Issuer = %v, want http://localhost:8081", claims.Issuer) + } + if len(claims.Audience) == 0 || claims.Audience[0] != "http://localhost:8080" { + t.Errorf("claims.Audience = %v, want [http://localhost:8080]", claims.Audience) + } + if claims.Subject != "test-client" { + t.Errorf("claims.Subject = %v, want test-client", claims.Subject) + } +} diff --git a/oauth/jwks.go b/oauth/jwks.go new file mode 100644 index 0000000..679dfc6 --- /dev/null +++ b/oauth/jwks.go @@ -0,0 +1,75 @@ +package oauth + +import ( + "crypto/rand" + "crypto/rsa" + "encoding/base64" + "fmt" + "math/big" +) + +// GenerateKeyID generates a random key ID for JWKS +func GenerateKeyID() (string, error) { + bytes := make([]byte, 16) + if _, err := rand.Read(bytes); err != nil { + return "", fmt.Errorf("failed to generate key ID: %w", err) + } + return base64.RawURLEncoding.EncodeToString(bytes), nil +} + +// RSAPublicKeyToJWK converts an RSA public key to JWK format +func RSAPublicKeyToJWK(publicKey *rsa.PublicKey, keyID string) JWK { + // Encode modulus (N) as base64url + nBytes := publicKey.N.Bytes() + n := base64.RawURLEncoding.EncodeToString(nBytes) + + // Encode exponent (E) as base64url + eBytes := big.NewInt(int64(publicKey.E)).Bytes() + e := base64.RawURLEncoding.EncodeToString(eBytes) + + return JWK{ + KeyType: "RSA", + Use: "sig", + KeyID: keyID, + Algorithm: "RS256", + N: n, + E: e, + } +} + +// JWKToRSAPublicKey converts a JWK to an RSA public key +func JWKToRSAPublicKey(jwk JWK) (*rsa.PublicKey, error) { + if jwk.KeyType != "RSA" { + return nil, fmt.Errorf("unsupported key type: %s", jwk.KeyType) + } + + // Decode modulus (N) from base64url + nBytes, err := base64.RawURLEncoding.DecodeString(jwk.N) + if err != nil { + return nil, fmt.Errorf("failed to decode modulus: %w", err) + } + n := new(big.Int).SetBytes(nBytes) + + // Decode exponent (E) from base64url + eBytes, err := base64.RawURLEncoding.DecodeString(jwk.E) + if err != nil { + return nil, fmt.Errorf("failed to decode exponent: %w", err) + } + e := new(big.Int).SetBytes(eBytes) + + // Create RSA public key + publicKey := &rsa.PublicKey{ + N: n, + E: int(e.Int64()), + } + + return publicKey, nil +} + +// CreateJWKS creates a JWKS from an RSA public key +func CreateJWKS(publicKey *rsa.PublicKey, keyID string) *JWKS { + jwk := RSAPublicKeyToJWK(publicKey, keyID) + return &JWKS{ + Keys: []JWK{jwk}, + } +} diff --git a/oauth/jwks_test.go b/oauth/jwks_test.go new file mode 100644 index 0000000..7b14990 --- /dev/null +++ b/oauth/jwks_test.go @@ -0,0 +1,150 @@ +package oauth + +import ( + "crypto/rand" + "crypto/rsa" + "testing" +) + +func TestGenerateKeyID(t *testing.T) { + // Generate multiple key IDs to ensure randomness + keyIDs := make(map[string]bool) + for i := 0; i < 10; i++ { + keyID, err := GenerateKeyID() + if err != nil { + t.Fatalf("GenerateKeyID() error = %v", err) + } + + // Check that it's not empty + if keyID == "" { + t.Error("GenerateKeyID() returned empty string") + } + + // Check uniqueness + if keyIDs[keyID] { + t.Errorf("GenerateKeyID() generated duplicate: %s", keyID) + } + keyIDs[keyID] = true + } +} + +func TestRSAPublicKeyToJWK(t *testing.T) { + // Generate RSA key pair + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("Failed to generate RSA key: %v", err) + } + + keyID := "test-key-id" + jwk := RSAPublicKeyToJWK(&privateKey.PublicKey, keyID) + + // Check JWK fields + if jwk.KeyType != "RSA" { + t.Errorf("JWK KeyType = %v, want RSA", jwk.KeyType) + } + if jwk.Use != "sig" { + t.Errorf("JWK Use = %v, want sig", jwk.Use) + } + if jwk.KeyID != keyID { + t.Errorf("JWK KeyID = %v, want %v", jwk.KeyID, keyID) + } + if jwk.Algorithm != "RS256" { + t.Errorf("JWK Algorithm = %v, want RS256", jwk.Algorithm) + } + if jwk.N == "" { + t.Error("JWK N (modulus) is empty") + } + if jwk.E == "" { + t.Error("JWK E (exponent) is empty") + } +} + +func TestJWKToRSAPublicKey(t *testing.T) { + // Generate RSA key pair + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("Failed to generate RSA key: %v", err) + } + + originalPublicKey := &privateKey.PublicKey + + // Convert to JWK + keyID := "test-key-id" + jwk := RSAPublicKeyToJWK(originalPublicKey, keyID) + + // Convert back to RSA public key + convertedPublicKey, err := JWKToRSAPublicKey(jwk) + if err != nil { + t.Fatalf("JWKToRSAPublicKey() error = %v", err) + } + + // Compare modulus + if originalPublicKey.N.Cmp(convertedPublicKey.N) != 0 { + t.Error("Converted public key modulus doesn't match original") + } + + // Compare exponent + if originalPublicKey.E != convertedPublicKey.E { + t.Errorf("Converted public key exponent = %v, want %v", convertedPublicKey.E, originalPublicKey.E) + } +} + +func TestJWKToRSAPublicKeyInvalidKeyType(t *testing.T) { + jwk := JWK{ + KeyType: "EC", + KeyID: "test-key", + } + + _, err := JWKToRSAPublicKey(jwk) + if err == nil { + t.Error("JWKToRSAPublicKey() should fail with non-RSA key type") + } +} + +func TestCreateJWKS(t *testing.T) { + // Generate RSA key pair + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("Failed to generate RSA key: %v", err) + } + + keyID := "test-key-id" + jwks := CreateJWKS(&privateKey.PublicKey, keyID) + + // Check JWKS structure + if jwks == nil { + t.Fatal("CreateJWKS() returned nil") + } + if len(jwks.Keys) != 1 { + t.Errorf("JWKS Keys length = %d, want 1", len(jwks.Keys)) + } + if jwks.Keys[0].KeyID != keyID { + t.Errorf("JWKS Keys[0].KeyID = %v, want %v", jwks.Keys[0].KeyID, keyID) + } +} + +func TestRSAPublicKeyRoundTrip(t *testing.T) { + // Generate RSA key pair + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("Failed to generate RSA key: %v", err) + } + + originalPublicKey := &privateKey.PublicKey + + // Convert to JWK and back + keyID := "test-key-id" + jwk := RSAPublicKeyToJWK(originalPublicKey, keyID) + convertedPublicKey, err := JWKToRSAPublicKey(jwk) + if err != nil { + t.Fatalf("Round trip conversion failed: %v", err) + } + + // Verify they match + if originalPublicKey.N.Cmp(convertedPublicKey.N) != 0 { + t.Error("Round trip: modulus doesn't match") + } + if originalPublicKey.E != convertedPublicKey.E { + t.Error("Round trip: exponent doesn't match") + } +} diff --git a/oauth/middleware.go b/oauth/middleware.go new file mode 100644 index 0000000..e910b91 --- /dev/null +++ b/oauth/middleware.go @@ -0,0 +1,82 @@ +package oauth + +import ( + "context" + "net/http" + "strings" +) + +type contextKey string + +const TokenClaimsKey contextKey = "token_claims" + +// AuthMiddleware creates HTTP middleware for OAuth token validation +func AuthMiddleware(resourceServer *ResourceServer, validator TokenValidator) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Extract Bearer token + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + resourceServer.SendUnauthorized(w, "edge-connect-mcp") + return + } + + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || parts[0] != "Bearer" { + resourceServer.SendUnauthorized(w, "edge-connect-mcp") + return + } + + token := parts[1] + + // Validate token + claims, err := validator.ValidateToken(r.Context(), token) + if err != nil { + resourceServer.SendUnauthorized(w, "edge-connect-mcp") + return + } + + // Add claims to context + ctx := context.WithValue(r.Context(), TokenClaimsKey, claims) + + // Continue to next handler + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// GetTokenClaims extracts token claims from request context +func GetTokenClaims(ctx context.Context) (*TokenClaims, bool) { + claims, ok := ctx.Value(TokenClaimsKey).(*TokenClaims) + return claims, ok +} + +// RequireScope creates middleware that checks for required scopes +func RequireScope(resourceServer *ResourceServer, requiredScope string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + claims, ok := GetTokenClaims(r.Context()) + if !ok { + resourceServer.SendUnauthorized(w, "edge-connect-mcp") + return + } + + // Check if required scope is present + hasScope := false + for _, scope := range claims.Scopes { + if scope == requiredScope { + hasScope = true + break + } + } + + if !hasScope { + resourceServer.SendInsufficientScope(w, requiredScope) + return + } + + // Continue to next handler + next.ServeHTTP(w, r) + }) + } +} diff --git a/oauth/middleware_test.go b/oauth/middleware_test.go new file mode 100644 index 0000000..b2402ba --- /dev/null +++ b/oauth/middleware_test.go @@ -0,0 +1,269 @@ +package oauth + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestAuthMiddlewareNoToken(t *testing.T) { + // Create a mock resource server and validator + validator := NewJWTValidator("http://localhost:8080", "http://localhost:8081", "http://localhost:8081/.well-known/jwks.json") + resourceServer := NewResourceServer( + "http://localhost:8080", + []string{"http://localhost:8081"}, + validator, + ) + + // Create middleware + middleware := AuthMiddleware(resourceServer, validator) + + // Create a test handler + testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("success")) + }) + + // Wrap with middleware + wrappedHandler := middleware(testHandler) + + // Make request without token + req := httptest.NewRequest(http.MethodGet, "/test", nil) + w := httptest.NewRecorder() + + wrappedHandler.ServeHTTP(w, req) + + // Should return 401 Unauthorized + if w.Code != http.StatusUnauthorized { + t.Errorf("AuthMiddleware() status = %d, want %d", w.Code, http.StatusUnauthorized) + } +} + +func TestAuthMiddlewareInvalidTokenFormat(t *testing.T) { + validator := NewJWTValidator("http://localhost:8080", "http://localhost:8081", "http://localhost:8081/.well-known/jwks.json") + resourceServer := NewResourceServer( + "http://localhost:8080", + []string{"http://localhost:8081"}, + validator, + ) + + middleware := AuthMiddleware(resourceServer, validator) + testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + wrappedHandler := middleware(testHandler) + + tests := []struct { + name string + header string + }{ + { + name: "missing bearer prefix", + header: "invalid-token", + }, + { + name: "wrong auth scheme", + header: "Basic dXNlcjpwYXNz", + }, + { + name: "empty token", + header: "Bearer ", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set("Authorization", tt.header) + w := httptest.NewRecorder() + + wrappedHandler.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("AuthMiddleware() status = %d, want %d", w.Code, http.StatusUnauthorized) + } + }) + } +} + +func TestAuthMiddlewareWithValidToken(t *testing.T) { + // Create auth server and issue a token + server, _ := NewBasicAuthServer("http://localhost:8081", "http://localhost:8081") + server.RegisterClient("test-client", []string{"http://localhost:3000/callback"}) + + // Issue token directly (no need to exchange code in this test) + token, err := server.issueAccessToken("test-client", "mcp", "http://localhost:8080") + if err != nil { + t.Fatalf("Failed to issue token: %v", err) + } + + // Create test JWKS server + jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server.HandleJWKS(w, r) + })) + defer jwksServer.Close() + + // Create validator and resource server + validator := NewJWTValidator("http://localhost:8080", "http://localhost:8081", jwksServer.URL) + resourceServer := NewResourceServer( + "http://localhost:8080", + []string{"http://localhost:8081"}, + validator, + ) + + // Create middleware + middleware := AuthMiddleware(resourceServer, validator) + + // Create test handler that checks for token claims + testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + claims, ok := GetTokenClaims(r.Context()) + if !ok { + t.Error("GetTokenClaims() failed to extract claims from context") + } + if claims.Subject != "test-client" { + t.Errorf("claims.Subject = %v, want test-client", claims.Subject) + } + w.WriteHeader(http.StatusOK) + w.Write([]byte("success")) + }) + + wrappedHandler := middleware(testHandler) + + // Wait for JWKS to be cached + time.Sleep(100 * time.Millisecond) + + // Make request with valid token + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + + wrappedHandler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("AuthMiddleware() status = %d, want %d, body: %s", w.Code, http.StatusOK, w.Body.String()) + } +} + +func TestGetTokenClaims(t *testing.T) { + // Test with claims in context + claims := &TokenClaims{ + Subject: "test-user", + Audience: []string{"http://localhost:8080"}, + Issuer: "http://localhost:8081", + } + + ctx := context.WithValue(context.Background(), TokenClaimsKey, claims) + + retrievedClaims, ok := GetTokenClaims(ctx) + if !ok { + t.Error("GetTokenClaims() should return true when claims are in context") + } + if retrievedClaims.Subject != "test-user" { + t.Errorf("retrievedClaims.Subject = %v, want test-user", retrievedClaims.Subject) + } + + // Test without claims in context + emptyCtx := context.Background() + _, ok = GetTokenClaims(emptyCtx) + if ok { + t.Error("GetTokenClaims() should return false when claims are not in context") + } +} + +func TestRequireScopeMiddleware(t *testing.T) { + validator := NewJWTValidator("http://localhost:8080", "http://localhost:8081", "http://localhost:8081/.well-known/jwks.json") + resourceServer := NewResourceServer( + "http://localhost:8080", + []string{"http://localhost:8081"}, + validator, + ) + + // Create test handler + testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("success")) + }) + + tests := []struct { + name string + requiredScope string + claimsScopes []string + wantStatus int + wantBodyPrefix string + }{ + { + name: "has required scope", + requiredScope: "admin", + claimsScopes: []string{"mcp", "admin"}, + wantStatus: http.StatusOK, + }, + { + name: "missing required scope", + requiredScope: "admin", + claimsScopes: []string{"mcp"}, + wantStatus: http.StatusForbidden, + }, + { + name: "no scopes", + requiredScope: "admin", + claimsScopes: []string{}, + wantStatus: http.StatusForbidden, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create middleware + scopeMiddleware := RequireScope(resourceServer, tt.requiredScope) + wrappedHandler := scopeMiddleware(testHandler) + + // Create request with claims in context + claims := &TokenClaims{ + Subject: "test-user", + Audience: []string{"http://localhost:8080"}, + Issuer: "http://localhost:8081", + Scopes: tt.claimsScopes, + } + ctx := context.WithValue(context.Background(), TokenClaimsKey, claims) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req = req.WithContext(ctx) + w := httptest.NewRecorder() + + wrappedHandler.ServeHTTP(w, req) + + if w.Code != tt.wantStatus { + t.Errorf("RequireScope() status = %d, want %d", w.Code, tt.wantStatus) + } + }) + } +} + +func TestRequireScopeMiddlewareNoClaims(t *testing.T) { + validator := NewJWTValidator("http://localhost:8080", "http://localhost:8081", "http://localhost:8081/.well-known/jwks.json") + resourceServer := NewResourceServer( + "http://localhost:8080", + []string{"http://localhost:8081"}, + validator, + ) + + testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + scopeMiddleware := RequireScope(resourceServer, "admin") + wrappedHandler := scopeMiddleware(testHandler) + + // Request without claims in context + req := httptest.NewRequest(http.MethodGet, "/test", nil) + w := httptest.NewRecorder() + + wrappedHandler.ServeHTTP(w, req) + + // Should return 401 Unauthorized + if w.Code != http.StatusUnauthorized { + t.Errorf("RequireScope() status = %d, want %d", w.Code, http.StatusUnauthorized) + } +} diff --git a/oauth/oauth.go b/oauth/oauth.go new file mode 100644 index 0000000..f3faa65 --- /dev/null +++ b/oauth/oauth.go @@ -0,0 +1,92 @@ +package oauth + +import ( + "context" + "net/http" + "time" +) + +// TokenValidator validates OAuth 2.1 Bearer tokens +type TokenValidator interface { + // ValidateToken validates a Bearer token and returns claims + ValidateToken(ctx context.Context, token string) (*TokenClaims, error) + + // GetJWKS returns the JSON Web Key Set for token verification + GetJWKS(ctx context.Context) (*JWKS, error) +} + +// TokenClaims represents validated token claims +type TokenClaims struct { + Subject string // "sub" - user identifier + Audience []string // "aud" - intended recipients (RFC 8707) + Issuer string // "iss" - authorization server + ExpiresAt time.Time // "exp" - expiration time + IssuedAt time.Time // "iat" - issue time + Scopes []string // "scope" - granted scopes + ClientID string // "client_id" - client identifier +} + +// AuthorizationServer defines the basic authorization server interface +type AuthorizationServer interface { + // HandleAuthorize handles authorization requests (GET /authorize) + HandleAuthorize(w http.ResponseWriter, r *http.Request) + + // HandleToken handles token requests (POST /token) + HandleToken(w http.ResponseWriter, r *http.Request) + + // HandleJWKS returns the JWKS document (GET /.well-known/jwks.json) + HandleJWKS(w http.ResponseWriter, r *http.Request) + + // GetMetadata returns authorization server metadata (RFC 8414) + GetMetadata() *AuthServerMetadata +} + +// ProtectedResourceMetadata represents RFC 9728 metadata +type ProtectedResourceMetadata struct { + Resource string `json:"resource"` + AuthorizationServers []string `json:"authorization_servers"` + BearerMethodsSupported []string `json:"bearer_methods_supported,omitempty"` + ResourceSigningAlgs []string `json:"resource_signing_alg_values_supported,omitempty"` +} + +// AuthServerMetadata represents RFC 8414 metadata +type AuthServerMetadata struct { + Issuer string `json:"issuer"` + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + JWKSURI string `json:"jwks_uri"` + ScopesSupported []string `json:"scopes_supported,omitempty"` + ResponseTypesSupported []string `json:"response_types_supported"` + GrantTypesSupported []string `json:"grant_types_supported"` + TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"` + CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"` +} + +// JWKS represents a JSON Web Key Set +type JWKS struct { + Keys []JWK `json:"keys"` +} + +// JWK represents a JSON Web Key +type JWK struct { + KeyType string `json:"kty"` + Use string `json:"use,omitempty"` + KeyID string `json:"kid,omitempty"` + Algorithm string `json:"alg,omitempty"` + N string `json:"n,omitempty"` // RSA modulus + E string `json:"e,omitempty"` // RSA exponent +} + +// TokenResponse represents an OAuth 2.1 token response +type TokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + Scope string `json:"scope,omitempty"` +} + +// TokenErrorResponse represents an OAuth 2.1 token error response +type TokenErrorResponse struct { + Error string `json:"error"` + ErrorDescription string `json:"error_description,omitempty"` +} diff --git a/oauth/pkce.go b/oauth/pkce.go new file mode 100644 index 0000000..f15c886 --- /dev/null +++ b/oauth/pkce.go @@ -0,0 +1,48 @@ +package oauth + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "fmt" +) + +// ValidatePKCE validates a PKCE code verifier against a code challenge +// using the S256 method (SHA256) +// As per OAuth 2.1, only S256 is supported +func ValidatePKCE(verifier, challenge string) bool { + if verifier == "" || challenge == "" { + return false + } + + // Compute SHA256 hash of verifier + hash := sha256.Sum256([]byte(verifier)) + + // Encode with base64url (no padding) + computed := base64.RawURLEncoding.EncodeToString(hash[:]) + + // Compare with challenge + return computed == challenge +} + +// GenerateCodeVerifier generates a random PKCE code verifier +// The verifier is a high-entropy cryptographic random string +// Length: 43-128 characters from [A-Z, a-z, 0-9, -, ., _, ~] +func GenerateCodeVerifier() (string, error) { + // Generate 32 random bytes (will be base64url encoded to 43 chars) + bytes := make([]byte, 32) + if _, err := rand.Read(bytes); err != nil { + return "", fmt.Errorf("failed to generate random bytes: %w", err) + } + + // Encode with base64url (no padding) + verifier := base64.RawURLEncoding.EncodeToString(bytes) + return verifier, nil +} + +// GenerateCodeChallenge generates a PKCE code challenge from a verifier +// using the S256 method (SHA256) +func GenerateCodeChallenge(verifier string) string { + hash := sha256.Sum256([]byte(verifier)) + return base64.RawURLEncoding.EncodeToString(hash[:]) +} diff --git a/oauth/pkce_test.go b/oauth/pkce_test.go new file mode 100644 index 0000000..7685dcf --- /dev/null +++ b/oauth/pkce_test.go @@ -0,0 +1,115 @@ +package oauth + +import ( + "testing" +) + +func TestValidatePKCE(t *testing.T) { + tests := []struct { + name string + verifier string + challenge string + want bool + }{ + { + name: "valid PKCE S256", + verifier: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk", + challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + want: true, + }, + { + name: "invalid verifier", + verifier: "wrong-verifier", + challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + want: false, + }, + { + name: "empty verifier", + verifier: "", + challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + want: false, + }, + { + name: "empty challenge", + verifier: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk", + challenge: "", + want: false, + }, + { + name: "both empty", + verifier: "", + challenge: "", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ValidatePKCE(tt.verifier, tt.challenge) + if got != tt.want { + t.Errorf("ValidatePKCE() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestGenerateCodeVerifier(t *testing.T) { + // Generate multiple verifiers to ensure randomness + verifiers := make(map[string]bool) + for i := 0; i < 10; i++ { + verifier, err := GenerateCodeVerifier() + if err != nil { + t.Fatalf("GenerateCodeVerifier() error = %v", err) + } + + // Check length (should be 43 characters for 32 random bytes base64url encoded) + if len(verifier) != 43 { + t.Errorf("GenerateCodeVerifier() length = %d, want 43", len(verifier)) + } + + // Check uniqueness + if verifiers[verifier] { + t.Errorf("GenerateCodeVerifier() generated duplicate: %s", verifier) + } + verifiers[verifier] = true + } +} + +func TestGenerateCodeChallenge(t *testing.T) { + tests := []struct { + name string + verifier string + want string + }{ + { + name: "known verifier", + verifier: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk", + want: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := GenerateCodeChallenge(tt.verifier) + if got != tt.want { + t.Errorf("GenerateCodeChallenge() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestPKCERoundTrip(t *testing.T) { + // Generate verifier + verifier, err := GenerateCodeVerifier() + if err != nil { + t.Fatalf("GenerateCodeVerifier() error = %v", err) + } + + // Generate challenge from verifier + challenge := GenerateCodeChallenge(verifier) + + // Validate that they match + if !ValidatePKCE(verifier, challenge) { + t.Errorf("PKCE round trip failed: verifier=%s, challenge=%s", verifier, challenge) + } +} diff --git a/oauth/resource_server.go b/oauth/resource_server.go new file mode 100644 index 0000000..a006f74 --- /dev/null +++ b/oauth/resource_server.go @@ -0,0 +1,89 @@ +package oauth + +import ( + "encoding/json" + "fmt" + "net/http" +) + +// ResourceServer implements OAuth 2.1 Protected Resource functionality +type ResourceServer struct { + resourceURI string + authorizationServers []string + validator TokenValidator +} + +// NewResourceServer creates a new protected resource server +func NewResourceServer(resourceURI string, authServers []string, validator TokenValidator) *ResourceServer { + return &ResourceServer{ + resourceURI: resourceURI, + authorizationServers: authServers, + validator: validator, + } +} + +// GetMetadata returns the Protected Resource Metadata (RFC 9728) +func (rs *ResourceServer) GetMetadata() *ProtectedResourceMetadata { + return &ProtectedResourceMetadata{ + Resource: rs.resourceURI, + AuthorizationServers: rs.authorizationServers, + BearerMethodsSupported: []string{"header"}, + ResourceSigningAlgs: []string{"RS256"}, + } +} + +// ServeMetadata serves the Protected Resource Metadata endpoint +func (rs *ResourceServer) ServeMetadata(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + metadata := rs.GetMetadata() + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "public, max-age=3600") // Cache for 1 hour + json.NewEncoder(w).Encode(metadata) +} + +// SendUnauthorized sends a 401 response with WWW-Authenticate header (RFC 9728 Section 5.1) +func (rs *ResourceServer) SendUnauthorized(w http.ResponseWriter, realm string) { + metadataURL := rs.resourceURI + "/.well-known/oauth-protected-resource" + + authHeader := fmt.Sprintf( + `Bearer realm="%s", resource_metadata="%s"`, + realm, + metadataURL, + ) + + w.Header().Set("WWW-Authenticate", authHeader) + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusUnauthorized) + + json.NewEncoder(w).Encode(map[string]string{ + "error": "invalid_token", + "error_description": "The access token is invalid or expired", + }) +} + +// SendInsufficientScope sends a 403 response for insufficient scope errors (RFC 6750 Section 3.1) +func (rs *ResourceServer) SendInsufficientScope(w http.ResponseWriter, requiredScope string) { + metadataURL := rs.resourceURI + "/.well-known/oauth-protected-resource" + + authHeader := fmt.Sprintf( + `Bearer error="insufficient_scope", scope="%s", resource_metadata="%s", error_description="Additional permissions required"`, + requiredScope, + metadataURL, + ) + + w.Header().Set("WWW-Authenticate", authHeader) + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusForbidden) + + json.NewEncoder(w).Encode(map[string]string{ + "error": "insufficient_scope", + "error_description": "Additional permissions required", + "scope": requiredScope, + }) +} diff --git a/oauth/resource_server_test.go b/oauth/resource_server_test.go new file mode 100644 index 0000000..80a9c31 --- /dev/null +++ b/oauth/resource_server_test.go @@ -0,0 +1,209 @@ +package oauth + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestNewResourceServer(t *testing.T) { + validator := NewJWTValidator("http://localhost:8080", "http://localhost:8081", "http://localhost:8081/.well-known/jwks.json") + resourceServer := NewResourceServer( + "http://localhost:8080", + []string{"http://localhost:8081"}, + validator, + ) + + if resourceServer.resourceURI != "http://localhost:8080" { + t.Errorf("resourceURI = %v, want http://localhost:8080", resourceServer.resourceURI) + } + if len(resourceServer.authorizationServers) != 1 { + t.Errorf("authorizationServers length = %d, want 1", len(resourceServer.authorizationServers)) + } + if resourceServer.validator == nil { + t.Error("validator is nil") + } +} + +func TestResourceServerGetMetadata(t *testing.T) { + validator := NewJWTValidator("http://localhost:8080", "http://localhost:8081", "http://localhost:8081/.well-known/jwks.json") + resourceServer := NewResourceServer( + "http://localhost:8080", + []string{"http://localhost:8081"}, + validator, + ) + + metadata := resourceServer.GetMetadata() + + if metadata.Resource != "http://localhost:8080" { + t.Errorf("metadata.Resource = %v, want http://localhost:8080", metadata.Resource) + } + if len(metadata.AuthorizationServers) != 1 { + t.Errorf("metadata.AuthorizationServers length = %d, want 1", len(metadata.AuthorizationServers)) + } + if metadata.AuthorizationServers[0] != "http://localhost:8081" { + t.Errorf("metadata.AuthorizationServers[0] = %v, want http://localhost:8081", metadata.AuthorizationServers[0]) + } + if len(metadata.BearerMethodsSupported) == 0 { + t.Error("metadata.BearerMethodsSupported is empty") + } + if metadata.BearerMethodsSupported[0] != "header" { + t.Errorf("metadata.BearerMethodsSupported[0] = %v, want header", metadata.BearerMethodsSupported[0]) + } + if len(metadata.ResourceSigningAlgs) == 0 { + t.Error("metadata.ResourceSigningAlgs is empty") + } + if metadata.ResourceSigningAlgs[0] != "RS256" { + t.Errorf("metadata.ResourceSigningAlgs[0] = %v, want RS256", metadata.ResourceSigningAlgs[0]) + } +} + +func TestResourceServerServeMetadata(t *testing.T) { + validator := NewJWTValidator("http://localhost:8080", "http://localhost:8081", "http://localhost:8081/.well-known/jwks.json") + resourceServer := NewResourceServer( + "http://localhost:8080", + []string{"http://localhost:8081"}, + validator, + ) + + req := httptest.NewRequest(http.MethodGet, "/.well-known/oauth-protected-resource", nil) + w := httptest.NewRecorder() + + resourceServer.ServeMetadata(w, req) + + if w.Code != http.StatusOK { + t.Errorf("ServeMetadata() status = %d, want %d", w.Code, http.StatusOK) + } + + contentType := w.Header().Get("Content-Type") + if contentType != "application/json" { + t.Errorf("Content-Type = %v, want application/json", contentType) + } + + var metadata ProtectedResourceMetadata + if err := json.NewDecoder(w.Body).Decode(&metadata); err != nil { + t.Fatalf("Failed to decode metadata: %v", err) + } + + if metadata.Resource != "http://localhost:8080" { + t.Errorf("metadata.Resource = %v, want http://localhost:8080", metadata.Resource) + } +} + +func TestResourceServerServeMetadataMethodNotAllowed(t *testing.T) { + validator := NewJWTValidator("http://localhost:8080", "http://localhost:8081", "http://localhost:8081/.well-known/jwks.json") + resourceServer := NewResourceServer( + "http://localhost:8080", + []string{"http://localhost:8081"}, + validator, + ) + + req := httptest.NewRequest(http.MethodPost, "/.well-known/oauth-protected-resource", nil) + w := httptest.NewRecorder() + + resourceServer.ServeMetadata(w, req) + + if w.Code != http.StatusMethodNotAllowed { + t.Errorf("ServeMetadata() status = %d, want %d", w.Code, http.StatusMethodNotAllowed) + } +} + +func TestResourceServerSendUnauthorized(t *testing.T) { + validator := NewJWTValidator("http://localhost:8080", "http://localhost:8081", "http://localhost:8081/.well-known/jwks.json") + resourceServer := NewResourceServer( + "http://localhost:8080", + []string{"http://localhost:8081"}, + validator, + ) + + w := httptest.NewRecorder() + resourceServer.SendUnauthorized(w, "test-realm") + + if w.Code != http.StatusUnauthorized { + t.Errorf("SendUnauthorized() status = %d, want %d", w.Code, http.StatusUnauthorized) + } + + // Check WWW-Authenticate header + authHeader := w.Header().Get("WWW-Authenticate") + if authHeader == "" { + t.Error("SendUnauthorized() missing WWW-Authenticate header") + } + + // Check for required parameters + if !contains(authHeader, "Bearer") { + t.Error("WWW-Authenticate header missing Bearer scheme") + } + if !contains(authHeader, "realm=") { + t.Error("WWW-Authenticate header missing realm parameter") + } + if !contains(authHeader, "resource_metadata=") { + t.Error("WWW-Authenticate header missing resource_metadata parameter") + } + + // Check response body + var errorResp map[string]string + if err := json.NewDecoder(w.Body).Decode(&errorResp); err != nil { + t.Fatalf("Failed to decode error response: %v", err) + } + + if errorResp["error"] != "invalid_token" { + t.Errorf("error = %v, want invalid_token", errorResp["error"]) + } +} + +func TestResourceServerSendInsufficientScope(t *testing.T) { + validator := NewJWTValidator("http://localhost:8080", "http://localhost:8081", "http://localhost:8081/.well-known/jwks.json") + resourceServer := NewResourceServer( + "http://localhost:8080", + []string{"http://localhost:8081"}, + validator, + ) + + w := httptest.NewRecorder() + resourceServer.SendInsufficientScope(w, "admin") + + if w.Code != http.StatusForbidden { + t.Errorf("SendInsufficientScope() status = %d, want %d", w.Code, http.StatusForbidden) + } + + // Check WWW-Authenticate header + authHeader := w.Header().Get("WWW-Authenticate") + if authHeader == "" { + t.Error("SendInsufficientScope() missing WWW-Authenticate header") + } + + // Check for required parameters + if !contains(authHeader, "error=\"insufficient_scope\"") { + t.Error("WWW-Authenticate header missing error=insufficient_scope") + } + if !contains(authHeader, "scope=") { + t.Error("WWW-Authenticate header missing scope parameter") + } + + // Check response body + var errorResp map[string]string + if err := json.NewDecoder(w.Body).Decode(&errorResp); err != nil { + t.Fatalf("Failed to decode error response: %v", err) + } + + if errorResp["error"] != "insufficient_scope" { + t.Errorf("error = %v, want insufficient_scope", errorResp["error"]) + } + if errorResp["scope"] != "admin" { + t.Errorf("scope = %v, want admin", errorResp["scope"]) + } +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && (s[:len(substr)] == substr || s[len(s)-len(substr):] == substr || containsMiddle(s, substr))) +} + +func containsMiddle(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/oauth/storage.go b/oauth/storage.go new file mode 100644 index 0000000..c0d7b71 --- /dev/null +++ b/oauth/storage.go @@ -0,0 +1,145 @@ +package oauth + +import ( + "crypto/rand" + "encoding/base64" + "fmt" + "sync" + "time" +) + +// AuthorizationCode represents an authorization code with metadata +type AuthorizationCode struct { + Code string + ClientID string + RedirectURI string + Scope string + CodeChallenge string + Resource string + ExpiresAt time.Time + Used bool +} + +// Client represents a registered OAuth client +type Client struct { + ClientID string + RedirectURIs []string +} + +// AuthStorage provides in-memory storage for authorization codes and clients +type AuthStorage struct { + mu sync.RWMutex + codes map[string]*AuthorizationCode + clients map[string]*Client +} + +// NewAuthStorage creates a new AuthStorage instance +func NewAuthStorage() *AuthStorage { + return &AuthStorage{ + codes: make(map[string]*AuthorizationCode), + clients: make(map[string]*Client), + } +} + +// CreateAuthorizationCode generates and stores a new authorization code +func (s *AuthStorage) CreateAuthorizationCode(clientID, redirectURI, scope, codeChallenge, resource string) (string, error) { + // Generate random authorization code + bytes := make([]byte, 32) + if _, err := rand.Read(bytes); err != nil { + return "", fmt.Errorf("failed to generate authorization code: %w", err) + } + code := base64.RawURLEncoding.EncodeToString(bytes) + + // Store authorization code + s.mu.Lock() + defer s.mu.Unlock() + + s.codes[code] = &AuthorizationCode{ + Code: code, + ClientID: clientID, + RedirectURI: redirectURI, + Scope: scope, + CodeChallenge: codeChallenge, + Resource: resource, + ExpiresAt: time.Now().Add(10 * time.Minute), // 10 minute expiration + Used: false, + } + + return code, nil +} + +// GetAuthorizationCode retrieves an authorization code +func (s *AuthStorage) GetAuthorizationCode(code string) (*AuthorizationCode, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + authCode, exists := s.codes[code] + if !exists { + return nil, fmt.Errorf("authorization code not found") + } + + if authCode.Used { + return nil, fmt.Errorf("authorization code already used") + } + + if time.Now().After(authCode.ExpiresAt) { + return nil, fmt.Errorf("authorization code expired") + } + + return authCode, nil +} + +// RevokeAuthorizationCode marks an authorization code as used +func (s *AuthStorage) RevokeAuthorizationCode(code string) { + s.mu.Lock() + defer s.mu.Unlock() + + if authCode, exists := s.codes[code]; exists { + authCode.Used = true + } +} + +// RegisterClient registers a new OAuth client +func (s *AuthStorage) RegisterClient(clientID string, redirectURIs []string) { + s.mu.Lock() + defer s.mu.Unlock() + + s.clients[clientID] = &Client{ + ClientID: clientID, + RedirectURIs: redirectURIs, + } +} + +// ValidateClient validates a client ID and redirect URI +func (s *AuthStorage) ValidateClient(clientID, redirectURI string) bool { + s.mu.RLock() + defer s.mu.RUnlock() + + client, exists := s.clients[clientID] + if !exists { + return false + } + + // Check if redirect URI is registered + for _, uri := range client.RedirectURIs { + if uri == redirectURI { + return true + } + } + + return false +} + +// CleanupExpiredCodes removes expired authorization codes +// This should be called periodically in production +func (s *AuthStorage) CleanupExpiredCodes() { + s.mu.Lock() + defer s.mu.Unlock() + + now := time.Now() + for code, authCode := range s.codes { + if now.After(authCode.ExpiresAt) || authCode.Used { + delete(s.codes, code) + } + } +} diff --git a/oauth/storage_test.go b/oauth/storage_test.go new file mode 100644 index 0000000..f49dbda --- /dev/null +++ b/oauth/storage_test.go @@ -0,0 +1,193 @@ +package oauth + +import ( + "testing" + "time" +) + +func TestAuthStorageCreateAuthorizationCode(t *testing.T) { + storage := NewAuthStorage() + + code, err := storage.CreateAuthorizationCode( + "client-123", + "http://localhost:3000/callback", + "mcp", + "challenge-abc", + "http://localhost:8080", + ) + + if err != nil { + t.Fatalf("CreateAuthorizationCode() error = %v", err) + } + + if code == "" { + t.Error("CreateAuthorizationCode() returned empty code") + } + + // Verify code is stored + authCode, err := storage.GetAuthorizationCode(code) + if err != nil { + t.Fatalf("GetAuthorizationCode() error = %v", err) + } + + if authCode.ClientID != "client-123" { + t.Errorf("authCode.ClientID = %v, want client-123", authCode.ClientID) + } + if authCode.RedirectURI != "http://localhost:3000/callback" { + t.Errorf("authCode.RedirectURI = %v, want http://localhost:3000/callback", authCode.RedirectURI) + } + if authCode.Scope != "mcp" { + t.Errorf("authCode.Scope = %v, want mcp", authCode.Scope) + } + if authCode.CodeChallenge != "challenge-abc" { + t.Errorf("authCode.CodeChallenge = %v, want challenge-abc", authCode.CodeChallenge) + } + if authCode.Resource != "http://localhost:8080" { + t.Errorf("authCode.Resource = %v, want http://localhost:8080", authCode.Resource) + } + if authCode.Used { + t.Error("authCode.Used = true, want false") + } +} + +func TestAuthStorageGetAuthorizationCodeNotFound(t *testing.T) { + storage := NewAuthStorage() + + _, err := storage.GetAuthorizationCode("non-existent-code") + if err == nil { + t.Error("GetAuthorizationCode() should return error for non-existent code") + } +} + +func TestAuthStorageRevokeAuthorizationCode(t *testing.T) { + storage := NewAuthStorage() + + code, _ := storage.CreateAuthorizationCode( + "client-123", + "http://localhost:3000/callback", + "mcp", + "challenge-abc", + "http://localhost:8080", + ) + + // Revoke the code + storage.RevokeAuthorizationCode(code) + + // Try to get the code + _, err := storage.GetAuthorizationCode(code) + if err == nil { + t.Error("GetAuthorizationCode() should return error for used code") + } +} + +func TestAuthStorageExpiredCode(t *testing.T) { + storage := NewAuthStorage() + + code, _ := storage.CreateAuthorizationCode( + "client-123", + "http://localhost:3000/callback", + "mcp", + "challenge-abc", + "http://localhost:8080", + ) + + // Manually expire the code + storage.mu.Lock() + if authCode, exists := storage.codes[code]; exists { + authCode.ExpiresAt = time.Now().Add(-1 * time.Minute) + } + storage.mu.Unlock() + + // Try to get the expired code + _, err := storage.GetAuthorizationCode(code) + if err == nil { + t.Error("GetAuthorizationCode() should return error for expired code") + } +} + +func TestAuthStorageRegisterClient(t *testing.T) { + storage := NewAuthStorage() + + clientID := "test-client" + redirectURIs := []string{"http://localhost:3000/callback", "http://localhost:3001/callback"} + + storage.RegisterClient(clientID, redirectURIs) + + // Validate with correct redirect URI + if !storage.ValidateClient(clientID, "http://localhost:3000/callback") { + t.Error("ValidateClient() should return true for registered client and redirect URI") + } + + // Validate with another correct redirect URI + if !storage.ValidateClient(clientID, "http://localhost:3001/callback") { + t.Error("ValidateClient() should return true for second registered redirect URI") + } + + // Validate with incorrect redirect URI + if storage.ValidateClient(clientID, "http://malicious.com/callback") { + t.Error("ValidateClient() should return false for unregistered redirect URI") + } + + // Validate with non-existent client + if storage.ValidateClient("non-existent-client", "http://localhost:3000/callback") { + t.Error("ValidateClient() should return false for non-existent client") + } +} + +func TestAuthStorageCleanupExpiredCodes(t *testing.T) { + storage := NewAuthStorage() + + // Create multiple codes + code1, _ := storage.CreateAuthorizationCode("client-1", "http://localhost:3000/callback", "mcp", "challenge-1", "http://localhost:8080") + code2, _ := storage.CreateAuthorizationCode("client-2", "http://localhost:3000/callback", "mcp", "challenge-2", "http://localhost:8080") + code3, _ := storage.CreateAuthorizationCode("client-3", "http://localhost:3000/callback", "mcp", "challenge-3", "http://localhost:8080") + + // Expire code1 and mark code2 as used + storage.mu.Lock() + if authCode, exists := storage.codes[code1]; exists { + authCode.ExpiresAt = time.Now().Add(-1 * time.Minute) + } + storage.mu.Unlock() + storage.RevokeAuthorizationCode(code2) + + // Cleanup + storage.CleanupExpiredCodes() + + // code1 should be removed (expired) + if _, err := storage.GetAuthorizationCode(code1); err == nil { + t.Error("Expired code should be removed") + } + + // code2 should be removed (used) + if _, err := storage.GetAuthorizationCode(code2); err == nil { + t.Error("Used code should be removed") + } + + // code3 should still exist + if _, err := storage.GetAuthorizationCode(code3); err != nil { + t.Error("Valid code should not be removed") + } +} + +func TestAuthStorageCodeUniqueness(t *testing.T) { + storage := NewAuthStorage() + + codes := make(map[string]bool) + for i := 0; i < 100; i++ { + code, err := storage.CreateAuthorizationCode( + "client-123", + "http://localhost:3000/callback", + "mcp", + "challenge-abc", + "http://localhost:8080", + ) + if err != nil { + t.Fatalf("CreateAuthorizationCode() error = %v", err) + } + + if codes[code] { + t.Errorf("Duplicate code generated: %s", code) + } + codes[code] = true + } +} diff --git a/oauth/token_validator.go b/oauth/token_validator.go new file mode 100644 index 0000000..c8b45be --- /dev/null +++ b/oauth/token_validator.go @@ -0,0 +1,231 @@ +package oauth + +import ( + "context" + "crypto/rsa" + "encoding/json" + "fmt" + "net/http" + "strings" + "sync" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// JWTValidator implements TokenValidator using JWT +type JWTValidator struct { + expectedAudience string + expectedIssuer string + jwksURL string + publicKeys map[string]*rsa.PublicKey + lastFetch time.Time + cacheDuration time.Duration + mu sync.RWMutex +} + +// NewJWTValidator creates a new JWT token validator +func NewJWTValidator(audience, issuer, jwksURL string) *JWTValidator { + return &JWTValidator{ + expectedAudience: audience, + expectedIssuer: issuer, + jwksURL: jwksURL, + publicKeys: make(map[string]*rsa.PublicKey), + cacheDuration: 15 * time.Minute, + } +} + +// ValidateToken validates a JWT token +func (v *JWTValidator) ValidateToken(ctx context.Context, tokenString string) (*TokenClaims, error) { + // Parse JWT without verification first to get key ID + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + // Verify signing method + if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + + // Get key ID from header + kid, ok := token.Header["kid"].(string) + if !ok { + return nil, fmt.Errorf("missing or invalid kid in token header") + } + + // Fetch public key + publicKey, err := v.getPublicKey(ctx, kid) + if err != nil { + return nil, fmt.Errorf("failed to get public key: %w", err) + } + + return publicKey, nil + }) + + if err != nil { + return nil, fmt.Errorf("failed to parse token: %w", err) + } + + if !token.Valid { + return nil, fmt.Errorf("token is invalid") + } + + // Extract claims + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return nil, fmt.Errorf("invalid token claims") + } + + // Validate audience (RFC 8707 - CRITICAL for security) + aud, err := claims.GetAudience() + if err != nil { + return nil, fmt.Errorf("invalid audience claim: %w", err) + } + + audienceValid := false + for _, a := range aud { + if a == v.expectedAudience { + audienceValid = true + break + } + } + if !audienceValid { + return nil, fmt.Errorf("token audience does not match expected audience") + } + + // Validate issuer + iss, err := claims.GetIssuer() + if err != nil || iss != v.expectedIssuer { + return nil, fmt.Errorf("invalid issuer: expected %s, got %s", v.expectedIssuer, iss) + } + + // Validate expiration + exp, err := claims.GetExpirationTime() + if err != nil || exp.Before(time.Now()) { + return nil, fmt.Errorf("token is expired") + } + + // Validate issued at + iat, err := claims.GetIssuedAt() + if err != nil { + return nil, fmt.Errorf("invalid issued at claim: %w", err) + } + + // Extract subject + sub, err := claims.GetSubject() + if err != nil { + return nil, fmt.Errorf("invalid subject claim: %w", err) + } + + // Extract scopes + scopes := []string{} + if scopeClaim, ok := claims["scope"].(string); ok { + if scopeClaim != "" { + scopes = strings.Split(scopeClaim, " ") + } + } + + // Build token claims + tokenClaims := &TokenClaims{ + Subject: sub, + Audience: aud, + Issuer: iss, + ExpiresAt: exp.Time, + IssuedAt: iat.Time, + Scopes: scopes, + } + + if clientID, ok := claims["client_id"].(string); ok { + tokenClaims.ClientID = clientID + } + + return tokenClaims, nil +} + +// GetJWKS fetches and returns the JWKS +func (v *JWTValidator) GetJWKS(ctx context.Context) (*JWKS, error) { + if err := v.refreshJWKS(ctx); err != nil { + return nil, err + } + + v.mu.RLock() + defer v.mu.RUnlock() + + // Build JWKS from cached keys + jwks := &JWKS{ + Keys: make([]JWK, 0, len(v.publicKeys)), + } + + for kid, pubKey := range v.publicKeys { + jwk := RSAPublicKeyToJWK(pubKey, kid) + jwks.Keys = append(jwks.Keys, jwk) + } + + return jwks, nil +} + +// getPublicKey fetches a public key by ID from JWKS endpoint +func (v *JWTValidator) getPublicKey(ctx context.Context, kid string) (*rsa.PublicKey, error) { + // Check cache + v.mu.RLock() + key, exists := v.publicKeys[kid] + lastFetch := v.lastFetch + v.mu.RUnlock() + + if exists && time.Since(lastFetch) < v.cacheDuration { + return key, nil + } + + // Fetch JWKS + if err := v.refreshJWKS(ctx); err != nil { + return nil, err + } + + // Check cache again after refresh + v.mu.RLock() + key, exists = v.publicKeys[kid] + v.mu.RUnlock() + + if exists { + return key, nil + } + + return nil, fmt.Errorf("public key with kid %s not found", kid) +} + +// refreshJWKS fetches the latest JWKS from the authorization server +func (v *JWTValidator) refreshJWKS(ctx context.Context) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, v.jwksURL, nil) + if err != nil { + return err + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("JWKS endpoint returned status %d", resp.StatusCode) + } + + var jwks JWKS + if err := json.NewDecoder(resp.Body).Decode(&jwks); err != nil { + return err + } + + // Parse and cache keys + v.mu.Lock() + defer v.mu.Unlock() + + for _, key := range jwks.Keys { + if key.KeyType == "RSA" && key.KeyID != "" { + publicKey, err := JWKToRSAPublicKey(key) + if err != nil { + continue + } + v.publicKeys[key.KeyID] = publicKey + } + } + + v.lastFetch = time.Now() + return nil +} From 431e8404978907f79f97355ad051767f4fb85dc0 Mon Sep 17 00:00:00 2001 From: Manuel Ganter Date: Wed, 7 Jan 2026 14:54:42 +0100 Subject: [PATCH 03/16] fix: oauth2 dev server now supports dynamic client registration --- config.go | 12 +- main.go | 18 ++- oauth/authz_server.go | 275 ++++++++++++++++++++++++++++++++----- oauth/authz_server_test.go | 147 ++++++++++++++++++++ oauth/jwks.go | 9 ++ oauth/jwks_test.go | 7 +- oauth/middleware.go | 6 + oauth/middleware_test.go | 6 +- oauth/oauth.go | 60 +++++--- oauth/resource_server.go | 16 ++- oauth/storage.go | 92 ++++++++++++- oauth/token_validator.go | 12 +- 12 files changed, 581 insertions(+), 79 deletions(-) diff --git a/config.go b/config.go index e5d2be3..7a8a01b 100644 --- a/config.go +++ b/config.go @@ -45,12 +45,12 @@ type Config struct { Debug bool `json:"debug"` // OAuth Configuration - OAuthEnabled bool `json:"oauth_enabled"` - OAuthMode string `json:"oauth_mode"` // "resource_server" - OAuthResourceURI string `json:"oauth_resource_uri"` - OAuthAuthServers []string `json:"oauth_auth_servers"` - OAuthIssuer string `json:"oauth_issuer"` - OAuthJWKSURL string `json:"oauth_jwks_url"` + OAuthEnabled bool `json:"oauth_enabled"` + OAuthMode string `json:"oauth_mode"` // "resource_server" + OAuthResourceURI string `json:"oauth_resource_uri"` + OAuthAuthServers []string `json:"oauth_auth_servers"` + OAuthIssuer string `json:"oauth_issuer"` + OAuthJWKSURL string `json:"oauth_jwks_url"` // Basic Auth Server (for testing) OAuthAuthServerEnabled bool `json:"oauth_auth_server_enabled"` diff --git a/main.go b/main.go index 4d3b127..9d74ba9 100644 --- a/main.go +++ b/main.go @@ -147,10 +147,11 @@ func startRemoteServer(mcpServer *mcp.Server, cfg *Config) error { // Create HTTP server addr := fmt.Sprintf("%s:%d", cfg.RemoteHost, cfg.RemotePort) httpServer := &http.Server{ - Addr: addr, - Handler: mux, - ReadTimeout: 30 * time.Second, - WriteTimeout: 30 * time.Second, + Addr: addr, + Handler: mux, + ReadTimeout: 30 * time.Second, + // WriteTimeout disabled for SSE long-lived connections + WriteTimeout: 0, IdleTimeout: 120 * time.Second, } @@ -291,6 +292,9 @@ func startBasicAuthServer(cfg *Config) { // Token endpoint (POST /token) mux.HandleFunc("/token", authServer.HandleToken) + // Registration endpoint (POST /register) - RFC 7591 + mux.HandleFunc("/register", authServer.HandleRegistration) + // JWKS endpoint (GET /.well-known/jwks.json) mux.HandleFunc("/.well-known/jwks.json", authServer.HandleJWKS) @@ -302,7 +306,9 @@ func startBasicAuthServer(cfg *Config) { } w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "public, max-age=3600") - json.NewEncoder(w).Encode(authServer.GetMetadata()) + if err := json.NewEncoder(w).Encode(authServer.GetMetadata()); err != nil { + log.Printf("Failed to encode metadata response: %v", err) + } }) // Start auth server @@ -310,9 +316,11 @@ func startBasicAuthServer(cfg *Config) { log.Printf("Basic OAuth 2.1 Authorization Server starting on http://localhost%s", addr) log.Printf(" Authorization endpoint: http://localhost%s/authorize", addr) log.Printf(" Token endpoint: http://localhost%s/token", addr) + log.Printf(" Registration endpoint: http://localhost%s/register", addr) log.Printf(" JWKS endpoint: http://localhost%s/.well-known/jwks.json", addr) log.Printf(" Metadata endpoint: http://localhost%s/.well-known/oauth-authorization-server", addr) log.Printf(" Registered client: %s", cfg.OAuthClientID) + log.Printf(" Dynamic client registration: enabled (RFC 7591)") if err := http.ListenAndServe(addr, mux); err != nil { log.Printf("Basic auth server error: %v", err) diff --git a/oauth/authz_server.go b/oauth/authz_server.go index 2f6daed..89c62fd 100644 --- a/oauth/authz_server.go +++ b/oauth/authz_server.go @@ -14,14 +14,15 @@ import ( // BasicAuthServer implements a simple OAuth 2.1 Authorization Server // for development and testing purposes type BasicAuthServer struct { - issuer string - baseURL string - privateKey *rsa.PrivateKey - publicKey *rsa.PublicKey - keyID string - storage *AuthStorage - tokenLifetime time.Duration - codeLifetime time.Duration + issuer string + baseURL string + privateKey *rsa.PrivateKey + publicKey *rsa.PublicKey + keyID string + storage *AuthStorage + tokenLifetime time.Duration + codeLifetime time.Duration + refreshTokenLifetime time.Duration } // NewBasicAuthServer creates a new basic authorization server @@ -39,14 +40,15 @@ func NewBasicAuthServer(issuer, baseURL string) (*BasicAuthServer, error) { } return &BasicAuthServer{ - issuer: issuer, - baseURL: baseURL, - privateKey: privateKey, - publicKey: &privateKey.PublicKey, - keyID: keyID, - storage: NewAuthStorage(), - tokenLifetime: 1 * time.Hour, - codeLifetime: 10 * time.Minute, + issuer: issuer, + baseURL: baseURL, + privateKey: privateKey, + publicKey: &privateKey.PublicKey, + keyID: keyID, + storage: NewAuthStorage(), + tokenLifetime: 1 * time.Hour, + codeLifetime: 10 * time.Minute, + refreshTokenLifetime: 30 * 24 * time.Hour, // 30 days }, nil } @@ -56,9 +58,10 @@ func (s *BasicAuthServer) GetMetadata() *AuthServerMetadata { Issuer: s.issuer, AuthorizationEndpoint: s.baseURL + "/authorize", TokenEndpoint: s.baseURL + "/token", + RegistrationEndpoint: s.baseURL + "/register", JWKSURI: s.baseURL + "/.well-known/jwks.json", ResponseTypesSupported: []string{"code"}, - GrantTypesSupported: []string{"authorization_code"}, + GrantTypesSupported: []string{"authorization_code", "refresh_token"}, CodeChallengeMethodsSupported: []string{"S256"}, TokenEndpointAuthMethodsSupported: []string{"none"}, ScopesSupported: []string{"mcp"}, @@ -123,22 +126,37 @@ func (s *BasicAuthServer) HandleAuthorize(w http.ResponseWriter, r *http.Request // HandleToken handles token requests (POST /token) func (s *BasicAuthServer) HandleToken(w http.ResponseWriter, r *http.Request) { + fmt.Printf("DEBUG: === Token endpoint request received ===\n") + fmt.Printf("DEBUG: Remote addr: %s\n", r.RemoteAddr) + fmt.Printf("DEBUG: User-Agent: %s\n", r.Header.Get("User-Agent")) + if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } if err := r.ParseForm(); err != nil { + fmt.Printf("DEBUG: Failed to parse form: %v\n", err) sendTokenError(w, "invalid_request", "Invalid form data") return } grantType := r.FormValue("grant_type") - if grantType != "authorization_code" { - sendTokenError(w, "unsupported_grant_type", "Only authorization_code grant is supported") - return - } + fmt.Printf("DEBUG: Token request with grant_type=%s\n", grantType) + fmt.Printf("DEBUG: Full form data: %v\n", r.Form) + switch grantType { + case "authorization_code": + s.handleAuthorizationCodeGrant(w, r) + case "refresh_token": + s.handleRefreshTokenGrant(w, r) + default: + sendTokenError(w, "unsupported_grant_type", "Only 'authorization_code' and 'refresh_token' grants are supported") + } +} + +// handleAuthorizationCodeGrant handles the authorization_code grant type +func (s *BasicAuthServer) handleAuthorizationCodeGrant(w http.ResponseWriter, r *http.Request) { code := r.FormValue("code") clientID := r.FormValue("client_id") redirectURI := r.FormValue("redirect_uri") @@ -148,18 +166,21 @@ func (s *BasicAuthServer) HandleToken(w http.ResponseWriter, r *http.Request) { // Validate authorization code authCode, err := s.storage.GetAuthorizationCode(code) if err != nil { + fmt.Printf("DEBUG: Authorization code validation failed: %v\n", err) sendTokenError(w, "invalid_grant", "Invalid authorization code") return } // Validate PKCE if !ValidatePKCE(codeVerifier, authCode.CodeChallenge) { + fmt.Printf("DEBUG: PKCE validation failed\n") sendTokenError(w, "invalid_grant", "Invalid PKCE verifier") return } // Validate client and redirect URI if authCode.ClientID != clientID || authCode.RedirectURI != redirectURI { + fmt.Printf("DEBUG: Client or redirect URI mismatch\n") sendTokenError(w, "invalid_grant", "Client or redirect URI mismatch") return } @@ -170,17 +191,85 @@ func (s *BasicAuthServer) HandleToken(w http.ResponseWriter, r *http.Request) { } // Issue access token - token, err := s.issueAccessToken(clientID, authCode.Scope, resource) + accessToken, err := s.issueAccessToken(clientID, authCode.Scope, resource) if err != nil { + fmt.Printf("DEBUG: Failed to issue access token: %v\n", err) sendTokenError(w, "server_error", "Failed to issue token") return } + // Issue refresh token + refreshToken, err := s.storage.CreateRefreshToken(clientID, authCode.Scope, resource, s.refreshTokenLifetime) + if err != nil { + fmt.Printf("DEBUG: Failed to issue refresh token: %v\n", err) + sendTokenError(w, "server_error", "Failed to issue refresh token") + return + } + // Mark code as used s.storage.RevokeAuthorizationCode(code) + fmt.Printf("DEBUG: Tokens issued successfully for client %s\n", clientID) + // Send token response - sendTokenResponse(w, token, int(s.tokenLifetime.Seconds()), authCode.Scope) + sendTokenResponse(w, accessToken, refreshToken, int(s.tokenLifetime.Seconds()), authCode.Scope) +} + +// handleRefreshTokenGrant handles the refresh_token grant type +func (s *BasicAuthServer) handleRefreshTokenGrant(w http.ResponseWriter, r *http.Request) { + refreshTokenValue := r.FormValue("refresh_token") + clientID := r.FormValue("client_id") + resource := r.FormValue("resource") + + fmt.Printf("DEBUG: Refresh token request: client_id=%s, resource=%s, token_length=%d\n", + clientID, resource, len(refreshTokenValue)) + + // Validate refresh token + refreshToken, err := s.storage.GetRefreshToken(refreshTokenValue) + if err != nil { + fmt.Printf("DEBUG: Refresh token validation failed: %v\n", err) + if len(refreshTokenValue) > 20 { + fmt.Printf("DEBUG: Token value (first 20 chars): %s...\n", refreshTokenValue[:20]) + } + sendTokenError(w, "invalid_grant", "Invalid refresh token") + return + } + + // Validate client + if refreshToken.ClientID != clientID { + fmt.Printf("DEBUG: Client ID mismatch\n") + sendTokenError(w, "invalid_grant", "Client ID mismatch") + return + } + + // Use resource from refresh token if not provided + if resource == "" { + resource = refreshToken.Resource + } + + // Issue new access token + accessToken, err := s.issueAccessToken(clientID, refreshToken.Scope, resource) + if err != nil { + fmt.Printf("DEBUG: Failed to issue access token: %v\n", err) + sendTokenError(w, "server_error", "Failed to issue token") + return + } + + // Issue new refresh token (refresh token rotation for security) + newRefreshToken, err := s.storage.CreateRefreshToken(clientID, refreshToken.Scope, resource, s.refreshTokenLifetime) + if err != nil { + fmt.Printf("DEBUG: Failed to issue new refresh token: %v\n", err) + sendTokenError(w, "server_error", "Failed to issue refresh token") + return + } + + // Revoke old refresh token + s.storage.RevokeRefreshToken(refreshTokenValue) + + fmt.Printf("DEBUG: Tokens refreshed successfully for client %s\n", clientID) + + // Send token response + sendTokenResponse(w, accessToken, newRefreshToken, int(s.tokenLifetime.Seconds()), refreshToken.Scope) } // HandleJWKS returns the JWKS document (GET /.well-known/jwks.json) @@ -194,7 +283,110 @@ func (s *BasicAuthServer) HandleJWKS(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "public, max-age=3600") // Cache for 1 hour - json.NewEncoder(w).Encode(jwks) + if err := json.NewEncoder(w).Encode(jwks); err != nil { + fmt.Printf("Failed to encode JWKS response: %v\n", err) + } +} + +// HandleRegistration handles dynamic client registration (POST /register) +// Implements RFC 7591 - OAuth 2.0 Dynamic Client Registration Protocol +func (s *BasicAuthServer) HandleRegistration(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Parse registration request + var req ClientRegistrationRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + fmt.Printf("DEBUG: Registration request parsing failed: %v\n", err) + sendRegistrationError(w, "invalid_request", "Invalid JSON body") + return + } + + fmt.Printf("DEBUG: Client registration request: redirect_uris=%v, grant_types=%v, response_types=%v\n", + req.RedirectURIs, req.GrantTypes, req.ResponseTypes) + + // Validate redirect URIs (required) + if len(req.RedirectURIs) == 0 { + sendRegistrationError(w, "invalid_redirect_uri", "At least one redirect_uri is required") + return + } + + // Validate redirect URIs format + for _, uri := range req.RedirectURIs { + if uri == "" { + sendRegistrationError(w, "invalid_redirect_uri", "redirect_uri cannot be empty") + return + } + } + + // Set defaults for optional fields + if req.TokenEndpointAuthMethod == "" { + req.TokenEndpointAuthMethod = "none" + } + + if len(req.GrantTypes) == 0 { + req.GrantTypes = []string{"authorization_code", "refresh_token"} + } + + if len(req.ResponseTypes) == 0 { + req.ResponseTypes = []string{"code"} + } + + // Validate that only supported values are used + if req.TokenEndpointAuthMethod != "none" { + sendRegistrationError(w, "invalid_client_metadata", "Only token_endpoint_auth_method 'none' is supported") + return + } + + for _, grantType := range req.GrantTypes { + if grantType != "authorization_code" && grantType != "refresh_token" { + sendRegistrationError(w, "invalid_client_metadata", "Only grant_types 'authorization_code' and 'refresh_token' are supported") + return + } + } + + for _, responseType := range req.ResponseTypes { + if responseType != "code" { + sendRegistrationError(w, "invalid_client_metadata", "Only response_type 'code' is supported") + return + } + } + + // Generate client ID + clientID, err := GenerateClientID() + if err != nil { + sendRegistrationError(w, "server_error", "Failed to generate client ID") + return + } + + // Register the client + s.storage.RegisterClient(clientID, req.RedirectURIs) + + // Build response + response := ClientRegistrationResponse{ + ClientID: clientID, + ClientIDIssuedAt: time.Now().Unix(), + RedirectURIs: req.RedirectURIs, + TokenEndpointAuthMethod: req.TokenEndpointAuthMethod, + GrantTypes: req.GrantTypes, + ResponseTypes: req.ResponseTypes, + ClientName: req.ClientName, + ClientURI: req.ClientURI, + Scope: req.Scope, + } + + fmt.Printf("DEBUG: Client registered successfully: client_id=%s, grant_types=%v\n", clientID, response.GrantTypes) + + // Send successful response + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Pragma", "no-cache") + w.WriteHeader(http.StatusCreated) + if err := json.NewEncoder(w).Encode(response); err != nil { + fmt.Printf("Failed to encode registration response: %v\n", err) + } } // RegisterClient registers a new OAuth client @@ -223,19 +415,22 @@ func (s *BasicAuthServer) issueAccessToken(clientID, scope, audience string) (st } // sendTokenResponse sends a successful token response -func sendTokenResponse(w http.ResponseWriter, accessToken string, expiresIn int, scope string) { +func sendTokenResponse(w http.ResponseWriter, accessToken string, refreshToken string, expiresIn int, scope string) { response := TokenResponse{ - AccessToken: accessToken, - TokenType: "Bearer", - ExpiresIn: expiresIn, - Scope: scope, + AccessToken: accessToken, + TokenType: "Bearer", + ExpiresIn: expiresIn, + RefreshToken: refreshToken, + Scope: scope, } w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") w.Header().Set("Pragma", "no-cache") w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(response) + if err := json.NewEncoder(w).Encode(response); err != nil { + fmt.Printf("Failed to encode token response: %v\n", err) + } } // sendTokenError sends an OAuth 2.1 token error response @@ -249,5 +444,23 @@ func sendTokenError(w http.ResponseWriter, error, errorDescription string) { w.Header().Set("Cache-Control", "no-store") w.Header().Set("Pragma", "no-cache") w.WriteHeader(http.StatusBadRequest) - json.NewEncoder(w).Encode(response) + if err := json.NewEncoder(w).Encode(response); err != nil { + fmt.Printf("Failed to encode token error response: %v\n", err) + } +} + +// sendRegistrationError sends an RFC 7591 client registration error response +func sendRegistrationError(w http.ResponseWriter, error, errorDescription string) { + response := TokenErrorResponse{ + Error: error, + ErrorDescription: errorDescription, + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Pragma", "no-cache") + w.WriteHeader(http.StatusBadRequest) + if err := json.NewEncoder(w).Encode(response); err != nil { + fmt.Printf("Failed to encode registration error response: %v\n", err) + } } diff --git a/oauth/authz_server_test.go b/oauth/authz_server_test.go index 5d7de05..c07acb8 100644 --- a/oauth/authz_server_test.go +++ b/oauth/authz_server_test.go @@ -44,6 +44,9 @@ func TestBasicAuthServerGetMetadata(t *testing.T) { if metadata.TokenEndpoint != "http://localhost:8081/token" { t.Errorf("metadata.TokenEndpoint = %v", metadata.TokenEndpoint) } + if metadata.RegistrationEndpoint != "http://localhost:8081/register" { + t.Errorf("metadata.RegistrationEndpoint = %v, want http://localhost:8081/register", metadata.RegistrationEndpoint) + } if metadata.JWKSURI != "http://localhost:8081/.well-known/jwks.json" { t.Errorf("metadata.JWKSURI = %v", metadata.JWKSURI) } @@ -401,3 +404,147 @@ func TestBasicAuthServerFullFlow(t *testing.T) { t.Errorf("claims.Subject = %v, want test-client", claims.Subject) } } + +func TestBasicAuthServerHandleRegistration(t *testing.T) { + server, _ := NewBasicAuthServer("http://localhost:8081", "http://localhost:8081") + + tests := []struct { + name string + request ClientRegistrationRequest + wantStatus int + wantError string + }{ + { + name: "valid registration request", + request: ClientRegistrationRequest{ + RedirectURIs: []string{"http://localhost:3000/callback"}, + TokenEndpointAuthMethod: "none", + GrantTypes: []string{"authorization_code"}, + ResponseTypes: []string{"code"}, + ClientName: "Test Client", + ClientURI: "http://localhost:3000", + Scope: "mcp", + }, + wantStatus: http.StatusCreated, + }, + { + name: "valid registration with defaults", + request: ClientRegistrationRequest{ + RedirectURIs: []string{"http://localhost:3000/callback"}, + }, + wantStatus: http.StatusCreated, + }, + { + name: "multiple redirect URIs", + request: ClientRegistrationRequest{ + RedirectURIs: []string{ + "http://localhost:3000/callback", + "http://localhost:3001/callback", + }, + }, + wantStatus: http.StatusCreated, + }, + { + name: "missing redirect_uris", + request: ClientRegistrationRequest{}, + wantStatus: http.StatusBadRequest, + wantError: "invalid_redirect_uri", + }, + { + name: "empty redirect_uri", + request: ClientRegistrationRequest{ + RedirectURIs: []string{""}, + }, + wantStatus: http.StatusBadRequest, + wantError: "invalid_redirect_uri", + }, + { + name: "unsupported token_endpoint_auth_method", + request: ClientRegistrationRequest{ + RedirectURIs: []string{"http://localhost:3000/callback"}, + TokenEndpointAuthMethod: "client_secret_basic", + }, + wantStatus: http.StatusBadRequest, + wantError: "invalid_client_metadata", + }, + { + name: "unsupported grant_type", + request: ClientRegistrationRequest{ + RedirectURIs: []string{"http://localhost:3000/callback"}, + GrantTypes: []string{"client_credentials"}, + }, + wantStatus: http.StatusBadRequest, + wantError: "invalid_client_metadata", + }, + { + name: "unsupported response_type", + request: ClientRegistrationRequest{ + RedirectURIs: []string{"http://localhost:3000/callback"}, + ResponseTypes: []string{"token"}, + }, + wantStatus: http.StatusBadRequest, + wantError: "invalid_client_metadata", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Marshal request to JSON + body, err := json.Marshal(tt.request) + if err != nil { + t.Fatalf("Failed to marshal request: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/register", strings.NewReader(string(body))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + server.HandleRegistration(w, req) + + if w.Code != tt.wantStatus { + t.Errorf("HandleRegistration() status = %d, want %d, body = %s", w.Code, tt.wantStatus, w.Body.String()) + } + + if tt.wantStatus == http.StatusCreated { + var resp ClientRegistrationResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("Failed to decode registration response: %v", err) + } + + // Verify response fields + if resp.ClientID == "" { + t.Error("HandleRegistration() missing client_id") + } + if resp.ClientIDIssuedAt == 0 { + t.Error("HandleRegistration() missing client_id_issued_at") + } + if len(resp.RedirectURIs) == 0 { + t.Error("HandleRegistration() missing redirect_uris") + } + if resp.TokenEndpointAuthMethod != "none" { + t.Errorf("HandleRegistration() token_endpoint_auth_method = %v, want none", resp.TokenEndpointAuthMethod) + } + if len(resp.GrantTypes) == 0 { + t.Error("HandleRegistration() missing grant_types") + } + if len(resp.ResponseTypes) == 0 { + t.Error("HandleRegistration() missing response_types") + } + + // Verify client was registered in storage + if !server.storage.ValidateClient(resp.ClientID, resp.RedirectURIs[0]) { + t.Error("HandleRegistration() client not registered in storage") + } + } else if tt.wantError != "" { + var errorResp TokenErrorResponse + if err := json.NewDecoder(w.Body).Decode(&errorResp); err != nil { + t.Fatalf("Failed to decode error response: %v", err) + } + + if errorResp.Error != tt.wantError { + t.Errorf("HandleRegistration() error = %v, want %v", errorResp.Error, tt.wantError) + } + } + }) + } +} diff --git a/oauth/jwks.go b/oauth/jwks.go index 679dfc6..f068b06 100644 --- a/oauth/jwks.go +++ b/oauth/jwks.go @@ -17,6 +17,15 @@ func GenerateKeyID() (string, error) { return base64.RawURLEncoding.EncodeToString(bytes), nil } +// GenerateClientID generates a random client ID for OAuth clients +func GenerateClientID() (string, error) { + bytes := make([]byte, 32) + if _, err := rand.Read(bytes); err != nil { + return "", fmt.Errorf("failed to generate client ID: %w", err) + } + return base64.RawURLEncoding.EncodeToString(bytes), nil +} + // RSAPublicKeyToJWK converts an RSA public key to JWK format func RSAPublicKeyToJWK(publicKey *rsa.PublicKey, keyID string) JWK { // Encode modulus (N) as base64url diff --git a/oauth/jwks_test.go b/oauth/jwks_test.go index 7b14990..e12e9ec 100644 --- a/oauth/jwks_test.go +++ b/oauth/jwks_test.go @@ -112,11 +112,8 @@ func TestCreateJWKS(t *testing.T) { jwks := CreateJWKS(&privateKey.PublicKey, keyID) // Check JWKS structure - if jwks == nil { - t.Fatal("CreateJWKS() returned nil") - } - if len(jwks.Keys) != 1 { - t.Errorf("JWKS Keys length = %d, want 1", len(jwks.Keys)) + if jwks == nil || len(jwks.Keys) != 1 { + t.Fatalf("CreateJWKS() returned invalid JWKS: jwks=%v", jwks) } if jwks.Keys[0].KeyID != keyID { t.Errorf("JWKS Keys[0].KeyID = %v, want %v", jwks.Keys[0].KeyID, keyID) diff --git a/oauth/middleware.go b/oauth/middleware.go index e910b91..a89ea1a 100644 --- a/oauth/middleware.go +++ b/oauth/middleware.go @@ -17,25 +17,31 @@ func AuthMiddleware(resourceServer *ResourceServer, validator TokenValidator) fu // Extract Bearer token authHeader := r.Header.Get("Authorization") if authHeader == "" { + println("DEBUG: No Authorization header") resourceServer.SendUnauthorized(w, "edge-connect-mcp") return } parts := strings.SplitN(authHeader, " ", 2) if len(parts) != 2 || parts[0] != "Bearer" { + println("DEBUG: Invalid Authorization header format") resourceServer.SendUnauthorized(w, "edge-connect-mcp") return } token := parts[1] + println("DEBUG: SSE request with Bearer token (length:", len(token), ")") // Validate token claims, err := validator.ValidateToken(r.Context(), token) if err != nil { + println("DEBUG: Token validation failed:", err.Error()) resourceServer.SendUnauthorized(w, "edge-connect-mcp") return } + println("DEBUG: Token validated successfully for subject:", claims.Subject) + // Add claims to context ctx := context.WithValue(r.Context(), TokenClaimsKey, claims) diff --git a/oauth/middleware_test.go b/oauth/middleware_test.go index b2402ba..6a1bfeb 100644 --- a/oauth/middleware_test.go +++ b/oauth/middleware_test.go @@ -23,7 +23,7 @@ func TestAuthMiddlewareNoToken(t *testing.T) { // Create a test handler testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) - w.Write([]byte("success")) + _, _ = w.Write([]byte("success")) }) // Wrap with middleware @@ -126,7 +126,7 @@ func TestAuthMiddlewareWithValidToken(t *testing.T) { t.Errorf("claims.Subject = %v, want test-client", claims.Subject) } w.WriteHeader(http.StatusOK) - w.Write([]byte("success")) + _, _ = w.Write([]byte("success")) }) wrappedHandler := middleware(testHandler) @@ -183,7 +183,7 @@ func TestRequireScopeMiddleware(t *testing.T) { // Create test handler testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) - w.Write([]byte("success")) + _, _ = w.Write([]byte("success")) }) tests := []struct { diff --git a/oauth/oauth.go b/oauth/oauth.go index f3faa65..d50329d 100644 --- a/oauth/oauth.go +++ b/oauth/oauth.go @@ -43,23 +43,24 @@ type AuthorizationServer interface { // ProtectedResourceMetadata represents RFC 9728 metadata type ProtectedResourceMetadata struct { - Resource string `json:"resource"` - AuthorizationServers []string `json:"authorization_servers"` - BearerMethodsSupported []string `json:"bearer_methods_supported,omitempty"` - ResourceSigningAlgs []string `json:"resource_signing_alg_values_supported,omitempty"` + Resource string `json:"resource"` + AuthorizationServers []string `json:"authorization_servers"` + BearerMethodsSupported []string `json:"bearer_methods_supported,omitempty"` + ResourceSigningAlgs []string `json:"resource_signing_alg_values_supported,omitempty"` } // AuthServerMetadata represents RFC 8414 metadata type AuthServerMetadata struct { - Issuer string `json:"issuer"` - AuthorizationEndpoint string `json:"authorization_endpoint"` - TokenEndpoint string `json:"token_endpoint"` - JWKSURI string `json:"jwks_uri"` - ScopesSupported []string `json:"scopes_supported,omitempty"` - ResponseTypesSupported []string `json:"response_types_supported"` - GrantTypesSupported []string `json:"grant_types_supported"` - TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"` - CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"` + Issuer string `json:"issuer"` + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + RegistrationEndpoint string `json:"registration_endpoint,omitempty"` + JWKSURI string `json:"jwks_uri"` + ScopesSupported []string `json:"scopes_supported,omitempty"` + ResponseTypesSupported []string `json:"response_types_supported"` + GrantTypesSupported []string `json:"grant_types_supported"` + TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"` + CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"` } // JWKS represents a JSON Web Key Set @@ -79,10 +80,11 @@ type JWK struct { // TokenResponse represents an OAuth 2.1 token response type TokenResponse struct { - AccessToken string `json:"access_token"` - TokenType string `json:"token_type"` - ExpiresIn int `json:"expires_in"` - Scope string `json:"scope,omitempty"` + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + RefreshToken string `json:"refresh_token,omitempty"` + Scope string `json:"scope,omitempty"` } // TokenErrorResponse represents an OAuth 2.1 token error response @@ -90,3 +92,27 @@ type TokenErrorResponse struct { Error string `json:"error"` ErrorDescription string `json:"error_description,omitempty"` } + +// ClientRegistrationRequest represents RFC 7591 client registration request +type ClientRegistrationRequest struct { + RedirectURIs []string `json:"redirect_uris"` + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"` + GrantTypes []string `json:"grant_types,omitempty"` + ResponseTypes []string `json:"response_types,omitempty"` + ClientName string `json:"client_name,omitempty"` + ClientURI string `json:"client_uri,omitempty"` + Scope string `json:"scope,omitempty"` +} + +// ClientRegistrationResponse represents RFC 7591 client registration response +type ClientRegistrationResponse struct { + ClientID string `json:"client_id"` + ClientIDIssuedAt int64 `json:"client_id_issued_at,omitempty"` + RedirectURIs []string `json:"redirect_uris,omitempty"` + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"` + GrantTypes []string `json:"grant_types,omitempty"` + ResponseTypes []string `json:"response_types,omitempty"` + ClientName string `json:"client_name,omitempty"` + ClientURI string `json:"client_uri,omitempty"` + Scope string `json:"scope,omitempty"` +} diff --git a/oauth/resource_server.go b/oauth/resource_server.go index a006f74..5af910e 100644 --- a/oauth/resource_server.go +++ b/oauth/resource_server.go @@ -42,7 +42,9 @@ func (rs *ResourceServer) ServeMetadata(w http.ResponseWriter, r *http.Request) metadata := rs.GetMetadata() w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "public, max-age=3600") // Cache for 1 hour - json.NewEncoder(w).Encode(metadata) + if err := json.NewEncoder(w).Encode(metadata); err != nil { + fmt.Printf("Failed to encode resource metadata: %v\n", err) + } } // SendUnauthorized sends a 401 response with WWW-Authenticate header (RFC 9728 Section 5.1) @@ -60,10 +62,12 @@ func (rs *ResourceServer) SendUnauthorized(w http.ResponseWriter, realm string) w.Header().Set("Cache-Control", "no-store") w.WriteHeader(http.StatusUnauthorized) - json.NewEncoder(w).Encode(map[string]string{ + if err := json.NewEncoder(w).Encode(map[string]string{ "error": "invalid_token", "error_description": "The access token is invalid or expired", - }) + }); err != nil { + fmt.Printf("Failed to encode unauthorized response: %v\n", err) + } } // SendInsufficientScope sends a 403 response for insufficient scope errors (RFC 6750 Section 3.1) @@ -81,9 +85,11 @@ func (rs *ResourceServer) SendInsufficientScope(w http.ResponseWriter, requiredS w.Header().Set("Cache-Control", "no-store") w.WriteHeader(http.StatusForbidden) - json.NewEncoder(w).Encode(map[string]string{ + if err := json.NewEncoder(w).Encode(map[string]string{ "error": "insufficient_scope", "error_description": "Additional permissions required", "scope": requiredScope, - }) + }); err != nil { + fmt.Printf("Failed to encode insufficient scope response: %v\n", err) + } } diff --git a/oauth/storage.go b/oauth/storage.go index c0d7b71..637fafd 100644 --- a/oauth/storage.go +++ b/oauth/storage.go @@ -20,6 +20,16 @@ type AuthorizationCode struct { Used bool } +// RefreshToken represents a refresh token with metadata +type RefreshToken struct { + Token string + ClientID string + Scope string + Resource string + ExpiresAt time.Time + Revoked bool +} + // Client represents a registered OAuth client type Client struct { ClientID string @@ -28,16 +38,18 @@ type Client struct { // AuthStorage provides in-memory storage for authorization codes and clients type AuthStorage struct { - mu sync.RWMutex - codes map[string]*AuthorizationCode - clients map[string]*Client + mu sync.RWMutex + codes map[string]*AuthorizationCode + refreshTokens map[string]*RefreshToken + clients map[string]*Client } // NewAuthStorage creates a new AuthStorage instance func NewAuthStorage() *AuthStorage { return &AuthStorage{ - codes: make(map[string]*AuthorizationCode), - clients: make(map[string]*Client), + codes: make(map[string]*AuthorizationCode), + refreshTokens: make(map[string]*RefreshToken), + clients: make(map[string]*Client), } } @@ -143,3 +155,73 @@ func (s *AuthStorage) CleanupExpiredCodes() { } } } + +// CreateRefreshToken generates and stores a new refresh token +func (s *AuthStorage) CreateRefreshToken(clientID, scope, resource string, lifetime time.Duration) (string, error) { + // Generate random refresh token + bytes := make([]byte, 32) + if _, err := rand.Read(bytes); err != nil { + return "", fmt.Errorf("failed to generate refresh token: %w", err) + } + token := base64.RawURLEncoding.EncodeToString(bytes) + + // Store refresh token + s.mu.Lock() + defer s.mu.Unlock() + + s.refreshTokens[token] = &RefreshToken{ + Token: token, + ClientID: clientID, + Scope: scope, + Resource: resource, + ExpiresAt: time.Now().Add(lifetime), + Revoked: false, + } + + return token, nil +} + +// GetRefreshToken retrieves a refresh token +func (s *AuthStorage) GetRefreshToken(token string) (*RefreshToken, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + refreshToken, exists := s.refreshTokens[token] + if !exists { + return nil, fmt.Errorf("refresh token not found") + } + + if refreshToken.Revoked { + return nil, fmt.Errorf("refresh token revoked") + } + + if time.Now().After(refreshToken.ExpiresAt) { + return nil, fmt.Errorf("refresh token expired") + } + + return refreshToken, nil +} + +// RevokeRefreshToken marks a refresh token as revoked +func (s *AuthStorage) RevokeRefreshToken(token string) { + s.mu.Lock() + defer s.mu.Unlock() + + if refreshToken, exists := s.refreshTokens[token]; exists { + refreshToken.Revoked = true + } +} + +// CleanupExpiredRefreshTokens removes expired refresh tokens +// This should be called periodically in production +func (s *AuthStorage) CleanupExpiredRefreshTokens() { + s.mu.Lock() + defer s.mu.Unlock() + + now := time.Now() + for token, refreshToken := range s.refreshTokens { + if now.After(refreshToken.ExpiresAt) || refreshToken.Revoked { + delete(s.refreshTokens, token) + } + } +} diff --git a/oauth/token_validator.go b/oauth/token_validator.go index c8b45be..d55e443 100644 --- a/oauth/token_validator.go +++ b/oauth/token_validator.go @@ -79,14 +79,20 @@ func (v *JWTValidator) ValidateToken(ctx context.Context, tokenString string) (* return nil, fmt.Errorf("invalid audience claim: %w", err) } + // Normalize expected audience (remove trailing slash) + expectedAudience := strings.TrimSuffix(v.expectedAudience, "/") + audienceValid := false for _, a := range aud { - if a == v.expectedAudience { + // Normalize token audience (remove trailing slash) + normalizedAud := strings.TrimSuffix(a, "/") + if normalizedAud == expectedAudience { audienceValid = true break } } if !audienceValid { + fmt.Printf("DEBUG: Audience validation failed. Expected: %s, Got: %v\n", expectedAudience, aud) return nil, fmt.Errorf("token audience does not match expected audience") } @@ -201,7 +207,9 @@ func (v *JWTValidator) refreshJWKS(ctx context.Context) error { if err != nil { return err } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() + }() if resp.StatusCode != http.StatusOK { return fmt.Errorf("JWKS endpoint returned status %d", resp.StatusCode) From 1afb42362dd3c9c39253dbfd2f2220daa9a6642e Mon Sep 17 00:00:00 2001 From: Manuel Ganter Date: Wed, 7 Jan 2026 14:57:33 +0100 Subject: [PATCH 04/16] fix: pre commit hook using gitleaks --- scripts/hooks/pre-commit | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/hooks/pre-commit b/scripts/hooks/pre-commit index a522e31..0c40200 100755 --- a/scripts/hooks/pre-commit +++ b/scripts/hooks/pre-commit @@ -23,6 +23,6 @@ make lint # Check for secrets with gitleaks echo "Checking for secrets..." -make gitleaks --staged +make gitleaks echo "Pre-commit checks passed!" From 429964c166d7ebfc9188031d5c405c4455dbc936 Mon Sep 17 00:00:00 2001 From: Manuel Ganter Date: Wed, 7 Jan 2026 16:36:29 +0100 Subject: [PATCH 05/16] feat: added serverlessConfig to mcp interface --- tools.go | 121 +++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 96 insertions(+), 25 deletions(-) diff --git a/tools.go b/tools.go index e3d82c1..ab664fa 100644 --- a/tools.go +++ b/tools.go @@ -9,25 +9,37 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" ) +// ServerlessConfig represents the serverless configuration for an app +type ServerlessConfig struct { + MinReplicas int `json:"min_replicas,omitempty"` + RAM int `json:"ram,omitempty"` + VCPUs int `json:"vcpus,omitempty"` + GPUConfig interface{} `json:"gpu_config,omitempty"` +} + // Apps Tool Registrations func registerCreateAppTool(s *mcp.Server) { type args struct { - Organization string `json:"organization" jsonschema:"Organization name"` - Name string `json:"name" jsonschema:"Application name"` - Version string `json:"version" jsonschema:"Application version (e.g. '1.0.0')"` - Deployment string `json:"deployment" jsonschema:"Deployment type: 'docker' or 'kubernetes'"` - ImageType *string `json:"image_type,omitempty" jsonschema:"Image type (default: 'ImageTypeDocker')"` - ImagePath string `json:"image_path" jsonschema:"Docker registry URL (e.g. 'https://registry-1.docker.io/library/nginx:latest')"` - AccessPorts *string `json:"access_ports,omitempty" jsonschema:"Access ports specification (e.g. 'tcp:80')"` - DefaultFlavorName *string `json:"default_flavor_name,omitempty" jsonschema:"Default flavor name (e.g. 'EU.small')"` - AllowServerless *bool `json:"allow_serverless,omitempty" jsonschema:"Allow serverless deployment"` - Region *string `json:"region,omitempty" jsonschema:"Region (e.g. 'EU' 'US'). If not specified uses default from config."` + Organization string `json:"organization" jsonschema:"Organization name"` + Name string `json:"name" jsonschema:"Application name"` + Version string `json:"version" jsonschema:"Application version (e.g. '1.0.0')"` + Deployment string `json:"deployment" jsonschema:"Deployment type: 'docker' or 'kubernetes'"` + ImageType *string `json:"image_type,omitempty" jsonschema:"Image type (default: 'ImageTypeDocker')"` + ImagePath string `json:"image_path" jsonschema:"Docker registry URL (e.g. 'https://registry-1.docker.io/library/nginx:latest')"` + DeploymentManifest *string `json:"deployment_manifest,omitempty" jsonschema:"Kubernetes manifest (YAML) or Docker Compose file content (optional)"` + AccessPorts *string `json:"access_ports,omitempty" jsonschema:"Access ports specification (e.g. 'tcp:80')"` + DefaultFlavorName *string `json:"default_flavor_name,omitempty" jsonschema:"Default flavor name (e.g. 'EU.small')"` + AllowServerless *bool `json:"allow_serverless,omitempty" jsonschema:"Allow serverless deployment"` + ServerlessMinReplicas *int `json:"serverless_min_replicas,omitempty" jsonschema:"Serverless minimum replicas (optional, e.g. 1)"` + ServerlessRAM *int `json:"serverless_ram,omitempty" jsonschema:"Serverless RAM in MB (optional, e.g. 512)"` + ServerlessVCPUs *int `json:"serverless_vcpus,omitempty" jsonschema:"Serverless vCPUs (optional, e.g. 2)"` + Region *string `json:"region,omitempty" jsonschema:"Region (e.g. 'EU' 'US'). If not specified uses default from config."` } mcp.AddTool(s, &mcp.Tool{ Name: "create_app", - Description: "Create a new Edge Connect application. Requires organization, name, version, deployment type, image path, and region.", + Description: "Create a new Edge Connect application. Requires organization, name, version, deployment type, and image path. Optionally accepts deployment manifest for Kubernetes or Docker Compose configurations. Either default_flavor_name or serverless configuration (serverless_min_replicas, serverless_ram, serverless_vcpus) must be specified.", }, func(ctx context.Context, req *mcp.CallToolRequest, a args) (*mcp.CallToolResult, any, error) { imageType := "ImageTypeDocker" if a.ImageType != nil { @@ -44,6 +56,11 @@ func registerCreateAppTool(s *mcp.Server) { allowServerless = *a.AllowServerless } + deploymentManifest := "" + if a.DeploymentManifest != nil { + deploymentManifest = *a.DeploymentManifest + } + region := config.DefaultRegion if a.Region != nil { region = *a.Region @@ -55,17 +72,35 @@ func registerCreateAppTool(s *mcp.Server) { Name: a.Name, Version: a.Version, }, - Deployment: a.Deployment, - ImageType: imageType, - ImagePath: a.ImagePath, - AccessPorts: accessPorts, - AllowServerless: allowServerless, + Deployment: a.Deployment, + ImageType: imageType, + ImagePath: a.ImagePath, + DeploymentManifest: deploymentManifest, + AccessPorts: accessPorts, + AllowServerless: allowServerless, } if a.DefaultFlavorName != nil && *a.DefaultFlavorName != "" { app.DefaultFlavor = v2.Flavor{Name: *a.DefaultFlavorName} } + // Build serverless config if any serverless parameters are provided + if a.ServerlessMinReplicas != nil || a.ServerlessRAM != nil || a.ServerlessVCPUs != nil { + serverlessConfig := ServerlessConfig{ + GPUConfig: map[string]interface{}{}, + } + if a.ServerlessMinReplicas != nil { + serverlessConfig.MinReplicas = *a.ServerlessMinReplicas + } + if a.ServerlessRAM != nil { + serverlessConfig.RAM = *a.ServerlessRAM + } + if a.ServerlessVCPUs != nil { + serverlessConfig.VCPUs = *a.ServerlessVCPUs + } + app.ServerlessConfig = serverlessConfig + } + input := &v2.NewAppInput{ Region: region, App: app, @@ -178,19 +213,23 @@ func registerListAppsTool(s *mcp.Server) { func registerUpdateAppTool(s *mcp.Server) { type args struct { - Organization string `json:"organization" jsonschema:"Organization name"` - Name string `json:"name" jsonschema:"Application name"` - Version string `json:"version" jsonschema:"Application version"` - ImagePath *string `json:"image_path,omitempty" jsonschema:"New Docker registry URL (optional)"` - AccessPorts *string `json:"access_ports,omitempty" jsonschema:"New access ports specification (optional)"` - DefaultFlavorName *string `json:"default_flavor_name,omitempty" jsonschema:"New default flavor name (optional)"` - AllowServerless *bool `json:"allow_serverless,omitempty" jsonschema:"New serverless setting (optional)"` - Region *string `json:"region,omitempty" jsonschema:"Region (e.g. 'EU' 'US'). If not specified uses default from config."` + Organization string `json:"organization" jsonschema:"Organization name"` + Name string `json:"name" jsonschema:"Application name"` + Version string `json:"version" jsonschema:"Application version"` + ImagePath *string `json:"image_path,omitempty" jsonschema:"New Docker registry URL (optional)"` + DeploymentManifest *string `json:"deployment_manifest,omitempty" jsonschema:"New Kubernetes manifest (YAML) or Docker Compose file content (optional)"` + AccessPorts *string `json:"access_ports,omitempty" jsonschema:"New access ports specification (optional)"` + DefaultFlavorName *string `json:"default_flavor_name,omitempty" jsonschema:"New default flavor name (optional)"` + AllowServerless *bool `json:"allow_serverless,omitempty" jsonschema:"New serverless setting (optional)"` + ServerlessMinReplicas *int `json:"serverless_min_replicas,omitempty" jsonschema:"New serverless minimum replicas (optional)"` + ServerlessRAM *int `json:"serverless_ram,omitempty" jsonschema:"New serverless RAM in MB (optional)"` + ServerlessVCPUs *int `json:"serverless_vcpus,omitempty" jsonschema:"New serverless vCPUs (optional)"` + Region *string `json:"region,omitempty" jsonschema:"Region (e.g. 'EU' 'US'). If not specified uses default from config."` } mcp.AddTool(s, &mcp.Tool{ Name: "update_app", - Description: "Update an existing Edge Connect application. Only specified fields will be updated.", + Description: "Update an existing Edge Connect application. Only specified fields will be updated. Supports updating deployment manifest, image path, access ports, flavor, serverless settings, and serverless configuration (min_replicas, ram, vcpus).", }, func(ctx context.Context, req *mcp.CallToolRequest, a args) (*mcp.CallToolResult, any, error) { region := config.DefaultRegion if a.Region != nil { @@ -215,6 +254,11 @@ func registerUpdateAppTool(s *mcp.Server) { fields = append(fields, v2.AppFieldImagePath) } + if a.DeploymentManifest != nil && *a.DeploymentManifest != "" { + currentApp.DeploymentManifest = *a.DeploymentManifest + fields = append(fields, v2.AppFieldDeploymentManifest) + } + if a.AccessPorts != nil && *a.AccessPorts != "" { currentApp.AccessPorts = *a.AccessPorts fields = append(fields, v2.AppFieldAccessPorts) @@ -230,6 +274,33 @@ func registerUpdateAppTool(s *mcp.Server) { fields = append(fields, v2.AppFieldAllowServerless) } + // Update serverless config if any serverless parameters are provided + if a.ServerlessMinReplicas != nil || a.ServerlessRAM != nil || a.ServerlessVCPUs != nil { + // Get existing serverless config or create new one + var serverlessConfig ServerlessConfig + if currentApp.ServerlessConfig != nil { + // Try to convert existing config + configBytes, _ := json.Marshal(currentApp.ServerlessConfig) + json.Unmarshal(configBytes, &serverlessConfig) + } else { + serverlessConfig.GPUConfig = map[string]interface{}{} + } + + // Update provided fields + if a.ServerlessMinReplicas != nil { + serverlessConfig.MinReplicas = *a.ServerlessMinReplicas + } + if a.ServerlessRAM != nil { + serverlessConfig.RAM = *a.ServerlessRAM + } + if a.ServerlessVCPUs != nil { + serverlessConfig.VCPUs = *a.ServerlessVCPUs + } + + currentApp.ServerlessConfig = serverlessConfig + fields = append(fields, v2.AppFieldServerlessConfig) + } + if len(fields) == 0 { return nil, nil, fmt.Errorf("no fields to update") } From 9e1808921b7731cce49ec1e22c5b23254385ab5c Mon Sep 17 00:00:00 2001 From: Manuel Ganter Date: Thu, 8 Jan 2026 14:27:57 +0100 Subject: [PATCH 06/16] feat: added serverless config --- tools.go | 37 +++++++++++++++++++++---------------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/tools.go b/tools.go index ab664fa..41ca225 100644 --- a/tools.go +++ b/tools.go @@ -21,20 +21,20 @@ type ServerlessConfig struct { func registerCreateAppTool(s *mcp.Server) { type args struct { - Organization string `json:"organization" jsonschema:"Organization name"` - Name string `json:"name" jsonschema:"Application name"` - Version string `json:"version" jsonschema:"Application version (e.g. '1.0.0')"` - Deployment string `json:"deployment" jsonschema:"Deployment type: 'docker' or 'kubernetes'"` - ImageType *string `json:"image_type,omitempty" jsonschema:"Image type (default: 'ImageTypeDocker')"` - ImagePath string `json:"image_path" jsonschema:"Docker registry URL (e.g. 'https://registry-1.docker.io/library/nginx:latest')"` - DeploymentManifest *string `json:"deployment_manifest,omitempty" jsonschema:"Kubernetes manifest (YAML) or Docker Compose file content (optional)"` - AccessPorts *string `json:"access_ports,omitempty" jsonschema:"Access ports specification (e.g. 'tcp:80')"` - DefaultFlavorName *string `json:"default_flavor_name,omitempty" jsonschema:"Default flavor name (e.g. 'EU.small')"` - AllowServerless *bool `json:"allow_serverless,omitempty" jsonschema:"Allow serverless deployment"` - ServerlessMinReplicas *int `json:"serverless_min_replicas,omitempty" jsonschema:"Serverless minimum replicas (optional, e.g. 1)"` - ServerlessRAM *int `json:"serverless_ram,omitempty" jsonschema:"Serverless RAM in MB (optional, e.g. 512)"` - ServerlessVCPUs *int `json:"serverless_vcpus,omitempty" jsonschema:"Serverless vCPUs (optional, e.g. 2)"` - Region *string `json:"region,omitempty" jsonschema:"Region (e.g. 'EU' 'US'). If not specified uses default from config."` + Organization string `json:"organization" jsonschema:"Organization name"` + Name string `json:"name" jsonschema:"Application name"` + Version string `json:"version" jsonschema:"Application version (e.g. '1.0.0')"` + Deployment string `json:"deployment" jsonschema:"Deployment type: 'docker' or 'kubernetes'"` + ImageType *string `json:"image_type,omitempty" jsonschema:"Image type (default: 'ImageTypeDocker')"` + ImagePath string `json:"image_path" jsonschema:"Docker registry URL (e.g. 'https://registry-1.docker.io/library/nginx:latest')"` + DeploymentManifest *string `json:"deployment_manifest,omitempty" jsonschema:"Kubernetes manifest (YAML) or Docker Compose file content (optional)"` + AccessPorts *string `json:"access_ports,omitempty" jsonschema:"Access ports specification (e.g. 'tcp:80')"` + DefaultFlavorName *string `json:"default_flavor_name,omitempty" jsonschema:"Default flavor name (e.g. 'EU.small')"` + AllowServerless *bool `json:"allow_serverless,omitempty" jsonschema:"Allow serverless deployment"` + ServerlessMinReplicas *int `json:"serverless_min_replicas,omitempty" jsonschema:"Serverless minimum replicas (optional, e.g. 1)"` + ServerlessRAM *int `json:"serverless_ram,omitempty" jsonschema:"Serverless RAM in MB (optional, e.g. 512)"` + ServerlessVCPUs *int `json:"serverless_vcpus,omitempty" jsonschema:"Serverless vCPUs (optional, e.g. 2)"` + Region *string `json:"region,omitempty" jsonschema:"Region (e.g. 'EU' 'US'). If not specified uses default from config."` } mcp.AddTool(s, &mcp.Tool{ @@ -280,8 +280,13 @@ func registerUpdateAppTool(s *mcp.Server) { var serverlessConfig ServerlessConfig if currentApp.ServerlessConfig != nil { // Try to convert existing config - configBytes, _ := json.Marshal(currentApp.ServerlessConfig) - json.Unmarshal(configBytes, &serverlessConfig) + configBytes, err := json.Marshal(currentApp.ServerlessConfig) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal serverless config: %w", err) + } + if err := json.Unmarshal(configBytes, &serverlessConfig); err != nil { + return nil, nil, fmt.Errorf("failed to unmarshal serverless config: %w", err) + } } else { serverlessConfig.GPUConfig = map[string]interface{}{} } From d16354d2604ac9176da0d532a083d82ba1047561 Mon Sep 17 00:00:00 2001 From: Manuel Ganter Date: Thu, 8 Jan 2026 14:53:38 +0100 Subject: [PATCH 07/16] feat: added relational context between deletion of app and app instance --- tools.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools.go b/tools.go index 41ca225..6f68daf 100644 --- a/tools.go +++ b/tools.go @@ -339,7 +339,7 @@ func registerDeleteAppTool(s *mcp.Server) { mcp.AddTool(s, &mcp.Tool{ Name: "delete_app", - Description: "Delete an Edge Connect application. This operation is idempotent (safe to call multiple times).", + Description: "Delete an Edge Connect application. This operation is idempotent (safe to call multiple times). Apps can only be deleted if all app instances referencing the app has been deleted.", }, func(ctx context.Context, req *mcp.CallToolRequest, a args) (*mcp.CallToolResult, any, error) { region := config.DefaultRegion if a.Region != nil { From 260d0cad40448590a8d2ca3217ccc01407ee8d28 Mon Sep 17 00:00:00 2001 From: Manuel Ganter Date: Thu, 8 Jan 2026 15:14:25 +0100 Subject: [PATCH 08/16] chore(sdk): bumped sdk to v2.2.0 --- go.mod | 4 ++-- go.sum | 4 ++-- tools.go | 29 ++++++----------------------- 3 files changed, 10 insertions(+), 27 deletions(-) diff --git a/go.mod b/go.mod index 3c0750c..1c92d67 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,9 @@ module edp.buildth.ing/DevFW-CICD/edge-connect-mcp go 1.25.5 require ( - edp.buildth.ing/DevFW-CICD/edge-connect-client/v2 v2.1.2 + edp.buildth.ing/DevFW-CICD/edge-connect-client/v2 v2.2.0 github.com/golang-jwt/jwt/v5 v5.3.0 + github.com/joho/godotenv v1.5.1 github.com/modelcontextprotocol/go-sdk v1.2.0 ) @@ -12,7 +13,6 @@ require ( github.com/google/jsonschema-go v0.3.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-retryablehttp v0.7.8 // indirect - github.com/joho/godotenv v1.5.1 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/tools v0.35.0 // indirect diff --git a/go.sum b/go.sum index 82db90d..add7c55 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -edp.buildth.ing/DevFW-CICD/edge-connect-client/v2 v2.1.2 h1:g1iY/8Au4T6UV6cFm8/SQXAAF+DvFcjR6Hb0TqTF064= -edp.buildth.ing/DevFW-CICD/edge-connect-client/v2 v2.1.2/go.mod h1:nPZ4K4BB7eXyeSrcHXvSPkNZbs+XgmxbDJOM4KhbI1A= +edp.buildth.ing/DevFW-CICD/edge-connect-client/v2 v2.2.0 h1:yULvMTngSD66uWgoPJDHI2IHfRfKvr8Ai3cdHD+FcII= +edp.buildth.ing/DevFW-CICD/edge-connect-client/v2 v2.2.0/go.mod h1:nPZ4K4BB7eXyeSrcHXvSPkNZbs+XgmxbDJOM4KhbI1A= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= diff --git a/tools.go b/tools.go index 6f68daf..cd558c5 100644 --- a/tools.go +++ b/tools.go @@ -9,14 +9,6 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" ) -// ServerlessConfig represents the serverless configuration for an app -type ServerlessConfig struct { - MinReplicas int `json:"min_replicas,omitempty"` - RAM int `json:"ram,omitempty"` - VCPUs int `json:"vcpus,omitempty"` - GPUConfig interface{} `json:"gpu_config,omitempty"` -} - // Apps Tool Registrations func registerCreateAppTool(s *mcp.Server) { @@ -86,9 +78,7 @@ func registerCreateAppTool(s *mcp.Server) { // Build serverless config if any serverless parameters are provided if a.ServerlessMinReplicas != nil || a.ServerlessRAM != nil || a.ServerlessVCPUs != nil { - serverlessConfig := ServerlessConfig{ - GPUConfig: map[string]interface{}{}, - } + serverlessConfig := v2.ServerlessConfig{} if a.ServerlessMinReplicas != nil { serverlessConfig.MinReplicas = *a.ServerlessMinReplicas } @@ -277,18 +267,11 @@ func registerUpdateAppTool(s *mcp.Server) { // Update serverless config if any serverless parameters are provided if a.ServerlessMinReplicas != nil || a.ServerlessRAM != nil || a.ServerlessVCPUs != nil { // Get existing serverless config or create new one - var serverlessConfig ServerlessConfig - if currentApp.ServerlessConfig != nil { - // Try to convert existing config - configBytes, err := json.Marshal(currentApp.ServerlessConfig) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal serverless config: %w", err) - } - if err := json.Unmarshal(configBytes, &serverlessConfig); err != nil { - return nil, nil, fmt.Errorf("failed to unmarshal serverless config: %w", err) - } - } else { - serverlessConfig.GPUConfig = map[string]interface{}{} + serverlessConfig := v2.ServerlessConfig{} + + // Try to preserve existing config by converting through JSON + if configBytes, err := json.Marshal(currentApp.ServerlessConfig); err == nil { + _ = json.Unmarshal(configBytes, &serverlessConfig) } // Update provided fields From 53995cf3a395f2659c669ad9cfcdf7d73a6cfd97 Mon Sep 17 00:00:00 2001 From: Martin McCaffery Date: Fri, 9 Jan 2026 17:27:19 +0100 Subject: [PATCH 09/16] Partially update README --- Makefile | 2 ++ QUICKSTART.md | 67 +++++++++++++++++++++++++++------------------------ 2 files changed, 37 insertions(+), 32 deletions(-) diff --git a/Makefile b/Makefile index 55a1c84..3797308 100644 --- a/Makefile +++ b/Makefile @@ -10,6 +10,8 @@ GITLEAKS := $(GO) run github.com/zricethezav/gitleaks/v8@v8.30.0 LDFLAGS := -s -w BUILD_FLAGS := -ldflags "$(LDFLAGS)" +default: run + .PHONY: all build clean fmt format lint gitleaks test run help vet tidy install-hooks # Default target diff --git a/QUICKSTART.md b/QUICKSTART.md index 90d0858..27c01ea 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -15,12 +15,12 @@ Get up and running with the Edge Connect MCP Server in 5 minutes. ```bash git clone cd edge-connect-mcp -go build -o edge-connect-mcp +make build ``` ### 2. Configure Environment -Create a `.env` file or export environment variables: +Create a `.env` file from `.env.example`, or export environment variables: ```bash export EDGE_CONNECT_BASE_URL="https://hub.apps.edge.platform.mg3.mdb.osc.live" @@ -34,17 +34,45 @@ export EDGE_CONNECT_DEFAULT_REGION="EU" ```bash # Verify it starts without errors -./edge-connect-mcp & +./edge-connect-mcp +# Alternatively: +make run # Kill it with Ctrl+C after verifying ``` ## Integration -### Option A: Claude Code (Recommended) +### Option A: Claude or Claude Desktop locally (Recommended) + +Edit your config file: + +**Claude Desktop**: `~/Library/Application Support/Claude/claude_desktop_config.json` +**Claude Code**: `~/.claude.json` + +```json +{ + "mcpServers": { + "edge-connect": { + "command": "/path/to/edge-connect-mcp", + "env": { + "EDGE_CONNECT_BASE_URL": "https://hub.apps.edge.platform.mg3.mdb.osc.live", + "EDGE_CONNECT_AUTH_TYPE": "credentials", + "EDGE_CONNECT_USERNAME": "your-username", + "EDGE_CONNECT_PASSWORD": "your-password", + "EDGE_CONNECT_DEFAULT_REGION": "EU" + } + } + } +} +``` + +Restart Claude Desktop or Claude Code to load the server. + +### Option B: Claude Code locally (not currently working) ```bash # Add the server -claude mcp add edge-connect +claude mcp add edge-connect ./edge-connect-mcp # Configure it (use absolute path to binary) claude mcp edit edge-connect --set command=$(pwd)/edge-connect-mcp @@ -61,32 +89,7 @@ claude mcp list claude mcp test edge-connect ``` -### Option B: Claude Desktop - -Edit your config file: - -**macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` - -```json -{ - "mcpServers": { - "edge-connect": { - "command": "/absolute/path/to/edge-connect-mcp", - "env": { - "EDGE_CONNECT_BASE_URL": "https://hub.apps.edge.platform.mg3.mdb.osc.live", - "EDGE_CONNECT_AUTH_TYPE": "credentials", - "EDGE_CONNECT_USERNAME": "your-username", - "EDGE_CONNECT_PASSWORD": "your-password", - "EDGE_CONNECT_DEFAULT_REGION": "EU" - } - } - } -} -``` - -Restart Claude Desktop to load the server. - -### Option C: Remote Server +### Option C: Remote Server (http communication) For remote access: @@ -113,7 +116,7 @@ Please create an Edge Connect app: - Organization: my-org - Name: my-nginx-app - Version: 1.0.0 -- Deployment: docker +- Deployment: kubernetes - Image: https://registry-1.docker.io/library/nginx:latest - Ports: tcp:80 - Flavor: EU.small From c883add6c355aad6331b78bdf76f646b7207d54c Mon Sep 17 00:00:00 2001 From: Martin McCaffery Date: Mon, 12 Jan 2026 13:24:56 +0100 Subject: [PATCH 10/16] Provisionally add MCP UI functionality --- MCP_UI.md | 419 ++++++++++++++++++++++++++++ README.md | 36 ++- go.mod | 3 + go.sum | 2 + main.go | 2 + tools.go | 73 ++++- ui.go | 800 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 1326 insertions(+), 9 deletions(-) create mode 100644 MCP_UI.md create mode 100644 ui.go diff --git a/MCP_UI.md b/MCP_UI.md new file mode 100644 index 0000000..e59377f --- /dev/null +++ b/MCP_UI.md @@ -0,0 +1,419 @@ +# MCP UI Integration + +This document describes the MCP UI integration in the Edge Connect MCP server, which provides interactive web-based visualizations for Edge Connect resources. + +## Overview + +The Edge Connect MCP server now includes support for [MCP-UI](https://github.com/think-ahead-technologies/mcp-ui), a protocol that enables servers to deliver interactive web components through the Model Context Protocol. When users query Edge Connect resources, they receive both traditional text-based responses and rich, interactive HTML dashboards. + +## What is MCP-UI? + +MCP-UI brings interactive web components to the Model Context Protocol, allowing MCP servers to deliver rich, dynamic UI resources directly to clients. Instead of receiving only plain text or JSON, users get beautifully rendered, interactive visualizations of their data. + +## Features + +### 1. Applications Dashboard (`list_apps` tool) + +When listing Edge Connect applications, users receive an interactive dashboard featuring: + +- **Statistics Cards**: + - Total application count + - Docker application count + - Kubernetes application count + - Serverless-enabled applications count + +- **Application Cards Grid**: + - Organization and application name + - Version badge + - Deployment type badge (Docker/Kubernetes) + - Serverless badge (if enabled) + - Image path (truncated for display) + - Access ports configuration + - Interactive buttons for viewing details and deletion + +- **Visual Design**: + - Purple gradient background (#667eea to #764ba2) + - Responsive grid layout (auto-adjusts to screen size) + - Hover effects and smooth transitions + - Card-based design with shadows + +### 2. Application Detail View (`show_app` tool) + +When viewing a specific application, users receive a detailed view with: + +- **Property Grid**: + - Organization, name, and version + - Region information + - Deployment type with colored badge + - Image type and full image path + - Access ports configuration + - Serverless status with colored badge + +- **Raw JSON Viewer**: + - Syntax-highlighted JSON display + - Complete application object for reference + - Dark theme code viewer + +### 3. Application Instances Dashboard (`list_app_instances` tool) + +When listing application instances, users receive an interactive dashboard featuring: + +- **Statistics Cards**: + - Total instances count + - Running instances count + - Stopped instances count + +- **Instances Table**: + - Instance name and organization + - Cloudlet location (org/name) + - Associated application and version + - Power state with status badges (Running/Stopped/Unknown) + - Flavor information + - Quick action buttons + +- **Visual Design**: + - Blue/purple gradient background (#4f46e5 to #7c3aed) + - Professional table layout + - Status badges with semantic colors + - Hover effects on rows + +## Technical Implementation + +### Architecture + +``` +┌─────────────────────────────────────────────┐ +│ MCP Client (Claude Desktop, etc.) │ +│ - Requests tool execution │ +│ - Renders embedded UI resources │ +└──────────────────┬──────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────┐ +│ Edge Connect MCP Server │ +│ ┌──────────────────────────────────────┐ │ +│ │ tools.go │ │ +│ │ - Executes Edge Connect API calls │ │ +│ │ - Calls UI generation functions │ │ +│ │ - Returns text + embedded resources │ │ +│ └──────────────┬───────────────────────┘ │ +│ │ │ +│ ┌──────────────▼───────────────────────┐ │ +│ │ ui.go │ │ +│ │ - createAppListUI() │ │ +│ │ - createAppDetailUI() │ │ +│ │ - createAppInstanceListUI() │ │ +│ │ - Generates HTML with inline CSS │ │ +│ └──────────────┬───────────────────────┘ │ +│ │ │ +│ ┌──────────────▼───────────────────────┐ │ +│ │ mcp-ui Go SDK │ │ +│ │ - CreateUIResource() │ │ +│ │ - Validates UI URIs │ │ +│ │ - Encodes HTML content │ │ +│ └──────────────────────────────────────┘ │ +└─────────────────────────────────────────────┘ +``` + +### Files Modified + +#### 1. `go.mod` and `go.sum` +Added dependency on the mcp-ui Go SDK: + +```go +require ( + github.com/MCP-UI-Org/mcp-ui/sdks/go/server v0.0.1 +) + +replace github.com/MCP-UI-Org/mcp-ui/sdks/go/server => + github.com/think-ahead-technologies/mcp-ui/sdks/go/server v0.0.0-20260112091603-a8343491b666 +``` + +#### 2. `ui.go` (New File) +Contains all UI generation logic: + +**Key Functions**: + +- `createAppListUI(apps []v2.App, region string) (*mcpuiserver.UIResource, error)` + - Generates interactive dashboard for application listings + - Returns a UIResource with HTML content + +- `createAppDetailUI(app v2.App, region string) (*mcpuiserver.UIResource, error)` + - Generates detailed view for a single application + - Includes property grid and JSON viewer + +- `createAppInstanceListUI(instances []v2.AppInstance, region string) (*mcpuiserver.UIResource, error)` + - Generates dashboard for application instances + - Includes statistics and interactive table + +**Helper Functions**: +- `generateAppCard(app v2.App) string` - Creates HTML for individual app cards +- `generateInstanceRow(inst v2.AppInstance) string` - Creates HTML table row for instance +- `countDeploymentType()`, `countServerlessApps()`, `countPowerState()` - Statistics helpers +- `getAccessPorts()`, `getServerlessBadgeClass()`, `getServerlessStatus()` - Display helpers + +#### 3. `tools.go` +Enhanced three tool handlers to include UI resources: + +**Modified Functions**: + +1. **`registerListAppsTool()`** (lines ~197-223): +```go +// Create UI resource for apps list +uiResource, err := createAppListUI(apps, region) +if err != nil { + // Fallback to text-only response + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: result}}, + }, nil, nil +} + +// Convert UIResource to MCP EmbeddedResource +resourceContents := &mcp.ResourceContents{ + URI: uiResource.Resource.URI, + MIMEType: uiResource.Resource.MimeType, + Text: uiResource.Resource.Text, +} + +// Return both text and UI resource +return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: result}, + &mcp.EmbeddedResource{Resource: resourceContents}, + }, +}, nil, nil +``` + +2. **`registerShowAppTool()`** (lines ~143-166): + - Similar pattern for app detail view + +3. **`registerListAppInstancesTool()`** (lines ~609-635): + - Similar pattern for instance dashboard + +### UI Resource Structure + +Each UI resource follows the MCP-UI protocol structure: + +```json +{ + "type": "resource", + "resource": { + "uri": "ui://apps-dashboard", + "mimeType": "text/html", + "text": "..." + } +} +``` + +**URI Patterns**: +- `ui://apps-dashboard` - Application listing dashboard +- `ui://app-detail/{org}/{name}/{version}` - Single app detail view +- `ui://app-instances-dashboard` - Instance listing dashboard + +## Usage + +### Prerequisites + +1. **MCP Client Support**: Your MCP client must support UI resources (embedded resources in tool responses) +2. **Edge Connect Configuration**: Server must be properly configured with Edge Connect credentials + +### Example Workflow + +1. **List Applications**: +```bash +# User asks: "List all Edge Connect applications" +# Server executes list_apps tool +# Returns: Text summary + Interactive HTML dashboard +``` + +2. **View Application Details**: +```bash +# User asks: "Show me details for app myorg/myapp:1.0.0" +# Server executes show_app tool +# Returns: JSON data + Interactive detail view +``` + +3. **List Instances**: +```bash +# User asks: "Show all application instances" +# Server executes list_app_instances tool +# Returns: Text summary + Interactive dashboard +``` + +### Graceful Degradation + +The implementation includes graceful degradation: + +1. If UI resource creation fails, the server returns text-only content +2. Clients that don't support UI resources can still use the text content +3. No breaking changes to existing tool behavior + +## Design Principles + +### Visual Design + +- **Modern Aesthetics**: Gradient backgrounds, card-based layouts, smooth shadows +- **Professional Color Scheme**: Purple/blue palette with semantic colors for status indicators +- **Typography**: System font stack for optimal rendering across platforms +- **Responsive Design**: Layouts adapt to different screen sizes using CSS Grid + +### User Experience + +- **Information Hierarchy**: Most important info at the top, details below +- **Scannable Content**: Clear labels, consistent spacing, visual grouping +- **Interactive Elements**: Hover effects, clickable cards (prepared for future actions) +- **Status Indicators**: Color-coded badges for quick status recognition + +### Code Quality + +- **Separation of Concerns**: UI logic isolated in `ui.go` +- **Error Handling**: Graceful fallbacks if UI generation fails +- **HTML Safety**: All user content is escaped using `html.EscapeString()` +- **Performance**: String concatenation in loops (acceptable for small datasets) + +## Security Considerations + +### HTML Escaping + +All dynamic content is properly escaped to prevent XSS vulnerabilities: + +```go +html.EscapeString(app.Key.Name) +html.EscapeString(app.ImagePath) +html.EscapeString(inst.PowerState) +``` + +### URI Validation + +The mcp-ui SDK validates that all resource URIs start with `ui://` to prevent unauthorized resource access. + +### Content Isolation + +UI resources are rendered in isolated contexts by MCP clients, preventing cross-site scripting or malicious code execution. + +## Future Enhancements + +### Planned Features + +1. **Interactive Actions**: + - Add UI action handlers for delete, update, refresh operations + - Use `UIActionResultToolCall()` to trigger backend operations from UI + +2. **Real-time Updates**: + - WebSocket integration for live status updates + - Auto-refresh dashboards when resources change + +3. **Advanced Visualizations**: + - Charts for resource utilization + - Geographic maps for cloudlet locations + - Timeline views for deployment history + +4. **Forms and Input**: + - Create/update forms embedded in UI + - Validation and error feedback + - Wizard-style multi-step processes + +5. **Customization**: + - Theme selection (light/dark mode) + - User-configurable layouts + - Saved filter preferences + +### Extensibility + +To add UI resources to other tools: + +1. Create a new UI generation function in `ui.go`: +```go +func createMyResourceUI(data MyData) (*mcpuiserver.UIResource, error) { + htmlContent := fmt.Sprintf(` + + + + + + + + + + `, /* your data */) + + return mcpuiserver.CreateUIResource( + "ui://my-resource", + &mcpuiserver.RawHTMLPayload{ + Type: mcpuiserver.ContentTypeRawHTML, + HTMLString: htmlContent, + }, + mcpuiserver.EncodingText, + ) +} +``` + +2. Call it from your tool handler in `tools.go`: +```go +uiResource, err := createMyResourceUI(data) +if err == nil { + resourceContents := &mcp.ResourceContents{ + URI: uiResource.Resource.URI, + MIMEType: uiResource.Resource.MimeType, + Text: uiResource.Resource.Text, + } + // Add to result.Content +} +``` + +## Troubleshooting + +### UI Not Displaying + +**Problem**: UI resources aren't showing up in the client. + +**Solutions**: +1. Verify your MCP client supports UI resources +2. Check server logs for UI generation errors +3. Ensure HTML content is valid (no syntax errors) +4. Verify URI starts with `ui://` + +### Styling Issues + +**Problem**: UI looks broken or unstyled. + +**Solutions**: +1. Check for CSS syntax errors in HTML +2. Verify inline styles are properly escaped +3. Test HTML in a browser independently +4. Ensure percentage signs are doubled in `fmt.Sprintf()` (e.g., `%%`) + +### Build Errors + +**Problem**: Compilation fails after integrating mcp-ui. + +**Solutions**: +1. Run `go mod tidy` to resolve dependencies +2. Verify replace directive in `go.mod` points to correct commit +3. Check import paths in `ui.go` and `tools.go` +4. Ensure mcp-ui SDK version is compatible + +## References + +- [MCP-UI GitHub Repository](https://github.com/think-ahead-technologies/mcp-ui) +- [Model Context Protocol Specification](https://modelcontextprotocol.io/) +- [Edge Connect API Documentation](https://edp.buildth.ing/) + +## Changelog + +### 2026-01-12 - Initial MCP-UI Integration + +**Added**: +- `ui.go` with three UI generation functions +- MCP-UI Go SDK dependency +- Interactive dashboards for apps and instances +- Graceful fallback to text-only responses + +**Modified**: +- `tools.go`: Enhanced `list_apps`, `show_app`, `list_app_instances` tools +- `go.mod`: Added mcp-ui dependency with replace directive +- `go.sum`: Updated checksums + +**Files**: +- New: `ui.go` (~780 lines) +- Modified: `tools.go` (+70 lines), `go.mod` (+3 lines) diff --git a/README.md b/README.md index 4ef688b..ac3103a 100644 --- a/README.md +++ b/README.md @@ -6,19 +6,31 @@ Supports both **local (stdio)** and **remote (HTTP/SSE)** operation modes. ## Features +### Interactive UI Resources (MCP-UI) + +This server includes **rich, interactive web-based visualizations** powered by [MCP-UI](https://github.com/think-ahead-technologies/mcp-ui). When using compatible MCP clients, you'll receive beautiful HTML dashboards instead of plain text: + +- **📊 Applications Dashboard** - Visual grid of applications with stats, deployment badges, and quick actions +- **🔍 Application Detail View** - Comprehensive property display with JSON viewer +- **⚡ Instances Dashboard** - Interactive table with status indicators and instance management + +For clients that don't support UI resources, the server gracefully falls back to text-based responses. See [MCP_UI.md](./MCP_UI.md) for full documentation. + +### Edge Connect API Tools + This MCP server implements all Edge Connect API endpoints for: -### Apps Management +#### Apps Management - `create_app` - Create a new Edge Connect application -- `show_app` - Retrieve a specific application by key -- `list_apps` - List all applications matching filter criteria +- `show_app` - Retrieve a specific application by key (includes UI) +- `list_apps` - List all applications matching filter criteria (includes UI) - `update_app` - Update an existing application - `delete_app` - Delete an application (idempotent) -### App Instance Management +#### App Instance Management - `create_app_instance` - Create a new application instance on a cloudlet - `show_app_instance` - Retrieve a specific application instance -- `list_app_instances` - List all application instances matching filter criteria +- `list_app_instances` - List all application instances matching filter criteria (includes UI) - `update_app_instance` - Update an existing application instance - `refresh_app_instance` - Refresh instance state - `delete_app_instance` - Delete an application instance (idempotent) @@ -354,6 +366,7 @@ When running in remote mode: - `edp.buildth.ing/DevFW-CICD/edge-connect-client/v2` - Edge Connect Go SDK - `github.com/modelcontextprotocol/go-sdk` - Model Context Protocol Go SDK +- `github.com/MCP-UI-Org/mcp-ui/sdks/go/server` - MCP-UI Go SDK for interactive visualizations ## Development @@ -364,7 +377,18 @@ When running in remote mode: ├── main.go # Server entry point and initialization ├── config.go # Configuration loading and validation ├── tools.go # MCP tool definitions and handlers -├── utils.go # Utility functions +├── ui.go # MCP-UI visualization generators +├── auth.go # Authentication utilities +├── oauth/ # OAuth 2.1 implementation +│ ├── oauth.go # OAuth types and interfaces +│ ├── authz_server.go # Basic authorization server +│ ├── resource_server.go # Protected resource server +│ ├── middleware.go # OAuth middleware +│ ├── token_validator.go # JWT token validation +│ ├── jwks.go # JWKS key management +│ ├── pkce.go # PKCE implementation +│ └── storage.go # In-memory storage +├── MCP_UI.md # MCP-UI integration documentation ├── README.md # This file └── .env.example # Example environment configuration ``` diff --git a/go.mod b/go.mod index 1c92d67..3dacd86 100644 --- a/go.mod +++ b/go.mod @@ -4,11 +4,14 @@ go 1.25.5 require ( edp.buildth.ing/DevFW-CICD/edge-connect-client/v2 v2.2.0 + github.com/MCP-UI-Org/mcp-ui/sdks/go/server v0.0.0-00010101000000-000000000000 github.com/golang-jwt/jwt/v5 v5.3.0 github.com/joho/godotenv v1.5.1 github.com/modelcontextprotocol/go-sdk v1.2.0 ) +replace github.com/MCP-UI-Org/mcp-ui/sdks/go/server => github.com/think-ahead-technologies/mcp-ui/sdks/go/server v0.0.0-20260112091603-a8343491b666 + require ( github.com/google/jsonschema-go v0.3.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect diff --git a/go.sum b/go.sum index add7c55..1aa361d 100644 --- a/go.sum +++ b/go.sum @@ -28,6 +28,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/think-ahead-technologies/mcp-ui/sdks/go/server v0.0.0-20260112091603-a8343491b666 h1:YlKBY6xtsQsyiaHCU/EJRXuLv8YFRHq8fLlkktiYBVA= +github.com/think-ahead-technologies/mcp-ui/sdks/go/server v0.0.0-20260112091603-a8343491b666/go.mod h1:4IuZxAliFv0IcIeWVHW6yDq2tdY9Sbu2WQePgh9Md6k= 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.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= diff --git a/main.go b/main.go index 9d74ba9..857ba5f 100644 --- a/main.go +++ b/main.go @@ -50,6 +50,8 @@ func main() { config.RemotePort = *port } + // config.Debug = true + // Validate configuration if err := config.Validate(); err != nil { log.Fatalf("Invalid configuration: %v", err) diff --git a/tools.go b/tools.go index cd558c5..1941fa6 100644 --- a/tools.go +++ b/tools.go @@ -140,8 +140,29 @@ func registerShowAppTool(s *mcp.Server) { return nil, nil, fmt.Errorf("failed to serialize app: %w", err) } + // Create UI resource for app detail + uiResource, err := createAppDetailUI(app, region) + if err != nil { + // If UI creation fails, just return text content + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: string(appJSON)}}, + }, nil, nil + } + + // Convert UIResource to MCP EmbeddedResource + resourceContents := &mcp.ResourceContents{ + URI: uiResource.Resource.URI, + MIMEType: uiResource.Resource.MimeType, + Text: uiResource.Resource.Text, + } + return &mcp.CallToolResult{ - Content: []mcp.Content{&mcp.TextContent{Text: string(appJSON)}}, + Content: []mcp.Content{ + &mcp.TextContent{Text: string(appJSON)}, + &mcp.EmbeddedResource{ + Resource: resourceContents, + }, + }, }, nil, nil }) } @@ -194,9 +215,32 @@ func registerListAppsTool(s *mcp.Server) { return nil, nil, fmt.Errorf("failed to serialize apps: %w", err) } + // Create UI resource for apps list + uiResource, err := createAppListUI(apps, region) + if err != nil { + // If UI creation fails, just return text content + result := fmt.Sprintf("Found %d apps:\n%s", len(apps), string(appsJSON)) + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: result}}, + }, nil, nil + } + result := fmt.Sprintf("Found %d apps:\n%s", len(apps), string(appsJSON)) + + // Convert UIResource to MCP EmbeddedResource + resourceContents := &mcp.ResourceContents{ + URI: uiResource.Resource.URI, + MIMEType: uiResource.Resource.MimeType, + Text: uiResource.Resource.Text, + } + return &mcp.CallToolResult{ - Content: []mcp.Content{&mcp.TextContent{Text: result}}, + Content: []mcp.Content{ + &mcp.TextContent{Text: result}, + &mcp.EmbeddedResource{ + Resource: resourceContents, + }, + }, }, nil, nil }) } @@ -562,9 +606,32 @@ func registerListAppInstancesTool(s *mcp.Server) { return nil, nil, fmt.Errorf("failed to serialize app instances: %w", err) } + // Create UI resource for app instances list + uiResource, err := createAppInstanceListUI(appInsts, region) + if err != nil { + // If UI creation fails, just return text content + result := fmt.Sprintf("Found %d app instances:\n%s", len(appInsts), string(appInstsJSON)) + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: result}}, + }, nil, nil + } + result := fmt.Sprintf("Found %d app instances:\n%s", len(appInsts), string(appInstsJSON)) + + // Convert UIResource to MCP EmbeddedResource + resourceContents := &mcp.ResourceContents{ + URI: uiResource.Resource.URI, + MIMEType: uiResource.Resource.MimeType, + Text: uiResource.Resource.Text, + } + return &mcp.CallToolResult{ - Content: []mcp.Content{&mcp.TextContent{Text: result}}, + Content: []mcp.Content{ + &mcp.TextContent{Text: result}, + &mcp.EmbeddedResource{ + Resource: resourceContents, + }, + }, }, nil, nil }) } diff --git a/ui.go b/ui.go new file mode 100644 index 0000000..13803f4 --- /dev/null +++ b/ui.go @@ -0,0 +1,800 @@ +package main + +import ( + "encoding/json" + "fmt" + "html" + "strings" + + v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2" + mcpuiserver "github.com/MCP-UI-Org/mcp-ui/sdks/go/server" +) + +// createAppListUI generates an interactive UI for listing apps +func createAppListUI(apps []v2.App, region string) (*mcpuiserver.UIResource, error) { + htmlContent := fmt.Sprintf(` + + + + + + Edge Connect Applications + + + +
+
+

Edge Connect Applications

+ Region: %s +
+
+
+
Total Applications
+
%d
+
+
+
Docker Apps
+
%d
+
+
+
Kubernetes Apps
+
%d
+
+
+
Serverless Enabled
+
%d
+
+
+`, region, len(apps), countDeploymentType(apps, "docker"), countDeploymentType(apps, "kubernetes"), countServerlessApps(apps)) + + if len(apps) == 0 { + htmlContent += ` +
+

No Applications Found

+

Start by creating your first Edge Connect application

+
+` + } else { + htmlContent += `
` + for _, app := range apps { + htmlContent += generateAppCard(app) + } + htmlContent += `
` + } + + htmlContent += ` +
+ + +` + + resource, err := mcpuiserver.CreateUIResource( + "ui://apps-dashboard", + &mcpuiserver.RawHTMLPayload{ + Type: mcpuiserver.ContentTypeRawHTML, + HTMLString: htmlContent, + }, + mcpuiserver.EncodingText, + ) + + return resource, err +} + +// generateAppCard creates HTML for a single app card +func generateAppCard(app v2.App) string { + deploymentBadge := fmt.Sprintf(`%s`, strings.ToUpper(app.Deployment)) + if strings.ToLower(app.Deployment) == "kubernetes" { + deploymentBadge = fmt.Sprintf(`%s`, strings.ToUpper(app.Deployment)) + } + + serverlessBadge := "" + if app.AllowServerless { + serverlessBadge = ` SERVERLESS` + } + + imagePath := html.EscapeString(app.ImagePath) + if len(imagePath) > 50 { + imagePath = imagePath[:47] + "..." + } + + return fmt.Sprintf(` +
+
%s
+
%s
+
v%s
+
+ Deployment + %s%s +
+
+ Image + %s +
+
+ Ports + %s +
+
+ + +
+
+`, + html.EscapeString(app.Key.Organization), + html.EscapeString(app.Key.Name), + html.EscapeString(app.Key.Version), + html.EscapeString(app.Key.Name), + html.EscapeString(app.Key.Organization), + html.EscapeString(app.Key.Version), + deploymentBadge, + serverlessBadge, + html.EscapeString(app.ImagePath), + imagePath, + getAccessPorts(app.AccessPorts), + html.EscapeString(app.Key.Organization), + html.EscapeString(app.Key.Name), + html.EscapeString(app.Key.Version), + html.EscapeString(app.Key.Organization), + html.EscapeString(app.Key.Name), + html.EscapeString(app.Key.Version), + ) +} + +// createAppDetailUI generates a detailed view for a single app +func createAppDetailUI(app v2.App, region string) (*mcpuiserver.UIResource, error) { + appJSON, _ := json.MarshalIndent(app, "", " ") + + htmlContent := fmt.Sprintf(` + + + + + + %s - App Details + + + +
+
+
+

%s

+ ← Back to Apps +
+
+
Organization
+
%s
+
Name
+
%s
+
Version
+
%s
+
Region
+
%s
+
Deployment
+
%s
+
Image Type
+
%s
+
Image Path
+
%s
+
Access Ports
+
%s
+
Serverless
+
%s
+
+
+
+

Raw JSON

+
%s
+
+
+ + +`, + html.EscapeString(app.Key.Name), + html.EscapeString(app.Key.Name), + html.EscapeString(app.Key.Organization), + html.EscapeString(app.Key.Name), + html.EscapeString(app.Key.Version), + html.EscapeString(region), + html.EscapeString(strings.ToUpper(app.Deployment)), + html.EscapeString(app.ImageType), + html.EscapeString(app.ImagePath), + getAccessPorts(app.AccessPorts), + getServerlessBadgeClass(app.AllowServerless), + getServerlessStatus(app.AllowServerless), + html.EscapeString(string(appJSON)), + ) + + resource, err := mcpuiserver.CreateUIResource( + fmt.Sprintf("ui://app-detail/%s/%s/%s", app.Key.Organization, app.Key.Name, app.Key.Version), + &mcpuiserver.RawHTMLPayload{ + Type: mcpuiserver.ContentTypeRawHTML, + HTMLString: htmlContent, + }, + mcpuiserver.EncodingText, + ) + + return resource, err +} + +// createAppInstanceListUI generates an interactive UI for listing app instances +func createAppInstanceListUI(instances []v2.AppInstance, region string) (*mcpuiserver.UIResource, error) { + htmlContent := fmt.Sprintf(` + + + + + + Edge Connect App Instances + + + +
+
+

Application Instances

+ Region: %s +
+
+
+
Total Instances
+
%d
+
+
+
Running
+
%d
+
+
+
Stopped
+
%d
+
+
+`, region, len(instances), countPowerState(instances, "PowerOn"), countPowerState(instances, "PowerOff")) + + if len(instances) == 0 { + htmlContent += ` +
+

No App Instances Found

+

Deploy your first application instance to get started

+
+` + } else { + htmlContent += ` +
+ + + + + + + + + + + + + +` + for _, inst := range instances { + htmlContent += generateInstanceRow(inst) + } + htmlContent += ` + +
Instance NameOrganizationCloudletApplicationStatusFlavorActions
+
+` + } + + htmlContent += ` +
+ + +` + + resource, err := mcpuiserver.CreateUIResource( + "ui://app-instances-dashboard", + &mcpuiserver.RawHTMLPayload{ + Type: mcpuiserver.ContentTypeRawHTML, + HTMLString: htmlContent, + }, + mcpuiserver.EncodingText, + ) + + return resource, err +} + +// generateInstanceRow creates HTML for a single instance table row +func generateInstanceRow(inst v2.AppInstance) string { + statusBadge := `UNKNOWN` + if inst.PowerState == "PowerOn" { + statusBadge = `RUNNING` + } else if inst.PowerState == "PowerOff" { + statusBadge = `STOPPED` + } + + return fmt.Sprintf(` + + %s + %s + %s/%s + %s:%s + %s + %s + +
+ + +
+ + +`, + html.EscapeString(inst.Key.Name), + html.EscapeString(inst.Key.Organization), + html.EscapeString(inst.Key.CloudletKey.Organization), + html.EscapeString(inst.Key.CloudletKey.Name), + html.EscapeString(inst.AppKey.Name), + html.EscapeString(inst.AppKey.Version), + statusBadge, + html.EscapeString(inst.Flavor.Name), + html.EscapeString(inst.Key.Organization), + html.EscapeString(inst.Key.Name), + html.EscapeString(inst.Key.CloudletKey.Organization), + html.EscapeString(inst.Key.CloudletKey.Name), + html.EscapeString(inst.Key.Organization), + html.EscapeString(inst.Key.Name), + html.EscapeString(inst.Key.CloudletKey.Organization), + html.EscapeString(inst.Key.CloudletKey.Name), + ) +} + +// Helper functions + +func countDeploymentType(apps []v2.App, deploymentType string) int { + count := 0 + for _, app := range apps { + if strings.ToLower(app.Deployment) == strings.ToLower(deploymentType) { + count++ + } + } + return count +} + +func countServerlessApps(apps []v2.App) int { + count := 0 + for _, app := range apps { + if app.AllowServerless { + count++ + } + } + return count +} + +func countPowerState(instances []v2.AppInstance, state string) int { + count := 0 + for _, inst := range instances { + if inst.PowerState == state { + count++ + } + } + return count +} + +func getAccessPorts(ports string) string { + if ports == "" { + return "None" + } + return ports +} + +func getServerlessBadgeClass(allowServerless bool) string { + if allowServerless { + return "success" + } + return "warning" +} + +func getServerlessStatus(allowServerless bool) string { + if allowServerless { + return "ENABLED" + } + return "DISABLED" +} From 9f41dcd22f460660241e52ebe642a6b9c846122b Mon Sep 17 00:00:00 2001 From: Manuel Ganter Date: Mon, 12 Jan 2026 16:07:46 +0100 Subject: [PATCH 11/16] feat: added mcp-ui --- tools.go | 3 +++ ui.go | 20 ++++++++++++++++---- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/tools.go b/tools.go index 1941fa6..f95d104 100644 --- a/tools.go +++ b/tools.go @@ -154,6 +154,7 @@ func registerShowAppTool(s *mcp.Server) { URI: uiResource.Resource.URI, MIMEType: uiResource.Resource.MimeType, Text: uiResource.Resource.Text, + Meta: uiResource.Resource.Meta, } return &mcp.CallToolResult{ @@ -232,6 +233,7 @@ func registerListAppsTool(s *mcp.Server) { URI: uiResource.Resource.URI, MIMEType: uiResource.Resource.MimeType, Text: uiResource.Resource.Text, + Meta: uiResource.Resource.Meta, } return &mcp.CallToolResult{ @@ -623,6 +625,7 @@ func registerListAppInstancesTool(s *mcp.Server) { URI: uiResource.Resource.URI, MIMEType: uiResource.Resource.MimeType, Text: uiResource.Resource.Text, + Meta: uiResource.Resource.Meta, } return &mcp.CallToolResult{ diff --git a/ui.go b/ui.go index 13803f4..ef772bb 100644 --- a/ui.go +++ b/ui.go @@ -247,6 +247,9 @@ func createAppListUI(apps []v2.App, region string) (*mcpuiserver.UIResource, err HTMLString: htmlContent, }, mcpuiserver.EncodingText, + mcpuiserver.WithUIMetadata(map[string]interface{}{ + "preferred-frame-size": []string{"800px", "600px"}, + }), ) return resource, err @@ -467,6 +470,9 @@ func createAppDetailUI(app v2.App, region string) (*mcpuiserver.UIResource, erro HTMLString: htmlContent, }, mcpuiserver.EncodingText, + mcpuiserver.WithUIMetadata(map[string]interface{}{ + "preferred-frame-size": []string{"800px", "600px"}, + }), ) return resource, err @@ -697,6 +703,9 @@ func createAppInstanceListUI(instances []v2.AppInstance, region string) (*mcpuis HTMLString: htmlContent, }, mcpuiserver.EncodingText, + mcpuiserver.WithUIMetadata(map[string]interface{}{ + "preferred-frame-size": []string{"800px", "600px"}, + }), ) return resource, err @@ -704,11 +713,14 @@ func createAppInstanceListUI(instances []v2.AppInstance, region string) (*mcpuis // generateInstanceRow creates HTML for a single instance table row func generateInstanceRow(inst v2.AppInstance) string { - statusBadge := `UNKNOWN` - if inst.PowerState == "PowerOn" { + var statusBadge string + switch inst.PowerState { + case "PowerOn": statusBadge = `RUNNING` - } else if inst.PowerState == "PowerOff" { + case "PowerOff": statusBadge = `STOPPED` + default: + statusBadge = `UNKNOWN` } return fmt.Sprintf(` @@ -751,7 +763,7 @@ func generateInstanceRow(inst v2.AppInstance) string { func countDeploymentType(apps []v2.App, deploymentType string) int { count := 0 for _, app := range apps { - if strings.ToLower(app.Deployment) == strings.ToLower(deploymentType) { + if strings.EqualFold(app.Deployment, deploymentType) { count++ } } From 408b16b9f85b3f1a3d12744c37913b00610ffb5e Mon Sep 17 00:00:00 2001 From: Manuel Ganter Date: Mon, 12 Jan 2026 16:33:38 +0100 Subject: [PATCH 12/16] feat: added view details and remove buttons for mcp-ui --- ui.go | 136 ++++++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 114 insertions(+), 22 deletions(-) diff --git a/ui.go b/ui.go index ef772bb..c773fa7 100644 --- a/ui.go +++ b/ui.go @@ -194,6 +194,55 @@ func createAppListUI(apps []v2.App, region string) (*mcpuiserver.UIResource, err +

Edge Connect Applications

@@ -247,7 +296,7 @@ func createAppListUI(apps []v2.App, region string) (*mcpuiserver.UIResource, err HTMLString: htmlContent, }, mcpuiserver.EncodingText, - mcpuiserver.WithUIMetadata(map[string]interface{}{ + mcpuiserver.WithUIMetadata(map[string]any{ "preferred-frame-size": []string{"800px", "600px"}, }), ) @@ -290,8 +339,8 @@ func generateAppCard(app v2.App) string { %s
- - + +
`, @@ -306,12 +355,6 @@ func generateAppCard(app v2.App) string { html.EscapeString(app.ImagePath), imagePath, getAccessPorts(app.AccessPorts), - html.EscapeString(app.Key.Organization), - html.EscapeString(app.Key.Name), - html.EscapeString(app.Key.Version), - html.EscapeString(app.Key.Organization), - html.EscapeString(app.Key.Name), - html.EscapeString(app.Key.Version), ) } @@ -470,7 +513,7 @@ func createAppDetailUI(app v2.App, region string) (*mcpuiserver.UIResource, erro HTMLString: htmlContent, }, mcpuiserver.EncodingText, - mcpuiserver.WithUIMetadata(map[string]interface{}{ + mcpuiserver.WithUIMetadata(map[string]any{ "preferred-frame-size": []string{"800px", "600px"}, }), ) @@ -635,6 +678,59 @@ func createAppInstanceListUI(instances []v2.AppInstance, region string) (*mcpuis +

Application Instances

@@ -703,7 +799,7 @@ func createAppInstanceListUI(instances []v2.AppInstance, region string) (*mcpuis HTMLString: htmlContent, }, mcpuiserver.EncodingText, - mcpuiserver.WithUIMetadata(map[string]interface{}{ + mcpuiserver.WithUIMetadata(map[string]any{ "preferred-frame-size": []string{"800px", "600px"}, }), ) @@ -724,7 +820,7 @@ func generateInstanceRow(inst v2.AppInstance) string { } return fmt.Sprintf(` - + %s %s %s/%s @@ -733,12 +829,16 @@ func generateInstanceRow(inst v2.AppInstance) string { %s
- - + +
`, + html.EscapeString(inst.Key.Organization), + html.EscapeString(inst.Key.Name), + html.EscapeString(inst.Key.CloudletKey.Organization), + html.EscapeString(inst.Key.CloudletKey.Name), html.EscapeString(inst.Key.Name), html.EscapeString(inst.Key.Organization), html.EscapeString(inst.Key.CloudletKey.Organization), @@ -747,14 +847,6 @@ func generateInstanceRow(inst v2.AppInstance) string { html.EscapeString(inst.AppKey.Version), statusBadge, html.EscapeString(inst.Flavor.Name), - html.EscapeString(inst.Key.Organization), - html.EscapeString(inst.Key.Name), - html.EscapeString(inst.Key.CloudletKey.Organization), - html.EscapeString(inst.Key.CloudletKey.Name), - html.EscapeString(inst.Key.Organization), - html.EscapeString(inst.Key.Name), - html.EscapeString(inst.Key.CloudletKey.Organization), - html.EscapeString(inst.Key.CloudletKey.Name), ) } From 365a6623ea010c2300e71424ae4969081709e730 Mon Sep 17 00:00:00 2001 From: Manuel Ganter Date: Mon, 12 Jan 2026 18:04:26 +0100 Subject: [PATCH 13/16] feat: added multi ui-protocol support --- go.mod | 6 +++--- go.sum | 12 ++++++------ tools.go | 38 +++++++++++++++++++++++++++++++++++--- ui.go | 9 ++++++--- 4 files changed, 50 insertions(+), 15 deletions(-) diff --git a/go.mod b/go.mod index 3dacd86..0a73cc5 100644 --- a/go.mod +++ b/go.mod @@ -10,13 +10,13 @@ require ( github.com/modelcontextprotocol/go-sdk v1.2.0 ) -replace github.com/MCP-UI-Org/mcp-ui/sdks/go/server => github.com/think-ahead-technologies/mcp-ui/sdks/go/server v0.0.0-20260112091603-a8343491b666 +replace github.com/MCP-UI-Org/mcp-ui/sdks/go/server => github.com/think-ahead-technologies/mcp-ui/sdks/go/server v0.0.0-20260112164030-ad21634cf92b require ( - github.com/google/jsonschema-go v0.3.0 // indirect + github.com/google/jsonschema-go v0.4.2 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-retryablehttp v0.7.8 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect - golang.org/x/oauth2 v0.30.0 // indirect + golang.org/x/oauth2 v0.34.0 // indirect golang.org/x/tools v0.35.0 // indirect ) diff --git a/go.sum b/go.sum index 1aa361d..4285d39 100644 --- a/go.sum +++ b/go.sum @@ -8,8 +8,8 @@ github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9v github.com/golang-jwt/jwt/v5 v5.3.0/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.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q= -github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= @@ -28,12 +28,12 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/think-ahead-technologies/mcp-ui/sdks/go/server v0.0.0-20260112091603-a8343491b666 h1:YlKBY6xtsQsyiaHCU/EJRXuLv8YFRHq8fLlkktiYBVA= -github.com/think-ahead-technologies/mcp-ui/sdks/go/server v0.0.0-20260112091603-a8343491b666/go.mod h1:4IuZxAliFv0IcIeWVHW6yDq2tdY9Sbu2WQePgh9Md6k= +github.com/think-ahead-technologies/mcp-ui/sdks/go/server v0.0.0-20260112164030-ad21634cf92b h1:61a70q4I3O1dl520AnUFsNXJSGUMVmDCs9hZ/N57iW0= +github.com/think-ahead-technologies/mcp-ui/sdks/go/server v0.0.0-20260112164030-ad21634cf92b/go.mod h1:4IuZxAliFv0IcIeWVHW6yDq2tdY9Sbu2WQePgh9Md6k= 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.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= -golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= diff --git a/tools.go b/tools.go index f95d104..3ce1b38 100644 --- a/tools.go +++ b/tools.go @@ -6,9 +6,32 @@ import ( "fmt" v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2" + mcpuiserver "github.com/MCP-UI-Org/mcp-ui/sdks/go/server" "github.com/modelcontextprotocol/go-sdk/mcp" ) +// getProtocolFromRequest extracts the protocol from the MCP initialize request +func getProtocolFromRequest(req *mcp.CallToolRequest) mcpuiserver.ProtocolType { + // Get initialize params from session + initParams := req.Session.InitializeParams() + if initParams == nil { + return "" // Return empty if not available + } + + // Convert InitializeParams to map for ParseProtocolFromInitialize + paramsMap := make(map[string]any) + paramsJSON, err := json.Marshal(initParams) + if err != nil { + return "" + } + if err := json.Unmarshal(paramsJSON, ¶msMap); err != nil { + return "" + } + + // Parse protocol from initialize params + return mcpuiserver.ParseProtocolFromInitialize(paramsMap) +} + // Apps Tool Registrations func registerCreateAppTool(s *mcp.Server) { @@ -140,8 +163,11 @@ func registerShowAppTool(s *mcp.Server) { return nil, nil, fmt.Errorf("failed to serialize app: %w", err) } + // Parse protocol from MCP initialize request + protocol := getProtocolFromRequest(req) + // Create UI resource for app detail - uiResource, err := createAppDetailUI(app, region) + uiResource, err := createAppDetailUI(app, region, protocol) if err != nil { // If UI creation fails, just return text content return &mcp.CallToolResult{ @@ -216,8 +242,11 @@ func registerListAppsTool(s *mcp.Server) { return nil, nil, fmt.Errorf("failed to serialize apps: %w", err) } + // Parse protocol from MCP initialize request + protocol := getProtocolFromRequest(req) + // Create UI resource for apps list - uiResource, err := createAppListUI(apps, region) + uiResource, err := createAppListUI(apps, region, protocol) if err != nil { // If UI creation fails, just return text content result := fmt.Sprintf("Found %d apps:\n%s", len(apps), string(appsJSON)) @@ -608,8 +637,11 @@ func registerListAppInstancesTool(s *mcp.Server) { return nil, nil, fmt.Errorf("failed to serialize app instances: %w", err) } + // Parse protocol from MCP initialize request + protocol := getProtocolFromRequest(req) + // Create UI resource for app instances list - uiResource, err := createAppInstanceListUI(appInsts, region) + uiResource, err := createAppInstanceListUI(appInsts, region, protocol) if err != nil { // If UI creation fails, just return text content result := fmt.Sprintf("Found %d app instances:\n%s", len(appInsts), string(appInstsJSON)) diff --git a/ui.go b/ui.go index c773fa7..0ac6dde 100644 --- a/ui.go +++ b/ui.go @@ -11,7 +11,7 @@ import ( ) // createAppListUI generates an interactive UI for listing apps -func createAppListUI(apps []v2.App, region string) (*mcpuiserver.UIResource, error) { +func createAppListUI(apps []v2.App, region string, protocol mcpuiserver.ProtocolType) (*mcpuiserver.UIResource, error) { htmlContent := fmt.Sprintf(` @@ -296,6 +296,7 @@ func createAppListUI(apps []v2.App, region string) (*mcpuiserver.UIResource, err HTMLString: htmlContent, }, mcpuiserver.EncodingText, + mcpuiserver.WithProtocol(protocol), mcpuiserver.WithUIMetadata(map[string]any{ "preferred-frame-size": []string{"800px", "600px"}, }), @@ -359,7 +360,7 @@ func generateAppCard(app v2.App) string { } // createAppDetailUI generates a detailed view for a single app -func createAppDetailUI(app v2.App, region string) (*mcpuiserver.UIResource, error) { +func createAppDetailUI(app v2.App, region string, protocol mcpuiserver.ProtocolType) (*mcpuiserver.UIResource, error) { appJSON, _ := json.MarshalIndent(app, "", " ") htmlContent := fmt.Sprintf(` @@ -513,6 +514,7 @@ func createAppDetailUI(app v2.App, region string) (*mcpuiserver.UIResource, erro HTMLString: htmlContent, }, mcpuiserver.EncodingText, + mcpuiserver.WithProtocol(protocol), mcpuiserver.WithUIMetadata(map[string]any{ "preferred-frame-size": []string{"800px", "600px"}, }), @@ -522,7 +524,7 @@ func createAppDetailUI(app v2.App, region string) (*mcpuiserver.UIResource, erro } // createAppInstanceListUI generates an interactive UI for listing app instances -func createAppInstanceListUI(instances []v2.AppInstance, region string) (*mcpuiserver.UIResource, error) { +func createAppInstanceListUI(instances []v2.AppInstance, region string, protocol mcpuiserver.ProtocolType) (*mcpuiserver.UIResource, error) { htmlContent := fmt.Sprintf(` @@ -799,6 +801,7 @@ func createAppInstanceListUI(instances []v2.AppInstance, region string) (*mcpuis HTMLString: htmlContent, }, mcpuiserver.EncodingText, + mcpuiserver.WithProtocol(protocol), mcpuiserver.WithUIMetadata(map[string]any{ "preferred-frame-size": []string{"800px", "600px"}, }), From 477aa5b5b81d1af1f6fee982df6f63f5c31ec138 Mon Sep 17 00:00:00 2001 From: Martin McCaffery Date: Fri, 16 Jan 2026 15:55:38 +0100 Subject: [PATCH 14/16] Add partial matching for listing apps / app instances --- tools.go | 186 ++++++++++++++++++++++++++++++++++++------------------- 1 file changed, 122 insertions(+), 64 deletions(-) diff --git a/tools.go b/tools.go index 3ce1b38..949b52b 100644 --- a/tools.go +++ b/tools.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "strings" v2 "edp.buildth.ing/DevFW-CICD/edge-connect-client/v2/sdk/edgeconnect/v2" mcpuiserver "github.com/MCP-UI-Org/mcp-ui/sdks/go/server" @@ -32,6 +33,105 @@ func getProtocolFromRequest(req *mcp.CallToolRequest) mcpuiserver.ProtocolType { return mcpuiserver.ParseProtocolFromInitialize(paramsMap) } +// filterAppInstances performs client-side partial matching on app instances +func filterAppInstances(instances []v2.AppInstance, organization, instanceName, cloudletOrg, cloudletName, appOrg, appName, appVersion *string) []v2.AppInstance { + if organization == nil && instanceName == nil && + cloudletOrg == nil && cloudletName == nil && + appOrg == nil && appName == nil && + appVersion == nil { + return instances // No filters, return all + } + + var filtered []v2.AppInstance + for _, inst := range instances { + match := true + + if organization != nil && *organization != "" { + if !strings.Contains(strings.ToLower(inst.Key.Organization), strings.ToLower(*organization)) { + match = false + } + } + + if match && instanceName != nil && *instanceName != "" { + if !strings.Contains(strings.ToLower(inst.Key.Name), strings.ToLower(*instanceName)) { + match = false + } + } + + if match && cloudletOrg != nil && *cloudletOrg != "" { + if !strings.Contains(strings.ToLower(inst.Key.CloudletKey.Organization), strings.ToLower(*cloudletOrg)) { + match = false + } + } + + if match && cloudletName != nil && *cloudletName != "" { + if !strings.Contains(strings.ToLower(inst.Key.CloudletKey.Name), strings.ToLower(*cloudletName)) { + match = false + } + } + + if match && appOrg != nil && *appOrg != "" { + if !strings.Contains(strings.ToLower(inst.AppKey.Organization), strings.ToLower(*appOrg)) { + match = false + } + } + + if match && appName != nil && *appName != "" { + if !strings.Contains(strings.ToLower(inst.AppKey.Name), strings.ToLower(*appName)) { + match = false + } + } + + if match && appVersion != nil && *appVersion != "" { + if !strings.Contains(strings.ToLower(inst.AppKey.Version), strings.ToLower(*appVersion)) { + match = false + } + } + + if match { + filtered = append(filtered, inst) + } + } + + return filtered +} + +// filterApps performs client-side partial matching on apps +func filterApps(apps []v2.App, organization, name, version *string) []v2.App { + if organization == nil && name == nil && version == nil { + return apps // No filters, return all + } + + var filtered []v2.App + for _, app := range apps { + match := true + + if organization != nil && *organization != "" { + if !strings.Contains(strings.ToLower(app.Key.Organization), strings.ToLower(*organization)) { + match = false + } + } + + if match && name != nil && *name != "" { + if !strings.Contains(strings.ToLower(app.Key.Name), strings.ToLower(*name)) { + match = false + } + } + + if match && version != nil && *version != "" { + if !strings.Contains(strings.ToLower(app.Key.Version), strings.ToLower(*version)) { + match = false + } + } + + if match { + filtered = append(filtered, app) + } + } + + return filtered +} + // Apps Tool Registrations func registerCreateAppTool(s *mcp.Server) { @@ -204,39 +304,28 @@ func registerListAppsTool(s *mcp.Server) { mcp.AddTool(s, &mcp.Tool{ Name: "list_apps", - Description: "List all Edge Connect applications matching the specified filter. Can filter by organization, name, or version pattern.", + Description: "List all Edge Connect applications matching the specified filter. Supports partial (substring) matching for all filter fields.", }, func(ctx context.Context, req *mcp.CallToolRequest, a args) (*mcp.CallToolResult, any, error) { - organization := "" - if a.Organization != nil { - organization = *a.Organization - } - - name := "" - if a.Name != nil { - name = *a.Name - } - - version := "" - if a.Version != nil { - version = *a.Version - } - region := config.DefaultRegion if a.Region != nil { region = *a.Region } + // Fetch all apps (empty filters) to enable client-side partial matching appKey := v2.AppKey{ - Organization: organization, - Name: name, - Version: version, + Organization: "", + Name: "", + Version: "", } - apps, err := edgeClient.ShowApps(ctx, appKey, region) + allApps, err := edgeClient.ShowApps(ctx, appKey, region) if err != nil { return nil, nil, fmt.Errorf("failed to list apps: %w", err) } + // Apply client-side partial matching filters + apps := filterApps(allApps, a.Organization, a.Name, a.Version) + appsJSON, err := json.MarshalIndent(apps, "", " ") if err != nil { return nil, nil, fmt.Errorf("failed to serialize apps: %w", err) @@ -570,68 +659,37 @@ func registerListAppInstancesTool(s *mcp.Server) { mcp.AddTool(s, &mcp.Tool{ Name: "list_app_instances", - Description: "List all Edge Connect application instances matching the specified filter.", + Description: "List all Edge Connect application instances matching the specified filter. Supports partial (substring) matching for all filter fields.", }, func(ctx context.Context, req *mcp.CallToolRequest, a args) (*mcp.CallToolResult, any, error) { - organization := "" - if a.Organization != nil { - organization = *a.Organization - } - - instanceName := "" - if a.InstanceName != nil { - instanceName = *a.InstanceName - } - - cloudletOrg := "" - if a.CloudletOrg != nil { - cloudletOrg = *a.CloudletOrg - } - - cloudletName := "" - if a.CloudletName != nil { - cloudletName = *a.CloudletName - } - - appOrg := "" - if a.AppOrg != nil { - appOrg = *a.AppOrg - } - - appName := "" - if a.AppName != nil { - appName = *a.AppName - } - - appVersion := "" - if a.AppVersion != nil { - appVersion = *a.AppVersion - } - region := config.DefaultRegion if a.Region != nil { region = *a.Region } + // Fetch all instances (empty filters) to enable client-side partial matching appInstKey := v2.AppInstanceKey{ - Organization: organization, - Name: instanceName, + Organization: "", + Name: "", CloudletKey: v2.CloudletKey{ - Organization: cloudletOrg, - Name: cloudletName, + Organization: "", + Name: "", }, } appKey := v2.AppKey{ - Organization: appOrg, - Name: appName, - Version: appVersion, + Organization: "", + Name: "", + Version: "", } - appInsts, err := edgeClient.ShowAppInstances(ctx, appInstKey, appKey, region) + allAppInsts, err := edgeClient.ShowAppInstances(ctx, appInstKey, appKey, region) if err != nil { return nil, nil, fmt.Errorf("failed to list app instances: %w", err) } + // Apply client-side partial matching filters + appInsts := filterAppInstances(allAppInsts, a.Organization, a.InstanceName, a.CloudletOrg, a.CloudletName, a.AppOrg, a.AppName, a.AppVersion) + appInstsJSON, err := json.MarshalIndent(appInsts, "", " ") if err != nil { return nil, nil, fmt.Errorf("failed to serialize app instances: %w", err) From 12beeaacd772f4b00cacab57de6fc56abe5d6465 Mon Sep 17 00:00:00 2001 From: Manuel Ganter Date: Mon, 19 Jan 2026 13:49:34 +0100 Subject: [PATCH 15/16] feat: reduced token usage of list operations by reducing the amount of returned fields --- tools.go | 96 +++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 92 insertions(+), 4 deletions(-) diff --git a/tools.go b/tools.go index 949b52b..71d419d 100644 --- a/tools.go +++ b/tools.go @@ -11,6 +11,90 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" ) +// Lightweight DTOs for list responses to reduce token usage + +// AppListItem represents a lightweight app for list responses +type AppListItem struct { + Key struct { + Organization string `json:"organization"` + Name string `json:"name"` + Version string `json:"version"` + } `json:"key"` + Deployment string `json:"deployment"` + ImagePath string `json:"image_path"` + AccessPorts string `json:"access_ports"` + AllowServerless bool `json:"allow_serverless"` +} + +// AppInstanceListItem represents a lightweight app instance for list responses +type AppInstanceListItem struct { + Key struct { + Organization string `json:"organization"` + Name string `json:"name"` + CloudletKey struct { + Organization string `json:"organization"` + Name string `json:"name"` + } `json:"cloudlet_key"` + } `json:"key"` + AppKey struct { + Organization string `json:"organization"` + Name string `json:"name"` + Version string `json:"version"` + } `json:"app_key"` + PowerState string `json:"power_state"` + Flavor struct { + Name string `json:"name"` + } `json:"flavor"` +} + +// convertToAppListItem converts a full App to a lightweight AppListItem +func convertToAppListItem(app v2.App) AppListItem { + item := AppListItem{ + Deployment: app.Deployment, + ImagePath: app.ImagePath, + AccessPorts: app.AccessPorts, + AllowServerless: app.AllowServerless, + } + item.Key.Organization = app.Key.Organization + item.Key.Name = app.Key.Name + item.Key.Version = app.Key.Version + return item +} + +// convertToAppListItems converts a slice of Apps to AppListItems +func convertToAppListItems(apps []v2.App) []AppListItem { + items := make([]AppListItem, len(apps)) + for i, app := range apps { + items[i] = convertToAppListItem(app) + } + return items +} + +// convertToAppInstanceListItem converts a full AppInstance to a lightweight AppInstanceListItem +func convertToAppInstanceListItem(inst v2.AppInstance) AppInstanceListItem { + item := AppInstanceListItem{ + PowerState: inst.PowerState, + } + item.Key.Organization = inst.Key.Organization + item.Key.Name = inst.Key.Name + item.Key.CloudletKey.Organization = inst.Key.CloudletKey.Organization + item.Key.CloudletKey.Name = inst.Key.CloudletKey.Name + item.AppKey.Organization = inst.AppKey.Organization + item.AppKey.Name = inst.AppKey.Name + item.AppKey.Version = inst.AppKey.Version + item.Flavor.Name = inst.Flavor.Name + return item +} + +// convertToAppInstanceListItems converts a slice of AppInstances to AppInstanceListItems +func convertToAppInstanceListItems(instances []v2.AppInstance) []AppInstanceListItem { + items := make([]AppInstanceListItem, len(instances)) + for i, inst := range instances { + items[i] = convertToAppInstanceListItem(inst) + } + return items +} + // getProtocolFromRequest extracts the protocol from the MCP initialize request func getProtocolFromRequest(req *mcp.CallToolRequest) mcpuiserver.ProtocolType { // Get initialize params from session @@ -326,7 +410,9 @@ func registerListAppsTool(s *mcp.Server) { // Apply client-side partial matching filters apps := filterApps(allApps, a.Organization, a.Name, a.Version) - appsJSON, err := json.MarshalIndent(apps, "", " ") + // Convert to lightweight DTOs for reduced token usage + appListItems := convertToAppListItems(apps) + appsJSON, err := json.MarshalIndent(appListItems, "", " ") if err != nil { return nil, nil, fmt.Errorf("failed to serialize apps: %w", err) } @@ -334,7 +420,7 @@ func registerListAppsTool(s *mcp.Server) { // Parse protocol from MCP initialize request protocol := getProtocolFromRequest(req) - // Create UI resource for apps list + // Create UI resource for apps list (uses full apps) uiResource, err := createAppListUI(apps, region, protocol) if err != nil { // If UI creation fails, just return text content @@ -690,7 +776,9 @@ func registerListAppInstancesTool(s *mcp.Server) { // Apply client-side partial matching filters appInsts := filterAppInstances(allAppInsts, a.Organization, a.InstanceName, a.CloudletOrg, a.CloudletName, a.AppOrg, a.AppName, a.AppVersion) - appInstsJSON, err := json.MarshalIndent(appInsts, "", " ") + // Convert to lightweight DTOs for reduced token usage + appInstListItems := convertToAppInstanceListItems(appInsts) + appInstsJSON, err := json.MarshalIndent(appInstListItems, "", " ") if err != nil { return nil, nil, fmt.Errorf("failed to serialize app instances: %w", err) } @@ -698,7 +786,7 @@ func registerListAppInstancesTool(s *mcp.Server) { // Parse protocol from MCP initialize request protocol := getProtocolFromRequest(req) - // Create UI resource for app instances list + // Create UI resource for app instances list (uses full instances) uiResource, err := createAppInstanceListUI(appInsts, region, protocol) if err != nil { // If UI creation fails, just return text content From 51400e8eda07b69cf7c1058383986583245a964a Mon Sep 17 00:00:00 2001 From: Manuel Ganter Date: Mon, 19 Jan 2026 16:26:26 +0100 Subject: [PATCH 16/16] feat: dropped sse endpoint support in favor of the more recent streamableHttp endpoint --- main.go | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/main.go b/main.go index 857ba5f..6bcca89 100644 --- a/main.go +++ b/main.go @@ -95,8 +95,7 @@ func startRemoteServer(mcpServer *mcp.Server, cfg *Config) error { // Create HTTP mux mux := http.NewServeMux() - // Create SSE handler that returns our MCP server - sseHandler := mcp.NewSSEHandler(func(r *http.Request) *mcp.Server { + streamableHttpHandler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server { // Simple bearer token auth - only if OAuth is disabled and auth is required if !cfg.OAuthEnabled && cfg.RemoteAuthRequired { if !authenticateRequest(r, cfg) { @@ -104,7 +103,7 @@ func startRemoteServer(mcpServer *mcp.Server, cfg *Config) error { } } return mcpServer - }, nil) + }, &mcp.StreamableHTTPOptions{}) // Health check endpoint (no auth required) mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { @@ -135,15 +134,14 @@ func startRemoteServer(mcpServer *mcp.Server, cfg *Config) error { // Create OAuth middleware authMiddleware := oauth.AuthMiddleware(resourceServer, validator) - // Wrap SSE handler with OAuth middleware - mux.Handle("/sse", authMiddleware(sseHandler)) + // Wrap MCP handler with OAuth middleware + mux.Handle("/mcp", authMiddleware(streamableHttpHandler)) log.Printf("OAuth 2.1 enabled") log.Printf("Protected Resource URI: %s", cfg.OAuthResourceURI) log.Printf("Authorization Servers: %v", cfg.OAuthAuthServers) } else { - // SSE endpoint without OAuth middleware - mux.Handle("/sse", sseHandler) + mux.Handle("/mcp", streamableHttpHandler) } // Create HTTP server