Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions components/frontend/src/app/api/oauth/mcp/callback/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from 'next/server'

/**
* OAuth callback endpoint for MCP servers (e.g., Atlassian MCP).
* This receives the OAuth redirect from the MCP provider and forwards
* the authorization code to the completion page, which communicates
* back to the Claude Code CLI running in the session pod.
*/
export async function GET(request: NextRequest) {
const code = request.nextUrl.searchParams.get('code')
const state = request.nextUrl.searchParams.get('state')
const error = request.nextUrl.searchParams.get('error')
const errorDescription = request.nextUrl.searchParams.get('error_description')

// Build redirect URL to completion page with all OAuth params
const completionUrl = new URL('/oauth/mcp/complete', request.url)

if (error) {
completionUrl.searchParams.set('error', error)
if (errorDescription) {
completionUrl.searchParams.set('error_description', errorDescription)
}
} else if (code) {
completionUrl.searchParams.set('code', code)
if (state) {
completionUrl.searchParams.set('state', state)
}
} else {
completionUrl.searchParams.set('error', 'invalid_request')
completionUrl.searchParams.set('error_description', 'Missing authorization code')
}

return NextResponse.redirect(completionUrl)
}
133 changes: 133 additions & 0 deletions components/frontend/src/app/oauth/mcp/complete/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
'use client'

import { useEffect, useState } from 'react'
import { useSearchParams } from 'next/navigation'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { CheckCircle2, XCircle, Loader2 } from 'lucide-react'

/**
* OAuth completion page for MCP servers.
* This page receives the OAuth callback parameters and posts them back to
* the Claude Code CLI window/iframe that initiated the OAuth flow.
*/
export default function MCPOAuthComplete() {
const searchParams = useSearchParams()
const [status, setStatus] = useState<'processing' | 'success' | 'error'>('processing')
const [errorMessage, setErrorMessage] = useState<string>('')

useEffect(() => {
const code = searchParams.get('code')
const state = searchParams.get('state')
const error = searchParams.get('error')
const errorDescription = searchParams.get('error_description')

if (error) {
setStatus('error')
setErrorMessage(errorDescription || error)
return
}

if (!code) {
setStatus('error')
setErrorMessage('No authorization code received')
return
}

// Post message to opener window (Claude Code CLI or parent session page)
const messageData = {
type: 'oauth-callback',
provider: 'mcp',
code,
state,
}

// Try to communicate with opener (popup scenario)
if (window.opener && !window.opener.closed) {
try {
window.opener.postMessage(messageData, window.location.origin)
setStatus('success')

// Auto-close after 2 seconds
setTimeout(() => {
window.close()
}, 2000)
} catch (err) {
console.error('Failed to post message to opener:', err)
setStatus('error')
setErrorMessage('Failed to communicate with parent window')
}
}
// Try parent (iframe scenario)
else if (window.parent && window.parent !== window) {
try {
window.parent.postMessage(messageData, window.location.origin)
setStatus('success')
} catch (err) {
console.error('Failed to post message to parent:', err)
setStatus('error')
setErrorMessage('Failed to communicate with parent window')
}
}
// Standalone page (shouldn't happen in normal flow)
else {
setStatus('error')
setErrorMessage('No parent window found to communicate with')
}
}, [searchParams])

return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="flex items-center gap-2">
{status === 'processing' && (
<>
<Loader2 className="h-5 w-5 animate-spin" />
Processing Authentication
</>
)}
{status === 'success' && (
<>
<CheckCircle2 className="h-5 w-5 text-green-600" />
Authentication Complete
</>
)}
{status === 'error' && (
<>
<XCircle className="h-5 w-5 text-red-600" />
Authentication Failed
</>
)}
</CardTitle>
<CardDescription>
{status === 'processing' && 'Completing MCP server authentication...'}
{status === 'success' && 'You can close this window now.'}
{status === 'error' && 'There was a problem with the authentication.'}
</CardDescription>
</CardHeader>
<CardContent>
{status === 'processing' && (
<p className="text-sm text-muted-foreground">
Communicating with Claude Code session...
</p>
)}
{status === 'success' && (
<p className="text-sm text-muted-foreground">
MCP server authentication was successful. This window will close automatically.
</p>
)}
{status === 'error' && (
<div className="space-y-2">
<p className="text-sm text-red-600 font-medium">
{errorMessage}
</p>
<p className="text-sm text-muted-foreground">
Please close this window and try again.
</p>
</div>
)}
</CardContent>
</Card>
</div>
)
}
63 changes: 63 additions & 0 deletions components/operator/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,15 @@
package config

