diff --git a/cmd/start.go b/cmd/start.go index 20ada54d..a139fd87 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -13,6 +13,7 @@ import ( "github.com/yaoapp/gou/api" "github.com/yaoapp/gou/connector" "github.com/yaoapp/gou/fs" + "github.com/yaoapp/gou/mcp" "github.com/yaoapp/gou/plugin" "github.com/yaoapp/gou/schedule" "github.com/yaoapp/gou/server/http" @@ -143,6 +144,7 @@ var startCmd = &cobra.Command{ printSchedules(false) printConnectors(false) printStores(false) + printMCPs(false) // printStudio(false, host) } @@ -230,6 +232,7 @@ var startCmd = &cobra.Command{ printSchedules(true) printConnectors(true) printStores(true) + printMCPs(true) } // Print the warnings @@ -498,6 +501,77 @@ func printApis(silent bool) { } } +func printMCPs(silent bool) { + clients := mcp.ListClients() + if len(clients) == 0 { + return + } + + if silent { + for _, clientID := range clients { + log.Info("[MCP] %s loaded", clientID) + } + return + } + + // Separate agent MCPs from standard MCPs + agentClients := []string{} + standardClients := []string{} + for _, clientID := range clients { + if len(clientID) >= 7 && clientID[:7] == "agents." { + agentClients = append(agentClients, clientID) + } else { + standardClients = append(standardClients, clientID) + } + } + + fmt.Println(color.WhiteString("\n---------------------------------")) + fmt.Println(color.WhiteString(L("MCP Clients List (%d)"), len(clients))) + fmt.Println(color.WhiteString("---------------------------------")) + + if len(standardClients) > 0 { + fmt.Println(color.WhiteString("\n%s (%d)", "Standard MCPs", len(standardClients))) + fmt.Println(color.WhiteString("--------------------------")) + for _, clientID := range standardClients { + mapping, err := mcp.GetClientMapping(clientID) + if err != nil { + fmt.Print(color.CyanString("[MCP] %s", clientID)) + fmt.Print(color.WhiteString("\tloaded\n")) + continue + } + + toolsCount := 0 + if mapping.Tools != nil { + toolsCount = len(mapping.Tools) + } + + fmt.Print(color.CyanString("[MCP] %s", clientID)) + fmt.Print(color.WhiteString("\ttools: %d\n", toolsCount)) + } + } + + if len(agentClients) > 0 { + fmt.Println(color.WhiteString("\n%s (%d)", "Agent MCPs", len(agentClients))) + fmt.Println(color.WhiteString("--------------------------")) + for _, clientID := range agentClients { + mapping, err := mcp.GetClientMapping(clientID) + if err != nil { + fmt.Print(color.CyanString("[MCP] %s", clientID)) + fmt.Print(color.WhiteString("\tloaded\n")) + continue + } + + toolsCount := 0 + if mapping.Tools != nil { + toolsCount = len(mapping.Tools) + } + + fmt.Print(color.CyanString("[MCP] %s", clientID)) + fmt.Print(color.WhiteString("\ttools: %d\n", toolsCount)) + } + } +} + func colorMehtod(method string) string { method = strings.ToUpper(method) switch method { diff --git a/mcp/mcp.go b/mcp/mcp.go index e7455586..84a64166 100644 --- a/mcp/mcp.go +++ b/mcp/mcp.go @@ -3,6 +3,7 @@ package mcp import ( "context" "fmt" + "path/filepath" "strings" "time" @@ -44,6 +45,14 @@ func Load(cfg config.Config) error { } } + // Load MCP clients from assistants + errsAssistants := loadAssistantMCPs() + if len(errsAssistants) > 0 { + for _, err := range errsAssistants { + log.Error("Load assistant MCP clients error: %s", err.Error()) + } + } + // Load database MCP clients (ignore error) errs := loadDatabaseMCPs() if len(errs) > 0 { @@ -54,6 +63,103 @@ func Load(cfg config.Config) error { return err } +// loadAssistantMCPs load MCP clients from assistants directory +func loadAssistantMCPs() []error { + var errs []error = []error{} + + // Check if assistants directory exists + exists, err := application.App.Exists("assistants") + if err != nil || !exists { + log.Trace("Assistants directory not found or not accessible") + return errs + } + + log.Trace("Loading MCP clients from assistants directory...") + + // Track processed assistants to avoid duplicates + processedAssistants := make(map[string]bool) + + // Walk through assistants directory to find all valid assistants with mcps + err = application.App.Walk("assistants", func(root, file string, isdir bool) error { + if !isdir { + return nil + } + + // Check if this is a valid assistant directory (has package.yao) + // file is relative path from root, so we need to join root + file + pkgFile := filepath.Join(root, file, "package.yao") + pkgExists, _ := application.App.Exists(pkgFile) + if !pkgExists { + return nil + } + + // Extract assistant ID from path (e.g., "/assistants/expense" -> "expense") + // file is like "/tests/mcpload", trim leading "/" and replace "/" with "." + assistantID := strings.TrimPrefix(file, "/") + assistantID = strings.ReplaceAll(assistantID, "/", ".") + + // Skip if already processed + if processedAssistants[assistantID] { + return nil + } + processedAssistants[assistantID] = true + + log.Trace("Found assistant: %s", assistantID) + + // Check if the assistant has an mcps directory + mcpsDir := filepath.Join(root, file, "mcps") + mcpsDirExists, _ := application.App.Exists(mcpsDir) + if !mcpsDirExists { + log.Trace("Assistant %s has no mcps directory", assistantID) + return nil + } + + log.Trace("Loading MCPs from assistant %s", assistantID) + + // Load MCP clients from the assistant's mcps directory + exts := []string{"*.mcp.yao", "*.mcp.json", "*.mcp.jsonc"} + err := application.App.Walk(mcpsDir, func(mcpRoot, mcpFile string, mcpIsDir bool) error { + if mcpIsDir { + return nil + } + + // Generate MCP client ID with agents../ prefix + // Support nested paths: "mcps/nested/tool.mcp.yao" -> "nested.tool" + relPath := strings.TrimPrefix(mcpFile, mcpsDir+"/") + relPath = strings.TrimPrefix(relPath, "/") + relPath = strings.TrimSuffix(relPath, ".mcp.yao") + relPath = strings.TrimSuffix(relPath, ".mcp.json") + relPath = strings.TrimSuffix(relPath, ".mcp.jsonc") + mcpName := strings.ReplaceAll(relPath, "/", ".") + clientID := fmt.Sprintf("agents.%s.%s", assistantID, mcpName) + + log.Trace("Loading MCP client %s from file %s", clientID, mcpFile) + + _, err := mcp.LoadClient(mcpFile, clientID) + if err != nil { + log.Error("Failed to load MCP client %s from assistant %s: %s", clientID, assistantID, err.Error()) + errs = append(errs, fmt.Errorf("failed to load MCP client %s: %w", clientID, err)) + return nil // Continue loading other MCPs + } + + log.Info("Loaded MCP client: %s", clientID) + return nil + }, exts...) + + if err != nil { + errs = append(errs, fmt.Errorf("failed to walk MCPs in assistant %s: %w", assistantID, err)) + } + + return nil + }, "") + + if err != nil { + errs = append(errs, fmt.Errorf("failed to walk assistants directory: %w", err)) + } + + return errs +} + // loadDatabaseMCPs load database MCP clients func loadDatabaseMCPs() []error { var errs []error = []error{} diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go index 2f82efb2..d407d471 100644 --- a/mcp/mcp_test.go +++ b/mcp/mcp_test.go @@ -1,6 +1,7 @@ package mcp import ( + "context" "testing" "github.com/stretchr/testify/assert" @@ -127,3 +128,86 @@ func TestUnloadClient(t *testing.T) { // Test that unloading non-existent client doesn't crash mcp.UnloadClient("non_existent") } + +func TestLoadAssistantMCPs(t *testing.T) { + test.Prepare(t, config.Conf) + defer test.Clean() + + // Load MCPs + err := Load(config.Conf) + if err != nil { + t.Logf("Load returned error: %v", err) + } + + // List all loaded clients + clients := mcp.ListClients() + t.Logf("Total loaded MCP clients: %d", len(clients)) + + // Filter agent clients + agentClients := []string{} + for _, id := range clients { + if len(id) >= 7 && id[:7] == "agents." { + agentClients = append(agentClients, id) + } + } + + t.Logf("Agent MCP clients: %v", agentClients) + + // Check if the test assistant MCP client is loaded + testClientID := "agents.tests.mcpload.test" + if mcp.Exists(testClientID) { + t.Logf("✓ Test assistant MCP client '%s' loaded successfully", testClientID) + + // Verify we can get the client + client, err := mcp.Select(testClientID) + assert.Nil(t, err) + assert.NotNil(t, client) + + // Try to list tools + ctx := context.Background() + toolsResp, err := client.ListTools(ctx, "") + if err == nil && toolsResp != nil { + t.Logf("✓ Available tools in %s: %d", testClientID, len(toolsResp.Tools)) + for _, tool := range toolsResp.Tools { + t.Logf(" - Tool: %s - %s", tool.Name, tool.Description) + } + } else { + t.Logf("Could not list tools: %v", err) + } + + } else { + t.Logf("Test assistant MCP client '%s' not found", testClientID) + t.Logf("This may be expected if the test assistant is not in the application") + } + + // Check for nested MCP client + nestedClientID := "agents.tests.mcpload.nested.tool" + if mcp.Exists(nestedClientID) { + t.Logf("✓ Nested MCP client '%s' loaded successfully", nestedClientID) + + client, err := mcp.Select(nestedClientID) + assert.Nil(t, err) + assert.NotNil(t, client) + + ctx := context.Background() + toolsResp, err := client.ListTools(ctx, "") + if err == nil && toolsResp != nil { + t.Logf("✓ Available tools in %s: %d", nestedClientID, len(toolsResp.Tools)) + for _, tool := range toolsResp.Tools { + t.Logf(" - Tool: %s - %s", tool.Name, tool.Description) + } + } + } else { + t.Logf("✗ Nested MCP client '%s' not found", nestedClientID) + } + + // Report all agent clients found + if len(agentClients) > 0 { + t.Logf("✓ Successfully loaded %d agent MCP client(s):", len(agentClients)) + for _, id := range agentClients { + t.Logf(" - %s", id) + } + } else { + t.Logf("No agent MCP clients found (this may be expected if no assistants have mcps)") + } +} diff --git a/model/model.go b/model/model.go index 046e7d1c..3b71629a 100644 --- a/model/model.go +++ b/model/model.go @@ -76,6 +76,14 @@ func Load(cfg config.Config) error { return fmt.Errorf("%s", strings.Join(messages, ";\n")) } + // Load models from assistants + errsAssistants := loadAssistantModels() + if len(errsAssistants) > 0 { + for _, err := range errsAssistants { + log.Error("Load assistant models error: %s", err.Error()) + } + } + // Load database models ( ignore error) errs := loadDatabaseModels() if len(errs) > 0 { @@ -131,6 +139,148 @@ func loadSystemModels() error { return nil } +// loadAssistantModels load models from assistants directory +func loadAssistantModels() []error { + var errs []error = []error{} + + // Check if assistants directory exists + exists, err := application.App.Exists("assistants") + if err != nil || !exists { + log.Trace("Assistants directory not found or not accessible") + return errs + } + + log.Trace("Loading models from assistants directory...") + + // Track processed assistants to avoid duplicates + processedAssistants := make(map[string]bool) + + // Walk through assistants directory to find all valid assistants with models + err = application.App.Walk("assistants", func(root, file string, isdir bool) error { + if !isdir { + return nil + } + + // Check if this is a valid assistant directory (has package.yao) + pkgFile := filepath.Join(root, file, "package.yao") + pkgExists, _ := application.App.Exists(pkgFile) + if !pkgExists { + return nil + } + + // Extract assistant ID from path + assistantID := strings.TrimPrefix(file, "/") + assistantID = strings.ReplaceAll(assistantID, "/", ".") + + // Skip if already processed + if processedAssistants[assistantID] { + return nil + } + processedAssistants[assistantID] = true + + log.Trace("Found assistant: %s", assistantID) + + // Check if the assistant has a models directory + modelsDir := filepath.Join(root, file, "models") + modelsDirExists, _ := application.App.Exists(modelsDir) + if !modelsDirExists { + log.Trace("Assistant %s has no models directory", assistantID) + return nil + } + + log.Trace("Loading models from assistant %s", assistantID) + + // Load models from the assistant's models directory + exts := []string{"*.mod.yao", "*.mod.json", "*.mod.jsonc"} + err := application.App.Walk(modelsDir, func(modelRoot, modelFile string, modelIsDir bool) error { + if modelIsDir { + return nil + } + + // Generate model ID with agents../ prefix + // Support nested paths: "models/foo/bar.mod.yao" -> "foo.bar" + relPath := strings.TrimPrefix(modelFile, modelsDir+"/") + relPath = strings.TrimPrefix(relPath, "/") + relPath = strings.TrimSuffix(relPath, ".mod.yao") + relPath = strings.TrimSuffix(relPath, ".mod.json") + relPath = strings.TrimSuffix(relPath, ".mod.jsonc") + modelName := strings.ReplaceAll(relPath, "/", ".") + modelID := fmt.Sprintf("agents.%s.%s", assistantID, modelName) + + log.Trace("Loading model %s from file %s", modelID, modelFile) + + // Read and modify model to add table prefix + content, err := application.App.Read(modelFile) + if err != nil { + log.Error("Failed to read model file %s: %s", modelFile, err.Error()) + errs = append(errs, fmt.Errorf("failed to read model %s: %w", modelID, err)) + return nil + } + + // Parse model + var modelData map[string]interface{} + err = application.Parse(modelFile, content, &modelData) + if err != nil { + log.Error("Failed to parse model %s: %s", modelID, err.Error()) + errs = append(errs, fmt.Errorf("failed to parse model %s: %w", modelID, err)) + return nil + } + + // Set table name prefix: agents__ + // Convert dots to underscores: tests.mcpload -> agents_tests_mcpload_ + if table, ok := modelData["table"].(map[string]interface{}); ok { + if tableName, ok := table["name"].(string); ok { + // Generate prefix from assistant ID + prefix := "agents_" + strings.ReplaceAll(assistantID, ".", "_") + "_" + + // Remove any existing prefix if present + tableName = strings.TrimPrefix(tableName, "agents_mcpload_") + tableName = strings.TrimPrefix(tableName, prefix) + + table["name"] = prefix + tableName + content, err = jsoniter.Marshal(modelData) + if err != nil { + log.Error("Failed to marshal model data for %s: %v", modelID, err) + errs = append(errs, fmt.Errorf("failed to marshal model %s: %w", modelID, err)) + return nil + } + } + } + + // Load model with modified content + mod, err := model.LoadSource(content, modelID, modelFile) + if err != nil { + log.Error("Failed to load model %s from assistant %s: %s", modelID, assistantID, err.Error()) + errs = append(errs, fmt.Errorf("failed to load model %s: %w", modelID, err)) + return nil // Continue loading other models + } + + // Auto migrate the model (like system models) + err = mod.Migrate(false, model.WithDonotInsertValues(true)) + if err != nil { + log.Error("Failed to migrate model %s from assistant %s: %s", modelID, assistantID, err.Error()) + errs = append(errs, fmt.Errorf("failed to migrate model %s: %w", modelID, err)) + return nil + } + + log.Info("Loaded and migrated model: %s", modelID) + return nil + }, exts...) + + if err != nil { + errs = append(errs, fmt.Errorf("failed to walk models in assistant %s: %w", assistantID, err)) + } + + return nil + }, "") + + if err != nil { + errs = append(errs, fmt.Errorf("failed to walk assistants directory: %w", err)) + } + + return errs +} + // LoadDatabaseModels load database models func loadDatabaseModels() []error { diff --git a/model/model_test.go b/model/model_test.go index 8fa84445..cb7b3972 100644 --- a/model/model_test.go +++ b/model/model_test.go @@ -24,6 +24,7 @@ func check(t *testing.T) { ids[id] = true } + // Standard models assert.True(t, ids["user"]) assert.True(t, ids["category"]) assert.True(t, ids["tag"]) @@ -31,4 +32,33 @@ func check(t *testing.T) { assert.True(t, ids["pet.tag"]) assert.True(t, ids["user.pet"]) assert.True(t, ids["tests.user"]) + + // Agent models + assert.True(t, ids["agents.tests.mcpload.test_record"], "Agent model agents.tests.mcpload.test_record should be loaded") + assert.True(t, ids["agents.tests.mcpload.nested.item"], "Agent nested model agents.tests.mcpload.nested.item should be loaded") + + // Verify table names have correct prefix + if testRecordModel, exists := model.Models["agents.tests.mcpload.test_record"]; exists { + assert.Equal(t, "agents_tests_mcpload_test_records", testRecordModel.MetaData.Table.Name, "Table name should have agents_tests_mcpload_ prefix") + t.Logf("✓ Agent model table name: %s", testRecordModel.MetaData.Table.Name) + } + + if nestedItemModel, exists := model.Models["agents.tests.mcpload.nested.item"]; exists { + assert.Equal(t, "agents_tests_mcpload_items", nestedItemModel.MetaData.Table.Name, "Nested model table name should have agents_tests_mcpload_ prefix") + t.Logf("✓ Nested agent model table name: %s", nestedItemModel.MetaData.Table.Name) + } + + // Log all agent models found + agentModels := []string{} + for id := range model.Models { + if len(id) >= 7 && id[:7] == "agents." { + agentModels = append(agentModels, id) + } + } + if len(agentModels) > 0 { + t.Logf("✓ Found %d agent model(s):", len(agentModels)) + for _, id := range agentModels { + t.Logf(" - %s", id) + } + } }