Skip to content

Commit 554fba7

Browse files
authored
feat(mcp): Add context7 and deepwiki MCP support (#856)
- Add supprot for Context7 and deepwiki - Add support to show connected MCPs <img width="2035" height="1734" alt="image" src="https://github.com/user-attachments/assets/179c0a85-5e02-4edf-9ebe-462843e1a7d0" />
1 parent 568fb74 commit 554fba7

15 files changed

Lines changed: 937 additions & 28 deletions

File tree

components/backend/handlers/integrations_status.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ func GetIntegrationsStatus(c *gin.Context) {
3939
// GitLab status
4040
response["gitlab"] = getGitLabStatusForUser(ctx, userID)
4141

42+
// MCP server credentials status
43+
response["mcpServers"] = getMCPServerStatusForUser(ctx, userID)
44+
4245
c.JSON(http.StatusOK, response)
4346
}
4447

Lines changed: 383 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,383 @@
1+
package handlers
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"log"
8+
"net/http"
9+
"regexp"
10+
"sort"
11+
"strings"
12+
"time"
13+
14+
"github.com/gin-gonic/gin"
15+
corev1 "k8s.io/api/core/v1"
16+
"k8s.io/apimachinery/pkg/api/errors"
17+
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
18+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
19+
)
20+
21+
// MCPServerCredentials represents generic credentials for an MCP server
22+
type MCPServerCredentials struct {
23+
UserID string `json:"userId"`
24+
ServerName string `json:"serverName"`
25+
Fields map[string]string `json:"fields"`
26+
UpdatedAt time.Time `json:"updatedAt"`
27+
}
28+
29+
const mcpCredentialsSecretName = "mcp-server-credentials"
30+
31+
// validServerName matches lowercase alphanumeric with hyphens, max 63 chars
32+
var validServerNameRegex = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$`)
33+
34+
func isValidServerName(name string) bool {
35+
if len(name) < 1 || len(name) > 63 {
36+
return false
37+
}
38+
// Allow single character names
39+
if len(name) == 1 {
40+
return name[0] >= 'a' && name[0] <= 'z' || name[0] >= '0' && name[0] <= '9'
41+
}
42+
return validServerNameRegex.MatchString(name)
43+
}
44+
45+
func mcpSecretKey(serverName, userID string) string {
46+
return serverName + ":" + userID
47+
}
48+
49+
// ConnectMCPServer handles POST /api/auth/mcp/:serverName/connect
50+
func ConnectMCPServer(c *gin.Context) {
51+
reqK8s, _ := GetK8sClientsForRequest(c)
52+
if reqK8s == nil {
53+
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or missing token"})
54+
return
55+
}
56+
57+
userID := c.GetString("userID")
58+
if userID == "" {
59+
c.JSON(http.StatusUnauthorized, gin.H{"error": "User authentication required"})
60+
return
61+
}
62+
if !isValidUserID(userID) {
63+
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user identifier"})
64+
return
65+
}
66+
67+
serverName := c.Param("serverName")
68+
if !isValidServerName(serverName) {
69+
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid server name: must be lowercase alphanumeric with hyphens, 1-63 chars"})
70+
return
71+
}
72+
73+
var req struct {
74+
Fields map[string]string `json:"fields" binding:"required"`
75+
}
76+
if err := c.ShouldBindJSON(&req); err != nil {
77+
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
78+
return
79+
}
80+
if len(req.Fields) == 0 {
81+
c.JSON(http.StatusBadRequest, gin.H{"error": "At least one credential field is required"})
82+
return
83+
}
84+
85+
creds := &MCPServerCredentials{
86+
UserID: userID,
87+
ServerName: serverName,
88+
Fields: req.Fields,
89+
UpdatedAt: time.Now(),
90+
}
91+
92+
if err := storeMCPCredentials(c.Request.Context(), creds); err != nil {
93+
log.Printf("Failed to store MCP credentials for server %s, user %s: %v", serverName, userID, err)
94+
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save MCP credentials"})
95+
return
96+
}
97+
98+
log.Printf("✓ Stored MCP credentials for server %s, user %s", serverName, userID)
99+
c.JSON(http.StatusOK, gin.H{
100+
"message": "MCP server credentials saved",
101+
"serverName": serverName,
102+
})
103+
}
104+
105+
// GetMCPServerStatus handles GET /api/auth/mcp/:serverName/status
106+
func GetMCPServerStatus(c *gin.Context) {
107+
reqK8s, _ := GetK8sClientsForRequest(c)
108+
if reqK8s == nil {
109+
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or missing token"})
110+
return
111+
}
112+
113+
userID := c.GetString("userID")
114+
if userID == "" {
115+
c.JSON(http.StatusUnauthorized, gin.H{"error": "User authentication required"})
116+
return
117+
}
118+
119+
serverName := c.Param("serverName")
120+
if !isValidServerName(serverName) {
121+
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid server name"})
122+
return
123+
}
124+
125+
creds, err := getMCPCredentials(c.Request.Context(), serverName, userID)
126+
if err != nil {
127+
if errors.IsNotFound(err) {
128+
c.JSON(http.StatusOK, gin.H{"connected": false, "serverName": serverName})
129+
return
130+
}
131+
log.Printf("Failed to get MCP credentials for server %s, user %s: %v", serverName, userID, err)
132+
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check MCP server status"})
133+
return
134+
}
135+
136+
if creds == nil {
137+
c.JSON(http.StatusOK, gin.H{"connected": false, "serverName": serverName})
138+
return
139+
}
140+
141+
fieldNames := make([]string, 0, len(creds.Fields))
142+
for k := range creds.Fields {
143+
fieldNames = append(fieldNames, k)
144+
}
145+
sort.Strings(fieldNames)
146+
147+
c.JSON(http.StatusOK, gin.H{
148+
"connected": true,
149+
"serverName": serverName,
150+
"fieldNames": fieldNames,
151+
"updatedAt": creds.UpdatedAt.Format(time.RFC3339),
152+
})
153+
}
154+
155+
// DisconnectMCPServer handles DELETE /api/auth/mcp/:serverName/disconnect
156+
func DisconnectMCPServer(c *gin.Context) {
157+
reqK8s, _ := GetK8sClientsForRequest(c)
158+
if reqK8s == nil {
159+
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or missing token"})
160+
return
161+
}
162+
163+
userID := c.GetString("userID")
164+
if userID == "" {
165+
c.JSON(http.StatusUnauthorized, gin.H{"error": "User authentication required"})
166+
return
167+
}
168+
169+
serverName := c.Param("serverName")
170+
if !isValidServerName(serverName) {
171+
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid server name"})
172+
return
173+
}
174+
175+
if err := deleteMCPCredentials(c.Request.Context(), serverName, userID); err != nil {
176+
log.Printf("Failed to delete MCP credentials for server %s, user %s: %v", serverName, userID, err)
177+
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to disconnect MCP server"})
178+
return
179+
}
180+
181+
log.Printf("✓ Deleted MCP credentials for server %s, user %s", serverName, userID)
182+
c.JSON(http.StatusOK, gin.H{"message": "MCP server disconnected"})
183+
}
184+
185+
// GetMCPCredentialsForSession handles GET /api/projects/:project/agentic-sessions/:session/credentials/mcp/:serverName
186+
func GetMCPCredentialsForSession(c *gin.Context) {
187+
project := c.Param("projectName")
188+
session := c.Param("sessionName")
189+
serverName := c.Param("serverName")
190+
191+
reqK8s, reqDyn := GetK8sClientsForRequest(c)
192+
if reqK8s == nil {
193+
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or missing token"})
194+
return
195+
}
196+
197+
if !isValidServerName(serverName) {
198+
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid server name"})
199+
return
200+
}
201+
202+
// Get userID from session CR
203+
gvr := GetAgenticSessionV1Alpha1Resource()
204+
obj, err := reqDyn.Resource(gvr).Namespace(project).Get(c.Request.Context(), session, v1.GetOptions{})
205+
if err != nil {
206+
if errors.IsNotFound(err) {
207+
c.JSON(http.StatusNotFound, gin.H{"error": "Session not found"})
208+
return
209+
}
210+
log.Printf("Failed to get session %s/%s: %v", project, session, err)
211+
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get session"})
212+
return
213+
}
214+
215+
userID, found, err := unstructured.NestedString(obj.Object, "spec", "userContext", "userId")
216+
if !found || err != nil || userID == "" {
217+
log.Printf("Failed to extract userID from session %s/%s: found=%v, err=%v", project, session, found, err)
218+
c.JSON(http.StatusInternalServerError, gin.H{"error": "User ID not found in session"})
219+
return
220+
}
221+
222+
// Verify authenticated user owns this session
223+
authenticatedUserID := c.GetString("userID")
224+
if authenticatedUserID != "" && authenticatedUserID != userID {
225+
log.Printf("RBAC violation: user %s attempted to access MCP credentials for session owned by %s", authenticatedUserID, userID)
226+
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied: session belongs to different user"})
227+
return
228+
}
229+
230+
creds, err := getMCPCredentials(c.Request.Context(), serverName, userID)
231+
if err != nil {
232+
log.Printf("Failed to get MCP credentials for server %s, user %s: %v", serverName, userID, err)
233+
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get MCP credentials"})
234+
return
235+
}
236+
237+
if creds == nil {
238+
c.JSON(http.StatusNotFound, gin.H{"error": "MCP credentials not configured for server " + serverName})
239+
return
240+
}
241+
242+
c.JSON(http.StatusOK, gin.H{
243+
"serverName": creds.ServerName,
244+
"fields": creds.Fields,
245+
})
246+
}
247+
248+
// storeMCPCredentials stores MCP server credentials in a cluster-level Secret
249+
func storeMCPCredentials(ctx context.Context, creds *MCPServerCredentials) error {
250+
if creds == nil || creds.UserID == "" || creds.ServerName == "" {
251+
return fmt.Errorf("invalid credentials payload")
252+
}
253+
254+
key := mcpSecretKey(creds.ServerName, creds.UserID)
255+
256+
for i := 0; i < 3; i++ {
257+
secret, err := K8sClient.CoreV1().Secrets(Namespace).Get(ctx, mcpCredentialsSecretName, v1.GetOptions{})
258+
if err != nil {
259+
if errors.IsNotFound(err) {
260+
secret = &corev1.Secret{
261+
ObjectMeta: v1.ObjectMeta{
262+
Name: mcpCredentialsSecretName,
263+
Namespace: Namespace,
264+
Labels: map[string]string{
265+
"app": "ambient-code",
266+
"ambient-code.io/provider": "mcp",
267+
},
268+
},
269+
Type: corev1.SecretTypeOpaque,
270+
Data: map[string][]byte{},
271+
}
272+
if _, cerr := K8sClient.CoreV1().Secrets(Namespace).Create(ctx, secret, v1.CreateOptions{}); cerr != nil && !errors.IsAlreadyExists(cerr) {
273+
return fmt.Errorf("failed to create Secret: %w", cerr)
274+
}
275+
secret, err = K8sClient.CoreV1().Secrets(Namespace).Get(ctx, mcpCredentialsSecretName, v1.GetOptions{})
276+
if err != nil {
277+
return fmt.Errorf("failed to fetch Secret after create: %w", err)
278+
}
279+
} else {
280+
return fmt.Errorf("failed to get Secret: %w", err)
281+
}
282+
}
283+
284+
if secret.Data == nil {
285+
secret.Data = map[string][]byte{}
286+
}
287+
288+
b, err := json.Marshal(creds)
289+
if err != nil {
290+
return fmt.Errorf("failed to marshal credentials: %w", err)
291+
}
292+
secret.Data[key] = b
293+
294+
if _, uerr := K8sClient.CoreV1().Secrets(Namespace).Update(ctx, secret, v1.UpdateOptions{}); uerr != nil {
295+
if errors.IsConflict(uerr) {
296+
continue
297+
}
298+
return fmt.Errorf("failed to update Secret: %w", uerr)
299+
}
300+
return nil
301+
}
302+
return fmt.Errorf("failed to update Secret after retries")
303+
}
304+
305+
// getMCPCredentials retrieves MCP server credentials for a user
306+
func getMCPCredentials(ctx context.Context, serverName, userID string) (*MCPServerCredentials, error) {
307+
if userID == "" || serverName == "" {
308+
return nil, fmt.Errorf("serverName and userID are required")
309+
}
310+
311+
key := mcpSecretKey(serverName, userID)
312+
313+
secret, err := K8sClient.CoreV1().Secrets(Namespace).Get(ctx, mcpCredentialsSecretName, v1.GetOptions{})
314+
if err != nil {
315+
return nil, err
316+
}
317+
318+
if secret.Data == nil || len(secret.Data[key]) == 0 {
319+
return nil, nil
320+
}
321+
322+
var creds MCPServerCredentials
323+
if err := json.Unmarshal(secret.Data[key], &creds); err != nil {
324+
return nil, fmt.Errorf("failed to parse credentials: %w", err)
325+
}
326+
327+
return &creds, nil
328+
}
329+
330+
// deleteMCPCredentials removes MCP server credentials for a user
331+
func deleteMCPCredentials(ctx context.Context, serverName, userID string) error {
332+
if userID == "" || serverName == "" {
333+
return fmt.Errorf("serverName and userID are required")
334+
}
335+
336+
key := mcpSecretKey(serverName, userID)
337+
338+
for i := 0; i < 3; i++ {
339+
secret, err := K8sClient.CoreV1().Secrets(Namespace).Get(ctx, mcpCredentialsSecretName, v1.GetOptions{})
340+
if err != nil {
341+
if errors.IsNotFound(err) {
342+
return nil
343+
}
344+
return fmt.Errorf("failed to get Secret: %w", err)
345+
}
346+
347+
if secret.Data == nil || len(secret.Data[key]) == 0 {
348+
return nil
349+
}
350+
351+
delete(secret.Data, key)
352+
353+
if _, uerr := K8sClient.CoreV1().Secrets(Namespace).Update(ctx, secret, v1.UpdateOptions{}); uerr != nil {
354+
if errors.IsConflict(uerr) {
355+
continue
356+
}
357+
return fmt.Errorf("failed to update Secret: %w", uerr)
358+
}
359+
return nil
360+
}
361+
return fmt.Errorf("failed to update Secret after retries")
362+
}
363+
364+
// getMCPServerStatusForUser returns status for all MCP servers a user has credentials for
365+
func getMCPServerStatusForUser(ctx context.Context, userID string) gin.H {
366+
secret, err := K8sClient.CoreV1().Secrets(Namespace).Get(ctx, mcpCredentialsSecretName, v1.GetOptions{})
367+
if err != nil || secret.Data == nil {
368+
return gin.H{}
369+
}
370+
371+
suffix := ":" + userID
372+
result := gin.H{}
373+
for key := range secret.Data {
374+
if strings.HasSuffix(key, suffix) {
375+
serverName := strings.TrimSuffix(key, suffix)
376+
result[serverName] = gin.H{
377+
"connected": true,
378+
"valid": true,
379+
}
380+
}
381+
}
382+
return result
383+
}

0 commit comments

Comments
 (0)