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
135 changes: 135 additions & 0 deletions components/backend/handlers/project_mcp.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package handlers

import (
"context"
"log"
"net/http"

"ambient-code-backend/types"

"github.com/gin-gonic/gin"
"k8s.io/apimachinery/pkg/api/errors"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

// GetProjectMCPServers returns the MCP server configuration from the ProjectSettings CR.
// GET /api/projects/:projectName/mcp-servers
func GetProjectMCPServers(c *gin.Context) {
project := c.GetString("project")
_, k8sDyn := GetK8sClientsForRequest(c)
if k8sDyn == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or missing token"})
return
}

gvr := GetProjectSettingsResource()
ps, err := k8sDyn.Resource(gvr).Namespace(project).Get(context.TODO(), "projectsettings", v1.GetOptions{})
if err != nil {
if errors.IsNotFound(err) {
// No project settings yet, return empty config
c.JSON(http.StatusOK, types.MCPServersConfig{})
return
}
log.Printf("Failed to get project settings for %s: %v", project, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get project settings"})
return
}

spec, ok := ps.Object["spec"].(map[string]interface{})
if !ok {
c.JSON(http.StatusOK, types.MCPServersConfig{})
return
}

mcpServers, ok := spec["mcpServers"].(map[string]interface{})
if !ok {
c.JSON(http.StatusOK, types.MCPServersConfig{})
return
}

result := types.MCPServersConfig{}
if custom, ok := mcpServers["custom"].(map[string]interface{}); ok {
result.Custom = make(map[string]map[string]interface{}, len(custom))
for name, cfg := range custom {
if cfgMap, ok := cfg.(map[string]interface{}); ok {
result.Custom[name] = cfgMap
}
}
}
if disabled, ok := mcpServers["disabled"].([]interface{}); ok {
for _, d := range disabled {
if s, ok := d.(string); ok {
result.Disabled = append(result.Disabled, s)
}
}
}

c.JSON(http.StatusOK, result)
}

// UpdateProjectMCPServers updates the MCP server configuration in the ProjectSettings CR.
// PUT /api/projects/:projectName/mcp-servers
func UpdateProjectMCPServers(c *gin.Context) {
project := c.GetString("project")
_, k8sDyn := GetK8sClientsForRequest(c)
if k8sDyn == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or missing token"})
return
}

var req types.MCPServersConfig
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}

gvr := GetProjectSettingsResource()
ps, err := k8sDyn.Resource(gvr).Namespace(project).Get(context.TODO(), "projectsettings", v1.GetOptions{})
if err != nil {
if errors.IsNotFound(err) {
c.JSON(http.StatusNotFound, gin.H{"error": "Project settings not found"})
return
}
log.Printf("Failed to get project settings for %s: %v", project, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get project settings"})
return
}

spec, ok := ps.Object["spec"].(map[string]interface{})
if !ok {
spec = map[string]interface{}{}
ps.Object["spec"] = spec
}

// Build the mcpServers map
mcpMap := map[string]interface{}{}
if len(req.Custom) > 0 {
customMap := make(map[string]interface{}, len(req.Custom))
for name, cfg := range req.Custom {
customMap[name] = cfg
}
mcpMap["custom"] = customMap
}
if len(req.Disabled) > 0 {
disabledArr := make([]interface{}, len(req.Disabled))
for i, d := range req.Disabled {
disabledArr[i] = d
}
mcpMap["disabled"] = disabledArr
}

if len(mcpMap) > 0 {
spec["mcpServers"] = mcpMap
} else {
delete(spec, "mcpServers")
}

_, err = k8sDyn.Resource(gvr).Namespace(project).Update(context.TODO(), ps, v1.UpdateOptions{})
if err != nil {
log.Printf("Failed to update project settings MCP for %s: %v", project, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update MCP configuration"})
return
}

c.JSON(http.StatusOK, req)
}
86 changes: 80 additions & 6 deletions components/backend/handlers/sessions.go
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,29 @@ func parseSpec(spec map[string]interface{}) types.AgenticSessionSpec {
result.ActiveWorkflow = ws
}

// Parse mcpServers
if mcpServers, ok := spec["mcpServers"].(map[string]interface{}); ok {
mcp := &types.MCPServersConfig{}
if custom, ok := mcpServers["custom"].(map[string]interface{}); ok {
mcp.Custom = make(map[string]map[string]interface{}, len(custom))
for name, cfg := range custom {
if cfgMap, ok := cfg.(map[string]interface{}); ok {
mcp.Custom[name] = cfgMap
}
}
}
if disabled, ok := mcpServers["disabled"].([]interface{}); ok {
for _, d := range disabled {
if s, ok := d.(string); ok {
mcp.Disabled = append(mcp.Disabled, s)
}
}
}
if len(mcp.Custom) > 0 || len(mcp.Disabled) > 0 {
result.MCPServers = mcp
}
}

return result
}

