@@ -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.
@@ -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"`
@@ -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)
@@ -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)