yao/registry/manager/mcp/script.go
Max 44af6ba759 Enhance command structure with MCP and Robot functionalities
- Introduce new MCP and Robot command groups in the CLI, allowing for better organization of package management commands.
- Add corresponding command descriptions for MCP and Robot functionalities to improve user guidance.
- Update the agent command group to include additional commands for enhanced agent management.
- Modify .gitignore to exclude specific design markdown files from version control.
- Add environment variable for test application path in GitHub workflows to streamline testing setup.
2026-03-03 08:11:43 +08:00

177 lines
5.4 KiB
Go

package mcp
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
goujson "github.com/yaoapp/gou/json"
)
// ProcessRef represents a process reference extracted from a .mcp.yao file.
type ProcessRef struct {
ToolName string // e.g. "search"
ProcessPath string // e.g. "scripts.yao.rag.Search"
ScriptPath string // resolved filesystem path: "scripts/yao/rag.ts"
Scope string // extracted scope: "yao"
}
// mcpDSL is a minimal representation of .mcp.yao for process reference extraction.
type mcpDSL struct {
Transport string `json:"transport"`
Tools map[string]string `json:"tools,omitempty"`
}
// ExtractProcessRefs parses a .mcp.yao file and extracts all "scripts.*" process references.
func ExtractProcessRefs(mcpYaoPath string) ([]ProcessRef, error) {
data, err := os.ReadFile(mcpYaoPath)
if err != nil {
return nil, err
}
return ExtractProcessRefsFromBytes(data)
}
// ExtractProcessRefsFromBytes extracts process refs from .mcp.yao content bytes.
// Supports JSONC format (// and /* */ comments) used by Yao DSL files.
func ExtractProcessRefsFromBytes(data []byte) ([]ProcessRef, error) {
var dsl mcpDSL
if err := goujson.ParseFile(".mcp.yao", data, &dsl); err != nil {
return nil, fmt.Errorf("parse .mcp.yao: %w", err)
}
if dsl.Transport != "process" {
return nil, nil
}
var refs []ProcessRef
for toolName, processPath := range dsl.Tools {
if !strings.HasPrefix(processPath, "scripts.") {
continue
}
ref, err := parseProcessRef(toolName, processPath)
if err != nil {
continue
}
refs = append(refs, ref)
}
return refs, nil
}
// parseProcessRef parses "scripts.yao.rag.Search" into a ProcessRef.
// Convention: scripts.{scope}.{path...}.{Function}
// Script file: scripts/{scope}/{path_joined}.ts
func parseProcessRef(toolName, processPath string) (ProcessRef, error) {
parts := strings.Split(processPath, ".")
// At minimum: scripts.scope.file.Function = 4 parts
if len(parts) < 4 {
return ProcessRef{}, fmt.Errorf("process path %q too short", processPath)
}
scope := parts[1]
// The middle parts (between scope and function name) form the script path
scriptParts := parts[2 : len(parts)-1]
scriptFile := strings.Join(scriptParts, "/") + ".ts"
scriptPath := filepath.Join("scripts", scope, scriptFile)
return ProcessRef{
ToolName: toolName,
ProcessPath: processPath,
ScriptPath: filepath.ToSlash(scriptPath),
Scope: scope,
}, nil
}
// RewriteProcessRefs rewrites all "scripts.{oldScope}." references to
// "scripts.{newScope}." in a .mcp.yao file content.
func RewriteProcessRefs(mcpContent []byte, oldScope, newScope string) []byte {
old := "scripts." + oldScope + "."
replacement := "scripts." + newScope + "."
return []byte(strings.ReplaceAll(string(mcpContent), old, replacement))
}
// ValidateScriptScope checks that all process references in a .mcp.yao point to
// scripts in the expected scope directory. Returns an error if any violate the rule.
func ValidateScriptScope(mcpYaoPath, expectedScope, appRoot string) error {
refs, err := ExtractProcessRefs(mcpYaoPath)
if err != nil {
return err
}
for _, ref := range refs {
if ref.Scope != expectedScope {
return fmt.Errorf(
"MCP script scope mismatch: %s references scripts.%s.* but expected scripts.%s.*\n"+
" Scripts must be in scripts/%s/ to match MCP scope",
mcpYaoPath, ref.Scope, expectedScope, expectedScope,
)
}
// Verify script file exists
scriptPath := filepath.Join(appRoot, ref.ScriptPath)
if _, err := os.Stat(scriptPath); err != nil {
return fmt.Errorf("script file %s referenced by %s not found", ref.ScriptPath, ref.ProcessPath)
}
}
return nil
}
// CollectScripts gathers all script files referenced by a .mcp.yao file.
// Returns a map of relative path (under package/) → absolute path on disk.
func CollectScripts(mcpYaoPath, appRoot string) (map[string]string, error) {
refs, err := ExtractProcessRefs(mcpYaoPath)
if err != nil {
return nil, err
}
scripts := map[string]string{}
for _, ref := range refs {
absPath := filepath.Join(appRoot, ref.ScriptPath)
if _, err := os.Stat(absPath); err != nil {
return nil, fmt.Errorf("script %s not found: %w", ref.ScriptPath, err)
}
scripts[ref.ScriptPath] = absPath
}
return scripts, nil
}
// FindMCPYaoFiles finds all .mcp.yao files in an MCP package directory.
func FindMCPYaoFiles(mcpDir string) ([]string, error) {
var files []string
err := filepath.Walk(mcpDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() && strings.HasSuffix(info.Name(), ".mcp.yao") {
files = append(files, path)
}
return nil
})
return files, err
}
// ScriptPathsFromFiles extracts script-related paths from a file hash map.
// Returns only entries starting with "scripts/".
func ScriptPathsFromFiles(files map[string]string) map[string]string {
result := map[string]string{}
for path, hash := range files {
if strings.HasPrefix(path, "scripts/") {
result[path] = hash
}
}
return result
}
// processRefRegex matches "scripts.{scope}.{rest}" patterns.
var processRefRegex = regexp.MustCompile(`scripts\.([a-zA-Z0-9_-]+)\.`)
// ExtractScopeFromProcessRef extracts the scope from a process reference like "scripts.yao.rag.Search".
func ExtractScopeFromProcessRef(processPath string) string {
matches := processRefRegex.FindStringSubmatch(processPath)
if len(matches) >= 2 {
return matches[1]
}
return ""
}