Add MCP client loading and printing functionality

- Introduced a new `printMCPs` function to display loaded MCP clients, categorizing them into standard and agent clients for better clarity.
- Enhanced the `Load` function to include loading MCP clients from the assistants directory, improving the overall MCP management.
- Added tests for loading assistant MCPs, ensuring that the functionality works as expected and that agent clients are correctly identified and logged.
- Updated model loading to support agent models from assistants, ensuring proper integration and functionality across the system.
This commit is contained in:
Max 2025-12-05 12:28:41 +08:00
parent 311350bfbc
commit cd5b4df503
5 changed files with 444 additions and 0 deletions

View file

@ -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 {

View file

@ -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.<assistantID>./ 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{}

View file

@ -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)")
}
}

View file

@ -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.<assistantID>./ 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_<assistantID>_
// 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 {

View file

@ -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)
}
}
}