diff --git a/README.md b/README.md index f69a451eaba21f92da001f075ad630fa43ff3aba..7f428d31ed38ba437d2df4edb6747452d9624e2b 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,28 @@ Crush can use LSPs for additional context to help inform its decisions, just lik } ``` +### MCPs + +Crush can also use MCPs for additional context. Add LSPs to the config like so: + +```json +{ + "mcp": { + "context7": { + "url": "https://mcp.context7.com/mcp", + "type": "http" + }, + "github": { + "type": "http", + "url": "https://api.githubcopilot.com/mcp/", + "headers": { + "Authorization": "$(echo Bearer $GH_MCP_TOKEN)" + } + } + } +} +``` + ### OpenAI-Compatible APIs Crush supports all OpenAI-compatible APIs. Here's an example configuration for Deepseek, which uses an OpenAI-compatible API. Don't forget to set `DEEPSEEK_API_KEY` in your environment. diff --git a/internal/config/config.go b/internal/config/config.go index 354005ac42f8fb1d179442353b819e871cc61259..d63a34f73d5210c2542be8a598ef38cb06339bd9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,8 +6,10 @@ import ( "slices" "strings" + "github.com/charmbracelet/crush/internal/env" "github.com/charmbracelet/crush/internal/fur/provider" "github.com/tidwall/sjson" + "golang.org/x/exp/slog" ) const ( @@ -90,12 +92,12 @@ const ( ) type MCPConfig struct { - Command string `json:"command,omitempty" ` - Env []string `json:"env,omitempty"` - Args []string `json:"args,omitempty"` - Type MCPType `json:"type"` - URL string `json:"url,omitempty"` - Disabled bool `json:"disabled,omitempty"` + Command string `json:"command,omitempty" ` + Env map[string]string `json:"env,omitempty"` + Args []string `json:"args,omitempty"` + Type MCPType `json:"type"` + URL string `json:"url,omitempty"` + Disabled bool `json:"disabled,omitempty"` // TODO: maybe make it possible to get the value from the env Headers map[string]string `json:"headers,omitempty"` @@ -165,6 +167,37 @@ func (l LSPs) Sorted() []LSP { return sorted } +func (m MCPConfig) ResolvedEnv() []string { + resolver := NewShellVariableResolver(env.New()) + for e, v := range m.Env { + var err error + m.Env[e], err = resolver.ResolveValue(v) + if err != nil { + slog.Error("error resolving environment variable", "error", err, "variable", e, "value", v) + continue + } + } + + env := make([]string, 0, len(m.Env)) + for k, v := range m.Env { + env = append(env, fmt.Sprintf("%s=%s", k, v)) + } + return env +} + +func (m MCPConfig) ResolvedHeaders() map[string]string { + resolver := NewShellVariableResolver(env.New()) + for e, v := range m.Headers { + var err error + m.Headers[e], err = resolver.ResolveValue(v) + if err != nil { + slog.Error("error resolving header variable", "error", err, "variable", e, "value", v) + continue + } + } + return m.Headers +} + type Agent struct { ID string `json:"id,omitempty"` Name string `json:"name,omitempty"` diff --git a/internal/llm/agent/mcp-tools.go b/internal/llm/agent/mcp-tools.go index 4ed4c023719642724c8dd49cbe3293376e3f3c57..0165b0f7194d029a6dee9113f82877820ce96c00 100644 --- a/internal/llm/agent/mcp-tools.go +++ b/internal/llm/agent/mcp-tools.go @@ -37,7 +37,7 @@ type MCPClient interface { } func (b *mcpTool) Name() string { - return fmt.Sprintf("%s_%s", b.mcpName, b.tool.Name) + return fmt.Sprintf("mcp_%s_%s", b.mcpName, b.tool.Name) } func (b *mcpTool) Info() tools.ToolInfo { @@ -46,7 +46,7 @@ func (b *mcpTool) Info() tools.ToolInfo { required = make([]string, 0) } return tools.ToolInfo{ - Name: fmt.Sprintf("%s_%s", b.mcpName, b.tool.Name), + Name: fmt.Sprintf("mcp_%s_%s", b.mcpName, b.tool.Name), Description: b.tool.Description, Parameters: b.tool.InputSchema.Properties, Required: required, @@ -108,14 +108,14 @@ func (b *mcpTool) Run(ctx context.Context, params tools.ToolCall) (tools.ToolRes }, ) if !p { - return tools.NewTextErrorResponse("permission denied"), nil + return tools.ToolResponse{}, permission.ErrorPermissionDenied } switch b.mcpConfig.Type { case config.MCPStdio: c, err := client.NewStdioMCPClient( b.mcpConfig.Command, - b.mcpConfig.Env, + b.mcpConfig.ResolvedEnv(), b.mcpConfig.Args..., ) if err != nil { @@ -125,7 +125,7 @@ func (b *mcpTool) Run(ctx context.Context, params tools.ToolCall) (tools.ToolRes case config.MCPHttp: c, err := client.NewStreamableHttpClient( b.mcpConfig.URL, - transport.WithHTTPHeaders(b.mcpConfig.Headers), + transport.WithHTTPHeaders(b.mcpConfig.ResolvedHeaders()), ) if err != nil { return tools.NewTextErrorResponse(err.Error()), nil @@ -134,7 +134,7 @@ func (b *mcpTool) Run(ctx context.Context, params tools.ToolCall) (tools.ToolRes case config.MCPSse: c, err := client.NewSSEMCPClient( b.mcpConfig.URL, - client.WithHeaders(b.mcpConfig.Headers), + client.WithHeaders(b.mcpConfig.ResolvedHeaders()), ) if err != nil { return tools.NewTextErrorResponse(err.Error()), nil @@ -210,7 +210,7 @@ func doGetMCPTools(ctx context.Context, permissions permission.Service, cfg *con case config.MCPStdio: c, err := client.NewStdioMCPClient( m.Command, - m.Env, + m.ResolvedEnv(), m.Args..., ) if err != nil { @@ -224,7 +224,7 @@ func doGetMCPTools(ctx context.Context, permissions permission.Service, cfg *con case config.MCPHttp: c, err := client.NewStreamableHttpClient( m.URL, - transport.WithHTTPHeaders(m.Headers), + transport.WithHTTPHeaders(m.ResolvedHeaders()), ) if err != nil { slog.Error("error creating mcp client", "error", err) @@ -236,7 +236,7 @@ func doGetMCPTools(ctx context.Context, permissions permission.Service, cfg *con case config.MCPSse: c, err := client.NewSSEMCPClient( m.URL, - client.WithHeaders(m.Headers), + client.WithHeaders(m.ResolvedHeaders()), ) if err != nil { slog.Error("error creating mcp client", "error", err) diff --git a/internal/tui/components/dialogs/permissions/permissions.go b/internal/tui/components/dialogs/permissions/permissions.go index 4051c02a4f09daf071f66a71cfad4b3103270f8e..7e0d27eb46ea398d34f5b8073dc1413df9cce828 100644 --- a/internal/tui/components/dialogs/permissions/permissions.go +++ b/internal/tui/components/dialogs/permissions/permissions.go @@ -409,13 +409,26 @@ func (p *permissionDialogCmp) generateDefaultContent() string { content := p.permission.Description - // Use the cache for markdown rendering - renderedContent := p.GetOrSetMarkdown(p.permission.ID, func() (string, error) { - r := styles.GetMarkdownRenderer(p.width - 4) - s, err := r.Render(content) - return s, err - }) + content = strings.TrimSpace(content) + content = "\n" + content + "\n" + lines := strings.Split(content, "\n") + + width := p.width - 4 + var out []string + for _, ln := range lines { + ln = " " + ln // left padding + if len(ln) > width { + ln = ansi.Truncate(ln, width, "…") + } + out = append(out, t.S().Muted. + Width(width). + Foreground(t.FgBase). + Background(t.BgSubtle). + Render(ln)) + } + // Use the cache for markdown rendering + renderedContent := strings.Join(out, "\n") finalContent := baseStyle. Width(p.contentViewPort.Width()). Render(renderedContent)