import (
"context"
"fmt"
"log"
"os"

corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/dynamic"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
Expand Down Expand Up @@ -60,6 +65,64 @@ func InitK8sClients() error {
return nil
}

// DiscoverFrontendURL attempts to discover the frontend external URL from OpenShift Route or Kubernetes Ingress
// Returns empty string if not found (MCP OAuth callbacks will not work)
func DiscoverFrontendURL(namespace string) string {
ctx := context.TODO()

// Try OpenShift Route (most common in vTeam deployments)
routeGVR := schema.GroupVersionResource{
Group: "route.openshift.io",
Version: "v1",
Resource: "routes",
}

route, err := DynamicClient.Resource(routeGVR).Namespace(namespace).Get(ctx, "frontend", metav1.GetOptions{})
if err == nil {
if spec, found, _ := unstructured.NestedMap(route.Object, "spec"); found {
if host, ok := spec["host"].(string); ok && host != "" {
// Check TLS
scheme := "http"
if tls, found, _ := unstructured.NestedMap(spec, "tls"); found && tls != nil {
scheme = "https"
}
url := fmt.Sprintf("%s://%s", scheme, host)
log.Printf("Discovered frontend URL from OpenShift Route: %s", url)
return url
}
}
}

// Try Kubernetes Ingress as fallback
ingressGVR := schema.GroupVersionResource{
Group: "networking.k8s.io",
Version: "v1",
Resource: "ingresses",
}

ingress, err := DynamicClient.Resource(ingressGVR).Namespace(namespace).Get(ctx, "frontend", metav1.GetOptions{})
if err == nil {
if spec, found, _ := unstructured.NestedMap(ingress.Object, "spec"); found {
if rules, found, _ := unstructured.NestedSlice(spec, "rules"); found && len(rules) > 0 {
if rule, ok := rules[0].(map[string]interface{}); ok {
if host, ok := rule["host"].(string); ok && host != "" {
scheme := "http"
if tls, found, _ := unstructured.NestedSlice(spec, "tls"); found && len(tls) > 0 {
scheme = "https"
}
url := fmt.Sprintf("%s://%s", scheme, host)
log.Printf("Discovered frontend URL from Kubernetes Ingress: %s", url)
return url
}
}
}
}
}

log.Printf("Warning: Could not discover frontend Route or Ingress in namespace %s - MCP OAuth will not work", namespace)
return ""
}

// LoadConfig loads the operator configuration from environment variables
func LoadConfig() *Config {
// Get namespace from environment or use default
Expand Down
2 changes: 2 additions & 0 deletions components/operator/internal/handlers/sessions.go
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,8 @@ func handleAgenticSessionEvent(obj *unstructured.Unstructured) error {
{Name: "BACKEND_API_URL", Value: fmt.Sprintf("http://backend-service.%s.svc.cluster.local:8080/api", appConfig.BackendNamespace)},
// WebSocket URL used by runner-shell to connect back to backend
{Name: "WEBSOCKET_URL", Value: fmt.Sprintf("ws://backend-service.%s.svc.cluster.local:8080/api/projects/%s/sessions/%s/ws", appConfig.BackendNamespace, sessionNamespace, name)},
// Frontend URL for MCP OAuth callbacks (discovered from Route/Ingress)
{Name: "VTEAM_FRONTEND_URL", Value: config.DiscoverFrontendURL(appConfig.BackendNamespace)},
// S3 disabled; backend persists messages
}

Expand Down
4 changes: 4 additions & 0 deletions components/runners/claude-code-runner/.mcp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"mcpServers": {
}
}
67 changes: 16 additions & 51 deletions components/runners/claude-code-runner/wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -1813,72 +1813,37 @@ def _filter_mcp_servers(self, servers: dict) -> dict:
return allowed_servers

def _load_mcp_config(self, cwd_path: str) -> dict | None:
"""Load MCP server configuration from .mcp.json file in the workspace.
"""Load MCP server configuration from the vTeam runner's .mcp.json file.

Searches for .mcp.json in the following locations:
1. MCP_CONFIG_PATH environment variable (if set)
2. cwd_path/.mcp.json (main working directory)
3. workspace root/.mcp.json (for multi-repo setups)
Only loads MCP servers from the centrally-controlled configuration file
in the runner's own directory. Does NOT load from user workspace repos
for security reasons.

The .mcp.json file should be located at:
/app/claude-runner/.mcp.json (in the container)

Only allows http and sse type MCP servers.

Returns the parsed MCP servers configuration dict, or None if not found.
"""
try:
# Check if MCP discovery is disabled
if os.getenv('MCP_CONFIG_SEARCH', '').strip().lower() in ('0', 'false', 'no'):
logging.info("MCP config search disabled by MCP_CONFIG_SEARCH env var")
return None

# Option 1: Explicit path from environment
explicit_path = os.getenv('MCP_CONFIG_PATH', '').strip()
if explicit_path:
mcp_file = Path(explicit_path)
if mcp_file.exists() and mcp_file.is_file():
logging.info(f"Loading MCP config from MCP_CONFIG_PATH: {mcp_file}")
with open(mcp_file, 'r') as f:
config = _json.load(f)
all_servers = config.get('mcpServers', {})
filtered_servers = self._filter_mcp_servers(all_servers)
if filtered_servers:
logging.info(f"MCP servers loaded: {list(filtered_servers.keys())}")
return filtered_servers
logging.info("No valid MCP servers found after filtering")
return None
else:
logging.warning(f"MCP_CONFIG_PATH specified but file not found: {explicit_path}")
# Only load from the runner's own directory
runner_mcp_file = Path("/app/claude-runner/.mcp.json")

# Option 2: Look in cwd_path (main working directory)
mcp_file = Path(cwd_path) / ".mcp.json"
if mcp_file.exists() and mcp_file.is_file():
logging.info(f"Found .mcp.json in working directory: {mcp_file}")
with open(mcp_file, 'r') as f:
if runner_mcp_file.exists() and runner_mcp_file.is_file():
logging.info(f"Loading MCP config from runner directory: {runner_mcp_file}")
with open(runner_mcp_file, 'r') as f:
config = _json.load(f)
all_servers = config.get('mcpServers', {})
filtered_servers = self._filter_mcp_servers(all_servers)
if filtered_servers:
logging.info(f"MCP servers loaded from {mcp_file}: {list(filtered_servers.keys())}")
logging.info(f"MCP servers loaded: {list(filtered_servers.keys())}")
return filtered_servers
logging.info("No valid MCP servers found after filtering")
return None

# Option 3: Look in workspace root (for multi-repo setups)
if self.context and self.context.workspace_path != cwd_path:
workspace_mcp_file = Path(self.context.workspace_path) / ".mcp.json"
if workspace_mcp_file.exists() and workspace_mcp_file.is_file():
logging.info(f"Found .mcp.json in workspace root: {workspace_mcp_file}")
with open(workspace_mcp_file, 'r') as f:
config = _json.load(f)
all_servers = config.get('mcpServers', {})
filtered_servers = self._filter_mcp_servers(all_servers)
if filtered_servers:
logging.info(f"MCP servers loaded from {workspace_mcp_file}: {list(filtered_servers.keys())}")
return filtered_servers
logging.info("No valid MCP servers found after filtering")
return None

logging.info("No .mcp.json file found in any search location")
return None
else:
logging.info("No .mcp.json file found in runner directory")
return None

except _json.JSONDecodeError as e:
logging.error(f"Failed to parse .mcp.json: {e}")
Expand Down
Loading