mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 23:40:28 -10:00
* feat: add MCP manager UX * fix: harden MCP manager UX * test: stabilize agent eval timeout on Windows * fix: preserve MCP server metadata * fix: clarify empty MCP view * feat: make MCP command a manager view * fix: make MCP manager usable in TUI * fix: make MCP manager selectable in TUI * fix: simplify MCP manager popup * feat: add searchable MCP marketplace * fix: address MCP review issues * fix: address MCP follow-up review * feat: add MCP setup wizard * feat: detect MCP setup requests in chat * fix: address MCP manager review feedback * fix: avoid hijacking MCP chat prompts * fix: persist MCP view cache fallback
168 lines
4.1 KiB
Go
168 lines
4.1 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/url"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/Gitlawb/zero/internal/config"
|
|
"github.com/Gitlawb/zero/internal/mcp"
|
|
"github.com/Gitlawb/zero/internal/tools"
|
|
)
|
|
|
|
type mcpToolListItem struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
SideEffect string `json:"sideEffect"`
|
|
Permission string `json:"permission"`
|
|
}
|
|
|
|
func registerMCPToolsForWorkspace(ctx context.Context, workspaceRoot string, registry *tools.Registry, deps appDeps, autonomy mcp.PermissionAutonomy) (mcpToolRuntime, error) {
|
|
cfg, err := deps.resolveMCPConfig(workspaceRoot)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(cfg.Servers) == 0 {
|
|
return noopMCPRuntime{}, nil
|
|
}
|
|
store, err := deps.newMCPStore()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return deps.registerMCPTools(ctx, registry, cfg, mcp.RegisterOptions{
|
|
PermissionStore: store,
|
|
Autonomy: autonomy,
|
|
})
|
|
}
|
|
|
|
func execMCPAutonomy(options execOptions) mcp.PermissionAutonomy {
|
|
if options.skipPermissionsUnsafe || strings.EqualFold(strings.TrimSpace(options.autonomy), "high") {
|
|
return mcp.AutonomyHigh
|
|
}
|
|
if strings.EqualFold(strings.TrimSpace(options.autonomy), "medium") {
|
|
return mcp.AutonomyMedium
|
|
}
|
|
return mcp.AutonomyLow
|
|
}
|
|
|
|
func mcpToolList(registry *tools.Registry) []mcpToolListItem {
|
|
registered := registry.All()
|
|
items := make([]mcpToolListItem, 0, len(registered))
|
|
for _, tool := range registered {
|
|
if !strings.HasPrefix(tool.Name(), "mcp_") {
|
|
continue
|
|
}
|
|
safety := tool.Safety()
|
|
items = append(items, mcpToolListItem{
|
|
Name: tool.Name(),
|
|
Description: tool.Description(),
|
|
SideEffect: string(safety.SideEffect),
|
|
Permission: string(safety.Permission),
|
|
})
|
|
}
|
|
sort.Slice(items, func(left int, right int) bool {
|
|
return items[left].Name < items[right].Name
|
|
})
|
|
return items
|
|
}
|
|
|
|
func formatMCPToolList(items []mcpToolListItem) string {
|
|
if len(items) == 0 {
|
|
return "No MCP tools configured."
|
|
}
|
|
lines := []string{"MCP Tools:"}
|
|
for _, item := range items {
|
|
lines = append(lines, fmt.Sprintf(" %s [%s/%s] - %s", item.Name, item.SideEffect, item.Permission, item.Description))
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func formatMCPServerList(servers map[string]config.MCPServerConfig) string {
|
|
if len(servers) == 0 {
|
|
return "No MCP servers configured."
|
|
}
|
|
names := make([]string, 0, len(servers))
|
|
for name := range servers {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
lines := []string{"MCP Servers:"}
|
|
for _, name := range names {
|
|
server := servers[name]
|
|
state := "enabled"
|
|
if server.Disabled {
|
|
state = "disabled"
|
|
}
|
|
identity := strings.TrimSpace(server.Command)
|
|
if identity == "" {
|
|
identity = redactMCPURL(server.URL, "[REDACTED]")
|
|
}
|
|
lines = append(lines, fmt.Sprintf(" %s [%s] %s %s", name, server.Type, state, identity))
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func redactMCPURL(raw string, marker string) string {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return ""
|
|
}
|
|
parsed, err := url.Parse(raw)
|
|
if err != nil {
|
|
return raw
|
|
}
|
|
if parsed.User != nil {
|
|
parsed.User = nil
|
|
}
|
|
if parsed.RawQuery != "" {
|
|
parsed.RawQuery = redactMCPRawQuery(parsed.RawQuery, marker)
|
|
}
|
|
if parsed.Fragment != "" {
|
|
parsed.Fragment = redactMCPRawQuery(parsed.Fragment, marker)
|
|
}
|
|
out := parsed.String()
|
|
if strings.TrimSpace(out) == "" {
|
|
return raw
|
|
}
|
|
return out
|
|
}
|
|
|
|
func redactMCPRawQuery(rawQuery string, marker string) string {
|
|
parts := strings.Split(rawQuery, "&")
|
|
for index, part := range parts {
|
|
if part == "" {
|
|
continue
|
|
}
|
|
key, _, hasValue := strings.Cut(part, "=")
|
|
decodedKey, err := url.QueryUnescape(key)
|
|
if err != nil {
|
|
decodedKey = key
|
|
}
|
|
if !isSensitiveMCPDisplayKey(decodedKey) {
|
|
continue
|
|
}
|
|
if hasValue {
|
|
parts[index] = key + "=" + marker
|
|
} else {
|
|
parts[index] = key
|
|
}
|
|
}
|
|
return strings.Join(parts, "&")
|
|
}
|
|
|
|
func isSensitiveMCPDisplayKey(key string) bool {
|
|
key = strings.ToLower(strings.TrimSpace(key))
|
|
key = strings.ReplaceAll(key, "-", "_")
|
|
if key == "key" {
|
|
return true
|
|
}
|
|
for _, token := range []string{"token", "secret", "password", "passwd", "api_key", "apikey", "access_key", "auth", "credential"} {
|
|
if strings.Contains(key, token) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|