From e068487ff7c08c80f92c9ab1b6978fdd6749e7db Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Wed, 15 Apr 2026 11:50:33 +0300 Subject: [PATCH] style(ui): fix gofmt alignment in MCPPromptInfo struct --- cmd/root.go | 58 +++- internal/agent/agent.go | 18 ++ internal/tools/mcp.go | 172 +++++++++++- internal/tools/mcp_prompts_test.go | 412 +++++++++++++++++++++++++++++ internal/ui/model.go | 252 +++++++++++++++++- pkg/kit/kit.go | 94 +++++++ 6 files changed, 997 insertions(+), 9 deletions(-) create mode 100644 internal/tools/mcp_prompts_test.go diff --git a/cmd/root.go b/cmd/root.go index 3b839543..fe854ff8 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1706,6 +1706,51 @@ func runNormalMode(ctx context.Context) error { return kitInstance.GetMCPToolCount() } + // Build MCP prompt provider callbacks for the TUI. + // Convert kit.MCPPrompt → ui.MCPPromptInfo for the UI layer. + convertMCPPromptsForUI := func() []ui.MCPPromptInfo { + prompts := kitInstance.ListMCPPrompts() + if len(prompts) == 0 { + return nil + } + result := make([]ui.MCPPromptInfo, len(prompts)) + for i, p := range prompts { + args := make([]ui.MCPPromptArgInfo, len(p.Arguments)) + for j, a := range p.Arguments { + args[j] = ui.MCPPromptArgInfo{ + Name: a.Name, + Description: a.Description, + Required: a.Required, + } + } + result[i] = ui.MCPPromptInfo{ + Name: p.Name, + Description: p.Description, + Arguments: args, + ServerName: p.ServerName, + } + } + return result + } + mcpPrompts := convertMCPPromptsForUI() + getMCPPrompts := func() []ui.MCPPromptInfo { + return convertMCPPromptsForUI() + } + expandMCPPrompt := func(serverName, promptName string, args map[string]string) (*ui.MCPPromptExpandResult, error) { + result, err := kitInstance.GetMCPPrompt(context.Background(), serverName, promptName, args) + if err != nil { + return nil, err + } + msgs := make([]ui.MCPPromptMessageInfo, len(result.Messages)) + for i, m := range result.Messages { + msgs[i] = ui.MCPPromptMessageInfo{ + Role: m.Role, + Content: m.Content, + } + } + return &ui.MCPPromptExpandResult{Messages: msgs}, nil + } + // Start a goroutine that waits for background MCP tool loading to // complete and notifies the TUI so it can refresh tool names and counts. if len(mcpConfig.MCPServers) > 0 { @@ -1842,7 +1887,7 @@ func runNormalMode(ctx context.Context) error { // Check if running in non-interactive mode if positionalPrompt != "" { - return runNonInteractiveModeApp(ctx, appInstance, cli, positionalPrompt, quietFlag, jsonFlag, noExitFlag, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, promptTemplates, contextPaths, skillItems, getPromptTemplates, getSkillItems, getToolNames, getMCPToolCount, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModelForUI, emitModelChangeForUI, kitInstance.IsReasoningModel(), kitInstance.GetThinkingLevel(), setThinkingLevelForUI, switchSessionForUI, reloadExtensionsForUI) + return runNonInteractiveModeApp(ctx, appInstance, cli, positionalPrompt, quietFlag, jsonFlag, noExitFlag, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, promptTemplates, contextPaths, skillItems, getPromptTemplates, getSkillItems, getToolNames, getMCPToolCount, mcpPrompts, getMCPPrompts, expandMCPPrompt, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModelForUI, emitModelChangeForUI, kitInstance.IsReasoningModel(), kitInstance.GetThinkingLevel(), setThinkingLevelForUI, switchSessionForUI, reloadExtensionsForUI) } // Quiet mode is not allowed in interactive mode @@ -1850,7 +1895,7 @@ func runNormalMode(ctx context.Context) error { return fmt.Errorf("--quiet requires a prompt") } - return runInteractiveModeBubbleTea(ctx, appInstance, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, promptTemplates, contextPaths, skillItems, getPromptTemplates, getSkillItems, getToolNames, getMCPToolCount, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModelForUI, emitModelChangeForUI, kitInstance.IsReasoningModel(), kitInstance.GetThinkingLevel(), setThinkingLevelForUI, switchSessionForUI, reloadExtensionsForUI, startupExtensionMessages) + return runInteractiveModeBubbleTea(ctx, appInstance, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, promptTemplates, contextPaths, skillItems, getPromptTemplates, getSkillItems, getToolNames, getMCPToolCount, mcpPrompts, getMCPPrompts, expandMCPPrompt, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModelForUI, emitModelChangeForUI, kitInstance.IsReasoningModel(), kitInstance.GetThinkingLevel(), setThinkingLevelForUI, switchSessionForUI, reloadExtensionsForUI, startupExtensionMessages) } // runNonInteractiveModeApp executes a single prompt via the app layer and exits, @@ -1863,7 +1908,7 @@ func runNormalMode(ctx context.Context) error { // // When --no-exit is set, after the prompt completes the interactive BubbleTea // TUI is started so the user can continue the conversation. -func runNonInteractiveModeApp(ctx context.Context, appInstance *app.App, cli *ui.CLI, prompt string, quiet, jsonOutput, noExit bool, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []commands.ExtensionCommand, promptTemplates []*prompts.PromptTemplate, contextPaths []string, skillItems []ui.SkillItem, getPromptTemplates func() []*prompts.PromptTemplate, getSkillItems func() []ui.SkillItem, getToolNames func() []string, getMCPToolCount func() int, getWidgets func(string) []ui.WidgetData, getHeader, getFooter func() *ui.WidgetData, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor, getUIVisibility func() *ui.UIVisibility, getStatusBarEntries func() []ui.StatusBarEntryData, emitBeforeFork func(string, bool, string) (bool, string), emitBeforeSessionSwitch func(string) (bool, string), getGlobalShortcuts func() map[string]func(), getExtensionCommands func() []commands.ExtensionCommand, setModel func(string) error, emitModelChange func(string, string, string), isReasoningModel bool, thinkingLevel string, setThinkingLevel func(string) error, switchSession func(string) error, reloadExtensions func() error) error { +func runNonInteractiveModeApp(ctx context.Context, appInstance *app.App, cli *ui.CLI, prompt string, quiet, jsonOutput, noExit bool, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []commands.ExtensionCommand, promptTemplates []*prompts.PromptTemplate, contextPaths []string, skillItems []ui.SkillItem, getPromptTemplates func() []*prompts.PromptTemplate, getSkillItems func() []ui.SkillItem, getToolNames func() []string, getMCPToolCount func() int, mcpPrompts []ui.MCPPromptInfo, getMCPPrompts func() []ui.MCPPromptInfo, expandMCPPrompt func(string, string, map[string]string) (*ui.MCPPromptExpandResult, error), getWidgets func(string) []ui.WidgetData, getHeader, getFooter func() *ui.WidgetData, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor, getUIVisibility func() *ui.UIVisibility, getStatusBarEntries func() []ui.StatusBarEntryData, emitBeforeFork func(string, bool, string) (bool, string), emitBeforeSessionSwitch func(string) (bool, string), getGlobalShortcuts func() map[string]func(), getExtensionCommands func() []commands.ExtensionCommand, setModel func(string) error, emitModelChange func(string, string, string), isReasoningModel bool, thinkingLevel string, setThinkingLevel func(string) error, switchSession func(string) error, reloadExtensions func() error) error { // Expand @file references in the prompt before sending to the agent. if cwd, err := os.Getwd(); err == nil { prompt = ui.ProcessFileAttachments(prompt, cwd) @@ -1906,7 +1951,7 @@ func runNonInteractiveModeApp(ctx context.Context, appInstance *app.App, cli *ui // If --no-exit was requested, hand off to the interactive TUI. if noExit { - return runInteractiveModeBubbleTea(ctx, appInstance, modelName, providerName, loadingMessage, serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, promptTemplates, contextPaths, skillItems, getPromptTemplates, getSkillItems, getToolNames, getMCPToolCount, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModel, emitModelChange, isReasoningModel, thinkingLevel, setThinkingLevel, switchSession, reloadExtensions, nil) + return runInteractiveModeBubbleTea(ctx, appInstance, modelName, providerName, loadingMessage, serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, promptTemplates, contextPaths, skillItems, getPromptTemplates, getSkillItems, getToolNames, getMCPToolCount, mcpPrompts, getMCPPrompts, expandMCPPrompt, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModel, emitModelChange, isReasoningModel, thinkingLevel, setThinkingLevel, switchSession, reloadExtensions, nil) } return nil @@ -2004,7 +2049,7 @@ func writeJSONError(err error) { // 4. Calls program.Run() which blocks until the user quits (Ctrl+C or /quit). // // SetupCLI is not used for interactive mode; the TUI (AppModel) handles its own rendering. -func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []commands.ExtensionCommand, promptTemplates []*prompts.PromptTemplate, contextPaths []string, skillItems []ui.SkillItem, getPromptTemplates func() []*prompts.PromptTemplate, getSkillItems func() []ui.SkillItem, getToolNames func() []string, getMCPToolCount func() int, getWidgets func(string) []ui.WidgetData, getHeader, getFooter func() *ui.WidgetData, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor, getUIVisibility func() *ui.UIVisibility, getStatusBarEntries func() []ui.StatusBarEntryData, emitBeforeFork func(string, bool, string) (bool, string), emitBeforeSessionSwitch func(string) (bool, string), getGlobalShortcuts func() map[string]func(), getExtensionCommands func() []commands.ExtensionCommand, setModel func(string) error, emitModelChange func(string, string, string), isReasoningModel bool, thinkingLevel string, setThinkingLevel func(string) error, switchSession func(string) error, reloadExtensions func() error, startupExtensionMessages []string) error { +func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []commands.ExtensionCommand, promptTemplates []*prompts.PromptTemplate, contextPaths []string, skillItems []ui.SkillItem, getPromptTemplates func() []*prompts.PromptTemplate, getSkillItems func() []ui.SkillItem, getToolNames func() []string, getMCPToolCount func() int, mcpPrompts []ui.MCPPromptInfo, getMCPPrompts func() []ui.MCPPromptInfo, expandMCPPrompt func(string, string, map[string]string) (*ui.MCPPromptExpandResult, error), getWidgets func(string) []ui.WidgetData, getHeader, getFooter func() *ui.WidgetData, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor, getUIVisibility func() *ui.UIVisibility, getStatusBarEntries func() []ui.StatusBarEntryData, emitBeforeFork func(string, bool, string) (bool, string), emitBeforeSessionSwitch func(string) (bool, string), getGlobalShortcuts func() map[string]func(), getExtensionCommands func() []commands.ExtensionCommand, setModel func(string) error, emitModelChange func(string, string, string), isReasoningModel bool, thinkingLevel string, setThinkingLevel func(string) error, switchSession func(string) error, reloadExtensions func() error, startupExtensionMessages []string) error { // Redirect all log output (stdlib and charm) to a file so that log // messages don't write to stderr and corrupt the TUI. Bubble Tea // captures stdout for rendering; any stray stderr output from @@ -2043,6 +2088,9 @@ func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelN ExtensionCommands: extCommands, PromptTemplates: promptTemplates, GetPromptTemplates: getPromptTemplates, + MCPPrompts: mcpPrompts, + GetMCPPrompts: getMCPPrompts, + ExpandMCPPrompt: expandMCPPrompt, ContextPaths: contextPaths, SkillItems: skillItems, GetSkillItems: getSkillItems, diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 6cdbbecb..8c1c28d3 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -912,6 +912,24 @@ func (a *Agent) GetLoadedServerNames() []string { return a.toolManager.GetLoadedServerNames() } +// GetMCPPrompts returns all prompts discovered from connected MCP servers. +// Returns nil if no MCP servers are configured or no prompts were found. +func (a *Agent) GetMCPPrompts() []tools.MCPPrompt { + if a.toolManager == nil { + return nil + } + return a.toolManager.GetPrompts() +} + +// GetMCPPrompt retrieves and expands a specific prompt from an MCP server. +// This is a lazy call — the server is contacted each time. +func (a *Agent) GetMCPPrompt(ctx context.Context, serverName, promptName string, args map[string]string) (*tools.MCPPromptResult, error) { + if a.toolManager == nil { + return nil, fmt.Errorf("no MCP servers configured") + } + return a.toolManager.GetPrompt(ctx, serverName, promptName, args) +} + // SetModel swaps the agent's LLM provider to a new model. The existing tools // and configuration are preserved. When the new model's ProviderConfig carries // a system prompt (from per-model settings), it replaces the agent's stored diff --git a/internal/tools/mcp.go b/internal/tools/mcp.go index 4adebde2..20fbc13b 100644 --- a/internal/tools/mcp.go +++ b/internal/tools/mcp.go @@ -39,6 +39,44 @@ type MCPToolResult struct { IsError bool } +// MCPPrompt represents a prompt discovered from an MCP server. +type MCPPrompt struct { + // Name is the prompt name on the MCP server. + Name string + // Description is the human-readable prompt description. + Description string + // Arguments lists the prompt's expected arguments. + Arguments []MCPPromptArgument + // ServerName is the MCP server this prompt belongs to. + ServerName string +} + +// MCPPromptArgument describes an argument that a prompt template can accept. +type MCPPromptArgument struct { + // Name is the argument name. + Name string + // Description is a human-readable description. + Description string + // Required indicates whether this argument must be provided. + Required bool +} + +// MCPPromptMessage is a single message returned by a prompt expansion. +type MCPPromptMessage struct { + // Role is "user" or "assistant". + Role string + // Content is the text content of the message. + Content string +} + +// MCPPromptResult is the result of expanding an MCP prompt via GetPrompt. +type MCPPromptResult struct { + // Description is an optional description returned by the server. + Description string + // Messages contains the expanded prompt messages. + Messages []MCPPromptMessage +} + // MCPToolManager manages MCP (Model Context Protocol) tools and clients across multiple servers. // It provides a unified interface for loading, managing, and executing tools from various MCP servers, // including stdio, SSE, streamable HTTP, and built-in server types. The manager handles connection @@ -48,7 +86,8 @@ type MCPToolManager struct { connectionPool *MCPConnectionPool tools []MCPTool toolMap map[string]*toolMapping // maps prefixed tool names to their server and original name - mu sync.Mutex // protects tools and toolMap during parallel loading + prompts []MCPPrompt // prompts discovered from all servers + mu sync.Mutex // protects tools, toolMap, and prompts during parallel loading authHandler MCPAuthHandler // OAuth handler for remote servers (nil = no OAuth) tokenStoreFactory TokenStoreFactory // factory for creating per-server token stores (nil = default FileTokenStore) config *config.Config @@ -175,7 +214,7 @@ func (m *MCPToolManager) AddServer(ctx context.Context, name string, cfg config. return count, nil } -// RemoveServer disconnects an MCP server and removes all its tools. +// RemoveServer disconnects an MCP server and removes all its tools and prompts. // After this call the agent will no longer see or be able to call tools from // the named server. Returns an error if the server is not loaded. func (m *MCPToolManager) RemoveServer(name string) error { @@ -183,7 +222,7 @@ func (m *MCPToolManager) RemoveServer(name string) error { m.mu.Lock() - // Check the server actually has tools loaded. + // Check the server actually has tools or prompts loaded. found := false for k := range m.toolMap { if len(k) >= len(prefix) && k[:len(prefix)] == prefix { @@ -191,6 +230,15 @@ func (m *MCPToolManager) RemoveServer(name string) error { break } } + if !found { + // Also check prompts — a server might expose only prompts. + for _, p := range m.prompts { + if p.ServerName == name { + found = true + break + } + } + } if !found { m.mu.Unlock() return fmt.Errorf("MCP server %q is not loaded", name) @@ -211,6 +259,16 @@ func (m *MCPToolManager) RemoveServer(name string) error { delete(m.toolMap, k) } } + + // Remove prompts belonging to this server. + newPrompts := make([]MCPPrompt, 0, len(m.prompts)) + for _, p := range m.prompts { + if p.ServerName != name { + newPrompts = append(newPrompts, p) + } + } + m.prompts = newPrompts + m.mu.Unlock() // Close the connection in the pool (best-effort). @@ -416,6 +474,9 @@ func (m *MCPToolManager) loadServerTools(ctx context.Context, serverName string, m.tools = append(m.tools, localTools...) m.mu.Unlock() + // Also load prompts from this server (best-effort, non-blocking). + m.loadServerPrompts(ctx, serverName, conn) + return len(localTools), nil } @@ -503,6 +564,111 @@ func (m *MCPToolManager) GetTools() []MCPTool { return m.tools } +// GetPrompts returns all prompts discovered from connected MCP servers. +func (m *MCPToolManager) GetPrompts() []MCPPrompt { + m.mu.Lock() + defer m.mu.Unlock() + result := make([]MCPPrompt, len(m.prompts)) + copy(result, m.prompts) + return result +} + +// GetPrompt retrieves and expands a specific prompt from an MCP server. +// The serverName identifies which server to query, promptName is the prompt's +// name on that server, and args are the template arguments to substitute. +// This call is lazy — it contacts the MCP server on each invocation. +func (m *MCPToolManager) GetPrompt(ctx context.Context, serverName, promptName string, args map[string]string) (*MCPPromptResult, error) { + if m.connectionPool == nil { + return nil, fmt.Errorf("no connection pool available") + } + + clients := m.connectionPool.GetClients() + mcpClient, ok := clients[serverName] + if !ok { + return nil, fmt.Errorf("MCP server %q not found", serverName) + } + + req := mcp.GetPromptRequest{} + req.Params.Name = promptName + if len(args) > 0 { + req.Params.Arguments = args + } + + result, err := mcpClient.GetPrompt(ctx, req) + if err != nil { + return nil, fmt.Errorf("failed to get prompt %q from server %q: %w", promptName, serverName, err) + } + + // Convert MCP messages to our types, extracting text content. + var messages []MCPPromptMessage + for _, msg := range result.Messages { + text := extractContentText(msg.Content) + if text != "" { + messages = append(messages, MCPPromptMessage{ + Role: string(msg.Role), + Content: text, + }) + } + } + + return &MCPPromptResult{ + Description: result.Description, + Messages: messages, + }, nil +} + +// extractContentText extracts text from an MCP Content value. +// Content can be TextContent, ImageContent, AudioContent, or EmbeddedResource. +// We only extract text content; other types are skipped. +func extractContentText(content mcp.Content) string { + if tc, ok := content.(mcp.TextContent); ok { + return tc.Text + } + // Try pointer form as well. + if tc, ok := content.(*mcp.TextContent); ok && tc != nil { + return tc.Text + } + return "" +} + +// loadServerPrompts loads prompts from a single MCP server connection. +// Called inside loadServerTools after a successful connection is established. +// Thread-safe: acquires m.mu to merge results. +func (m *MCPToolManager) loadServerPrompts(ctx context.Context, serverName string, conn *MCPConnection) { + listResult, err := conn.client.ListPrompts(ctx, mcp.ListPromptsRequest{}) + if err != nil { + // Prompts are optional — servers may not support them. + // Silently skip. + return + } + + if len(listResult.Prompts) == 0 { + return + } + + var localPrompts []MCPPrompt + for _, p := range listResult.Prompts { + var args []MCPPromptArgument + for _, a := range p.Arguments { + args = append(args, MCPPromptArgument{ + Name: a.Name, + Description: a.Description, + Required: a.Required, + }) + } + localPrompts = append(localPrompts, MCPPrompt{ + Name: p.Name, + Description: p.Description, + Arguments: args, + ServerName: serverName, + }) + } + + m.mu.Lock() + m.prompts = append(m.prompts, localPrompts...) + m.mu.Unlock() +} + // GetLoadedServerNames returns the names of all successfully loaded MCP servers. // This includes servers that are currently connected and have had their tools loaded, // regardless of their current health status. Useful for debugging and status reporting. diff --git a/internal/tools/mcp_prompts_test.go b/internal/tools/mcp_prompts_test.go new file mode 100644 index 00000000..8f09aa10 --- /dev/null +++ b/internal/tools/mcp_prompts_test.go @@ -0,0 +1,412 @@ +package tools + +import ( + "context" + "fmt" + "testing" + + mcpclient "github.com/mark3labs/mcp-go/client" + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +// newTestPromptServer creates an in-process MCP server with prompt capabilities +// and the specified prompts + handlers. Returns an initialized MCPClient. +func newTestPromptServer(t *testing.T, prompts ...server.ServerPrompt) mcpclient.MCPClient { + t.Helper() + + mcpServer := server.NewMCPServer( + "test-prompt-server", "1.0.0", + server.WithPromptCapabilities(true), + server.WithToolCapabilities(true), + ) + + if len(prompts) > 0 { + mcpServer.AddPrompts(prompts...) + } + + // Add a dummy tool so loadServerTools has something to list. + mcpServer.AddTool( + mcp.NewTool("noop", mcp.WithDescription("no-op tool")), + func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return mcp.NewToolResultText("ok"), nil + }, + ) + + client, err := mcpclient.NewInProcessClient(mcpServer) + if err != nil { + t.Fatalf("NewInProcessClient: %v", err) + } + + ctx := context.Background() + if err := client.Start(ctx); err != nil { + t.Fatalf("client.Start: %v", err) + } + + initReq := mcp.InitializeRequest{} + initReq.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION + initReq.Params.ClientInfo = mcp.Implementation{Name: "test", Version: "1.0"} + if _, err := client.Initialize(ctx, initReq); err != nil { + t.Fatalf("client.Initialize: %v", err) + } + + t.Cleanup(func() { _ = client.Close() }) + return client +} + +// injectClientIntoManager sets up an MCPToolManager with a pre-connected +// in-process client, bypassing the normal connection pool flow. +func injectClientIntoManager(t *testing.T, serverName string, client mcpclient.MCPClient) *MCPToolManager { + t.Helper() + + m := NewMCPToolManager() + + // Create a minimal connection pool and inject our client. + pool := NewMCPConnectionPool(DefaultConnectionPoolConfig(), false, nil, nil) + pool.mu.Lock() + pool.connections[serverName] = &MCPConnection{ + client: client, + serverName: serverName, + isHealthy: true, + } + pool.mu.Unlock() + m.connectionPool = pool + + return m +} + +func TestLoadServerPrompts_Basic(t *testing.T) { + ctx := context.Background() + + client := newTestPromptServer(t, + server.ServerPrompt{ + Prompt: mcp.NewPrompt("review-pr", + mcp.WithPromptDescription("Review a pull request"), + mcp.WithArgument("pr_number", + mcp.ArgumentDescription("The PR number to review"), + mcp.RequiredArgument(), + ), + mcp.WithArgument("focus", + mcp.ArgumentDescription("Area to focus on"), + ), + ), + Handler: func(ctx context.Context, req mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { + prNum := req.Params.Arguments["pr_number"] + return &mcp.GetPromptResult{ + Description: "PR review prompt", + Messages: []mcp.PromptMessage{ + { + Role: mcp.RoleUser, + Content: mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Please review PR #%s", prNum), + }, + }, + }, + }, nil + }, + }, + server.ServerPrompt{ + Prompt: mcp.NewPrompt("explain-code", + mcp.WithPromptDescription("Explain a piece of code"), + ), + Handler: func(ctx context.Context, req mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { + return &mcp.GetPromptResult{ + Messages: []mcp.PromptMessage{ + { + Role: mcp.RoleUser, + Content: mcp.TextContent{ + Type: "text", + Text: "Please explain the following code.", + }, + }, + }, + }, nil + }, + }, + ) + + m := injectClientIntoManager(t, "github", client) + + conn := &MCPConnection{ + client: client, + serverName: "github", + isHealthy: true, + } + m.loadServerPrompts(ctx, "github", conn) + + prompts := m.GetPrompts() + if len(prompts) != 2 { + t.Fatalf("expected 2 prompts, got %d", len(prompts)) + } + + // Find review-pr prompt. + var reviewPR *MCPPrompt + for i := range prompts { + if prompts[i].Name == "review-pr" { + reviewPR = &prompts[i] + break + } + } + if reviewPR == nil { + t.Fatal("review-pr prompt not found") + } + if reviewPR.Description != "Review a pull request" { + t.Errorf("unexpected description: %q", reviewPR.Description) + } + if reviewPR.ServerName != "github" { + t.Errorf("unexpected server name: %q", reviewPR.ServerName) + } + if len(reviewPR.Arguments) != 2 { + t.Fatalf("expected 2 arguments, got %d", len(reviewPR.Arguments)) + } + + // Verify argument metadata. + arg0 := reviewPR.Arguments[0] + if arg0.Name != "pr_number" { + t.Errorf("expected first arg name 'pr_number', got %q", arg0.Name) + } + if !arg0.Required { + t.Error("expected first arg to be required") + } + arg1 := reviewPR.Arguments[1] + if arg1.Name != "focus" { + t.Errorf("expected second arg name 'focus', got %q", arg1.Name) + } + if arg1.Required { + t.Error("expected second arg to be optional") + } +} + +func TestGetPrompt_ExpandsWithArgs(t *testing.T) { + ctx := context.Background() + + client := newTestPromptServer(t, + server.ServerPrompt{ + Prompt: mcp.NewPrompt("greet", + mcp.WithPromptDescription("Greet someone"), + mcp.WithArgument("name", mcp.RequiredArgument()), + ), + Handler: func(ctx context.Context, req mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { + name := req.Params.Arguments["name"] + return &mcp.GetPromptResult{ + Description: "Greeting", + Messages: []mcp.PromptMessage{ + { + Role: mcp.RoleUser, + Content: mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Hello, %s!", name), + }, + }, + }, + }, nil + }, + }, + ) + + m := injectClientIntoManager(t, "myserver", client) + + result, err := m.GetPrompt(ctx, "myserver", "greet", map[string]string{"name": "World"}) + if err != nil { + t.Fatalf("GetPrompt error: %v", err) + } + if result.Description != "Greeting" { + t.Errorf("unexpected description: %q", result.Description) + } + if len(result.Messages) != 1 { + t.Fatalf("expected 1 message, got %d", len(result.Messages)) + } + if result.Messages[0].Role != "user" { + t.Errorf("unexpected role: %q", result.Messages[0].Role) + } + if result.Messages[0].Content != "Hello, World!" { + t.Errorf("unexpected content: %q", result.Messages[0].Content) + } +} + +func TestGetPrompt_MultipleMessages(t *testing.T) { + ctx := context.Background() + + client := newTestPromptServer(t, + server.ServerPrompt{ + Prompt: mcp.NewPrompt("chat-starter"), + Handler: func(ctx context.Context, req mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { + return &mcp.GetPromptResult{ + Messages: []mcp.PromptMessage{ + { + Role: mcp.RoleUser, + Content: mcp.TextContent{Type: "text", Text: "What is Go?"}, + }, + { + Role: mcp.RoleAssistant, + Content: mcp.TextContent{Type: "text", Text: "Go is a programming language."}, + }, + { + Role: mcp.RoleUser, + Content: mcp.TextContent{Type: "text", Text: "Tell me more."}, + }, + }, + }, nil + }, + }, + ) + + m := injectClientIntoManager(t, "server", client) + + result, err := m.GetPrompt(ctx, "server", "chat-starter", nil) + if err != nil { + t.Fatalf("GetPrompt error: %v", err) + } + if len(result.Messages) != 3 { + t.Fatalf("expected 3 messages, got %d", len(result.Messages)) + } + if result.Messages[0].Role != "user" { + t.Errorf("msg[0] role: got %q, want 'user'", result.Messages[0].Role) + } + if result.Messages[1].Role != "assistant" { + t.Errorf("msg[1] role: got %q, want 'assistant'", result.Messages[1].Role) + } + if result.Messages[2].Content != "Tell me more." { + t.Errorf("msg[2] content: got %q, want 'Tell me more.'", result.Messages[2].Content) + } +} + +func TestGetPrompt_ServerNotFound(t *testing.T) { + m := NewMCPToolManager() + pool := NewMCPConnectionPool(DefaultConnectionPoolConfig(), false, nil, nil) + m.connectionPool = pool + + _, err := m.GetPrompt(context.Background(), "nonexistent", "foo", nil) + if err == nil { + t.Fatal("expected error for nonexistent server") + } +} + +func TestGetPrompt_NoPool(t *testing.T) { + m := NewMCPToolManager() + + _, err := m.GetPrompt(context.Background(), "any", "foo", nil) + if err == nil { + t.Fatal("expected error with no pool") + } +} + +func TestRemoveServer_RemovesPrompts(t *testing.T) { + ctx := context.Background() + + client := newTestPromptServer(t, + server.ServerPrompt{ + Prompt: mcp.NewPrompt("my-prompt", + mcp.WithPromptDescription("A test prompt"), + ), + Handler: func(ctx context.Context, req mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { + return &mcp.GetPromptResult{ + Messages: []mcp.PromptMessage{ + {Role: mcp.RoleUser, Content: mcp.TextContent{Type: "text", Text: "hi"}}, + }, + }, nil + }, + }, + ) + + m := injectClientIntoManager(t, "testsvr", client) + + // Manually populate tools and prompts as loadServerTools would. + conn := m.connectionPool.connections["testsvr"] + m.loadServerPrompts(ctx, "testsvr", conn) + + // Also add a fake tool mapping so RemoveServer finds the server. + m.toolMap["testsvr__noop"] = &toolMapping{ + serverName: "testsvr", + originalName: "noop", + } + m.tools = append(m.tools, MCPTool{ + Name: "testsvr__noop", + ServerName: "testsvr", + }) + + // Verify prompts exist before removal. + if got := len(m.GetPrompts()); got != 1 { + t.Fatalf("expected 1 prompt before removal, got %d", got) + } + + // Remove the server. + err := m.RemoveServer("testsvr") + if err != nil { + t.Fatalf("RemoveServer error: %v", err) + } + + // Verify prompts are gone. + if got := len(m.GetPrompts()); got != 0 { + t.Fatalf("expected 0 prompts after removal, got %d", got) + } +} + +func TestLoadServerPrompts_NoPromptCapability(t *testing.T) { + // Server without prompt capabilities — ListPrompts should fail gracefully. + mcpServer := server.NewMCPServer("no-prompts", "1.0.0", + server.WithToolCapabilities(true), + // No WithPromptCapabilities + ) + mcpServer.AddTool( + mcp.NewTool("noop"), + func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return mcp.NewToolResultText("ok"), nil + }, + ) + + client, err := mcpclient.NewInProcessClient(mcpServer) + if err != nil { + t.Fatalf("NewInProcessClient: %v", err) + } + ctx := context.Background() + _ = client.Start(ctx) + initReq := mcp.InitializeRequest{} + initReq.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION + initReq.Params.ClientInfo = mcp.Implementation{Name: "test", Version: "1.0"} + _, _ = client.Initialize(ctx, initReq) + t.Cleanup(func() { _ = client.Close() }) + + m := NewMCPToolManager() + conn := &MCPConnection{ + client: client, + serverName: "no-prompts", + isHealthy: true, + } + + // Should not panic or error — just silently skip. + m.loadServerPrompts(ctx, "no-prompts", conn) + + if got := len(m.GetPrompts()); got != 0 { + t.Fatalf("expected 0 prompts from server without prompt capability, got %d", got) + } +} + +func TestExtractContentText(t *testing.T) { + tests := []struct { + name string + content mcp.Content + want string + }{ + { + name: "TextContent", + content: mcp.TextContent{Type: "text", Text: "hello world"}, + want: "hello world", + }, + { + name: "ImageContent", + content: mcp.ImageContent{Type: "image", Data: "base64data", MIMEType: "image/png"}, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractContentText(tt.content) + if got != tt.want { + t.Errorf("extractContentText() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/internal/ui/model.go b/internal/ui/model.go index 28c6faa6..50b6643d 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -134,6 +134,33 @@ type SkillItem struct { Source string // "project" or "user" (global). } +// MCPPromptInfo describes an MCP prompt for display in the TUI (autocomplete, +// help). This is a pure UI type — it carries no MCP client dependencies. +type MCPPromptInfo struct { + Name string // Prompt name on the MCP server. + Description string // Human-readable description. + Arguments []MCPPromptArgInfo // Expected arguments. + ServerName string // Owning MCP server name. +} + +// MCPPromptArgInfo describes an argument for an MCP prompt. +type MCPPromptArgInfo struct { + Name string + Description string + Required bool +} + +// MCPPromptExpandResult is the result of lazily expanding an MCP prompt. +type MCPPromptExpandResult struct { + Messages []MCPPromptMessageInfo +} + +// MCPPromptMessageInfo is a single message from an expanded MCP prompt. +type MCPPromptMessageInfo struct { + Role string // "user" or "assistant" + Content string +} + // ToolRendererData holds extension-provided rendering functions for a specific // tool. The UI layer uses this to override the default tool header/body // rendering without depending on the extensions package directly. @@ -310,6 +337,19 @@ type AppModelOptions struct { // watcher detects changes. May be nil if prompt hot-reload is not needed. GetPromptTemplates func() []*prompts.PromptTemplate + // MCPPrompts are prompts discovered from MCP servers at startup. + // They appear in autocomplete as /: commands. + MCPPrompts []MCPPromptInfo + + // GetMCPPrompts, if non-nil, returns the current MCP prompts. + // Called on MCPToolsReadyEvent to refresh after background loading. + GetMCPPrompts func() []MCPPromptInfo + + // ExpandMCPPrompt, if non-nil, lazily expands an MCP prompt by + // calling the MCP server's GetPrompt. Called asynchronously when the + // user invokes an MCP prompt slash command. + ExpandMCPPrompt func(serverName, promptName string, args map[string]string) (*MCPPromptExpandResult, error) + // ContextPaths lists absolute paths of loaded context files (e.g. // AGENTS.md). Displayed in the [Context] startup section. ContextPaths []string @@ -534,6 +574,17 @@ type AppModel struct { // refresh the template list after content hot-reload. May be nil. getPromptTemplates func() []*prompts.PromptTemplate + // mcpPrompts are prompts discovered from MCP servers, shown as + // /: slash commands. + mcpPrompts []MCPPromptInfo + + // getMCPPrompts returns the current MCP prompts. Called on + // MCPToolsReadyEvent to refresh after background loading. + getMCPPrompts func() []MCPPromptInfo + + // expandMCPPrompt lazily expands an MCP prompt via the server. + expandMCPPrompt func(serverName, promptName string, args map[string]string) (*MCPPromptExpandResult, error) + // treeSelector is the tree navigation overlay, active in stateTreeSelector. treeSelector *TreeSelectorComponent @@ -762,6 +813,9 @@ func NewAppModel(appCtrl AppController, opts AppModelOptions) *AppModel { m.extensionCommands = opts.ExtensionCommands m.promptTemplates = opts.PromptTemplates m.getPromptTemplates = opts.GetPromptTemplates + m.mcpPrompts = opts.MCPPrompts + m.getMCPPrompts = opts.GetMCPPrompts + m.expandMCPPrompt = opts.ExpandMCPPrompt m.getWidgets = opts.GetWidgets m.getHeader = opts.GetHeader m.getFooter = opts.GetFooter @@ -832,6 +886,25 @@ func NewAppModel(appCtrl AppController, opts AppModelOptions) *AppModel { } } + // Merge MCP prompts into autocomplete as /: commands. + if ic, ok := m.input.(*InputComponent); ok && len(opts.MCPPrompts) > 0 { + for _, p := range opts.MCPPrompts { + hasArgs := false + for _, a := range p.Arguments { + if a.Required { + hasArgs = true + break + } + } + ic.commands = append(ic.commands, commands.SlashCommand{ + Name: fmt.Sprintf("/%s:%s", p.ServerName, p.Name), + Description: p.Description, + Category: "MCP Prompts", + HasArgs: hasArgs, + }) + } + } + m.stream = NewStreamComponent(width, opts.ModelName) m.stream.SetThinkingVisible(m.thinkingVisible) @@ -1483,6 +1556,12 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } + // Check MCP prompt commands (/: [args]). + if cmd := m.handleMCPPromptCommand(msg.Text); cmd != nil { + cmds = append(cmds, cmd) + return m, tea.Batch(cmds...) + } + // Expand prompt templates. If the input matches a template name, // substitute arguments and use the expanded content as the prompt. if expanded, ok, validationErr := m.expandPromptTemplate(msg.Text); validationErr != "" { @@ -1934,9 +2013,10 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.printSystemMessage("Prompts and skills reloaded.") case app.MCPToolsReadyEvent: - // Background MCP tool loading completed — refresh tool names and count. + // Background MCP tool loading completed — refresh tool names, count, and prompts. m.refreshToolNames() m.refreshMCPToolCount() + m.refreshMCPPrompts() case app.MCPServerLoadedEvent: // A single MCP server finished loading — display a system message. @@ -2022,6 +2102,32 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.printSystemMessage(msg.output) } + case mcpPromptResultMsg: + // Async MCP prompt expansion completed. Submit the expanded text + // as a user message (same behavior as local prompt templates). + if msg.err != nil { + m.printSystemMessage(fmt.Sprintf("MCP prompt error: %v", msg.err)) + } else if msg.text != "" { + // Process @file references and submit. + processedText := msg.text + if m.cwd != "" { + processedText = fileutil.ProcessFileAttachments(msg.text, m.cwd) + } + if m.appCtrl != nil { + qLen := m.appCtrl.Run(processedText) + if qLen > 0 { + m.queuedMessages = append(m.queuedMessages, msg.text) + m.layoutDirty = true + } else { + m.pendingUserPrints = append(m.pendingUserPrints, msg.text) + m.flushStreamAndPendingUserMessages() + } + if m.state != stateWorking { + m.state = stateWorking + } + } + } + case externalEditorMsg: // User returned from $EDITOR. Replace input textarea content with // whatever they saved in the temp file. On error (e.g. :cq in vim) @@ -2892,6 +2998,107 @@ func (m *AppModel) handleExtensionCommand(text string) tea.Cmd { return noopCmd } +// handleMCPPromptCommand checks if the submitted text matches an MCP prompt +// command (/: [args]) and returns a tea.Cmd that expands it +// asynchronously. Returns nil if no MCP prompt matches. +// +// Arguments are parsed as key=value pairs. Positional arguments are mapped +// to prompt argument names by order. +func (m *AppModel) handleMCPPromptCommand(text string) tea.Cmd { + if len(m.mcpPrompts) == 0 || m.expandMCPPrompt == nil { + return nil + } + + if !strings.HasPrefix(text, "/") { + return nil + } + + // Split: "/: key=val ..." → command, args + cmdPart, argStr, _ := strings.Cut(text, " ") + cmdPart = strings.TrimPrefix(cmdPart, "/") + + // Must contain a colon to be an MCP prompt command. + serverName, promptName, ok := strings.Cut(cmdPart, ":") + if !ok || serverName == "" || promptName == "" { + return nil + } + + // Find matching MCP prompt. + var matched *MCPPromptInfo + for i := range m.mcpPrompts { + if m.mcpPrompts[i].ServerName == serverName && m.mcpPrompts[i].Name == promptName { + matched = &m.mcpPrompts[i] + break + } + } + if matched == nil { + return nil + } + + // Parse arguments: support key=value pairs, with positional fallback. + args := parseMCPPromptArgs(argStr, matched.Arguments) + + // Validate required arguments. + for _, a := range matched.Arguments { + if a.Required { + if _, exists := args[a.Name]; !exists { + m.printSystemMessage(fmt.Sprintf( + "/%s:%s requires argument '%s'", + serverName, promptName, a.Name, + )) + // Re-populate input for the user to add missing args. + if ic, ok := m.input.(*InputComponent); ok { + ic.textarea.SetValue(text + " ") + ic.textarea.CursorEnd() + } + return noopCmd + } + } + } + + // Expand asynchronously. + expand := m.expandMCPPrompt + ctrl := m.appCtrl + go func() { + result, err := expand(serverName, promptName, args) + if err != nil { + ctrl.SendEvent(mcpPromptResultMsg{err: err}) + return + } + // Concatenate user-role messages as the prompt text. + var parts []string + for _, msg := range result.Messages { + if msg.Role == "user" { + parts = append(parts, msg.Content) + } + } + ctrl.SendEvent(mcpPromptResultMsg{text: strings.Join(parts, "\n\n")}) + }() + + return noopCmd +} + +// parseMCPPromptArgs parses "key=value" pairs from a space-separated arg +// string. Tokens without "=" are assigned to prompt arguments positionally. +func parseMCPPromptArgs(argStr string, argDefs []MCPPromptArgInfo) map[string]string { + result := make(map[string]string) + if strings.TrimSpace(argStr) == "" { + return result + } + + tokens := strings.Fields(argStr) + positionalIdx := 0 + for _, tok := range tokens { + if k, v, ok := strings.Cut(tok, "="); ok && k != "" { + result[k] = v + } else if positionalIdx < len(argDefs) { + result[argDefs[positionalIdx].Name] = tok + positionalIdx++ + } + } + return result +} + // expandPromptTemplate checks if the submitted text matches a prompt template // and returns the expanded content with arguments substituted. // @@ -2975,6 +3182,42 @@ func (m *AppModel) refreshSkillItems() { m.skillItems = m.getSkillItems() } +// refreshMCPPrompts reloads MCP prompts from the provider callback and +// updates the autocomplete entries. Called on MCPToolsReadyEvent. +func (m *AppModel) refreshMCPPrompts() { + if m.getMCPPrompts == nil { + return + } + newPrompts := m.getMCPPrompts() + m.mcpPrompts = newPrompts + + if ic, ok := m.input.(*InputComponent); ok { + // Remove old MCP Prompts commands and add fresh ones. + var kept []commands.SlashCommand + for _, sc := range ic.commands { + if sc.Category != "MCP Prompts" { + kept = append(kept, sc) + } + } + for _, p := range newPrompts { + hasArgs := false + for _, a := range p.Arguments { + if a.Required { + hasArgs = true + break + } + } + kept = append(kept, commands.SlashCommand{ + Name: fmt.Sprintf("/%s:%s", p.ServerName, p.Name), + Description: p.Description, + Category: "MCP Prompts", + HasArgs: hasArgs, + }) + } + ic.commands = kept + } +} + // refreshToolNames reloads tool names from the provider callback. // Called on MCPToolsReadyEvent when background MCP tool loading completes. func (m *AppModel) refreshToolNames() { @@ -4166,6 +4409,13 @@ type extensionCmdResultMsg struct { err error } +// mcpPromptResultMsg carries the result of an asynchronously expanded MCP +// prompt. The expansion runs in a goroutine since it contacts the MCP server. +type mcpPromptResultMsg struct { + text string // concatenated user messages to submit as the prompt + err error // error from the server +} + // beforeSessionSwitchResultMsg carries the result of an asynchronously // executed before-session-switch hook. The hook runs in a goroutine so that // blocking operations like ctx.PromptConfirm() do not deadlock the TUI. diff --git a/pkg/kit/kit.go b/pkg/kit/kit.go index 39eeda23..a5d0ff1b 100644 --- a/pkg/kit/kit.go +++ b/pkg/kit/kit.go @@ -225,6 +225,100 @@ func (m *Kit) GetExtensionToolCount() int { return m.agent.GetExtensionToolCount() } +// -------------------------------------------------------------------------- +// MCP Prompts +// -------------------------------------------------------------------------- + +// MCPPrompt describes a prompt exposed by an MCP server. +type MCPPrompt struct { + // Name is the prompt name on the MCP server. + Name string + // Description is a human-readable description. + Description string + // Arguments lists the prompt's expected arguments. + Arguments []MCPPromptArgument + // ServerName is the MCP server that provides this prompt. + ServerName string +} + +// MCPPromptArgument describes a single argument for an MCP prompt. +type MCPPromptArgument struct { + // Name is the argument name. + Name string + // Description is a human-readable description. + Description string + // Required indicates whether this argument must be provided. + Required bool +} + +// MCPPromptMessage is a single message returned by a prompt expansion. +type MCPPromptMessage struct { + // Role is "user" or "assistant". + Role string + // Content is the text content of the message. + Content string +} + +// MCPPromptResult is the result of expanding an MCP prompt. +type MCPPromptResult struct { + // Description is an optional description returned by the server. + Description string + // Messages contains the expanded prompt messages. + Messages []MCPPromptMessage +} + +// ListMCPPrompts returns all prompts discovered from connected MCP servers. +// If MCP servers are still loading in the background, this returns only the +// prompts discovered so far. Returns nil if no prompts are available. +func (m *Kit) ListMCPPrompts() []MCPPrompt { + internal := m.agent.GetMCPPrompts() + if len(internal) == 0 { + return nil + } + result := make([]MCPPrompt, len(internal)) + for i, p := range internal { + args := make([]MCPPromptArgument, len(p.Arguments)) + for j, a := range p.Arguments { + args[j] = MCPPromptArgument{ + Name: a.Name, + Description: a.Description, + Required: a.Required, + } + } + result[i] = MCPPrompt{ + Name: p.Name, + Description: p.Description, + Arguments: args, + ServerName: p.ServerName, + } + } + return result +} + +// GetMCPPrompt retrieves and expands a specific prompt from an MCP server. +// This is a lazy call — the server is contacted each time to get the latest +// prompt content. Arguments are passed as key=value pairs to the server for +// template substitution. +// +// Returns an error if the server is not found or the prompt expansion fails. +func (m *Kit) GetMCPPrompt(ctx context.Context, serverName, promptName string, args map[string]string) (*MCPPromptResult, error) { + internal, err := m.agent.GetMCPPrompt(ctx, serverName, promptName, args) + if err != nil { + return nil, err + } + msgs := make([]MCPPromptMessage, len(internal.Messages)) + for i, msg := range internal.Messages { + msgs[i] = MCPPromptMessage{ + Role: msg.Role, + Content: msg.Content, + } + } + return &MCPPromptResult{ + Description: internal.Description, + Messages: msgs, + }, nil +} + // GetBufferedDebugMessages returns any debug messages that were buffered // during initialization, then clears the buffer. Returns nil if no messages // were buffered or if buffered logging was not configured.