Compare commits

...

16 commits
v0.0.1 ... main

Author SHA1 Message Date
51400e8eda
feat: dropped sse endpoint support in favor of the more recent streamableHttp endpoint 2026-01-19 16:26:26 +01:00
12beeaacd7
feat: reduced token usage of list operations by reducing the amount of returned fields 2026-01-19 13:49:34 +01:00
477aa5b5b8
Add partial matching for listing apps / app instances 2026-01-16 15:55:38 +01:00
365a6623ea
feat: added multi ui-protocol support 2026-01-12 18:04:26 +01:00
408b16b9f8
feat: added view details and remove buttons for mcp-ui 2026-01-12 16:33:38 +01:00
9f41dcd22f
feat: added mcp-ui 2026-01-12 16:08:11 +01:00
c883add6c3
Provisionally add MCP UI functionality 2026-01-12 13:24:56 +01:00
53995cf3a3
Partially update README 2026-01-09 17:27:19 +01:00
260d0cad40
chore(sdk): bumped sdk to v2.2.0 2026-01-08 15:14:25 +01:00
d16354d260
feat: added relational context between deletion of app and app instance 2026-01-08 14:53:38 +01:00
9e1808921b
feat: added serverless config 2026-01-08 14:27:57 +01:00
429964c166
feat: added serverlessConfig to mcp interface 2026-01-07 16:36:29 +01:00
1afb42362d
fix: pre commit hook using gitleaks 2026-01-07 14:57:33 +01:00
431e840497
fix: oauth2 dev server now supports dynamic client registration 2026-01-07 14:54:42 +01:00
101bd6f5e3
added OAuth2.1 flow 2026-01-05 16:59:11 +01:00
fcf1d7a21f
chore: added gitleaks pre commit hook 2026-01-05 16:04:29 +01:00
29 changed files with 5854 additions and 191 deletions

View file

@ -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

4
.gitleaksignore Normal file
View file

@ -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

419
MCP_UI.md Normal file
View file

@ -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": "<html>...</html>"
}
}
```
**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(`
<!DOCTYPE html>
<html>
<head>
<style>/* Your CSS */</style>
</head>
<body>
<!-- Your HTML -->
</body>
</html>
`, /* 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)

View file

@ -4,12 +4,15 @@
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
default: run
.PHONY: all build clean fmt format lint gitleaks test run help vet tidy install-hooks
# Default target
all: fmt vet lint build
@ -36,6 +39,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

View file

@ -15,12 +15,12 @@ Get up and running with the Edge Connect MCP Server in 5 minutes.
```bash
git clone <repository-url>
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

118
README.md
View file

@ -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)
@ -110,6 +122,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,25 +342,31 @@ 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
- `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
@ -298,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
```

136
config.go
View file

@ -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
}

458
docs/OAUTH_SECURITY.md Normal file
View file

@ -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)

315
docs/OAUTH_SETUP.md Normal file
View file

@ -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

13
go.mod
View file

@ -1,17 +1,22 @@
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
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-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
)

20
go.sum
View file

@ -1,21 +1,23 @@
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=
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=
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=
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=
@ -26,10 +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-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=

128
main.go
View file

@ -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 (
@ -47,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)
@ -90,34 +95,63 @@ 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 {
// Authentication middleware
if cfg.RemoteAuthRequired {
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) {
return nil
}
}
return mcpServer
}, nil)
}, &mcp.StreamableHTTPOptions{})
// 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 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 {
mux.Handle("/mcp", streamableHttpHandler)
}
// 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,
}
@ -127,13 +161,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 +268,61 @@ 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)
// 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)
// 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")
if err := json.NewEncoder(w).Encode(authServer.GetMetadata()); err != nil {
log.Printf("Failed to encode metadata response: %v", err)
}
})
// 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(" 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)
}
}

466
oauth/authz_server.go Normal file
View file

