feat: expand mcp transports and skill execution

This commit is contained in:
lpf
2026-03-08 11:08:41 +08:00
parent db86b3471d
commit f043de5384
21 changed files with 1447 additions and 84 deletions

View File

@@ -617,13 +617,132 @@ func (s *Server) handleWebUITools(w http.ResponseWriter, r *http.Request) {
mcpItems = append(mcpItems, item)
}
}
serverChecks := []map[string]interface{}{}
if strings.TrimSpace(s.configPath) != "" {
if cfg, err := cfgpkg.LoadConfig(s.configPath); err == nil {
serverChecks = buildMCPServerChecks(cfg)
}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"tools": toolsList,
"mcp_tools": mcpItems,
"tools": toolsList,
"mcp_tools": mcpItems,
"mcp_server_checks": serverChecks,
})
}
func buildMCPServerChecks(cfg *cfgpkg.Config) []map[string]interface{} {
if cfg == nil {
return nil
}
names := make([]string, 0, len(cfg.Tools.MCP.Servers))
for name := range cfg.Tools.MCP.Servers {
names = append(names, name)
}
sort.Strings(names)
items := make([]map[string]interface{}, 0, len(names))
for _, name := range names {
server := cfg.Tools.MCP.Servers[name]
transport := strings.ToLower(strings.TrimSpace(server.Transport))
if transport == "" {
transport = "stdio"
}
command := strings.TrimSpace(server.Command)
status := "missing_command"
message := "command is empty"
resolved := ""
missingCommand := false
if !server.Enabled {
status = "disabled"
message = "server is disabled"
} else if transport != "stdio" {
status = "not_applicable"
message = "command check not required for non-stdio transport"
} else if command != "" {
if filepath.IsAbs(command) {
if info, err := os.Stat(command); err == nil && !info.IsDir() {
status = "ok"
message = "command found"
resolved = command
} else {
status = "missing_command"
message = fmt.Sprintf("command not found: %s", command)
missingCommand = true
}
} else if path, err := exec.LookPath(command); err == nil {
status = "ok"
message = "command found"
resolved = path
} else {
status = "missing_command"
message = fmt.Sprintf("command not found in PATH: %s", command)
missingCommand = true
}
}
installSpec := inferMCPInstallSpec(server)
items = append(items, map[string]interface{}{
"name": name,
"enabled": server.Enabled,
"transport": transport,
"status": status,
"message": message,
"command": command,
"resolved": resolved,
"package": installSpec.Package,
"installer": installSpec.Installer,
"installable": missingCommand && installSpec.AutoInstallSupported,
"missing_command": missingCommand,
})
}
return items
}
type mcpInstallSpec struct {
Installer string
Package string
AutoInstallSupported bool
}
func inferMCPInstallSpec(server cfgpkg.MCPServerConfig) mcpInstallSpec {
if pkgName := strings.TrimSpace(server.Package); pkgName != "" {
return mcpInstallSpec{Installer: "npm", Package: pkgName, AutoInstallSupported: true}
}
command := strings.TrimSpace(server.Command)
args := make([]string, 0, len(server.Args))
for _, arg := range server.Args {
if v := strings.TrimSpace(arg); v != "" {
args = append(args, v)
}
}
base := filepath.Base(command)
switch base {
case "npx":
return mcpInstallSpec{Installer: "npm", Package: firstNonFlagArg(args), AutoInstallSupported: firstNonFlagArg(args) != ""}
case "uvx":
pkgName := firstNonFlagArg(args)
return mcpInstallSpec{Installer: "uv", Package: pkgName, AutoInstallSupported: pkgName != ""}
case "bunx":
pkgName := firstNonFlagArg(args)
return mcpInstallSpec{Installer: "bun", Package: pkgName, AutoInstallSupported: pkgName != ""}
case "python", "python3":
if len(args) >= 2 && args[0] == "-m" {
return mcpInstallSpec{Installer: "pip", Package: strings.TrimSpace(args[1]), AutoInstallSupported: false}
}
}
return mcpInstallSpec{}
}
func firstNonFlagArg(args []string) string {
for _, arg := range args {
item := strings.TrimSpace(arg)
if item == "" || strings.HasPrefix(item, "-") {
continue
}
return item
}
return ""
}
func (s *Server) handleWebUIMCPInstall(w http.ResponseWriter, r *http.Request) {
if !s.checkAuth(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
@@ -634,7 +753,8 @@ func (s *Server) handleWebUIMCPInstall(w http.ResponseWriter, r *http.Request) {
return
}
var body struct {
Package string `json:"package"`
Package string `json:"package"`
Installer string `json:"installer"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
@@ -645,7 +765,7 @@ func (s *Server) handleWebUIMCPInstall(w http.ResponseWriter, r *http.Request) {
http.Error(w, "package required", http.StatusBadRequest)
return
}
out, binName, binPath, err := ensureMCPPackageInstalled(r.Context(), pkgName)
out, binName, binPath, err := ensureMCPPackageInstalledWithInstaller(r.Context(), pkgName, body.Installer)
if err != nil {
msg := err.Error()
if strings.TrimSpace(out) != "" {
@@ -1766,38 +1886,84 @@ func ensureClawHubReady(ctx context.Context) (string, error) {
}
func ensureMCPPackageInstalled(ctx context.Context, pkgName string) (output string, binName string, binPath string, err error) {
return ensureMCPPackageInstalledWithInstaller(ctx, pkgName, "npm")
}
func ensureMCPPackageInstalledWithInstaller(ctx context.Context, pkgName, installer string) (output string, binName string, binPath string, err error) {
pkgName = strings.TrimSpace(pkgName)
if pkgName == "" {
return "", "", "", fmt.Errorf("package empty")
}
installer = strings.ToLower(strings.TrimSpace(installer))
if installer == "" {
installer = "npm"
}
outs := make([]string, 0, 4)
nodeOut, err := ensureNodeRuntime(ctx)
if nodeOut != "" {
outs = append(outs, nodeOut)
}
if err != nil {
return strings.Join(outs, "\n"), "", "", err
}
installOut, err := runInstallCommand(ctx, "npm i -g "+shellEscapeArg(pkgName))
if installOut != "" {
outs = append(outs, installOut)
}
if err != nil {
return strings.Join(outs, "\n"), "", "", err
}
binName, err = resolveNpmPackageBin(ctx, pkgName)
if err != nil {
return strings.Join(outs, "\n"), "", "", err
switch installer {
case "npm":
nodeOut, err := ensureNodeRuntime(ctx)
if nodeOut != "" {
outs = append(outs, nodeOut)
}
if err != nil {
return strings.Join(outs, "\n"), "", "", err
}
installOut, err := runInstallCommand(ctx, "npm i -g "+shellEscapeArg(pkgName))
if installOut != "" {
outs = append(outs, installOut)
}
if err != nil {
return strings.Join(outs, "\n"), "", "", err
}
binName, err = resolveNpmPackageBin(ctx, pkgName)
if err != nil {
return strings.Join(outs, "\n"), "", "", err
}
case "uv":
if !commandExists("uv") {
return "", "", "", fmt.Errorf("uv is not installed; install uv first to auto-install %s", pkgName)
}
installOut, err := runInstallCommand(ctx, "uv tool install "+shellEscapeArg(pkgName))
if installOut != "" {
outs = append(outs, installOut)
}
if err != nil {
return strings.Join(outs, "\n"), "", "", err
}
binName = guessSimpleCommandName(pkgName)
case "bun":
if !commandExists("bun") {
return "", "", "", fmt.Errorf("bun is not installed; install bun first to auto-install %s", pkgName)
}
installOut, err := runInstallCommand(ctx, "bun add -g "+shellEscapeArg(pkgName))
if installOut != "" {
outs = append(outs, installOut)
}
if err != nil {
return strings.Join(outs, "\n"), "", "", err
}
binName = guessSimpleCommandName(pkgName)
default:
return "", "", "", fmt.Errorf("unsupported installer: %s", installer)
}
binPath = resolveInstalledBinary(ctx, binName)
if strings.TrimSpace(binPath) == "" {
return strings.Join(outs, "\n"), binName, "", fmt.Errorf("installed %s but binary %q not found in PATH", pkgName, binName)
}
outs = append(outs, fmt.Sprintf("installed %s", pkgName))
outs = append(outs, fmt.Sprintf("installed %s via %s", pkgName, installer))
outs = append(outs, fmt.Sprintf("resolved binary: %s", binPath))
return strings.Join(outs, "\n"), binName, binPath, nil
}
func guessSimpleCommandName(pkgName string) string {
pkgName = strings.TrimSpace(pkgName)
pkgName = strings.TrimPrefix(pkgName, "@")
if idx := strings.LastIndex(pkgName, "/"); idx >= 0 {
pkgName = pkgName[idx+1:]
}
return strings.TrimSpace(pkgName)
}
func resolveNpmPackageBin(ctx context.Context, pkgName string) (string, error) {
cctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()