Skip to content

Commit 091315a

Browse files
add OAuth and jira mcp
Signed-off-by: Michael Clifford <mcliffor@redhat.com>
1 parent b347240 commit 091315a

File tree

5 files changed

+241
-1
lines changed

5 files changed

+241
-1
lines changed
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { NextRequest, NextResponse } from 'next/server'
2+
3+
/**
4+
* OAuth callback endpoint for MCP servers (e.g., Atlassian MCP).
5+
* This receives the OAuth redirect from the MCP provider and forwards
6+
* the authorization code to the completion page, which communicates
7+
* back to the Claude Code CLI running in the session pod.
8+
*/
9+
export async function GET(request: NextRequest) {
10+
const code = request.nextUrl.searchParams.get('code')
11+
const state = request.nextUrl.searchParams.get('state')
12+
const error = request.nextUrl.searchParams.get('error')
13+
const errorDescription = request.nextUrl.searchParams.get('error_description')
14+
15+
// Build redirect URL to completion page with all OAuth params
16+
const completionUrl = new URL('/oauth/mcp/complete', request.url)
17+
18+
if (error) {
19+
completionUrl.searchParams.set('error', error)
20+
if (errorDescription) {
21+
completionUrl.searchParams.set('error_description', errorDescription)
22+
}
23+
} else if (code) {
24+
completionUrl.searchParams.set('code', code)
25+
if (state) {
26+
completionUrl.searchParams.set('state', state)
27+
}
28+
} else {
29+
completionUrl.searchParams.set('error', 'invalid_request')
30+
completionUrl.searchParams.set('error_description', 'Missing authorization code')
31+
}
32+
33+
return NextResponse.redirect(completionUrl)
34+
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
'use client'
2+
3+
import { useEffect, useState } from 'react'
4+
import { useSearchParams } from 'next/navigation'
5+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
6+
import { CheckCircle2, XCircle, Loader2 } from 'lucide-react'
7+
8+
/**
9+
* OAuth completion page for MCP servers.
10+
* This page receives the OAuth callback parameters and posts them back to
11+
* the Claude Code CLI window/iframe that initiated the OAuth flow.
12+
*/
13+
export default function MCPOAuthComplete() {
14+
const searchParams = useSearchParams()
15+
const [status, setStatus] = useState<'processing' | 'success' | 'error'>('processing')
16+
const [errorMessage, setErrorMessage] = useState<string>('')
17+
18+
useEffect(() => {
19+
const code = searchParams.get('code')
20+
const state = searchParams.get('state')
21+
const error = searchParams.get('error')
22+
const errorDescription = searchParams.get('error_description')
23+
24+
if (error) {
25+
setStatus('error')
26+
setErrorMessage(errorDescription || error)
27+
return
28+
}
29+
30+
if (!code) {
31+
setStatus('error')
32+
setErrorMessage('No authorization code received')
33+
return
34+
}
35+
36+
// Post message to opener window (Claude Code CLI or parent session page)
37+
const messageData = {
38+
type: 'oauth-callback',
39+
provider: 'mcp',
40+
code,
41+
state,
42+
}
43+
44+
// Try to communicate with opener (popup scenario)
45+
if (window.opener && !window.opener.closed) {
46+
try {
47+
window.opener.postMessage(messageData, window.location.origin)
48+
setStatus('success')
49+
50+
// Auto-close after 2 seconds
51+
setTimeout(() => {
52+
window.close()
53+
}, 2000)
54+
} catch (err) {
55+
console.error('Failed to post message to opener:', err)
56+
setStatus('error')
57+
setErrorMessage('Failed to communicate with parent window')
58+
}
59+
}
60+
// Try parent (iframe scenario)
61+
else if (window.parent && window.parent !== window) {
62+
try {
63+
window.parent.postMessage(messageData, window.location.origin)
64+
setStatus('success')
65+
} catch (err) {
66+
console.error('Failed to post message to parent:', err)
67+
setStatus('error')
68+
setErrorMessage('Failed to communicate with parent window')
69+
}
70+
}
71+
// Standalone page (shouldn't happen in normal flow)
72+
else {
73+
setStatus('error')
74+
setErrorMessage('No parent window found to communicate with')
75+
}
76+
}, [searchParams])
77+
78+
return (
79+
<div className="flex min-h-screen items-center justify-center bg-background p-4">
80+
<Card className="w-full max-w-md">
81+
<CardHeader>
82+
<CardTitle className="flex items-center gap-2">
83+
{status === 'processing' && (
84+
<>
85+
<Loader2 className="h-5 w-5 animate-spin" />
86+
Processing Authentication
87+
</>
88+
)}
89+
{status === 'success' && (
90+
<>
91+
<CheckCircle2 className="h-5 w-5 text-green-600" />
92+
Authentication Complete
93+
</>
94+
)}
95+
{status === 'error' && (
96+
<>
97+
<XCircle className="h-5 w-5 text-red-600" />
98+
Authentication Failed
99+
</>
100+
)}
101+
</CardTitle>
102+
<CardDescription>
103+
{status === 'processing' && 'Completing MCP server authentication...'}
104+
{status === 'success' && 'You can close this window now.'}
105+
{status === 'error' && 'There was a problem with the authentication.'}
106+
</CardDescription>
107+
</CardHeader>
108+
<CardContent>
109+
{status === 'processing' && (
110+
<p className="text-sm text-muted-foreground">
111+
Communicating with Claude Code session...
112+
</p>
113+
)}
114+
{status === 'success' && (
115+
<p className="text-sm text-muted-foreground">
116+
MCP server authentication was successful. This window will close automatically.
117+
</p>
118+
)}
119+
{status === 'error' && (
120+
<div className="space-y-2">
121+
<p className="text-sm text-red-600 font-medium">
122+
{errorMessage}
123+
</p>
124+
<p className="text-sm text-muted-foreground">
125+
Please close this window and try again.
126+
</p>
127+
</div>
128+
)}
129+
</CardContent>
130+
</Card>
131+
</div>
132+
)
133+
}

components/operator/internal/config/config.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,15 @@
22
package config
33

44
import (
5+
"context"
56
"fmt"
7+
"log"
68
"os"
79

810
corev1 "k8s.io/api/core/v1"
11+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
12+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
13+
"k8s.io/apimachinery/pkg/runtime/schema"
914
"k8s.io/client-go/dynamic"
1015
"k8s.io/client-go/kubernetes"
1116
"k8s.io/client-go/rest"
@@ -60,6 +65,64 @@ func InitK8sClients() error {
6065
return nil
6166
}
6267

68+
// DiscoverFrontendURL attempts to discover the frontend external URL from OpenShift Route or Kubernetes Ingress
69+
// Returns empty string if not found (MCP OAuth callbacks will not work)
70+
func DiscoverFrontendURL(namespace string) string {
71+
ctx := context.TODO()
72+
73+
// Try OpenShift Route (most common in vTeam deployments)
74+
routeGVR := schema.GroupVersionResource{
75+
Group: "route.openshift.io",
76+
Version: "v1",
77+
Resource: "routes",
78+
}
79+
80+
route, err := DynamicClient.Resource(routeGVR).Namespace(namespace).Get(ctx, "frontend", metav1.GetOptions{})
81+
if err == nil {
82+
if spec, found, _ := unstructured.NestedMap(route.Object, "spec"); found {
83+
if host, ok := spec["host"].(string); ok && host != "" {
84+
// Check TLS
85+
scheme := "http"
86+
if tls, found, _ := unstructured.NestedMap(spec, "tls"); found && tls != nil {
87+
scheme = "https"
88+
}
89+
url := fmt.Sprintf("%s://%s", scheme, host)
90+
log.Printf("Discovered frontend URL from OpenShift Route: %s", url)
91+
return url
92+
}
93+
}
94+
}
95+
96+
// Try Kubernetes Ingress as fallback
97+
ingressGVR := schema.GroupVersionResource{
98+
Group: "networking.k8s.io",
99+
Version: "v1",
100+
Resource: "ingresses",
101+
}
102+
103+
ingress, err := DynamicClient.Resource(ingressGVR).Namespace(namespace).Get(ctx, "frontend", metav1.GetOptions{})
104+
if err == nil {
105+
if spec, found, _ := unstructured.NestedMap(ingress.Object, "spec"); found {
106+
if rules, found, _ := unstructured.NestedSlice(spec, "rules"); found && len(rules) > 0 {
107+
if rule, ok := rules[0].(map[string]interface{}); ok {
108+
if host, ok := rule["host"].(string); ok && host != "" {
109+
scheme := "http"
110+
if tls, found, _ := unstructured.NestedSlice(spec, "tls"); found && len(tls) > 0 {
111+
scheme = "https"
112+
}
113+
url := fmt.Sprintf("%s://%s", scheme, host)
114+
log.Printf("Discovered frontend URL from Kubernetes Ingress: %s", url)
115+
return url
116+
}
117+
}
118+
}
119+
}
120+
}
121+
122+
log.Printf("Warning: Could not discover frontend Route or Ingress in namespace %s - MCP OAuth will not work", namespace)
123+
return ""
124+
}
125+
63126
// LoadConfig loads the operator configuration from environment variables
64127
func LoadConfig() *Config {
65128
// Get namespace from environment or use default

components/operator/internal/handlers/sessions.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -479,6 +479,8 @@ func handleAgenticSessionEvent(obj *unstructured.Unstructured) error {
479479
{Name: "BACKEND_API_URL", Value: fmt.Sprintf("http://backend-service.%s.svc.cluster.local:8080/api", appConfig.BackendNamespace)},
480480
// WebSocket URL used by runner-shell to connect back to backend
481481
{Name: "WEBSOCKET_URL", Value: fmt.Sprintf("ws://backend-service.%s.svc.cluster.local:8080/api/projects/%s/sessions/%s/ws", appConfig.BackendNamespace, sessionNamespace, name)},
482+
// Frontend URL for MCP OAuth callbacks (discovered from Route/Ingress)
483+
{Name: "VTEAM_FRONTEND_URL", Value: config.DiscoverFrontendURL(appConfig.BackendNamespace)},
482484
// S3 disabled; backend persists messages
483485
}
484486

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
11
{
2-
"mcpServers": {}
2+
"mcpServers": {
3+
"atlassian": {
4+
"type": "sse",
5+
"url": "https://mcp.atlassian.com/v1/sse",
6+
"oauth": {
7+
"redirect_uri": "${VTEAM_FRONTEND_URL}/api/oauth/mcp/callback"
8+
}
9+
}
10+
}
311
}

0 commit comments

Comments
 (0)