on main help sign in
feat(mcp): refactor, support prompts

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

commit4ca34bbcdd91a906069999a83f6c3ee0fcff2fa6 authorCarlos Alexandro Becker <caarlos0@users.noreply.github.com> dateMon 3 Nov 2025, 11:02 (1 year ago) parent0182d94
11 files changed+972−657
internal/agent/coordinator.go+7−8 internal/agent/tools/mcp-tools.go−525 internal/agent/tools/mcp/init.go+400 internal/agent/tools/mcp/prompts.go+83 internal/agent/tools/mcp/tools.go+169 internal/app/app.go+8−3 internal/tui/components/dialogs/commands/arguments.go+93−69 internal/tui/components/dialogs/commands/commands.go+79−25 internal/tui/components/dialogs/commands/loader.go+86−15 internal/tui/components/mcp/mcp.go+14−10 internal/tui/tui.go+33−2
internal/agent/coordinator.go+7−8
@@ -18,6 +18,7 @@ import (
1818 "github.com/charmbracelet/catwalk/pkg/catwalk"
1919 "github.com/charmbracelet/crush/internal/agent/prompt"
2020 "github.com/charmbracelet/crush/internal/agent/tools"
21+ "github.com/charmbracelet/crush/internal/agent/tools/mcp"
2122 "github.com/charmbracelet/crush/internal/config"
2223 "github.com/charmbracelet/crush/internal/csync"
2324 "github.com/charmbracelet/crush/internal/history"
@@ -344,25 +345,23 @@ func (c *coordinator) buildTools(ctx context.Context, agent config.Agent) ([]fan
344345 }
345346 }
346347
347− mcpTools := tools.GetMCPTools(context.Background(), c.permissions, c.cfg)
348−
349− for _, mcpTool := range mcpTools {
348+ for tool := range mcp.GetMCPTools() {
350349 if agent.AllowedMCP == nil {
351350 // No MCP restrictions
352− filteredTools = append(filteredTools, mcpTool)
351+ filteredTools = append(filteredTools, tool)
353352 } else if len(agent.AllowedMCP) == 0 {
354353 // no mcps allowed
355354 break
356355 }
357356
358357 for mcp, tools := range agent.AllowedMCP {
359− if mcp == mcpTool.MCP() {
358+ if mcp == tool.MCP() {
360359 if len(tools) == 0 {
361− filteredTools = append(filteredTools, mcpTool)
360+ filteredTools = append(filteredTools, tool)
362361 }
363362 for _, t := range tools {
364− if t == mcpTool.MCPToolName() {
365− filteredTools = append(filteredTools, mcpTool)
363+ if t == tool.MCPToolName() {
364+ filteredTools = append(filteredTools, tool)
366365 }
367366 }
368367 break
internal/agent/tools/mcp-tools.godeleted−525
@@ -1,525 +0,0 @@
1−package tools
2−
3−import (
4− "cmp"
5− "context"
6− "encoding/json"
7− "errors"
8− "fmt"
9− "io"
10− "log/slog"
11− "maps"
12− "net/http"
13− "os"
14− "os/exec"
15− "slices"
16− "strings"
17− "sync"
18− "time"
19−
20− "charm.land/fantasy"
21− "github.com/charmbracelet/crush/internal/config"
22− "github.com/charmbracelet/crush/internal/csync"
23− "github.com/charmbracelet/crush/internal/home"
24− "github.com/charmbracelet/crush/internal/permission"
25− "github.com/charmbracelet/crush/internal/pubsub"
26− "github.com/charmbracelet/crush/internal/version"
27− "github.com/modelcontextprotocol/go-sdk/mcp"
28−)
29−
30−// MCPState represents the current state of an MCP client
31−type MCPState int
32−
33−const (
34− MCPStateDisabled MCPState = iota
35− MCPStateStarting
36− MCPStateConnected
37− MCPStateError
38−)
39−
40−func (s MCPState) String() string {
41− switch s {
42− case MCPStateDisabled:
43− return "disabled"
44− case MCPStateStarting:
45− return "starting"
46− case MCPStateConnected:
47− return "connected"
48− case MCPStateError:
49− return "error"
50− default:
51− return "unknown"
52− }
53−}
54−
55−// MCPEventType represents the type of MCP event
56−type MCPEventType string
57−
58−const (
59− MCPEventStateChanged MCPEventType = "state_changed"
60− MCPEventToolsListChanged MCPEventType = "tools_list_changed"
61−)
62−
63−// MCPEvent represents an event in the MCP system
64−type MCPEvent struct {
65− Type MCPEventType
66− Name string
67− State MCPState
68− Error error
69− ToolCount int
70−}
71−
72−// MCPClientInfo holds information about an MCP client's state
73−type MCPClientInfo struct {
74− Name string
75− State MCPState
76− Error error
77− Client *mcp.ClientSession
78− ToolCount int
79− ConnectedAt time.Time
80−}
81−
82−var (
83− mcpToolsOnce sync.Once
84− mcpTools = csync.NewMap[string, *McpTool]()
85− mcpClient2Tools = csync.NewMap[string, []*McpTool]()
86− mcpClients = csync.NewMap[string, *mcp.ClientSession]()
87− mcpStates = csync.NewMap[string, MCPClientInfo]()
88− mcpBroker = pubsub.NewBroker[MCPEvent]()
89−)
90−
91−type McpTool struct {
92− mcpName string
93− tool *mcp.Tool
94− permissions permission.Service
95− workingDir string
96− providerOptions fantasy.ProviderOptions
97−}
98−
99−func (m *McpTool) SetProviderOptions(opts fantasy.ProviderOptions) {
100− m.providerOptions = opts
101−}
102−
103−func (m *McpTool) ProviderOptions() fantasy.ProviderOptions {
104− return m.providerOptions
105−}
106−
107−func (m *McpTool) Name() string {
108− return fmt.Sprintf("mcp_%s_%s", m.mcpName, m.tool.Name)
109−}
110−
111−func (m *McpTool) MCP() string {
112− return m.mcpName
113−}
114−
115−func (m *McpTool) MCPToolName() string {
116− return m.tool.Name
117−}
118−
119−func (b *McpTool) Info() fantasy.ToolInfo {
120− parameters := make(map[string]any)
121− required := make([]string, 0)
122−
123− if input, ok := b.tool.InputSchema.(map[string]any); ok {
124− if props, ok := input["properties"].(map[string]any); ok {
125− parameters = props
126− }
127− if req, ok := input["required"].([]any); ok {
128− // Convert []any -> []string when elements are strings
129− for _, v := range req {
130− if s, ok := v.(string); ok {
131− required = append(required, s)
132− }
133− }
134− } else if reqStr, ok := input["required"].([]string); ok {
135− // Handle case where it's already []string
136− required = reqStr
137− }
138− }
139−
140− return fantasy.ToolInfo{
141− Name: fmt.Sprintf("mcp_%s_%s", b.mcpName, b.tool.Name),
142− Description: b.tool.Description,
143− Parameters: parameters,
144− Required: required,
145− }
146−}
147−
148−func runTool(ctx context.Context, name, toolName string, input string) (fantasy.ToolResponse, error) {
149− var args map[string]any
150− if err := json.Unmarshal([]byte(input), &args); err != nil {
151− return fantasy.NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
152− }
153−
154− c, err := getOrRenewClient(ctx, name)
155− if err != nil {
156− return fantasy.NewTextErrorResponse(err.Error()), nil
157− }
158− result, err := c.CallTool(ctx, &mcp.CallToolParams{
159− Name: toolName,
160− Arguments: args,
161− })
162− if err != nil {
163− return fantasy.NewTextErrorResponse(err.Error()), nil
164− }
165−
166− output := make([]string, 0, len(result.Content))
167− for _, v := range result.Content {
168− if vv, ok := v.(*mcp.TextContent); ok {
169− output = append(output, vv.Text)
170− } else {
171− output = append(output, fmt.Sprintf("%v", v))
172− }
173− }
174− return fantasy.NewTextResponse(strings.Join(output, "\n")), nil
175−}
176−
177−func getOrRenewClient(ctx context.Context, name string) (*mcp.ClientSession, error) {
178− sess, ok := mcpClients.Get(name)
179− if !ok {
180− return nil, fmt.Errorf("mcp '%s' not available", name)
181− }
182−
183− cfg := config.Get()
184− m := cfg.MCP[name]
185− state, _ := mcpStates.Get(name)
186−
187− timeout := mcpTimeout(m)
188− pingCtx, cancel := context.WithTimeout(ctx, timeout)
189− defer cancel()
190− err := sess.Ping(pingCtx, nil)
191− if err == nil {
192− return sess, nil
193− }
194− updateMCPState(name, MCPStateError, maybeTimeoutErr(err, timeout), nil, state.ToolCount)
195−
196− sess, err = createMCPSession(ctx, name, m, cfg.Resolver())
197− if err != nil {
198− return nil, err
199− }
200−
201− updateMCPState(name, MCPStateConnected, nil, sess, state.ToolCount)
202− mcpClients.Set(name, sess)
203− return sess, nil
204−}
205−
206−func (m *McpTool) Run(ctx context.Context, params fantasy.ToolCall) (fantasy.ToolResponse, error) {
207− sessionID := GetSessionFromContext(ctx)
208− if sessionID == "" {
209− return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
210− }
211− permissionDescription := fmt.Sprintf("execute %s with the following parameters:", m.Info().Name)
212− p := m.permissions.Request(
213− permission.CreatePermissionRequest{
214− SessionID: sessionID,
215− ToolCallID: params.ID,
216− Path: m.workingDir,
217− ToolName: m.Info().Name,
218− Action: "execute",
219− Description: permissionDescription,
220− Params: params.Input,
221− },
222− )
223− if !p {
224− return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
225− }
226−
227− return runTool(ctx, m.mcpName, m.tool.Name, params.Input)
228−}
229−
230−func getTools(ctx context.Context, name string, permissions permission.Service, c *mcp.ClientSession, workingDir string) ([]*McpTool, error) {
231− result, err := c.ListTools(ctx, &mcp.ListToolsParams{})
232− if err != nil {
233− return nil, err
234− }
235− mcpTools := make([]*McpTool, 0, len(result.Tools))
236− for _, tool := range result.Tools {
237− mcpTools = append(mcpTools, &McpTool{
238− mcpName: name,
239− tool: tool,
240− permissions: permissions,
241− workingDir: workingDir,
242− })
243− }
244− return mcpTools, nil
245−}
246−
247−// SubscribeMCPEvents returns a channel for MCP events
248−func SubscribeMCPEvents(ctx context.Context) <-chan pubsub.Event[MCPEvent] {
249− return mcpBroker.Subscribe(ctx)
250−}
251−
252−// GetMCPStates returns the current state of all MCP clients
253−func GetMCPStates() map[string]MCPClientInfo {
254− return maps.Collect(mcpStates.Seq2())
255−}
256−
257−// GetMCPState returns the state of a specific MCP client
258−func GetMCPState(name string) (MCPClientInfo, bool) {
259− return mcpStates.Get(name)
260−}
261−
262−// updateMCPState updates the state of an MCP client and publishes an event
263−func updateMCPState(name string, state MCPState, err error, client *mcp.ClientSession, toolCount int) {
264− info := MCPClientInfo{
265− Name: name,
266− State: state,
267− Error: err,
268− Client: client,
269− ToolCount: toolCount,
270− }
271− switch state {
272− case MCPStateConnected:
273− info.ConnectedAt = time.Now()
274− case MCPStateError:
275− updateMcpTools(name, nil)
276− mcpClients.Del(name)
277− }
278− mcpStates.Set(name, info)
279−
280− // Publish state change event
281− mcpBroker.Publish(pubsub.UpdatedEvent, MCPEvent{
282− Type: MCPEventStateChanged,
283− Name: name,
284− State: state,
285− Error: err,
286− ToolCount: toolCount,
287− })
288−}
289−
290−// CloseMCPClients closes all MCP clients. This should be called during application shutdown.
291−func CloseMCPClients() error {
292− var errs []error
293− for name, c := range mcpClients.Seq2() {
294− if err := c.Close(); err != nil &&
295− !errors.Is(err, io.EOF) &&
296− !errors.Is(err, context.Canceled) &&
297− err.Error() != "signal: killed" {
298− errs = append(errs, fmt.Errorf("close mcp: %s: %w", name, err))
299− }
300− }
301− mcpBroker.Shutdown()
302− return errors.Join(errs...)
303−}
304−
305−func GetMCPTools(ctx context.Context, permissions permission.Service, cfg *config.Config) []*McpTool {
306− mcpToolsOnce.Do(func() {
307− var wg sync.WaitGroup
308− // Initialize states for all configured MCPs
309− for name, m := range cfg.MCP {
310− if m.Disabled {
311− updateMCPState(name, MCPStateDisabled, nil, nil, 0)
312− slog.Debug("skipping disabled mcp", "name", name)
313− continue
314− }
315−
316− // Set initial starting state
317− updateMCPState(name, MCPStateStarting, nil, nil, 0)
318−
319− wg.Add(1)
320− go func(name string, m config.MCPConfig) {
321− defer func() {
322− wg.Done()
323− if r := recover(); r != nil {
324− var err error
325− switch v := r.(type) {
326− case error:
327− err = v
328− case string:
329− err = fmt.Errorf("panic: %s", v)
330− default:
331− err = fmt.Errorf("panic: %v", v)
332− }
333− updateMCPState(name, MCPStateError, err, nil, 0)
334− slog.Error("panic in mcp client initialization", "error", err, "name", name)
335− }
336− }()
337−
338− ctx, cancel := context.WithTimeout(ctx, mcpTimeout(m))
339− defer cancel()
340−
341− c, err := createMCPSession(ctx, name, m, cfg.Resolver())
342− if err != nil {
343− return
344− }
345−
346− mcpClients.Set(name, c)
347−
348− tools, err := getTools(ctx, name, permissions, c, cfg.WorkingDir())
349− if err != nil {
350− slog.Error("error listing tools", "error", err)
351− updateMCPState(name, MCPStateError, err, nil, 0)
352− c.Close()
353− return
354− }
355−
356− updateMcpTools(name, tools)
357− mcpClients.Set(name, c)
358− updateMCPState(name, MCPStateConnected, nil, c, len(tools))
359− }(name, m)
360− }
361− wg.Wait()
362− })
363− return slices.Collect(mcpTools.Seq())
364−}
365−
366−// updateMcpTools updates the global mcpTools and mcpClient2Tools maps
367−func updateMcpTools(mcpName string, tools []*McpTool) {
368− if len(tools) == 0 {
369− mcpClient2Tools.Del(mcpName)
370− } else {
371− mcpClient2Tools.Set(mcpName, tools)
372− }
373− for _, tools := range mcpClient2Tools.Seq2() {
374− for _, t := range tools {
375− mcpTools.Set(t.Name(), t)
376− }
377− }
378−}
379−
380−func createMCPSession(ctx context.Context, name string, m config.MCPConfig, resolver config.VariableResolver) (*mcp.ClientSession, error) {
381− timeout := mcpTimeout(m)
382− mcpCtx, cancel := context.WithCancel(ctx)
383− cancelTimer := time.AfterFunc(timeout, cancel)
384−
385− transport, err := createMCPTransport(mcpCtx, m, resolver)
386− if err != nil {
387− updateMCPState(name, MCPStateError, err, nil, 0)
388− slog.Error("error creating mcp client", "error", err, "name", name)
389− cancel()
390− cancelTimer.Stop()
391− return nil, err
392− }
393−
394− client := mcp.NewClient(
395− &mcp.Implementation{
396− Name: "crush",
397− Version: version.Version,
398− Title: "Crush",
399− },
400− &mcp.ClientOptions{
401− ToolListChangedHandler: func(context.Context, *mcp.ToolListChangedRequest) {
402− mcpBroker.Publish(pubsub.UpdatedEvent, MCPEvent{
403− Type: MCPEventToolsListChanged,
404− Name: name,
405− })
406− },
407− KeepAlive: time.Minute * 10,
408− },
409− )
410−
411− session, err := client.Connect(mcpCtx, transport, nil)
412− if err != nil {
413− err = maybeStdioErr(err, transport)
414− updateMCPState(name, MCPStateError, maybeTimeoutErr(err, timeout), nil, 0)
415− slog.Error("error starting mcp client", "error", err, "name", name)
416− cancel()
417− cancelTimer.Stop()
418− return nil, err
419− }
420−
421− cancelTimer.Stop()
422− slog.Info("Initialized mcp client", "name", name)
423− return session, nil
424−}
425−
426−// maybeStdioErr if a stdio mcp prints an error in non-json format, it'll fail
427−// to parse, and the cli will then close it, causing the EOF error.
428−// so, if we got an EOF err, and the transport is STDIO, we try to exec it
429−// again with a timeout and collect the output so we can add details to the
430−// error.
431−// this happens particularly when starting things with npx, e.g. if node can't
432−// be found or some other error like that.
433−func maybeStdioErr(err error, transport mcp.Transport) error {
434− if !errors.Is(err, io.EOF) {
435− return err
436− }
437− ct, ok := transport.(*mcp.CommandTransport)
438− if !ok {
439− return err
440− }
441− if err2 := stdioMCPCheck(ct.Command); err2 != nil {
442− err = errors.Join(err, err2)
443− }
444− return err
445−}
446−
447−func maybeTimeoutErr(err error, timeout time.Duration) error {
448− if errors.Is(err, context.Canceled) {
449− return fmt.Errorf("timed out after %s", timeout)
450− }
451− return err
452−}
453−
454−func createMCPTransport(ctx context.Context, m config.MCPConfig, resolver config.VariableResolver) (mcp.Transport, error) {
455− switch m.Type {
456− case config.MCPStdio:
457− command, err := resolver.ResolveValue(m.Command)
458− if err != nil {
459− return nil, fmt.Errorf("invalid mcp command: %w", err)
460− }
461− if strings.TrimSpace(command) == "" {
462− return nil, fmt.Errorf("mcp stdio config requires a non-empty 'command' field")
463− }
464− cmd := exec.CommandContext(ctx, home.Long(command), m.Args...)
465− cmd.Env = append(os.Environ(), m.ResolvedEnv()...)
466− return &mcp.CommandTransport{
467− Command: cmd,
468− }, nil
469− case config.MCPHttp:
470− if strings.TrimSpace(m.URL) == "" {
471− return nil, fmt.Errorf("mcp http config requires a non-empty 'url' field")
472− }
473− client := &http.Client{
474− Transport: &headerRoundTripper{
475− headers: m.ResolvedHeaders(),
476− },
477− }
478− return &mcp.StreamableClientTransport{
479− Endpoint: m.URL,
480− HTTPClient: client,
481− }, nil
482− case config.MCPSSE:
483− if strings.TrimSpace(m.URL) == "" {
484− return nil, fmt.Errorf("mcp sse config requires a non-empty 'url' field")
485− }
486− client := &http.Client{
487− Transport: &headerRoundTripper{
488− headers: m.ResolvedHeaders(),
489− },
490− }
491− return &mcp.SSEClientTransport{
492− Endpoint: m.URL,
493− HTTPClient: client,
494− }, nil
495− default:
496− return nil, fmt.Errorf("unsupported mcp type: %s", m.Type)
497− }
498−}
499−
500−type headerRoundTripper struct {
501− headers map[string]string
502−}
503−
504−func (rt headerRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
505− for k, v := range rt.headers {
506− req.Header.Set(k, v)
507− }
508− return http.DefaultTransport.RoundTrip(req)
509−}
510−
511−func mcpTimeout(m config.MCPConfig) time.Duration {
512− return time.Duration(cmp.Or(m.Timeout, 15)) * time.Second
513−}
514−
515−func stdioMCPCheck(old *exec.Cmd) error {
516− ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
517− defer cancel()
518− cmd := exec.CommandContext(ctx, old.Path, old.Args...)
519− cmd.Env = old.Env
520− out, err := cmd.CombinedOutput()
521− if err == nil || errors.Is(ctx.Err(), context.DeadlineExceeded) {
522− return nil
523− }
524− return fmt.Errorf("%w: %s", err, string(out))
525−}
internal/agent/tools/mcp/init.gonew file+400
@@ -0,0 +1,400 @@
1+package mcp
2+
3+import (
4+ "cmp"
5+ "context"
6+ "errors"
7+ "fmt"
8+ "io"
9+ "log/slog"
10+ "maps"
11+ "net/http"
12+ "os"
13+ "os/exec"
14+ "strings"
15+ "sync"
16+ "time"
17+
18+ "github.com/charmbracelet/crush/internal/config"
19+ "github.com/charmbracelet/crush/internal/csync"
20+ "github.com/charmbracelet/crush/internal/home"
21+ "github.com/charmbracelet/crush/internal/permission"
22+ "github.com/charmbracelet/crush/internal/pubsub"
23+ "github.com/charmbracelet/crush/internal/version"
24+ "github.com/modelcontextprotocol/go-sdk/mcp"
25+)
26+
27+var (
28+ sessions = csync.NewMap[string, *mcp.ClientSession]()
29+ states = csync.NewMap[string, ClientInfo]()
30+ broker = pubsub.NewBroker[Event]()
31+)
32+
33+// State represents the current state of an MCP client
34+type State int
35+
36+const (
37+ StateDisabled State = iota
38+ StateStarting
39+ StateConnected
40+ StateError
41+)
42+
43+func (s State) String() string {
44+ switch s {
45+ case StateDisabled:
46+ return "disabled"
47+ case StateStarting:
48+ return "starting"
49+ case StateConnected:
50+ return "connected"
51+ case StateError:
52+ return "error"
53+ default:
54+ return "unknown"
55+ }
56+}
57+
58+// EventType represents the type of MCP event
59+type EventType string
60+
61+const (
62+ EventStateChanged EventType = "state_changed"
63+ EventToolsListChanged EventType = "tools_list_changed"
64+ EventPromptsListChanged EventType = "prompts_list_changed"
65+)
66+
67+// Event represents an event in the MCP system
68+type Event struct {
69+ Type EventType
70+ Name string
71+ State State
72+ Error error
73+ Counts Counts
74+}
75+
76+// Counts number of available tools, prompts, etc.
77+type Counts struct {
78+ Tools int
79+ Prompts int
80+}
81+
82+// ClientInfo holds information about an MCP client's state
83+type ClientInfo struct {
84+ Name string
85+ State State
86+ Error error
87+ Client *mcp.ClientSession
88+ Counts Counts
89+ ConnectedAt time.Time
90+}
91+
92+// SubscribeEvents returns a channel for MCP events
93+func SubscribeEvents(ctx context.Context) <-chan pubsub.Event[Event] {
94+ return broker.Subscribe(ctx)
95+}
96+
97+// GetStates returns the current state of all MCP clients
98+func GetStates() map[string]ClientInfo {
99+ return maps.Collect(states.Seq2())
100+}
101+
102+// GetState returns the state of a specific MCP client
103+func GetState(name string) (ClientInfo, bool) {
104+ return states.Get(name)
105+}
106+
107+// Close closes all MCP clients. This should be called during application shutdown.
108+func Close() error {
109+ var errs []error
110+ for name, c := range sessions.Seq2() {
111+ if err := c.Close(); err != nil &&
112+ !errors.Is(err, io.EOF) &&
113+ !errors.Is(err, context.Canceled) &&
114+ err.Error() != "signal: killed" {
115+ errs = append(errs, fmt.Errorf("close mcp: %s: %w", name, err))
116+ }
117+ }
118+ broker.Shutdown()
119+ return errors.Join(errs...)
120+}
121+
122+// Initialize initializes MCP clients based on the provided configuration.
123+func Initialize(ctx context.Context, permissions permission.Service, cfg *config.Config) {
124+ var wg sync.WaitGroup
125+ // Initialize states for all configured MCPs
126+ for name, m := range cfg.MCP {
127+ if m.Disabled {
128+ updateState(name, StateDisabled, nil, nil, Counts{})
129+ slog.Debug("skipping disabled mcp", "name", name)
130+ continue
131+ }
132+
133+ // Set initial starting state
134+ updateState(name, StateStarting, nil, nil, Counts{})
135+
136+ wg.Add(1)
137+ go func(name string, m config.MCPConfig) {
138+ defer func() {
139+ wg.Done()
140+ if r := recover(); r != nil {
141+ var err error
142+ switch v := r.(type) {
143+ case error:
144+ err = v
145+ case string:
146+ err = fmt.Errorf("panic: %s", v)
147+ default:
148+ err = fmt.Errorf("panic: %v", v)
149+ }
150+ updateState(name, StateError, err, nil, Counts{})
151+ slog.Error("panic in mcp client initialization", "error", err, "name", name)
152+ }
153+ }()
154+
155+ ctx, cancel := context.WithTimeout(ctx, mcpTimeout(m))
156+ defer cancel()
157+
158+ session, err := createSession(ctx, name, m, cfg.Resolver())
159+ if err != nil {
160+ return
161+ }
162+
163+ tools, err := getTools(ctx, name, permissions, session, cfg.WorkingDir())
164+ if err != nil {
165+ slog.Error("error listing tools", "error", err)
166+ updateState(name, StateError, err, nil, Counts{})
167+ session.Close()
168+ return
169+ }
170+
171+ prompts, err := getPrompts(ctx, session)
172+ if err != nil {
173+ slog.Error("error listing prompts", "error", err)
174+ updateState(name, StateError, err, nil, Counts{})
175+ session.Close()
176+ return
177+ }
178+
179+ updateTools(name, tools)
180+ updatePrompts(name, prompts)
181+ sessions.Set(name, session)
182+
183+ updateState(name, StateConnected, nil, session, Counts{
184+ Tools: len(tools),
185+ Prompts: len(prompts),
186+ })
187+ }(name, m)
188+ }
189+ wg.Wait()
190+}
191+
192+func getOrRenewClient(ctx context.Context, name string) (*mcp.ClientSession, error) {
193+ sess, ok := sessions.Get(name)
194+ if !ok {
195+ return nil, fmt.Errorf("mcp '%s' not available", name)
196+ }
197+
198+ cfg := config.Get()
199+ m := cfg.MCP[name]
200+ state, _ := states.Get(name)
201+
202+ timeout := mcpTimeout(m)
203+ pingCtx, cancel := context.WithTimeout(ctx, timeout)
204+ defer cancel()
205+ err := sess.Ping(pingCtx, nil)
206+ if err == nil {
207+ return sess, nil
208+ }
209+ updateState(name, StateError, maybeTimeoutErr(err, timeout), nil, state.Counts)
210+
211+ sess, err = createSession(ctx, name, m, cfg.Resolver())
212+ if err != nil {
213+ return nil, err
214+ }
215+
216+ updateState(name, StateConnected, nil, sess, state.Counts)
217+ sessions.Set(name, sess)
218+ return sess, nil
219+}
220+
221+// updateState updates the state of an MCP client and publishes an event
222+func updateState(name string, state State, err error, client *mcp.ClientSession, counts Counts) {
223+ info := ClientInfo{
224+ Name: name,
225+ State: state,
226+ Error: err,
227+ Client: client,
228+ Counts: counts,
229+ }
230+ switch state {
231+ case StateConnected:
232+ info.ConnectedAt = time.Now()
233+ case StateError:
234+ updateTools(name, nil)
235+ sessions.Del(name)
236+ }
237+ states.Set(name, info)
238+
239+ // Publish state change event
240+ broker.Publish(pubsub.UpdatedEvent, Event{
241+ Type: EventStateChanged,
242+ Name: name,
243+ State: state,
244+ Error: err,
245+ Counts: counts,
246+ })
247+}
248+
249+func createSession(ctx context.Context, name string, m config.MCPConfig, resolver config.VariableResolver) (*mcp.ClientSession, error) {
250+ timeout := mcpTimeout(m)
251+ mcpCtx, cancel := context.WithCancel(ctx)
252+ cancelTimer := time.AfterFunc(timeout, cancel)
253+
254+ transport, err := createTransport(mcpCtx, m, resolver)
255+ if err != nil {
256+ updateState(name, StateError, err, nil, Counts{})
257+ slog.Error("error creating mcp client", "error", err, "name", name)
258+ cancel()
259+ cancelTimer.Stop()
260+ return nil, err
261+ }
262+
263+ client := mcp.NewClient(
264+ &mcp.Implementation{
265+ Name: "crush",
266+ Version: version.Version,
267+ Title: "Crush",
268+ },
269+ &mcp.ClientOptions{
270+ ToolListChangedHandler: func(context.Context, *mcp.ToolListChangedRequest) {
271+ broker.Publish(pubsub.UpdatedEvent, Event{
272+ Type: EventToolsListChanged,
273+ Name: name,
274+ })
275+ },
276+ PromptListChangedHandler: func(context.Context, *mcp.PromptListChangedRequest) {
277+ broker.Publish(pubsub.UpdatedEvent, Event{
278+ Type: EventPromptsListChanged,
279+ Name: name,
280+ })
281+ },
282+ KeepAlive: time.Minute * 10,
283+ },
284+ )
285+
286+ session, err := client.Connect(mcpCtx, transport, nil)
287+ if err != nil {
288+ err = maybeStdioErr(err, transport)
289+ updateState(name, StateError, maybeTimeoutErr(err, timeout), nil, Counts{})
290+ slog.Error("error starting mcp client", "error", err, "name", name)
291+ cancel()
292+ cancelTimer.Stop()
293+ return nil, err
294+ }
295+
296+ cancelTimer.Stop()
297+ slog.Info("Initialized mcp client", "name", name)
298+ return session, nil
299+}
300+
301+// maybeStdioErr if a stdio mcp prints an error in non-json format, it'll fail
302+// to parse, and the cli will then close it, causing the EOF error.
303+// so, if we got an EOF err, and the transport is STDIO, we try to exec it
304+// again with a timeout and collect the output so we can add details to the
305+// error.
306+// this happens particularly when starting things with npx, e.g. if node can't
307+// be found or some other error like that.
308+func maybeStdioErr(err error, transport mcp.Transport) error {
309+ if !errors.Is(err, io.EOF) {
310+ return err
311+ }
312+ ct, ok := transport.(*mcp.CommandTransport)
313+ if !ok {
314+ return err
315+ }
316+ if err2 := stdioCheck(ct.Command); err2 != nil {
317+ err = errors.Join(err, err2)
318+ }
319+ return err
320+}
321+
322+func maybeTimeoutErr(err error, timeout time.Duration) error {
323+ if errors.Is(err, context.Canceled) {
324+ return fmt.Errorf("timed out after %s", timeout)
325+ }
326+ return err
327+}
328+
329+func createTransport(ctx context.Context, m config.MCPConfig, resolver config.VariableResolver) (mcp.Transport, error) {
330+ switch m.Type {
331+ case config.MCPStdio:
332+ command, err := resolver.ResolveValue(m.Command)
333+ if err != nil {
334+ return nil, fmt.Errorf("invalid mcp command: %w", err)
335+ }
336+ if strings.TrimSpace(command) == "" {
337+ return nil, fmt.Errorf("mcp stdio config requires a non-empty 'command' field")
338+ }
339+ cmd := exec.CommandContext(ctx, home.Long(command), m.Args...)
340+ cmd.Env = append(os.Environ(), m.ResolvedEnv()...)
341+ return &mcp.CommandTransport{
342+ Command: cmd,
343+ }, nil
344+ case config.MCPHttp:
345+ if strings.TrimSpace(m.URL) == "" {
346+ return nil, fmt.Errorf("mcp http config requires a non-empty 'url' field")
347+ }
348+ client := &http.Client{
349+ Transport: &headerRoundTripper{
350+ headers: m.ResolvedHeaders(),
351+ },
352+ }
353+ return &mcp.StreamableClientTransport{
354+ Endpoint: m.URL,
355+ HTTPClient: client,
356+ }, nil
357+ case config.MCPSSE:
358+ if strings.TrimSpace(m.URL) == "" {
359+ return nil, fmt.Errorf("mcp sse config requires a non-empty 'url' field")
360+ }
361+ client := &http.Client{
362+ Transport: &headerRoundTripper{
363+ headers: m.ResolvedHeaders(),
364+ },
365+ }
366+ return &mcp.SSEClientTransport{
367+ Endpoint: m.URL,
368+ HTTPClient: client,
369+ }, nil
370+ default:
371+ return nil, fmt.Errorf("unsupported mcp type: %s", m.Type)
372+ }
373+}
374+
375+type headerRoundTripper struct {
376+ headers map[string]string
377+}
378+
379+func (rt headerRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
380+ for k, v := range rt.headers {
381+ req.Header.Set(k, v)
382+ }
383+ return http.DefaultTransport.RoundTrip(req)
384+}
385+
386+func mcpTimeout(m config.MCPConfig) time.Duration {
387+ return time.Duration(cmp.Or(m.Timeout, 15)) * time.Second
388+}
389+
390+func stdioCheck(old *exec.Cmd) error {
391+ ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
392+ defer cancel()
393+ cmd := exec.CommandContext(ctx, old.Path, old.Args...)
394+ cmd.Env = old.Env
395+ out, err := cmd.CombinedOutput()
396+ if err == nil || errors.Is(ctx.Err(), context.DeadlineExceeded) {
397+ return nil
398+ }
399+ return fmt.Errorf("%w: %s", err, string(out))
400+}
internal/agent/tools/mcp/prompts.gonew file+83
@@ -0,0 +1,83 @@
1+package mcp
2+
3+import (
4+ "context"
5+ "iter"
6+
7+ "github.com/charmbracelet/crush/internal/csync"
8+ "github.com/modelcontextprotocol/go-sdk/mcp"
9+)
10+
11+type Prompt = mcp.Prompt
12+
13+var (
14+ allPrompts = csync.NewMap[string, *Prompt]()
15+ client2Prompts = csync.NewMap[string, []*Prompt]()
16+)
17+
18+// GetPrompts returns all available MCP prompts.
19+func GetPrompts() iter.Seq2[string, *Prompt] {
20+ return allPrompts.Seq2()
21+}
22+
23+// GetPrompt returns a specific MCP prompt by name.
24+func GetPrompt(name string) (*Prompt, bool) {
25+ return allPrompts.Get(name)
26+}
27+
28+// GetPromptsByClient returns all prompts for a specific MCP client.
29+func GetPromptsByClient(clientName string) ([]*Prompt, bool) {
30+ return client2Prompts.Get(clientName)
31+}
32+
33+// GetPromptMessages retrieves the content of an MCP prompt with the given arguments.
34+func GetPromptMessages(ctx context.Context, clientName, promptName string, args map[string]string) ([]string, error) {
35+ c, err := getOrRenewClient(ctx, clientName)
36+ if err != nil {
37+ return nil, err
38+ }
39+ result, err := c.GetPrompt(ctx, &mcp.GetPromptParams{
40+ Name: promptName,
41+ Arguments: args,
42+ })
43+ if err != nil {
44+ return nil, err
45+ }
46+
47+ var messages []string
48+ for _, msg := range result.Messages {
49+ if msg.Role != "user" {
50+ continue
51+ }
52+ if textContent, ok := msg.Content.(*mcp.TextContent); ok {
53+ messages = append(messages, textContent.Text)
54+ }
55+ }
56+ return messages, nil
57+}
58+
59+func getPrompts(ctx context.Context, c *mcp.ClientSession) ([]*Prompt, error) {
60+ if c.InitializeResult().Capabilities.Prompts == nil {
61+ return nil, nil
62+ }
63+ result, err := c.ListPrompts(ctx, &mcp.ListPromptsParams{})
64+ if err != nil {
65+ return nil, err
66+ }
67+ return result.Prompts, nil
68+}
69+
70+// updatePrompts updates the global mcpPrompts and mcpClient2Prompts maps
71+func updatePrompts(mcpName string, prompts []*Prompt) {
72+ if len(prompts) == 0 {
73+ client2Prompts.Del(mcpName)
74+ } else {
75+ client2Prompts.Set(mcpName, prompts)
76+ }
77+ for mcpName, prompts := range client2Prompts.Seq2() {
78+ for _, p := range prompts {
79+ key := mcpName + ":" + p.Name
80+ allPrompts.Set(key, p)
81+ }
82+ }
83+}
internal/agent/tools/mcp/tools.gonew file+169
@@ -0,0 +1,169 @@
1+package mcp
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "iter"
8+ "strings"
9+
10+ "charm.land/fantasy"
11+ "github.com/charmbracelet/crush/internal/agent/tools"
12+ "github.com/charmbracelet/crush/internal/csync"
13+ "github.com/charmbracelet/crush/internal/permission"
14+ "github.com/modelcontextprotocol/go-sdk/mcp"
15+)
16+
17+var (
18+ allTools = csync.NewMap[string, *Tool]()
19+ client2Tools = csync.NewMap[string, []*Tool]()
20+)
21+
22+// GetMCPTools returns all available MCP tools.
23+func GetMCPTools() iter.Seq[*Tool] {
24+ return allTools.Seq()
25+}
26+
27+type Tool struct {
28+ mcpName string
29+ tool *mcp.Tool
30+ permissions permission.Service
31+ workingDir string
32+ providerOptions fantasy.ProviderOptions
33+}
34+
35+func (m *Tool) SetProviderOptions(opts fantasy.ProviderOptions) {
36+ m.providerOptions = opts
37+}
38+
39+func (m *Tool) ProviderOptions() fantasy.ProviderOptions {
40+ return m.providerOptions
41+}
42+
43+func (m *Tool) Name() string {
44+ return fmt.Sprintf("mcp_%s_%s", m.mcpName, m.tool.Name)
45+}
46+
47+func (m *Tool) MCP() string {
48+ return m.mcpName
49+}
50+
51+func (m *Tool) MCPToolName() string {
52+ return m.tool.Name
53+}
54+
55+func (m *Tool) Info() fantasy.ToolInfo {
56+ parameters := make(map[string]any)
57+ required := make([]string, 0)
58+
59+ if input, ok := m.tool.InputSchema.(map[string]any); ok {
60+ if props, ok := input["properties"].(map[string]any); ok {
61+ parameters = props
62+ }
63+ if req, ok := input["required"].([]any); ok {
64+ // Convert []any -> []string when elements are strings
65+ for _, v := range req {
66+ if s, ok := v.(string); ok {
67+ required = append(required, s)
68+ }
69+ }
70+ } else if reqStr, ok := input["required"].([]string); ok {
71+ // Handle case where it's already []string
72+ required = reqStr
73+ }
74+ }
75+
76+ return fantasy.ToolInfo{
77+ Name: fmt.Sprintf("mcp_%s_%s", m.mcpName, m.tool.Name),
78+ Description: m.tool.Description,
79+ Parameters: parameters,
80+ Required: required,
81+ }
82+}
83+
84+func (m *Tool) Run(ctx context.Context, params fantasy.ToolCall) (fantasy.ToolResponse, error) {
85+ sessionID := tools.GetSessionFromContext(ctx)
86+ if sessionID == "" {
87+ return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
88+ }
89+ permissionDescription := fmt.Sprintf("execute %s with the following parameters:", m.Info().Name)
90+ p := m.permissions.Request(
91+ permission.CreatePermissionRequest{
92+ SessionID: sessionID,
93+ ToolCallID: params.ID,
94+ Path: m.workingDir,
95+ ToolName: m.Info().Name,
96+ Action: "execute",
97+ Description: permissionDescription,
98+ Params: params.Input,
99+ },
100+ )
101+ if !p {
102+ return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
103+ }
104+
105+ return runTool(ctx, m.mcpName, m.tool.Name, params.Input)
106+}
107+
108+func runTool(ctx context.Context, name, toolName string, input string) (fantasy.ToolResponse, error) {
109+ var args map[string]any
110+ if err := json.Unmarshal([]byte(input), &args); err != nil {
111+ return fantasy.NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
112+ }
113+
114+ c, err := getOrRenewClient(ctx, name)
115+ if err != nil {
116+ return fantasy.NewTextErrorResponse(err.Error()), nil
117+ }
118+ result, err := c.CallTool(ctx, &mcp.CallToolParams{
119+ Name: toolName,
120+ Arguments: args,
121+ })
122+ if err != nil {
123+ return fantasy.NewTextErrorResponse(err.Error()), nil
124+ }
125+
126+ output := make([]string, 0, len(result.Content))
127+ for _, v := range result.Content {
128+ if vv, ok := v.(*mcp.TextContent); ok {
129+ output = append(output, vv.Text)
130+ } else {
131+ output = append(output, fmt.Sprintf("%v", v))
132+ }
133+ }
134+ return fantasy.NewTextResponse(strings.Join(output, "\n")), nil
135+}
136+
137+func getTools(ctx context.Context, name string, permissions permission.Service, c *mcp.ClientSession, workingDir string) ([]*Tool, error) {
138+ if c.InitializeResult().Capabilities.Tools == nil {
139+ return nil, nil
140+ }
141+ result, err := c.ListTools(ctx, &mcp.ListToolsParams{})
142+ if err != nil {
143+ return nil, err
144+ }
145+ mcpTools := make([]*Tool, 0, len(result.Tools))
146+ for _, tool := range result.Tools {
147+ mcpTools = append(mcpTools, &Tool{
148+ mcpName: name,
149+ tool: tool,
150+ permissions: permissions,
151+ workingDir: workingDir,
152+ })
153+ }
154+ return mcpTools, nil
155+}
156+
157+// updateTools updates the global mcpTools and mcpClient2Tools maps
158+func updateTools(mcpName string, tools []*Tool) {
159+ if len(tools) == 0 {
160+ client2Tools.Del(mcpName)
161+ } else {
162+ client2Tools.Set(mcpName, tools)
163+ }
164+ for _, tools := range client2Tools.Seq2() {
165+ for _, t := range tools {
166+ allTools.Set(t.Name(), t)
167+ }
168+ }
169+}
internal/app/app.go+8−3
@@ -16,7 +16,7 @@ import (
1616 "charm.land/fantasy"
1717 tea "github.com/charmbracelet/bubbletea/v2"
1818 "github.com/charmbracelet/crush/internal/agent"
19− "github.com/charmbracelet/crush/internal/agent/tools"
19+ "github.com/charmbracelet/crush/internal/agent/tools/mcp"
2020 "github.com/charmbracelet/crush/internal/config"
2121 "github.com/charmbracelet/crush/internal/csync"
2222 "github.com/charmbracelet/crush/internal/db"
@@ -91,6 +91,11 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) {
9191 // Initialize LSP clients in the background.
9292 app.initLSPClients(ctx)
9393
94+ go func() {
95+ slog.Info("Initializing MCP clients")
96+ mcp.Initialize(ctx, app.Permissions, cfg)
97+ }()
98+
9499 // cleanup database upon app shutdown
95100 app.cleanupFuncs = append(app.cleanupFuncs, conn.Close)
96101
@@ -260,7 +265,7 @@ func (app *App) setupEvents() {
260265 setupSubscriber(ctx, app.serviceEventsWG, "permissions", app.Permissions.Subscribe, app.events)
261266 setupSubscriber(ctx, app.serviceEventsWG, "permissions-notifications", app.Permissions.SubscribeNotifications, app.events)
262267 setupSubscriber(ctx, app.serviceEventsWG, "history", app.History.Subscribe, app.events)
263− setupSubscriber(ctx, app.serviceEventsWG, "mcp", tools.SubscribeMCPEvents, app.events)
268+ setupSubscriber(ctx, app.serviceEventsWG, "mcp", mcp.SubscribeEvents, app.events)
264269 setupSubscriber(ctx, app.serviceEventsWG, "lsp", SubscribeLSPEvents, app.events)
265270 cleanupFunc := func() error {
266271 cancel()
@@ -324,7 +329,7 @@ func (app *App) InitCoderAgent(ctx context.Context) error {
324329 }
325330
326331 // Add MCP client cleanup to shutdown process
327− app.cleanupFuncs = append(app.cleanupFuncs, tools.CloseMCPClients)
332+ app.cleanupFuncs = append(app.cleanupFuncs, mcp.Close)
328333 return nil
329334}
330335
internal/tui/components/dialogs/commands/arguments.go+93−69
@@ -1,8 +1,7 @@
11package commands
22
33import (
4− "fmt"
5− "strings"
4+ "cmp"
65
76 "github.com/charmbracelet/bubbles/v2/help"
87 "github.com/charmbracelet/bubbles/v2/key"
@@ -20,9 +19,10 @@ const (
2019
2120// ShowArgumentsDialogMsg is a message that is sent to show the arguments dialog.
2221type ShowArgumentsDialogMsg struct {
23− CommandID string
24− Content string
25− ArgNames []string
22+ CommandID string
23+ Description string
24+ ArgNames []string
25+ OnSubmit func(args map[string]string) tea.Cmd
2626}
2727
2828// CloseArgumentsDialogMsg is a message that is sent when the arguments dialog is closed.
@@ -39,26 +39,39 @@ type CommandArgumentsDialog interface {
3939}
4040
4141type commandArgumentsDialogCmp struct {
42− width int
43− wWidth int // Width of the terminal window
44− wHeight int // Height of the terminal window
45−
46− inputs []textinput.Model
47− focusIndex int
48− keys ArgumentsDialogKeyMap
49− commandID string
50− content string
51− argNames []string
52− help help.Model
42+ wWidth, wHeight int
43+ width, height int
44+
45+ inputs []textinput.Model
46+ focused int
47+ keys ArgumentsDialogKeyMap
48+ arguments []Argument
49+ help help.Model
50+
51+ id string
52+ title string
53+ name string
54+ description string
55+
56+ onSubmit func(args map[string]string) tea.Cmd
57+}
58+
59+type Argument struct {
60+ Name, Title, Description string
61+ Required bool
5362}
5463
55−func NewCommandArgumentsDialog(commandID, content string, argNames []string) CommandArgumentsDialog {
64+func NewCommandArgumentsDialog(
65+ id, title, name, description string,
66+ arguments []Argument,
67+ onSubmit func(args map[string]string) tea.Cmd,
68+) CommandArgumentsDialog {
5669 t := styles.CurrentTheme()
57− inputs := make([]textinput.Model, len(argNames))
70+ inputs := make([]textinput.Model, len(arguments))
5871
59− for i, name := range argNames {
72+ for i, arg := range arguments {
6073 ti := textinput.New()
61− ti.Placeholder = fmt.Sprintf("Enter value for %s...", name)
74+ ti.Placeholder = cmp.Or(arg.Description, "Enter value for "+arg.Title)
6275 ti.SetWidth(40)
6376 ti.SetVirtualCursor(false)
6477 ti.Prompt = ""
@@ -75,14 +88,16 @@ func NewCommandArgumentsDialog(commandID, content string, argNames []string) Com
7588 }
7689
7790 return &commandArgumentsDialogCmp{
78− inputs: inputs,
79− keys: DefaultArgumentsDialogKeyMap(),
80− commandID: commandID,
81− content: content,
82− argNames: argNames,
83− focusIndex: 0,
84− width: 60,
85− help: help.New(),
91+ inputs: inputs,
92+ keys: DefaultArgumentsDialogKeyMap(),
93+ id: id,
94+ name: name,
95+ title: title,
96+ description: description,
97+ arguments: arguments,
98+ width: 60,
99+ help: help.New(),
100+ onSubmit: onSubmit,
86101 }
87102}
88103
@@ -97,47 +112,51 @@ func (c *commandArgumentsDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
97112 case tea.WindowSizeMsg:
98113 c.wWidth = msg.Width
99114 c.wHeight = msg.Height
115+ c.width = min(90, c.wWidth)
116+ c.height = min(15, c.wHeight)
117+ for i := range c.inputs {
118+ c.inputs[i].SetWidth(c.width - (paddingHorizontal * 2))
119+ }
100120 case tea.KeyPressMsg:
101121 switch {
122+ case key.Matches(msg, c.keys.Close):
123+ return c, util.CmdHandler(dialogs.CloseDialogMsg{})
102124 case key.Matches(msg, c.keys.Confirm):
103− if c.focusIndex == len(c.inputs)-1 {
104− content := c.content
105− for i, name := range c.argNames {
125+ if c.focused == len(c.inputs)-1 {
126+ args := make(map[string]string)
127+ for i, arg := range c.arguments {
106128 value := c.inputs[i].Value()
107− placeholder := "$" + name
108− content = strings.ReplaceAll(content, placeholder, value)
129+ args[arg.Name] = value
109130 }
110131 return c, tea.Sequence(
111132 util.CmdHandler(dialogs.CloseDialogMsg{}),
112− util.CmdHandler(CommandRunCustomMsg{
113− Content: content,
114− }),
133+ c.onSubmit(args),
115134 )
116135 }
117136 // Otherwise, move to the next input
118− c.inputs[c.focusIndex].Blur()
119− c.focusIndex++
120− c.inputs[c.focusIndex].Focus()
137+ c.inputs[c.focused].Blur()
138+ c.focused++
139+ c.inputs[c.focused].Focus()
121140 case key.Matches(msg, c.keys.Next):
122141 // Move to the next input
123− c.inputs[c.focusIndex].Blur()
124− c.focusIndex = (c.focusIndex + 1) % len(c.inputs)
125− c.inputs[c.focusIndex].Focus()
142+ c.inputs[c.focused].Blur()
143+ c.focused = (c.focused + 1) % len(c.inputs)
144+ c.inputs[c.focused].Focus()
126145 case key.Matches(msg, c.keys.Previous):
127146 // Move to the previous input
128− c.inputs[c.focusIndex].Blur()
129− c.focusIndex = (c.focusIndex - 1 + len(c.inputs)) % len(c.inputs)
130− c.inputs[c.focusIndex].Focus()
147+ c.inputs[c.focused].Blur()
148+ c.focused = (c.focused - 1 + len(c.inputs)) % len(c.inputs)
149+ c.inputs[c.focused].Focus()
131150 case key.Matches(msg, c.keys.Close):
132151 return c, util.CmdHandler(dialogs.CloseDialogMsg{})
133152 default:
134153 var cmd tea.Cmd
135− c.inputs[c.focusIndex], cmd = c.inputs[c.focusIndex].Update(msg)
154+ c.inputs[c.focused], cmd = c.inputs[c.focused].Update(msg)
136155 return c, cmd
137156 }
138157 case tea.PasteMsg:
139158 var cmd tea.Cmd
140− c.inputs[c.focusIndex], cmd = c.inputs[c.focusIndex].Update(msg)
159+ c.inputs[c.focused], cmd = c.inputs[c.focused].Update(msg)
141160 return c, cmd
142161 }
143162 return c, nil
@@ -152,26 +171,28 @@ func (c *commandArgumentsDialogCmp) View() string {
152171 Foreground(t.Primary).
153172 Bold(true).
154173 Padding(0, 1).
155− Render("Command Arguments")
174+ Render(cmp.Or(c.title, c.name))
156175
157− explanation := t.S().Text.
176+ promptName := t.S().Text.
158177 Padding(0, 1).
159− Render("This command requires arguments.")
178+ Render(c.description)
160179
161− // Create input fields for each argument
162180 inputFields := make([]string, len(c.inputs))
163181 for i, input := range c.inputs {
164− // Highlight the label of the focused input
165− labelStyle := baseStyle.
166− Padding(1, 1, 0, 1)
182+ labelStyle := baseStyle.Padding(1, 1, 0, 1)
167183
168− if i == c.focusIndex {
184+ if i == c.focused {
169185 labelStyle = labelStyle.Foreground(t.FgBase).Bold(true)
170186 } else {
171187 labelStyle = labelStyle.Foreground(t.FgMuted)
172188 }
173189
174− label := labelStyle.Render(c.argNames[i] + ":")
190+ arg := c.arguments[i]
191+ argName := cmp.Or(arg.Title, arg.Name)
192+ if arg.Required {
193+ argName += "*"
194+ }
195+ label := labelStyle.Render(argName + ":")
175196
176197 field := t.S().Text.
177198 Padding(0, 1).
@@ -180,18 +201,14 @@ func (c *commandArgumentsDialogCmp) View() string {
180201 inputFields[i] = lipgloss.JoinVertical(lipgloss.Left, label, field)
181202 }
182203
183− // Join all elements vertically
184− elements := []string{title, explanation}
204+ elements := []string{title, promptName}
185205 elements = append(elements, inputFields...)
186206
187207 c.help.ShowAll = false
188208 helpText := baseStyle.Padding(0, 1).Render(c.help.View(c.keys))
189209 elements = append(elements, "", helpText)
190210
191− content := lipgloss.JoinVertical(
192− lipgloss.Left,
193− elements...,
194− )
211+ content := lipgloss.JoinVertical(lipgloss.Left, elements...)
195212
196213 return baseStyle.Padding(1, 1, 0, 1).
197214 Border(lipgloss.RoundedBorder()).
@@ -201,26 +218,33 @@ func (c *commandArgumentsDialogCmp) View() string {
201218}
202219
203220func (c *commandArgumentsDialogCmp) Cursor() *tea.Cursor {
204− cursor := c.inputs[c.focusIndex].Cursor()
221+ if len(c.inputs) == 0 {
222+ return nil
223+ }
224+ cursor := c.inputs[c.focused].Cursor()
205225 if cursor != nil {
206226 cursor = c.moveCursor(cursor)
207227 }
208228 return cursor
209229}
210230
231+const (
232+ headerHeight = 3
233+ itemHeight = 3
234+ paddingHorizontal = 3
235+)
236+
211237func (c *commandArgumentsDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
212238 row, col := c.Position()
213− offset := row + 3 + (1+c.focusIndex)*3
239+ offset := row + headerHeight + (1+c.focused)*itemHeight
214240 cursor.Y += offset
215− cursor.X = cursor.X + col + 3
241+ cursor.X = cursor.X + col + paddingHorizontal
216242 return cursor
217243}
218244
219245func (c *commandArgumentsDialogCmp) Position() (int, int) {
220− row := c.wHeight / 2
221− row -= c.wHeight / 2
222− col := c.wWidth / 2
223− col -= c.width / 2
246+ row := (c.wHeight / 2) - (c.height / 2)
247+ col := (c.wWidth / 2) - (c.width / 2)
224248 return row, col
225249}
226250
internal/tui/components/dialogs/commands/commands.go+79−25
@@ -2,6 +2,8 @@ package commands
22
33import (
44 "os"
5+ "slices"
6+ "strings"
57
68 "github.com/charmbracelet/bubbles/v2/help"
79 "github.com/charmbracelet/bubbles/v2/key"
@@ -10,7 +12,10 @@ import (
1012 "github.com/charmbracelet/lipgloss/v2"
1113
1214 "github.com/charmbracelet/crush/internal/agent"
15+ "github.com/charmbracelet/crush/internal/agent/tools/mcp"
1316 "github.com/charmbracelet/crush/internal/config"
17+ "github.com/charmbracelet/crush/internal/csync"
18+ "github.com/charmbracelet/crush/internal/pubsub"
1419 "github.com/charmbracelet/crush/internal/tui/components/chat"
1520 "github.com/charmbracelet/crush/internal/tui/components/core"
1621 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
@@ -25,9 +30,14 @@ const (
2530 defaultWidth int = 70
2631)
2732
33+type commandType uint
34+
35+func (c commandType) String() string { return []string{"System", "User", "MCP"}[c] }
36+
2837const (
29− SystemCommands int = iota
38+ SystemCommands commandType = iota
3039 UserCommands
40+ MCPPrompts
3141)
3242
3343type listModel = list.FilterableList[list.CompletionItem[Command]]
@@ -54,9 +64,10 @@ type commandDialogCmp struct {
5464 commandList listModel
5565 keyMap CommandsDialogKeyMap
5666 help help.Model
57− commandType int // SystemCommands or UserCommands
58− userCommands []Command // User-defined commands
59− sessionID string // Current session ID
67+ selected commandType // Selected SystemCommands, UserCommands, or MCPPrompts
68+ userCommands []Command // User-defined commands
69+ mcpPrompts *csync.Slice[Command] // MCP prompts
70+ sessionID string // Current session ID
6071}
6172
6273type (
@@ -102,8 +113,9 @@ func NewCommandDialog(sessionID string) CommandsDialog {
102113 width: defaultWidth,
103114 keyMap: DefaultCommandsDialogKeyMap(),
104115 help: help,
105− commandType: SystemCommands,
116+ selected: SystemCommands,
106117 sessionID: sessionID,
118+ mcpPrompts: csync.NewSlice[Command](),
107119 }
108120}
109121
@@ -113,7 +125,8 @@ func (c *commandDialogCmp) Init() tea.Cmd {
113125 return util.ReportError(err)
114126 }
115127 c.userCommands = commands
116− return c.SetCommandType(c.commandType)
128+ c.mcpPrompts.SetSlice(loadMCPPrompts())
129+ return c.setCommandType(c.selected)
117130}
118131
119132func (c *commandDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
@@ -122,9 +135,19 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
122135 c.wWidth = msg.Width
123136 c.wHeight = msg.Height
124137 return c, tea.Batch(
125− c.SetCommandType(c.commandType),
138+ c.setCommandType(c.selected),
126139 c.commandList.SetSize(c.listWidth(), c.listHeight()),
127140 )
141+ case pubsub.Event[mcp.Event]:
142+ // Reload MCP prompts when MCP state changes
143+ if msg.Type == pubsub.UpdatedEvent {
144+ c.mcpPrompts.SetSlice(loadMCPPrompts())
145+ // If we're currently viewing MCP prompts, refresh the list
146+ if c.selected == MCPPrompts {
147+ return c, c.setCommandType(MCPPrompts)
148+ }
149+ return c, nil
150+ }
128151 case tea.KeyPressMsg:
129152 switch {
130153 case key.Matches(msg, c.keyMap.Select):
@@ -138,15 +161,10 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
138161 command.Handler(command),
139162 )
140163 case key.Matches(msg, c.keyMap.Tab):
141− if len(c.userCommands) == 0 {
164+ if len(c.userCommands) == 0 && c.mcpPrompts.Len() == 0 {
142165 return c, nil
143166 }
144− // Toggle command type between System and User commands
145− if c.commandType == SystemCommands {
146− return c, c.SetCommandType(UserCommands)
147− } else {
148− return c, c.SetCommandType(SystemCommands)
149− }
167+ return c, c.setCommandType(c.next())
150168 case key.Matches(msg, c.keyMap.Close):
151169 return c, util.CmdHandler(dialogs.CloseDialogMsg{})
152170 default:
@@ -158,13 +176,35 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
158176 return c, nil
159177}
160178
179+func (c *commandDialogCmp) next() commandType {
180+ switch c.selected {
181+ case SystemCommands:
182+ if len(c.userCommands) > 0 {
183+ return UserCommands
184+ }
185+ if c.mcpPrompts.Len() > 0 {
186+ return MCPPrompts
187+ }
188+ fallthrough
189+ case UserCommands:
190+ if c.mcpPrompts.Len() > 0 {
191+ return MCPPrompts
192+ }
193+ fallthrough
194+ case MCPPrompts:
195+ return SystemCommands
196+ default:
197+ return SystemCommands
198+ }
199+}
200+
161201func (c *commandDialogCmp) View() string {
162202 t := styles.CurrentTheme()
163203 listView := c.commandList
164204 radio := c.commandTypeRadio()
165205
166206 header := t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-lipgloss.Width(radio)-5) + " " + radio)
167− if len(c.userCommands) == 0 {
207+ if len(c.userCommands) == 0 && c.mcpPrompts.Len() == 0 {
168208 header = t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-4))
169209 }
170210 content := lipgloss.JoinVertical(
@@ -190,27 +230,41 @@ func (c *commandDialogCmp) Cursor() *tea.Cursor {
190230
191231func (c *commandDialogCmp) commandTypeRadio() string {
192232 t := styles.CurrentTheme()
193− choices := []string{"System", "User"}
194− iconSelected := "◉"
195− iconUnselected := "○"
196− if c.commandType == SystemCommands {
197− return t.S().Base.Foreground(t.FgHalfMuted).Render(iconSelected + " " + choices[0] + " " + iconUnselected + " " + choices[1])
233+
234+ fn := func(i commandType) string {
235+ if i == c.selected {
236+ return "◉ " + i.String()
237+ }
238+ return "○ " + i.String()
239+ }
240+
241+ parts := []string{
242+ fn(SystemCommands),
243+ }
244+ if len(c.userCommands) > 0 {
245+ parts = append(parts, fn(UserCommands))
246+ }
247+ if c.mcpPrompts.Len() > 0 {
248+ parts = append(parts, fn(MCPPrompts))
198249 }
199− return t.S().Base.Foreground(t.FgHalfMuted).Render(iconUnselected + " " + choices[0] + " " + iconSelected + " " + choices[1])
250+ return t.S().Base.Foreground(t.FgHalfMuted).Render(strings.Join(parts, " "))
200251}
201252
202253func (c *commandDialogCmp) listWidth() int {
203254 return defaultWidth - 2 // 4 for padding
204255}
205256
206−func (c *commandDialogCmp) SetCommandType(commandType int) tea.Cmd {
207− c.commandType = commandType
257+func (c *commandDialogCmp) setCommandType(commandType commandType) tea.Cmd {
258+ c.selected = commandType
208259
209260 var commands []Command
210− if c.commandType == SystemCommands {
261+ switch c.selected {
262+ case SystemCommands:
211263 commands = c.defaultCommands()
212− } else {
264+ case UserCommands:
213265 commands = c.userCommands
266+ case MCPPrompts:
267+ commands = slices.Collect(c.mcpPrompts.Seq())
214268 }
215269
216270 commandItems := []list.CompletionItem[Command]{}
internal/tui/components/dialogs/commands/loader.go+86−15
@@ -1,22 +1,26 @@
11package commands
22
33import (
4+ "context"
45 "fmt"
56 "io/fs"
7+ "log/slog"
68 "os"
79 "path/filepath"
810 "regexp"
911 "strings"
1012
1113 tea "github.com/charmbracelet/bubbletea/v2"
14+ "github.com/charmbracelet/crush/internal/agent/tools/mcp"
1215 "github.com/charmbracelet/crush/internal/config"
1316 "github.com/charmbracelet/crush/internal/home"
17+ "github.com/charmbracelet/crush/internal/tui/components/chat"
1418 "github.com/charmbracelet/crush/internal/tui/util"
1519)
1620
1721const (
18− UserCommandPrefix = "user:"
19− ProjectCommandPrefix = "project:"
22+ userCommandPrefix = "user:"
23+ projectCommandPrefix = "project:"
2024)
2125
2226var namedArgPattern = regexp.MustCompile(`\$([A-Z][A-Z0-9_]*)`)
@@ -50,7 +54,7 @@ func buildCommandSources(cfg *config.Config) []commandSource {
5054 if dir := getXDGCommandsDir(); dir != "" {
5155 sources = append(sources, commandSource{
5256 path: dir,
53− prefix: UserCommandPrefix,
57+ prefix: userCommandPrefix,
5458 })
5559 }
5660
@@ -58,14 +62,14 @@ func buildCommandSources(cfg *config.Config) []commandSource {
5862 if home := home.Dir(); home != "" {
5963 sources = append(sources, commandSource{
6064 path: filepath.Join(home, ".crush", "commands"),
61− prefix: UserCommandPrefix,
65+ prefix: userCommandPrefix,
6266 })
6367 }
6468
6569 // Project directory
6670 sources = append(sources, commandSource{
6771 path: filepath.Join(cfg.Options.DataDirectory, "commands"),
68− prefix: ProjectCommandPrefix,
72+ prefix: projectCommandPrefix,
6973 })
7074
7175 return sources
@@ -127,12 +131,13 @@ func (l *commandLoader) loadCommand(path, baseDir, prefix string) (Command, erro
127131 }
128132
129133 id := buildCommandID(path, baseDir, prefix)
134+ desc := fmt.Sprintf("Custom command from %s", filepath.Base(path))
130135
131136 return Command{
132137 ID: id,
133138 Title: id,
134− Description: fmt.Sprintf("Custom command from %s", filepath.Base(path)),
135− Handler: createCommandHandler(id, string(content)),
139+ Description: desc,
140+ Handler: createCommandHandler(id, desc, string(content)),
136141 }, nil
137142}
138143
@@ -149,21 +154,35 @@ func buildCommandID(path, baseDir, prefix string) string {
149154 return prefix + strings.Join(parts, ":")
150155}
151156
152−func createCommandHandler(id string, content string) func(Command) tea.Cmd {
157+func createCommandHandler(id, desc, content string) func(Command) tea.Cmd {
153158 return func(cmd Command) tea.Cmd {
154159 args := extractArgNames(content)
155160
156− if len(args) > 0 {
157− return util.CmdHandler(ShowArgumentsDialogMsg{
158− CommandID: id,
159− Content: content,
160− ArgNames: args,
161+ if len(args) == 0 {
162+ return util.CmdHandler(CommandRunCustomMsg{
163+ Content: content,
161164 })
162165 }
166+ return util.CmdHandler(ShowArgumentsDialogMsg{
167+ CommandID: id,
168+ Description: desc,
169+ ArgNames: args,
170+ OnSubmit: func(args map[string]string) tea.Cmd {
171+ return execUserPrompt(content, args)
172+ },
173+ })
174+ }
175+}
163176
164− return util.CmdHandler(CommandRunCustomMsg{
177+func execUserPrompt(content string, args map[string]string) tea.Cmd {
178+ return func() tea.Msg {
179+ for name, value := range args {
180+ placeholder := "$" + name
181+ content = strings.ReplaceAll(content, placeholder, value)
182+ }
183+ return CommandRunCustomMsg{
165184 Content: content,
166− })
185+ }
167186 }
168187}
169188
@@ -201,3 +220,55 @@ func isMarkdownFile(name string) bool {
201220type CommandRunCustomMsg struct {
202221 Content string
203222}
223+
224+func loadMCPPrompts() []Command {
225+ var commands []Command
226+ for key, prompt := range mcp.GetPrompts() {
227+ clientName, promptName, ok := strings.Cut(key, ":")
228+ if !ok {
229+ slog.Warn("prompt not found", "key", key)
230+ continue
231+ }
232+ commands = append(commands, Command{
233+ ID: key,
234+ Title: clientName + ":" + promptName,
235+ Description: prompt.Description,
236+ Handler: createMCPPromptHandler(clientName, promptName, prompt),
237+ })
238+ }
239+
240+ return commands
241+}
242+
243+func createMCPPromptHandler(clientName, promptName string, prompt *mcp.Prompt) func(Command) tea.Cmd {
244+ return func(cmd Command) tea.Cmd {
245+ if len(prompt.Arguments) == 0 {
246+ return execMCPPrompt(clientName, promptName, nil)
247+ }
248+ return util.CmdHandler(ShowMCPPromptArgumentsDialogMsg{
249+ Prompt: prompt,
250+ OnSubmit: func(args map[string]string) tea.Cmd {
251+ return execMCPPrompt(clientName, promptName, args)
252+ },
253+ })
254+ }
255+}
256+
257+func execMCPPrompt(clientName, promptName string, args map[string]string) tea.Cmd {
258+ return func() tea.Msg {
259+ ctx := context.Background()
260+ result, err := mcp.GetPromptMessages(ctx, clientName, promptName, args)
261+ if err != nil {
262+ return util.ReportError(err)
263+ }
264+
265+ return chat.SendMsg{
266+ Text: strings.Join(result, " "),
267+ }
268+ }
269+}
270+
271+type ShowMCPPromptArgumentsDialogMsg struct {
272+ Prompt *mcp.Prompt
273+ OnSubmit func(arg map[string]string) tea.Cmd
274+}
internal/tui/components/mcp/mcp.go+14−10
@@ -2,10 +2,11 @@ package mcp
22
33import (
44 "fmt"
5+ "strings"
56
67 "github.com/charmbracelet/lipgloss/v2"
78
8− "github.com/charmbracelet/crush/internal/agent/tools"
9+ "github.com/charmbracelet/crush/internal/agent/tools/mcp"
910 "github.com/charmbracelet/crush/internal/config"
1011 "github.com/charmbracelet/crush/internal/tui/components/core"
1112 "github.com/charmbracelet/crush/internal/tui/styles"
@@ -40,7 +41,7 @@ func RenderMCPList(opts RenderOptions) []string {
4041 }
4142
4243 // Get MCP states
43− mcpStates := tools.GetMCPStates()
44+ mcpStates := mcp.GetStates()
4445
4546 // Determine how many items to show
4647 maxItems := len(mcps)
@@ -56,21 +57,24 @@ func RenderMCPList(opts RenderOptions) []string {
5657 // Determine icon and color based on state
5758 icon := t.ItemOfflineIcon
5859 description := ""
59− extraContent := ""
60+ extraContent := []string{}
6061
6162 if state, exists := mcpStates[l.Name]; exists {
6263 switch state.State {
63− case tools.MCPStateDisabled:
64+ case mcp.StateDisabled:
6465 description = t.S().Subtle.Render("disabled")
65− case tools.MCPStateStarting:
66+ case mcp.StateStarting:
6667 icon = t.ItemBusyIcon
6768 description = t.S().Subtle.Render("starting...")
68− case tools.MCPStateConnected:
69+ case mcp.StateConnected:
6970 icon = t.ItemOnlineIcon
70− if state.ToolCount > 0 {
71− extraContent = t.S().Subtle.Render(fmt.Sprintf("%d tools", state.ToolCount))
71+ if count := state.Counts.Tools; count > 0 {
72+ extraContent = append(extraContent, t.S().Subtle.Render(fmt.Sprintf("%d tools", count)))
7273 }
73− case tools.MCPStateError:
74+ if count := state.Counts.Prompts; count > 0 {
75+ extraContent = append(extraContent, t.S().Subtle.Render(fmt.Sprintf("%d prompts", count)))
76+ }
77+ case mcp.StateError:
7478 icon = t.ItemErrorIcon
7579 if state.Error != nil {
7680 description = t.S().Subtle.Render(fmt.Sprintf("error: %s", state.Error.Error()))
@@ -88,7 +92,7 @@ func RenderMCPList(opts RenderOptions) []string {
8892 Icon: icon.String(),
8993 Title: l.Name,
9094 Description: description,
91− ExtraContent: extraContent,
95+ ExtraContent: strings.Join(extraContent, " "),
9296 },
9397 opts.MaxWidth,
9498 ),
internal/tui/tui.go+33−2
@@ -33,6 +33,8 @@ import (
3333 "github.com/charmbracelet/crush/internal/tui/styles"
3434 "github.com/charmbracelet/crush/internal/tui/util"
3535 "github.com/charmbracelet/lipgloss/v2"
36+ "golang.org/x/text/cases"
37+ "golang.org/x/text/language"
3638)
3739
3840var lastMouseEvent time.Time
@@ -156,15 +158,44 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
156158 a.dialog = u.(dialogs.DialogCmp)
157159 return a, tea.Batch(completionCmd, dialogCmd)
158160 case commands.ShowArgumentsDialogMsg:
161+ var args []commands.Argument
162+ for _, arg := range msg.ArgNames {
163+ args = append(args, commands.Argument{
164+ Name: arg,
165+ Title: cases.Title(language.English).String(arg),
166+ Required: true,
167+ })
168+ }
159169 return a, util.CmdHandler(
160170 dialogs.OpenDialogMsg{
161171 Model: commands.NewCommandArgumentsDialog(
162172 msg.CommandID,
163− msg.Content,
164− msg.ArgNames,
173+ msg.CommandID,
174+ msg.CommandID,
175+ msg.Description,
176+ args,
177+ msg.OnSubmit,
165178 ),
166179 },
167180 )
181+ case commands.ShowMCPPromptArgumentsDialogMsg:
182+ args := make([]commands.Argument, 0, len(msg.Prompt.Arguments))
183+ for _, arg := range msg.Prompt.Arguments {
184+ args = append(args, commands.Argument(*arg))
185+ }
186+ dialog := commands.NewCommandArgumentsDialog(
187+ msg.Prompt.Name,
188+ msg.Prompt.Title,
189+ msg.Prompt.Name,
190+ msg.Prompt.Description,
191+ args,
192+ msg.OnSubmit,
193+ )
194+ return a, util.CmdHandler(
195+ dialogs.OpenDialogMsg{
196+ Model: dialog,
197+ },
198+ )
168199 // Page change messages
169200 case page.PageChangeMsg:
170201 return a, a.moveToPage(msg.ID)