@ -0,0 +1,466 @@
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
refreshTokenLifetime 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,
refreshTokenLifetime: 30 * 24 * time.Hour, // 30 days
}, 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",
RegistrationEndpoint: s.baseURL + "/register",
JWKSURI: s.baseURL + "/.well-known/jwks.json",
ResponseTypesSupported: []string{"code"},
GrantTypesSupported: []string{"authorization_code", "refresh_token"},
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) {
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")
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")
codeVerifier := r.FormValue("code_verifier")
resource := r.FormValue("resource")
// 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
}
// Use resource from auth code if not provided in token request
if resource == "" {
resource = authCode.Resource
}
// Issue access token
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, 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)
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
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
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, refreshToken string, expiresIn int, scope string) {
response := TokenResponse{
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)
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
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)
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)
}
}

550
oauth/authz_server_test.go Normal file
View file

@ -0,0 +1,550 @@
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.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)
}
// 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)
}
}
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)
}
}
})
}
}

84
oauth/jwks.go Normal file
View file

@ -0,0 +1,84 @@
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
}
// 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
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},
}
}

147
oauth/jwks_test.go Normal file
View file

@ -0,0 +1,147 @@
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 || 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)
}
}
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")
}
}

88
oauth/middleware.go Normal file
View file

@ -0,0 +1,88 @@
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 == "" {
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)
// 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)
})
}
}

269
oauth/middleware_test.go Normal file
View file

@ -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)
}
}

118
oauth/oauth.go Normal file
View file

@ -0,0 +1,118 @@
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"`
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
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"`
RefreshToken string `json:"refresh_token,omitempty"`
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"`
}
// 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"`
}

48
oauth/pkce.go Normal file
View file

@ -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[:])
}

115
oauth/pkce_test.go Normal file
View file

@ -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)
}
}

95
oauth/resource_server.go Normal file
View file

@ -0,0 +1,95 @@
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
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)
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)
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)
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)
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)
}
}

View file

@ -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
}

227
oauth/storage.go Normal file
View file

@ -0,0 +1,227 @@
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
}
// 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
RedirectURIs []string
}
// AuthStorage provides in-memory storage for authorization codes and clients
type AuthStorage struct {
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),
refreshTokens: make(map[string]*RefreshToken),
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)
}
}
}
// 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)
}
}
}

193
oauth/storage_test.go Normal file
View file

@ -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
}
}

239
oauth/token_validator.go Normal file
View file

@ -0,0 +1,239 @@
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)
}
// Normalize expected audience (remove trailing slash)
expectedAudience := strings.TrimSuffix(v.expectedAudience, "/")
audienceValid := false
for _, a := range aud {
// 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")
}
// 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 func() {
_ = 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
}

View file

@ -21,4 +21,8 @@ fi
echo "Running linter..."
make lint
# Check for secrets with gitleaks
echo "Checking for secrets..."
make gitleaks
echo "Pre-commit checks passed!"

497
tools.go
View file

@ -4,30 +4,241 @@ 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"
"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
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, &paramsMap); err != nil {
return ""
}
// Parse protocol from initialize params
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) {
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 +255,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 +271,33 @@ 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 := v2.ServerlessConfig{}
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,
@ -115,8 +347,33 @@ 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, protocol)
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,
Meta: uiResource.Resource.Meta,
}
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
})
}
@ -131,66 +388,88 @@ 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)
}
appsJSON, err := json.MarshalIndent(apps, "", " ")
// Apply client-side partial matching filters
apps := filterApps(allApps, a.Organization, a.Name, a.Version)
// 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)
}
// Parse protocol from MCP initialize request
protocol := getProtocolFromRequest(req)
// 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
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,
Meta: uiResource.Resource.Meta,
}
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: result}},
Content: []mcp.Content{
&mcp.TextContent{Text: result},
&mcp.EmbeddedResource{
Resource: resourceContents,
},
},
}, nil, nil
})
}
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 +494,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 +514,31 @@ 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
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
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")
}
@ -263,7 +572,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 {
@ -436,76 +745,74 @@ 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)
}
appInstsJSON, err := json.MarshalIndent(appInsts, "", " ")
// Apply client-side partial matching filters
appInsts := filterAppInstances(allAppInsts, a.Organization, a.InstanceName, a.CloudletOrg, a.CloudletName, a.AppOrg, a.AppName, a.AppVersion)
// 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)
}
// Parse protocol from MCP initialize request
protocol := getProtocolFromRequest(req)
// 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
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,
Meta: uiResource.Resource.Meta,
}
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: result}},
Content: []mcp.Content{
&mcp.TextContent{Text: result},
&mcp.EmbeddedResource{
Resource: resourceContents,
},
},
}, nil, nil
})
}