Expand Down Expand Up @@ -820,6 +843,29 @@ func CreateSession(c *gin.Context) {
}
}

// Set MCP servers configuration if provided
if req.MCPServers != nil {
spec := session["spec"].(map[string]interface{})
mcpMap := map[string]interface{}{}
if len(req.MCPServers.Custom) > 0 {
customMap := make(map[string]interface{}, len(req.MCPServers.Custom))
for name, cfg := range req.MCPServers.Custom {
customMap[name] = cfg
}
mcpMap["custom"] = customMap
}
if len(req.MCPServers.Disabled) > 0 {
disabledArr := make([]interface{}, len(req.MCPServers.Disabled))
for i, d := range req.MCPServers.Disabled {
disabledArr[i] = d
}
mcpMap["disabled"] = disabledArr
}
if len(mcpMap) > 0 {
spec["mcpServers"] = mcpMap
}
}

// Set active workflow if provided
if req.ActiveWorkflow != nil && strings.TrimSpace(req.ActiveWorkflow.GitURL) != "" {
spec := session["spec"].(map[string]interface{})
Expand Down Expand Up @@ -1227,15 +1273,19 @@ func UpdateSession(c *gin.Context) {
return
}

// Prevent spec changes while session is running or being created
// Check session phase
isMCPOnlyUpdate := req.MCPServers != nil && req.InitialPrompt == nil && req.DisplayName == nil && req.LLMSettings == nil && req.Timeout == nil
if status, ok := item.Object["status"].(map[string]interface{}); ok {
if phase, ok := status["phase"].(string); ok {
if strings.EqualFold(phase, "Running") || strings.EqualFold(phase, "Creating") {
c.JSON(http.StatusConflict, gin.H{
"error": "Cannot modify session specification while the session is running",
"phase": phase,
})
return
// Allow MCP-only updates on running sessions (persisted for next restart)
if !isMCPOnlyUpdate {
c.JSON(http.StatusConflict, gin.H{
"error": "Cannot modify session specification while the session is running",
"phase": phase,
})
return
}
}
}
}
Expand Down Expand Up @@ -1267,6 +1317,30 @@ func UpdateSession(c *gin.Context) {
spec["timeout"] = *req.Timeout
}

// Update MCP servers configuration
if req.MCPServers != nil {
mcpMap := map[string]interface{}{}
if len(req.MCPServers.Custom) > 0 {
customMap := make(map[string]interface{}, len(req.MCPServers.Custom))
for name, cfg := range req.MCPServers.Custom {
customMap[name] = cfg
}
mcpMap["custom"] = customMap
}
if len(req.MCPServers.Disabled) > 0 {
disabledArr := make([]interface{}, len(req.MCPServers.Disabled))
for i, d := range req.MCPServers.Disabled {
disabledArr[i] = d
}
mcpMap["disabled"] = disabledArr
}
if len(mcpMap) > 0 {
spec["mcpServers"] = mcpMap
} else {
delete(spec, "mcpServers")
}
}

// Update the resource
updated, err := k8sDyn.Resource(gvr).Namespace(project).Update(context.TODO(), item, v1.UpdateOptions{})
if err != nil {
Expand Down
4 changes: 4 additions & 0 deletions components/backend/routes.go
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,10 @@ func registerRoutes(r *gin.Engine) {
projectGroup.POST("/keys", handlers.CreateProjectKey)
projectGroup.DELETE("/keys/:keyId", handlers.DeleteProjectKey)

// Project-level MCP server configuration
projectGroup.GET("/mcp-servers", handlers.GetProjectMCPServers)
projectGroup.PUT("/mcp-servers", handlers.UpdateProjectMCPServers)

projectGroup.GET("/secrets", handlers.ListNamespaceSecrets)
projectGroup.GET("/runner-secrets", handlers.ListRunnerSecrets)
projectGroup.PUT("/runner-secrets", handlers.UpdateRunnerSecrets)
Expand Down
20 changes: 16 additions & 4 deletions components/backend/types/session.go
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ type AgenticSession struct {
AutoBranch string `json:"autoBranch,omitempty"`
}

// MCPServersConfig holds custom MCP server configuration for a session or project.
type MCPServersConfig struct {
// Custom MCP servers to add (map of server name -> config)
Custom map[string]map[string]interface{} `json:"custom,omitempty"`
// Default MCP server names to disable
Disabled []string `json:"disabled,omitempty"`
}

type AgenticSessionSpec struct {
InitialPrompt string `json:"initialPrompt,omitempty"`
DisplayName string `json:"displayName"`
Expand All @@ -28,6 +36,8 @@ type AgenticSessionSpec struct {
Repos []SimpleRepo `json:"repos,omitempty"`
// Active workflow for dynamic workflow switching
ActiveWorkflow *WorkflowSelection `json:"activeWorkflow,omitempty"`
// Custom MCP server configuration
MCPServers *MCPServersConfig `json:"mcpServers,omitempty"`
}

// SimpleRepo represents a simplified repository configuration
Expand Down Expand Up @@ -67,6 +77,7 @@ type CreateAgenticSessionRequest struct {
EnvironmentVariables map[string]string `json:"environmentVariables,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
Annotations map[string]string `json:"annotations,omitempty"`
MCPServers *MCPServersConfig `json:"mcpServers,omitempty"`
}

type CloneSessionRequest struct {
Expand All @@ -75,10 +86,11 @@ type CloneSessionRequest struct {
}

type UpdateAgenticSessionRequest struct {
InitialPrompt *string `json:"initialPrompt,omitempty"`
DisplayName *string `json:"displayName,omitempty"`
Timeout *int `json:"timeout,omitempty"`
LLMSettings *LLMSettings `json:"llmSettings,omitempty"`
InitialPrompt *string `json:"initialPrompt,omitempty"`
DisplayName *string `json:"displayName,omitempty"`
Timeout *int `json:"timeout,omitempty"`
LLMSettings *LLMSettings `json:"llmSettings,omitempty"`
MCPServers *MCPServersConfig `json:"mcpServers,omitempty"`
}

type CloneAgenticSessionRequest struct {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ vi.mock('@/services/queries', () => ({

vi.mock('@/services/queries/use-mcp', () => ({
useMcpStatus: vi.fn(() => ({ data: null })),
useUpdateSessionMcpServers: vi.fn(() => ({ mutate: vi.fn(), isPending: false })),
}));

vi.mock('@/services/queries/use-google', () => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { AgenticSession } from '@/types/agentic-session';

vi.mock('@/services/queries/use-mcp', () => ({
useMcpStatus: vi.fn(() => ({ data: { servers: [] } })),
useUpdateSessionMcpServers: vi.fn(() => ({ mutate: vi.fn(), isPending: false })),
}));

vi.mock('@/services/queries/use-integrations', () => ({
Expand Down Expand Up @@ -61,7 +62,7 @@ describe('SessionSettingsModal', () => {

it('renders Settings title', () => {
render(<SessionSettingsModal {...defaultProps} />);
expect(screen.getByText('Settings')).toBeDefined();
expect(screen.getByText('Session Settings')).toBeDefined();
});

it('renders sidebar nav tabs (Session, MCP Servers, Integrations)', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,12 +84,12 @@ export function SessionSettingsModal({

return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl p-0 gap-0">
<DialogContent className="max-w-5xl p-0 gap-0">
<DialogHeader className="px-6 py-4 border-b">
<DialogTitle>Settings</DialogTitle>
<DialogTitle>Session Settings</DialogTitle>
</DialogHeader>

<div className="flex flex-col md:flex-row h-auto md:h-[540px] max-h-[70vh]">
<div className="flex flex-col md:flex-row h-auto md:h-[600px] max-h-[80vh]">
{/* Sidebar nav */}
<nav className="flex md:flex-col md:w-48 border-b md:border-b-0 md:border-r p-2 gap-1 flex-shrink-0 overflow-x-auto">
{tabs.map((tab) => {
Expand Down Expand Up @@ -129,6 +129,7 @@ export function SessionSettingsModal({
projectName={projectName}
sessionName={session.metadata.name}
sessionPhase={phase}
session={session}
/>
)}
{activeTab === "integrations" && <IntegrationsPanel />}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const mockUseMcpStatus = vi.fn((): { data: McpData; isPending: boolean } => ({

vi.mock('@/services/queries/use-mcp', () => ({
useMcpStatus: () => mockUseMcpStatus(),
useUpdateSessionMcpServers: vi.fn(() => ({ mutate: vi.fn(), isPending: false })),
}));

describe('McpServersPanel', () => {
Expand Down
Loading
Loading