diff --git a/docker/Dockerfile.app b/docker/Dockerfile.app index 777ee62f2..ec290d6e5 100644 --- a/docker/Dockerfile.app +++ b/docker/Dockerfile.app @@ -49,7 +49,10 @@ WORKDIR /app # Install runtime dependencies RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apk/repositories && \ apk update && apk upgrade && \ - apk add --no-cache build-base postgresql-client mysql-client ca-certificates tzdata sed curl bash vim wget + apk add --no-cache build-base postgresql-client mysql-client ca-certificates tzdata sed curl bash vim wget \ + nodejs npm python3 py3-pip && \ + pip3 install --no-cache-dir pipx && \ + pipx install uvx # Create a non-root user and switch to it RUN mkdir -p /data/files && \ diff --git a/frontend/src/api/mcp-service.ts b/frontend/src/api/mcp-service.ts index 226ebfb11..0691f487f 100644 --- a/frontend/src/api/mcp-service.ts +++ b/frontend/src/api/mcp-service.ts @@ -6,8 +6,8 @@ export interface MCPService { name: string description: string enabled: boolean - transport_type: 'sse' | 'http-streamable' - url: string + transport_type: 'sse' | 'http-streamable' | 'stdio' + url?: string // Optional: required for SSE/HTTP Streamable headers?: Record auth_config?: { api_key?: string @@ -19,6 +19,11 @@ export interface MCPService { retry_count?: number retry_delay?: number } + stdio_config?: { + command: 'uvx' | 'npx' // Command: uvx or npx + args: string[] // Command arguments array + } + env_vars?: Record // Environment variables for stdio transport created_at?: string updated_at?: string } diff --git a/frontend/src/views/settings/McpSettings.vue b/frontend/src/views/settings/McpSettings.vue index 7cd61c359..8555aaf51 100644 --- a/frontend/src/views/settings/McpSettings.vue +++ b/frontend/src/views/settings/McpSettings.vue @@ -36,11 +36,11 @@
{{ service.name }} - {{ service.transport_type === 'sse' ? 'SSE' : 'HTTP Streamable' }} + {{ getTransportTypeLabel(service.transport_type) }}
@@ -274,6 +274,34 @@ const handleMenuAction = (data: { value: string }, service: MCPService) => { } } +// Get transport type theme for tag +const getTransportTypeTheme = (transportType: string) => { + switch (transportType) { + case 'sse': + return 'success' + case 'http-streamable': + return 'primary' + case 'stdio': + return 'warning' + default: + return 'default' + } +} + +// Get transport type label +const getTransportTypeLabel = (transportType: string) => { + switch (transportType) { + case 'sse': + return 'SSE' + case 'http-streamable': + return 'HTTP Streamable' + case 'stdio': + return 'Stdio' + default: + return transportType + } +} + onMounted(() => { loadServices() }) diff --git a/frontend/src/views/settings/components/McpServiceDialog.vue b/frontend/src/views/settings/components/McpServiceDialog.vue index 5c0ca3454..0cf1a8a02 100644 --- a/frontend/src/views/settings/components/McpServiceDialog.vue +++ b/frontend/src/views/settings/components/McpServiceDialog.vue @@ -29,13 +29,101 @@ SSE (Server-Sent Events) HTTP Streamable + Stdio - + + + + + @@ -122,8 +210,13 @@ const formData = ref({ name: '', description: '', enabled: true, - transport_type: 'sse' as 'sse' | 'http-streamable', + transport_type: 'sse' as 'sse' | 'http-streamable' | 'stdio', url: '', + stdio_config: { + command: 'uvx' as 'uvx' | 'npx', + args: [''] + }, + env_vars: {} as Record, auth_config: { api_key: '', token: '' @@ -135,12 +228,54 @@ const formData = ref({ } }) +// Track env var keys separately for easier editing +const envVarKeys = ref([]) + const rules: Record = { name: [{ required: true, message: '请输入服务名称', type: 'error' }], transport_type: [{ required: true, message: '请选择传输类型', type: 'error' }], url: [ - { required: true, message: '请输入服务 URL', type: 'error' }, - { url: true, message: '请输入有效的 URL', type: 'error' } + { + validator: (val: string) => { + if (formData.value.transport_type !== 'stdio') { + if (!val || val.trim() === '') { + return { result: false, message: '请输入服务 URL', type: 'error' } + } + // Basic URL validation + try { + new URL(val) + return { result: true } + } catch { + return { result: false, message: '请输入有效的 URL', type: 'error' } + } + } + return { result: true } + } + } + ], + 'stdio_config.command': [ + { + validator: (val: string) => { + if (formData.value.transport_type === 'stdio') { + if (!val || (val !== 'uvx' && val !== 'npx')) { + return { result: false, message: '请选择命令 (uvx 或 npx)', type: 'error' } + } + } + return { result: true } + } + } + ], + 'stdio_config.args': [ + { + validator: (val: string[]) => { + if (formData.value.transport_type === 'stdio') { + if (!val || val.length === 0 || val.every(arg => !arg || arg.trim() === '')) { + return { result: false, message: '请至少输入一个参数', type: 'error' } + } + } + return { result: true } + } + } ] } @@ -157,6 +292,11 @@ const resetForm = () => { enabled: true, transport_type: 'sse', url: '', + stdio_config: { + command: 'uvx', + args: [''] + }, + env_vars: {}, auth_config: { api_key: '', token: '' @@ -167,9 +307,74 @@ const resetForm = () => { retry_delay: 1 } } + envVarKeys.value = [] formRef.value?.clearValidate() } +// Watch transport_type to reset related fields +watch( + () => formData.value.transport_type, + (newType) => { + if (newType === 'stdio') { + formData.value.url = '' + if (!formData.value.stdio_config || formData.value.stdio_config.args.length === 0) { + formData.value.stdio_config = { + command: 'uvx', + args: [''] + } + } + } else { + formData.value.stdio_config = { + command: 'uvx', + args: [''] + } + formData.value.env_vars = {} + envVarKeys.value = [] + } + formRef.value?.clearValidate() + } +) + +// Args management +const addArg = () => { + formData.value.stdio_config.args.push('') +} + +const removeArg = (index: number) => { + if (formData.value.stdio_config.args.length > 1) { + formData.value.stdio_config.args.splice(index, 1) + } +} + +// Env vars management +const addEnvVar = () => { + const key = `VAR_${Date.now()}` + formData.value.env_vars[key] = '' + envVarKeys.value.push(key) +} + +const removeEnvVar = (key: string) => { + delete formData.value.env_vars[key] + const index = envVarKeys.value.indexOf(key) + if (index > -1) { + envVarKeys.value.splice(index, 1) + } +} + +const updateEnvVarKey = (index: number, newKey: string) => { + const oldKey = envVarKeys.value[index] + if (oldKey && oldKey !== newKey && formData.value.env_vars[oldKey] !== undefined) { + const value = formData.value.env_vars[oldKey] + delete formData.value.env_vars[oldKey] + if (newKey && newKey.trim() !== '') { + formData.value.env_vars[newKey] = value + envVarKeys.value[index] = newKey + } else { + envVarKeys.value.splice(index, 1) + } + } +} + // Watch service prop to initialize form watch( () => props.service, @@ -181,6 +386,11 @@ watch( enabled: service.enabled ?? true, transport_type: service.transport_type || 'sse', url: service.url || '', + stdio_config: service.stdio_config || { + command: 'uvx', + args: [''] + }, + env_vars: service.env_vars || {}, auth_config: { api_key: service.auth_config?.api_key || '', token: service.auth_config?.token || '' @@ -191,6 +401,8 @@ watch( retry_delay: service.advanced_config?.retry_delay || 1 } } + // Initialize env var keys + envVarKeys.value = Object.keys(formData.value.env_vars) } else { resetForm() } @@ -210,7 +422,6 @@ const handleSubmit = async () => { description: formData.value.description, enabled: formData.value.enabled, transport_type: formData.value.transport_type, - url: formData.value.url, auth_config: { api_key: formData.value.auth_config.api_key || undefined, token: formData.value.auth_config.token || undefined @@ -218,6 +429,26 @@ const handleSubmit = async () => { advanced_config: formData.value.advanced_config } + // Add URL or stdio_config based on transport type + if (formData.value.transport_type === 'stdio') { + // Filter out empty args + const args = formData.value.stdio_config.args.filter(arg => arg && arg.trim() !== '') + data.stdio_config = { + command: formData.value.stdio_config.command, + args: args.length > 0 ? args : undefined + } + // Filter out empty env vars + const envVars: Record = {} + for (const [key, value] of Object.entries(formData.value.env_vars)) { + if (key && key.trim() !== '' && value && value.trim() !== '') { + envVars[key] = value + } + } + data.env_vars = Object.keys(envVars).length > 0 ? envVars : undefined + } else { + data.url = formData.value.url || undefined + } + if (props.mode === 'add') { await createMCPService(data) MessagePlugin.success('MCP 服务已创建') @@ -243,3 +474,49 @@ const handleClose = () => { } + + diff --git a/internal/agent/tools/mcp_tool.go b/internal/agent/tools/mcp_tool.go index 670ef42de..7b88e2969 100644 --- a/internal/agent/tools/mcp_tool.go +++ b/internal/agent/tools/mcp_tool.go @@ -73,6 +73,18 @@ func (t *MCPTool) Execute(ctx context.Context, args map[string]interface{}) (*ty }, nil } + // For stdio transport, ensure connection is released after use + isStdio := t.service.TransportType == types.MCPTransportStdio + if isStdio { + defer func() { + if err := client.Disconnect(); err != nil { + logger.GetLogger(ctx).Warnf("Failed to disconnect stdio MCP client: %v", err) + } else { + logger.GetLogger(ctx).Infof("Stdio MCP client disconnected after tool execution") + } + }() + } + // Call the tool via MCP result, err := client.CallTool(ctx, t.mcpTool.Name, args) if err != nil { @@ -194,6 +206,16 @@ func RegisterMCPTools(ctx context.Context, registry *ToolRegistry, services []*t continue } + // For stdio transport, ensure connection is released after listing tools + isStdio := service.TransportType == types.MCPTransportStdio + if isStdio { + defer func() { + if err := client.Disconnect(); err != nil { + logger.GetLogger(ctx).Warnf("Failed to disconnect stdio MCP client after listing tools: %v", err) + } + }() + } + // List tools from the service with timeout // Create a new context with timeout for this specific operation listCtx, cancel := context.WithTimeout(ctx, listToolsTimeout) diff --git a/internal/application/repository/mcp_service.go b/internal/application/repository/mcp_service.go index 995e3914a..5c0726bf1 100644 --- a/internal/application/repository/mcp_service.go +++ b/internal/application/repository/mcp_service.go @@ -108,8 +108,14 @@ func (r *mcpServiceRepository) Update(ctx context.Context, service *types.MCPSer if service.TransportType != "" { updateMap["transport_type"] = service.TransportType } - if service.URL != "" { - updateMap["url"] = service.URL + if service.URL != nil { + updateMap["url"] = *service.URL + } + if service.StdioConfig != nil { + updateMap["stdio_config"] = service.StdioConfig + } + if service.EnvVars != nil { + updateMap["env_vars"] = service.EnvVars } if service.Headers != nil { updateMap["headers"] = service.Headers diff --git a/internal/application/service/mcp_service.go b/internal/application/service/mcp_service.go index 579affa1d..512dbff32 100644 --- a/internal/application/service/mcp_service.go +++ b/internal/application/service/mcp_service.go @@ -127,9 +127,15 @@ func (s *mcpServiceService) UpdateMCPService(ctx context.Context, service *types if service.TransportType != "" { existing.TransportType = service.TransportType } - if service.URL != "" { + if service.URL != nil { existing.URL = service.URL } + if service.StdioConfig != nil { + existing.StdioConfig = service.StdioConfig + } + if service.EnvVars != nil { + existing.EnvVars = service.EnvVars + } if service.Headers != nil { existing.Headers = service.Headers } @@ -149,9 +155,19 @@ func (s *mcpServiceService) UpdateMCPService(ctx context.Context, service *types return fmt.Errorf("failed to update MCP service: %w", err) } - // Check if critical configuration changed (URL, transport type, or auth config) + // Check if critical configuration changed (URL/StdioConfig, transport type, or auth config) configChanged := false - if service.URL != "" && service.URL != existing.URL { + if service.URL != nil && existing.URL != nil && *service.URL != *existing.URL { + configChanged = true + } else if (service.URL != nil) != (existing.URL != nil) { + configChanged = true + } + if service.StdioConfig != nil && existing.StdioConfig != nil { + if service.StdioConfig.Command != existing.StdioConfig.Command || + !equalStringSlices(service.StdioConfig.Args, existing.StdioConfig.Args) { + configChanged = true + } + } else if (service.StdioConfig != nil) != (existing.StdioConfig != nil) { configChanged = true } if service.TransportType != "" && service.TransportType != existing.TransportType { @@ -322,3 +338,16 @@ func (s *mcpServiceService) GetMCPServiceResources(ctx context.Context, tenantID return resources, nil } + +// equalStringSlices compares two string slices for equality +func equalStringSlices(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/internal/handler/mcp_service.go b/internal/handler/mcp_service.go index 85bd81a6a..f428409c0 100644 --- a/internal/handler/mcp_service.go +++ b/internal/handler/mcp_service.go @@ -151,8 +151,34 @@ func (h *MCPServiceHandler) UpdateMCPService(c *gin.Context) { if transportType, ok := updateData["transport_type"].(string); ok { service.TransportType = types.MCPTransportType(transportType) } - if url, ok := updateData["url"].(string); ok { - service.URL = url + if url, ok := updateData["url"].(string); ok && url != "" { + service.URL = &url + } else if _, exists := updateData["url"]; exists { + // Explicitly set to nil if provided as null/empty + service.URL = nil + } + if stdioConfig, ok := updateData["stdio_config"].(map[string]interface{}); ok { + config := &types.MCPStdioConfig{} + if command, ok := stdioConfig["command"].(string); ok { + config.Command = command + } + if args, ok := stdioConfig["args"].([]interface{}); ok { + config.Args = make([]string, len(args)) + for i, arg := range args { + if str, ok := arg.(string); ok { + config.Args[i] = str + } + } + } + service.StdioConfig = config + } + if envVars, ok := updateData["env_vars"].(map[string]interface{}); ok { + service.EnvVars = make(types.MCPEnvVars) + for k, v := range envVars { + if str, ok := v.(string); ok { + service.EnvVars[k] = str + } + } } if headers, ok := updateData["headers"].(map[string]interface{}); ok { service.Headers = make(types.MCPHeaders) diff --git a/internal/mcp/client.go b/internal/mcp/client.go index 2147e24d9..98cc05a9b 100644 --- a/internal/mcp/client.go +++ b/internal/mcp/client.go @@ -95,7 +95,10 @@ func NewMCPClient(config *ClientConfig) (MCPClient, error) { var err error switch config.Service.TransportType { case types.MCPTransportSSE: - mcpClient, err = client.NewSSEMCPClient(config.Service.URL, + if config.Service.URL == nil || *config.Service.URL == "" { + return nil, fmt.Errorf("URL is required for SSE transport") + } + mcpClient, err = client.NewSSEMCPClient(*config.Service.URL, client.WithHTTPClient(httpClient), client.WithHeaders(headers), ) @@ -103,14 +106,38 @@ func NewMCPClient(config *ClientConfig) (MCPClient, error) { return nil, fmt.Errorf("failed to create SSE client: %w", err) } case types.MCPTransportHTTPStreamable: + if config.Service.URL == nil || *config.Service.URL == "" { + return nil, fmt.Errorf("URL is required for HTTP Streamable transport") + } // For HTTP streamable, we need to use transport options - mcpClient, err = client.NewStreamableHttpClient(config.Service.URL, + mcpClient, err = client.NewStreamableHttpClient(*config.Service.URL, transport.WithHTTPBasicClient(httpClient), transport.WithHTTPHeaders(headers), ) if err != nil { return nil, fmt.Errorf("failed to create HTTP streamable client: %w", err) } + case types.MCPTransportStdio: + if config.Service.StdioConfig == nil { + return nil, fmt.Errorf("stdio_config is required for stdio transport") + } + + // Convert env vars map to []string format (KEY=value) + envVars := make([]string, 0, len(config.Service.EnvVars)) + for key, value := range config.Service.EnvVars { + envVars = append(envVars, fmt.Sprintf("%s=%s", key, value)) + } + + // Create stdio client with options + // NewStdioMCPClientWithOptions(command string, env []string, args []string, opts ...transport.StdioOption) + mcpClient, err = client.NewStdioMCPClientWithOptions( + config.Service.StdioConfig.Command, + envVars, + config.Service.StdioConfig.Args, + ) + if err != nil { + return nil, fmt.Errorf("failed to create stdio client: %w", err) + } default: return nil, ErrUnsupportedTransport } @@ -133,7 +160,12 @@ func (c *mcpGoClient) Connect(ctx context.Context) error { } c.connected = true - logger.GetLogger(ctx).Infof("MCP client connected to %s", c.service.URL) + if c.service.TransportType == types.MCPTransportStdio { + logger.GetLogger(ctx).Infof("MCP stdio client connected: %s %v", + c.service.StdioConfig.Command, c.service.StdioConfig.Args) + } else { + logger.GetLogger(ctx).Infof("MCP client connected to %s", *c.service.URL) + } return nil } diff --git a/internal/mcp/manager.go b/internal/mcp/manager.go index 58a7f1543..a9e7d3e11 100644 --- a/internal/mcp/manager.go +++ b/internal/mcp/manager.go @@ -35,13 +35,20 @@ func NewMCPManager() *MCPManager { } // GetOrCreateClient gets an existing client or creates a new one +// For stdio transport, always creates a new client (not cached) +// For SSE/HTTP Streamable, caches and reuses existing connections func (m *MCPManager) GetOrCreateClient(service *types.MCPService) (MCPClient, error) { // Check if service is enabled if !service.Enabled { return nil, fmt.Errorf("MCP service %s is not enabled", service.Name) } - // Check if client already exists + // For stdio transport, always create a new client (don't cache) + if service.TransportType == types.MCPTransportStdio { + return m.createStdioClient(service) + } + + // For SSE/HTTP Streamable, check if client already exists and reuse m.clientsMu.RLock() client, exists := m.clients[service.ID] m.clientsMu.RUnlock() @@ -95,13 +102,52 @@ func (m *MCPManager) GetOrCreateClient(service *types.MCPService) (MCPClient, er return nil, fmt.Errorf("failed to initialize MCP client: %w", err) } - // Store client + // Store client (only for non-stdio transports) m.clients[service.ID] = client logger.GetLogger(m.ctx).Infof("MCP client created and initialized for service: %s", service.Name) return client, nil } +// createStdioClient creates a new stdio client (not cached) +func (m *MCPManager) createStdioClient(service *types.MCPService) (MCPClient, error) { + // Create new client + config := &ClientConfig{ + Service: service, + } + + client, err := NewMCPClient(config) + if err != nil { + return nil, fmt.Errorf("failed to create stdio MCP client: %w", err) + } + + // For stdio, Connect() starts the subprocess + // Use manager's context for the connection lifecycle + if err := client.Connect(m.ctx); err != nil { + return nil, fmt.Errorf("failed to connect to stdio MCP service: %w", err) + } + + // Initialize needs a timeout to prevent hanging + initTimeout := 30 * time.Second + if service.AdvancedConfig != nil && service.AdvancedConfig.Timeout > 0 { + initTimeout = time.Duration(service.AdvancedConfig.Timeout) * time.Second + if initTimeout > 60*time.Second { + initTimeout = 60 * time.Second + } + } + + initCtx, initCancel := context.WithTimeout(m.ctx, initTimeout) + defer initCancel() + + if _, err := client.Initialize(initCtx); err != nil { + client.Disconnect() + return nil, fmt.Errorf("failed to initialize stdio MCP client: %w", err) + } + + logger.GetLogger(m.ctx).Infof("MCP stdio client created and initialized for service: %s", service.Name) + return client, nil +} + // GetClient gets an existing client func (m *MCPManager) GetClient(serviceID string) (MCPClient, bool) { m.clientsMu.RLock() diff --git a/internal/types/mcp.go b/internal/types/mcp.go index 0cd12bcbd..c64de5bee 100644 --- a/internal/types/mcp.go +++ b/internal/types/mcp.go @@ -15,6 +15,7 @@ type MCPTransportType string const ( MCPTransportSSE MCPTransportType = "sse" // Server-Sent Events MCPTransportHTTPStreamable MCPTransportType = "http-streamable" // HTTP Streamable + MCPTransportStdio MCPTransportType = "stdio" // Stdio (Standard Input/Output) ) // MCPService represents an MCP (Model Context Protocol) service configuration @@ -25,10 +26,12 @@ type MCPService struct { Description string `json:"description" gorm:"type:text"` Enabled bool `json:"enabled" gorm:"default:true;index"` TransportType MCPTransportType `json:"transport_type" gorm:"type:varchar(50);not null"` - URL string `json:"url" gorm:"type:varchar(512);not null"` + URL *string `json:"url,omitempty" gorm:"type:varchar(512)"` // Optional: required for SSE/HTTP Streamable Headers MCPHeaders `json:"headers" gorm:"type:json"` AuthConfig *MCPAuthConfig `json:"auth_config" gorm:"type:json"` AdvancedConfig *MCPAdvancedConfig `json:"advanced_config" gorm:"type:json"` + StdioConfig *MCPStdioConfig `json:"stdio_config,omitempty" gorm:"type:json"` // Required for stdio transport + EnvVars MCPEnvVars `json:"env_vars,omitempty" gorm:"type:json"` // Environment variables for stdio CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"` @@ -51,6 +54,15 @@ type MCPAdvancedConfig struct { RetryDelay int `json:"retry_delay"` // Delay between retries in seconds, default: 1 } +// MCPStdioConfig represents stdio transport configuration +type MCPStdioConfig struct { + Command string `json:"command"` // Command: "uvx" or "npx" + Args []string `json:"args"` // Command arguments array +} + +// MCPEnvVars represents environment variables as a map +type MCPEnvVars map[string]string + // MCPTool represents a tool exposed by an MCP service type MCPTool struct { Name string `json:"name"` @@ -143,6 +155,47 @@ func (c *MCPAdvancedConfig) Scan(value interface{}) error { return json.Unmarshal(b, c) } +// Value implements driver.Valuer interface for MCPStdioConfig +func (c *MCPStdioConfig) Value() (driver.Value, error) { + if c == nil { + return nil, nil + } + return json.Marshal(c) +} + +// Scan implements sql.Scanner interface for MCPStdioConfig +func (c *MCPStdioConfig) Scan(value interface{}) error { + if value == nil { + return nil + } + b, ok := value.([]byte) + if !ok { + return nil + } + return json.Unmarshal(b, c) +} + +// Value implements driver.Valuer interface for MCPEnvVars +func (e MCPEnvVars) Value() (driver.Value, error) { + if e == nil { + return nil, nil + } + return json.Marshal(e) +} + +// Scan implements sql.Scanner interface for MCPEnvVars +func (e *MCPEnvVars) Scan(value interface{}) error { + if value == nil { + *e = nil + return nil + } + b, ok := value.([]byte) + if !ok { + return nil + } + return json.Unmarshal(b, e) +} + // GetDefaultAdvancedConfig returns default advanced configuration func GetDefaultAdvancedConfig() *MCPAdvancedConfig { return &MCPAdvancedConfig{ diff --git a/migrations/mysql/06-add-mcp-stdio-support.sql b/migrations/mysql/06-add-mcp-stdio-support.sql new file mode 100644 index 000000000..585e0c126 --- /dev/null +++ b/migrations/mysql/06-add-mcp-stdio-support.sql @@ -0,0 +1,19 @@ +-- 06-add-mcp-stdio-support.sql +-- Add stdio transport support for MCP services + +-- Add stdio_config and env_vars columns +ALTER TABLE mcp_services +ADD COLUMN stdio_config JSON COMMENT 'Stdio configuration: {command: "uvx"/"npx", args: [...]}', +ADD COLUMN env_vars JSON COMMENT 'Environment variables for stdio transport'; + +-- Make url column optional (remove NOT NULL constraint) +ALTER TABLE mcp_services +MODIFY COLUMN url VARCHAR(512) NULL; + +-- Add check constraint: stdio transport requires stdio_config, others require url +ALTER TABLE mcp_services +ADD CONSTRAINT chk_mcp_transport_config CHECK ( + (transport_type = 'stdio' AND stdio_config IS NOT NULL) OR + (transport_type != 'stdio' AND url IS NOT NULL) +); + diff --git a/migrations/paradedb/06-add-mcp-stdio-support.sql b/migrations/paradedb/06-add-mcp-stdio-support.sql new file mode 100644 index 000000000..99127905c --- /dev/null +++ b/migrations/paradedb/06-add-mcp-stdio-support.sql @@ -0,0 +1,19 @@ +-- 06-add-mcp-stdio-support.sql +-- Add stdio transport support for MCP services + +-- Add stdio_config and env_vars columns +ALTER TABLE mcp_services +ADD COLUMN IF NOT EXISTS stdio_config JSONB, -- Stdio configuration: {command: "uvx"/"npx", args: [...]} +ADD COLUMN IF NOT EXISTS env_vars JSONB; -- Environment variables for stdio transport + +-- Make url column optional (remove NOT NULL constraint) +ALTER TABLE mcp_services +ALTER COLUMN url DROP NOT NULL; + +-- Add check constraint: stdio transport requires stdio_config, others require url +ALTER TABLE mcp_services +ADD CONSTRAINT chk_mcp_transport_config CHECK ( + (transport_type = 'stdio' AND stdio_config IS NOT NULL) OR + (transport_type != 'stdio' AND url IS NOT NULL) +); + diff --git a/migrations/versioned/000006_add_mcp_stdio_support.down.sql b/migrations/versioned/000006_add_mcp_stdio_support.down.sql new file mode 100644 index 000000000..1cf3cad06 --- /dev/null +++ b/migrations/versioned/000006_add_mcp_stdio_support.down.sql @@ -0,0 +1,20 @@ +-- 06-add-mcp-stdio-support.sql +-- Rollback stdio transport support for MCP services + +BEGIN; + +-- Remove check constraint +ALTER TABLE mcp_services +DROP CONSTRAINT IF EXISTS chk_mcp_transport_config; + +-- Make url column required again +ALTER TABLE mcp_services +ALTER COLUMN url SET NOT NULL; + +-- Remove stdio_config and env_vars columns +ALTER TABLE mcp_services +DROP COLUMN IF EXISTS env_vars, +DROP COLUMN IF EXISTS stdio_config; + +COMMIT; + diff --git a/migrations/versioned/000006_add_mcp_stdio_support.up.sql b/migrations/versioned/000006_add_mcp_stdio_support.up.sql new file mode 100644 index 000000000..cffe38342 --- /dev/null +++ b/migrations/versioned/000006_add_mcp_stdio_support.up.sql @@ -0,0 +1,23 @@ +-- 06-add-mcp-stdio-support.sql +-- Add stdio transport support for MCP services + +BEGIN; + +-- Add stdio_config and env_vars columns +ALTER TABLE mcp_services +ADD COLUMN IF NOT EXISTS stdio_config JSONB, -- Stdio configuration: {command: "uvx"/"npx", args: [...]} +ADD COLUMN IF NOT EXISTS env_vars JSONB; -- Environment variables for stdio transport + +-- Make url column optional (remove NOT NULL constraint) +ALTER TABLE mcp_services +ALTER COLUMN url DROP NOT NULL; + +-- Add check constraint: stdio transport requires stdio_config, others require url +ALTER TABLE mcp_services +ADD CONSTRAINT chk_mcp_transport_config CHECK ( + (transport_type = 'stdio' AND stdio_config IS NOT NULL) OR + (transport_type != 'stdio' AND url IS NOT NULL) +); + +COMMIT; +