907
ui.go Normal file
View file

@ -0,0 +1,907 @@
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, protocol mcpuiserver.ProtocolType) (*mcpuiserver.UIResource, error) {
htmlContent := fmt.Sprintf(`
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Edge Connect Applications</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%%, #764ba2 100%%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1400px;
margin: 0 auto;
}
.header {
background: white;
border-radius: 12px;
padding: 24px;
margin-bottom: 24px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
.header h1 {
color: #2d3748;
font-size: 28px;
margin-bottom: 8px;
}
.header .region {
display: inline-block;
background: #667eea;
color: white;
padding: 4px 12px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
background: white;
border-radius: 12px;
padding: 20px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
.stat-card .label {
color: #718096;
font-size: 14px;
margin-bottom: 8px;
}
.stat-card .value {
color: #2d3748;
font-size: 32px;
font-weight: bold;
}
.apps-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
gap: 20px;
}
.app-card {
background: white;
border-radius: 12px;
padding: 24px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
transition: transform 0.2s, box-shadow 0.2s;
cursor: pointer;
}
.app-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 12px rgba(0,0,0,0.15);
}
.app-card .app-name {
font-size: 20px;
font-weight: bold;
color: #2d3748;
margin-bottom: 8px;
}
.app-card .app-org {
color: #718096;
font-size: 14px;
margin-bottom: 4px;
}
.app-card .app-version {
display: inline-block;
background: #e2e8f0;
color: #4a5568;
padding: 4px 10px;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
margin-bottom: 16px;
}
.app-card .detail-row {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid #e2e8f0;
font-size: 14px;
}
.app-card .detail-row:last-child {
border-bottom: none;
}
.app-card .detail-label {
color: #718096;
font-weight: 500;
}
.app-card .detail-value {
color: #2d3748;
font-weight: 400;
}
.badge {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
}
.badge.docker {
background: #bee3f8;
color: #2c5282;
}
.badge.kubernetes {
background: #c6f6d5;
color: #22543d;
}
.badge.serverless {
background: #fef5e7;
color: #975a16;
}
.actions {
display: flex;
gap: 8px;
margin-top: 16px;
}
.btn {
flex: 1;
padding: 10px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.2s;
}
.btn:hover {
opacity: 0.8;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-danger {
background: #f56565;
color: white;
}
.empty-state {
background: white;
border-radius: 12px;
padding: 60px 20px;
text-align: center;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
.empty-state h2 {
color: #2d3748;
font-size: 24px;
margin-bottom: 12px;
}
.empty-state p {
color: #718096;
font-size: 16px;
}
</style>
</head>
<body>
<script>
function sendMessage(type, payload) {
const messageId = 'msg-' + Date.now();
console.log('Sending:', type, payload);
window.parent.postMessage({ type, messageId, payload }, '*');
}
document.addEventListener('DOMContentLoaded', function() {
// Attach event listeners to all view buttons
document.querySelectorAll('.btn-view-app').forEach(function(btn) {
btn.addEventListener('click', function() {
const card = this.closest('.app-card');
const org = card.dataset.org;
const name = card.dataset.name;
const version = card.dataset.version;
console.log('View app clicked:', org, name, version);
sendMessage('tool', {
toolName: 'show_app',
params: {
organization: org,
name: name,
version: version
}
});
});
});
// Attach event listeners to all delete buttons
document.querySelectorAll('.btn-delete-app').forEach(function(btn) {
btn.addEventListener('click', function() {
const card = this.closest('.app-card');
const org = card.dataset.org;
const name = card.dataset.name;
const version = card.dataset.version;
console.log('Delete app clicked:', org, name, version);
sendMessage('tool', {
toolName: 'delete_app',
params: {
organization: org,
name: name,
version: version
}
});
});
});
});
</script>
<div class="container">
<div class="header">
<h1>Edge Connect Applications</h1>
<span class="region">Region: %s</span>
</div>
<div class="stats">
<div class="stat-card">
<div class="label">Total Applications</div>
<div class="value">%d</div>
</div>
<div class="stat-card">
<div class="label">Docker Apps</div>
<div class="value">%d</div>
</div>
<div class="stat-card">
<div class="label">Kubernetes Apps</div>
<div class="value">%d</div>
</div>
<div class="stat-card">
<div class="label">Serverless Enabled</div>
<div class="value">%d</div>
</div>
</div>
`, region, len(apps), countDeploymentType(apps, "docker"), countDeploymentType(apps, "kubernetes"), countServerlessApps(apps))
if len(apps) == 0 {
htmlContent += `
<div class="empty-state">
<h2>No Applications Found</h2>
<p>Start by creating your first Edge Connect application</p>
</div>
`
} else {
htmlContent += ` <div class="apps-grid">`
for _, app := range apps {
htmlContent += generateAppCard(app)
}
htmlContent += ` </div>`
}
htmlContent += `
</div>
</body>
</html>
`
resource, err := mcpuiserver.CreateUIResource(
"ui://apps-dashboard",
&mcpuiserver.RawHTMLPayload{
Type: mcpuiserver.ContentTypeRawHTML,
HTMLString: htmlContent,
},
mcpuiserver.EncodingText,
mcpuiserver.WithProtocol(protocol),
mcpuiserver.WithUIMetadata(map[string]any{
"preferred-frame-size": []string{"800px", "600px"},
}),
)
return resource, err
}
// generateAppCard creates HTML for a single app card
func generateAppCard(app v2.App) string {
deploymentBadge := fmt.Sprintf(`<span class="badge docker">%s</span>`, strings.ToUpper(app.Deployment))
if strings.ToLower(app.Deployment) == "kubernetes" {
deploymentBadge = fmt.Sprintf(`<span class="badge kubernetes">%s</span>`, strings.ToUpper(app.Deployment))
}
serverlessBadge := ""
if app.AllowServerless {
serverlessBadge = ` <span class="badge serverless">SERVERLESS</span>`
}
imagePath := html.EscapeString(app.ImagePath)
if len(imagePath) > 50 {
imagePath = imagePath[:47] + "..."
}
return fmt.Sprintf(`
<div class="app-card" data-org="%s" data-name="%s" data-version="%s">
<div class="app-name">%s</div>
<div class="app-org">%s</div>
<div class="app-version">v%s</div>
<div class="detail-row">
<span class="detail-label">Deployment</span>
<span class="detail-value">%s%s</span>
</div>
<div class="detail-row">
<span class="detail-label">Image</span>
<span class="detail-value" title="%s">%s</span>
</div>
<div class="detail-row">
<span class="detail-label">Ports</span>
<span class="detail-value">%s</span>
</div>
<div class="actions">
<button class="btn btn-primary btn-view-app">View Details</button>
<button class="btn btn-danger btn-delete-app">Delete</button>
</div>
</div>
`,
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),
)
}
// createAppDetailUI generates a detailed view for a single app
func createAppDetailUI(app v2.App, region string, protocol mcpuiserver.ProtocolType) (*mcpuiserver.UIResource, error) {
appJSON, _ := json.MarshalIndent(app, "", " ")
htmlContent := fmt.Sprintf(`
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>%s - App Details</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%%, #764ba2 100%%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1000px;
margin: 0 auto;
}
.card {
background: white;
border-radius: 12px;
padding: 24px;
margin-bottom: 20px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.header h1 {
color: #2d3748;
font-size: 28px;
}
.back-btn {
background: #e2e8f0;
color: #2d3748;
padding: 10px 20px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
text-decoration: none;
}
.detail-grid {
display: grid;
grid-template-columns: 200px 1fr;
gap: 16px 24px;
}
.detail-label {
color: #718096;
font-weight: 600;
font-size: 14px;
}
.detail-value {
color: #2d3748;
font-size: 14px;
word-break: break-all;
}
.json-viewer {
background: #2d3748;
color: #68d391;
padding: 16px;
border-radius: 8px;
font-family: 'Courier New', monospace;
font-size: 13px;
overflow-x: auto;
white-space: pre-wrap;
}
.badge {
display: inline-block;
padding: 4px 12px;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
}
.badge.success {
background: #c6f6d5;
color: #22543d;
}
.badge.info {
background: #bee3f8;
color: #2c5282;
}
.badge.warning {
background: #fef5e7;
color: #975a16;
}
</style>
</head>
<body>
<div class="container">
<div class="card">
<div class="header">
<h1>%s</h1>
<a href="#" class="back-btn" onclick="history.back()"> Back to Apps</a>
</div>
<div class="detail-grid">
<div class="detail-label">Organization</div>
<div class="detail-value">%s</div>
<div class="detail-label">Name</div>
<div class="detail-value">%s</div>
<div class="detail-label">Version</div>
<div class="detail-value">%s</div>
<div class="detail-label">Region</div>
<div class="detail-value">%s</div>
<div class="detail-label">Deployment</div>
<div class="detail-value"><span class="badge info">%s</span></div>
<div class="detail-label">Image Type</div>
<div class="detail-value">%s</div>
<div class="detail-label">Image Path</div>
<div class="detail-value">%s</div>
<div class="detail-label">Access Ports</div>
<div class="detail-value">%s</div>
<div class="detail-label">Serverless</div>
<div class="detail-value"><span class="badge %s">%s</span></div>
</div>
</div>
<div class="card">
<h2 style="margin-bottom: 16px; color: #2d3748;">Raw JSON</h2>
<div class="json-viewer">%s</div>
</div>
</div>
</body>
</html>
`,
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,
mcpuiserver.WithProtocol(protocol),
mcpuiserver.WithUIMetadata(map[string]any{
"preferred-frame-size": []string{"800px", "600px"},
}),
)
return resource, err
}
// createAppInstanceListUI generates an interactive UI for listing app instances
func createAppInstanceListUI(instances []v2.AppInstance, region string, protocol mcpuiserver.ProtocolType) (*mcpuiserver.UIResource, error) {
htmlContent := fmt.Sprintf(`
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Edge Connect App Instances</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #4f46e5 0%%, #7c3aed 100%%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1400px;
margin: 0 auto;
}
.header {
background: white;
border-radius: 12px;
padding: 24px;
margin-bottom: 24px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
.header h1 {
color: #2d3748;
font-size: 28px;
margin-bottom: 8px;
}
.header .region {
display: inline-block;
background: #4f46e5;
color: white;
padding: 4px 12px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
background: white;
border-radius: 12px;
padding: 20px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
.stat-card .label {
color: #718096;
font-size: 14px;
margin-bottom: 8px;
}
.stat-card .value {
color: #2d3748;
font-size: 32px;
font-weight: bold;
}
.instances-table {
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
table {
width: 100%%;
border-collapse: collapse;
}
thead {
background: #f7fafc;
}
th {
padding: 16px;
text-align: left;
color: #2d3748;
font-weight: 600;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
td {
padding: 16px;
border-top: 1px solid #e2e8f0;
color: #4a5568;
font-size: 14px;
}
tr:hover {
background: #f7fafc;
}
.badge {
display: inline-block;
padding: 4px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
}
.badge.running {
background: #c6f6d5;
color: #22543d;
}
.badge.stopped {
background: #fed7d7;
color: #742a2a;
}
.badge.unknown {
background: #e2e8f0;
color: #4a5568;
}
.btn-group {
display: flex;
gap: 8px;
}
.btn {
padding: 6px 12px;
border: none;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.2s;
}
.btn:hover {
opacity: 0.8;
}
.btn-sm {
background: #4f46e5;
color: white;
}
.btn-danger {
background: #f56565;
color: white;
}
.empty-state {
background: white;
border-radius: 12px;
padding: 60px 20px;
text-align: center;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
.empty-state h2 {
color: #2d3748;
font-size: 24px;
margin-bottom: 12px;
}
.empty-state p {
color: #718096;
font-size: 16px;
}
</style>
</head>
<body>
<script>
function sendMessage(type, payload) {
const messageId = 'msg-' + Date.now();
console.log('Sending:', type, payload);
window.parent.postMessage({ type, messageId, payload }, '*');
}
document.addEventListener('DOMContentLoaded', function() {
// Attach event listeners to all view buttons
document.querySelectorAll('.btn-view-instance').forEach(function(btn) {
btn.addEventListener('click', function() {
const row = this.closest('tr');
const org = row.dataset.org;
const name = row.dataset.name;
const cloudletOrg = row.dataset.cloudletOrg;
const cloudletName = row.dataset.cloudletName;
console.log('View instance clicked:', org, name, cloudletOrg, cloudletName);
sendMessage('tool', {
toolName: 'show_app_instance',
params: {
organization: org,
instance_name: name,
cloudlet_org: cloudletOrg,
cloudlet_name: cloudletName
}
});
});
});
// Attach event listeners to all delete buttons
document.querySelectorAll('.btn-delete-instance').forEach(function(btn) {
btn.addEventListener('click', function() {
const row = this.closest('tr');
const org = row.dataset.org;
const name = row.dataset.name;
const cloudletOrg = row.dataset.cloudletOrg;
const cloudletName = row.dataset.cloudletName;
console.log('Delete instance clicked:', org, name, cloudletOrg, cloudletName);
sendMessage('tool', {
toolName: 'delete_app_instance',
params: {
organization: org,
instance_name: name,
cloudlet_org: cloudletOrg,
cloudlet_name: cloudletName
}
});
});
});
});
</script>
<div class="container">
<div class="header">
<h1>Application Instances</h1>
<span class="region">Region: %s</span>
</div>
<div class="stats">
<div class="stat-card">
<div class="label">Total Instances</div>
<div class="value">%d</div>
</div>
<div class="stat-card">
<div class="label">Running</div>
<div class="value">%d</div>
</div>
<div class="stat-card">
<div class="label">Stopped</div>
<div class="value">%d</div>
</div>
</div>
`, region, len(instances), countPowerState(instances, "PowerOn"), countPowerState(instances, "PowerOff"))
if len(instances) == 0 {
htmlContent += `
<div class="empty-state">
<h2>No App Instances Found</h2>
<p>Deploy your first application instance to get started</p>
</div>
`
} else {
htmlContent += `
<div class="instances-table">
<table>
<thead>
<tr>
<th>Instance Name</th>
<th>Organization</th>
<th>Cloudlet</th>
<th>Application</th>
<th>Status</th>
<th>Flavor</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
`
for _, inst := range instances {
htmlContent += generateInstanceRow(inst)
}
htmlContent += `
</tbody>
</table>
</div>
`
}
htmlContent += `
</div>
</body>
</html>
`
resource, err := mcpuiserver.CreateUIResource(
"ui://app-instances-dashboard",
&mcpuiserver.RawHTMLPayload{
Type: mcpuiserver.ContentTypeRawHTML,
HTMLString: htmlContent,
},
mcpuiserver.EncodingText,
mcpuiserver.WithProtocol(protocol),
mcpuiserver.WithUIMetadata(map[string]any{
"preferred-frame-size": []string{"800px", "600px"},
}),
)
return resource, err
}
// generateInstanceRow creates HTML for a single instance table row
func generateInstanceRow(inst v2.AppInstance) string {
var statusBadge string
switch inst.PowerState {
case "PowerOn":
statusBadge = `<span class="badge running">RUNNING</span>`
case "PowerOff":
statusBadge = `<span class="badge stopped">STOPPED</span>`
default:
statusBadge = `<span class="badge unknown">UNKNOWN</span>`
}
return fmt.Sprintf(`
<tr data-org="%s" data-name="%s" data-cloudlet-org="%s" data-cloudlet-name="%s">
<td><strong>%s</strong></td>
<td>%s</td>
<td>%s/%s</td>
<td>%s:%s</td>
<td>%s</td>
<td>%s</td>
<td>
<div class="btn-group">
<button class="btn btn-sm btn-view-instance">View</button>
<button class="btn btn-danger btn-delete-instance">Delete</button>
</div>
</td>
</tr>
`,
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),
html.EscapeString(inst.Key.CloudletKey.Name),
html.EscapeString(inst.AppKey.Name),
html.EscapeString(inst.AppKey.Version),
statusBadge,
html.EscapeString(inst.Flavor.Name),
)
}
// Helper functions
func countDeploymentType(apps []v2.App, deploymentType string) int {
count := 0
for _, app := range apps {
if strings.EqualFold(app.Deployment, 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"
}