diff --git a/doc/.vitepress/config.mjs b/doc/.vitepress/config.mjs index a22d5563a..9ac826e9f 100644 --- a/doc/.vitepress/config.mjs +++ b/doc/.vitepress/config.mjs @@ -119,6 +119,14 @@ export default withMermaid( { text: "Installation", link: "/installation" }, { text: "Getting Started", link: "/getting-started" }, { text: "Upgrading", link: "/upgrading" }, + { + text: "Agent Client Protocol (ACP)", + link: "agent-client-protocol", + }, + { + text: "Model Context Protocol (MCP)", + link: "model-context-protocol", + }, { text: "Configuration", collapsed: true, @@ -132,6 +140,7 @@ export default withMermaid( text: "Inline Assistant", link: "/configuration/inline-assistant", }, + { text: "MCP", link: "/configuration/mcp" }, { text: "Prompt Library", link: "/configuration/prompt-library" }, { text: "Rules", link: "/configuration/rules" }, { text: "System Prompt", link: "/configuration/system-prompt" }, @@ -143,7 +152,6 @@ export default withMermaid( collapsed: false, items: [ { text: "Introduction", link: "/usage/introduction" }, - { text: "ACP Protocol", link: "/usage/acp-protocol" }, { text: "Action Palette", link: "/usage/action-palette" }, { text: "Chat Buffer", @@ -162,6 +170,8 @@ export default withMermaid( }, { text: "Events", link: "/usage/events" }, { text: "Inline Assistant", link: "/usage/inline-assistant" }, + { text: "MCP", link: "/usage/mcp" }, + { text: "Action Palette", link: "/usage/action-palette" }, { text: "Prompt Library", link: "/usage/prompt-library" }, { text: "Workflows", link: "/usage/workflows" }, { text: "UI", link: "/usage/ui" }, diff --git a/doc/usage/acp-protocol.md b/doc/agent-client-protocol.md similarity index 58% rename from doc/usage/acp-protocol.md rename to doc/agent-client-protocol.md index 2ccab238f..ed9a632b0 100644 --- a/doc/usage/acp-protocol.md +++ b/doc/agent-client-protocol.md @@ -2,27 +2,28 @@ description: How CodeCompanion implements the Agent Client Protocol (ACP) --- -# ACP Protocol Reference +# Agent Client Protocol (ACP) Support CodeCompanion implements the [Agent Client Protocol (ACP)](https://agentclientprotocol.com/) to enable you to work with coding agents from within Neovim. ACP is an open standard that enables structured interaction between clients (like CodeCompanion) and AI agents, providing capabilities such as session management, file system operations, tool execution, and permission handling. This page provides a technical reference for what's supported in CodeCompanion and how it's been implemented. -## Protocol Support +## Implementation CodeCompanion provides comprehensive support for the ACP specification: -| Feature Category | Support Level | Details | +| Feature Category | Supported | Details | |------------------|---------------|---------| -| **Core Protocol** | ✅ Full | JSON-RPC 2.0, streaming responses, message buffering | -| **Session Management** | ✅ Full | Create, load, and persist sessions with state tracking | -| **Authentication** | ✅ Full | Multiple auth methods, adapter-level hooks | -| **File System** | ✅ Full | Read/write text files with line ranges | -| **Permissions** | ✅ Full | Interactive UI with diff preview for tool approval | -| **Content Types** | ✅ Full | Text, images, embedded resources | -| **Tool Calls** | ✅ Full | Content blocks, file diffs, status updates | -| **Session Modes** | ✅ Full | Mode switching and state management | -| **MCP Integration** | ✅ Full | Stdio, HTTP, and SSE transports | +| **Core Protocol** | ✅ | JSON-RPC 2.0, streaming responses, message buffering | +| **Authentication** | ✅ | Multiple auth methods, adapter-level hooks | +| **Content Types** | ✅ | Text, images, embedded resources | +| **File System** | ✅ | Read/write text files with line ranges | +| **MCP Integration** | ✅ | Stdio, HTTP, and SSE transports | +| **Permissions** | ✅ | Interactive UI with diff preview for tool approval | +| **Session Management** | ✅ | Create, load, and persist sessions with state tracking | +| **Session Modes** | ✅ | Mode switching | +| **Session Models** | ✅ | Select specific models | +| **Tool Calls** | ✅ | Content blocks, file diffs, status updates | | **Agent Plans** | ❌ | Visual display of an agent's execution plan | | **Terminal Operations** | ❌ | Terminal capabilities not implemented | @@ -45,42 +46,22 @@ CodeCompanion advertises the following capabilities to ACP agents: } ``` -### Content Support +### Content Types | Content Type | Send to Agent | Receive from Agent | |--------------|---------------|-------------------| | Text | ✅ | ✅ | -| Images | ✅ | ✅ | -| Embedded Resources | ✅ | ✅ | -| Audio | ❌ | ❌ | | File Diffs | N/A | ✅ | +| Images | ✅ | ❌ | +| Audio | ❌ | ❌ | +| Embedded Resources | ❌ | ❌ | -### Session Updates Handled - -CodeCompanion processes the following session update types: - -- **Message chunks**: Streamed text from agent responses -- **Thought chunks**: Agent reasoning displayed separately -- **Tool calls**: Full execution lifecycle with status tracking -- **Mode changes**: Automatic UI updates when modes switch -- **Available commands**: Dynamic command registration for completion - -## Implementation Notes - -### Message Buffering - -JSON-RPC message boundaries don't always align with I/O boundaries. CodeCompanion buffers stdout from the agent process and extracts complete JSON-RPC messages line-by-line, ensuring robust parsing even with partial reads. - ### State Management Unlike HTTP adapters which are stateless (sending the full conversation history with each request), ACP adapters are stateful. The agent maintains the conversation context, so CodeCompanion only sends new messages with each prompt. Session IDs are tracked throughout the conversation lifecycle. -### Tool Call Caching - -Tool call state is maintained in memory to support permission requests. When an agent requests permission for a tool call, the cached details enable features like the diff preview UI for file edits. - ### File Context Handling When sending files as embedded resources to agents, CodeCompanion re-reads the file content rather than using the chat buffer representation. This avoids HTTP-style `` tags that are used for LLM adapters but don't make sense for ACP agents. @@ -93,41 +74,27 @@ ACP agents can advertise their own slash commands dynamically. You can access th CodeCompanion implements a `session/set_model` method that allows you to select a model for the current session. This feature is not part of the [official ACP specification](https://agentclientprotocol.com/protocol/draft/schema#session-set_model) and is subject to change in future versions. -### Graceful Degradation - -CodeCompanion checks an agent's capabilities during initialization and gracefully falls back to supported content types. For example, if an agent doesn't support embedded context, files are sent as plain text instead. - ### Cleanup and Lifecycle CodeCompanion ensures clean disconnection from ACP agents by hooking into Neovim's `VimLeavePre` autocmd. This guarantees that agent processes are properly terminated even if Neovim exits unexpectedly. -## Key Features - -- **Streaming**: Real-time response streaming with chunk-by-chunk rendering -- **Permission System**: Interactive approval for file operations with diff preview -- **Session Persistence**: Resume previous conversations across Neovim sessions -- **Mode Management**: Switch between agent modes (e.g. ask, architect, code) -- **MCP Servers**: Connect agents to external tools via the Model Context Protocol -- **Slash Command Completion**: Auto-complete agent-specific commands with `\command` syntax -- **Error Handling**: Comprehensive error messages and graceful degradation - ## Protocol Version CodeCompanion currently implements **ACP Protocol Version 1**. The protocol version is negotiated during initialization. If an agent selects a different version, CodeCompanion will log a warning but continue to operate, following the agent's selected version. -## Known Limitations +## Current Limitations - **Terminal Operations**: The `terminal/*` family of methods (`terminal/create`, `terminal/output`, `terminal/release`, etc.) are not implemented. CodeCompanion doesn't advertise terminal capabilities to agents. - **Agent Plan Rendering**: [Plan](https://agentclientprotocol.com/protocol/agent-plan) updates from agents are received and logged, but they're not currently rendered in the chat buffer UI. -- **Audio Content**: Audio content blocks aren't sent in prompts, despite capability detection. +- **Audio Content**: Audio can't be sent or received ## See Also +- [Agent Client Protocol Specification](https://agentclientprotocol.com/) - Official ACP documentation - [Configuring ACP Adapters](/configuration/adapters-acp) - Setup instructions for specific agents - [Using Agents](/usage/chat-buffer/agents) - How to interact with agents in chat -- [Agent Client Protocol Specification](https://agentclientprotocol.com/) - Official ACP documentation diff --git a/doc/codecompanion.txt b/doc/codecompanion.txt index 85678180b..867be3214 100644 --- a/doc/codecompanion.txt +++ b/doc/codecompanion.txt @@ -1,4 +1,4 @@ -*codecompanion.txt* For NVIM v0.11 Last change: 2026 January 24 +*codecompanion.txt* For NVIM v0.11 Last change: 2026 February 01 ============================================================================== Table of Contents *codecompanion-table-of-contents* @@ -28,20 +28,30 @@ Table of Contents *codecompanion-table-of-contents* - v18.6.0 to v19.0.0 |codecompanion-upgrading-general-v18.6.0-to-v19.0.0| - v17.33.0 to v18.0.0 |codecompanion-upgrading-general-v17.33.0-to-v18.0.0| - default_memory has been renamed to autoload (#2509)|codecompanion-upgrading-general-default_memory-has-been-renamed-to-autoload-(#2509)| -5. Configuration |codecompanion-configuration| +5. ACP |codecompanion-acp| + - Implementation |codecompanion-acp-implementation| + - Protocol Version |codecompanion-acp-protocol-version| + - Current Limitations |codecompanion-acp-current-limitations| + - See Also |codecompanion-acp-see-also| +6. MCP |codecompanion-mcp| + - Usage |codecompanion-mcp-usage| + - Implementation |codecompanion-mcp-implementation| + - Protocol Version |codecompanion-mcp-protocol-version| + - See Also |codecompanion-mcp-see-also| +7. Configuration |codecompanion-configuration| - Action Palette |codecompanion-configuration-action-palette| - ACP Adapters |codecompanion-configuration-acp-adapters| - HTTP Adapters |codecompanion-configuration-http-adapters| - Chat Buffer |codecompanion-configuration-chat-buffer| - Inline Assistant |codecompanion-configuration-inline-assistant| + - MCP Servers |codecompanion-configuration-mcp-servers| - Rules |codecompanion-configuration-rules| - Prompt Library |codecompanion-configuration-prompt-library| - System Prompts |codecompanion-configuration-system-prompts| - Extensions |codecompanion-configuration-extensions| - Other Options |codecompanion-configuration-other-options| -6. Usage |codecompanion-usage| +8. Usage |codecompanion-usage| - General |codecompanion-usage-general| - - ACP Protocol Reference |codecompanion-usage-acp-protocol-reference| - Action Palette |codecompanion-usage-action-palette| - Chat Buffer |codecompanion-usage-chat-buffer| - Agents |codecompanion-usage-agents| @@ -54,7 +64,7 @@ Table of Contents *codecompanion-table-of-contents* - Prompt Library |codecompanion-usage-prompt-library| - User Interface |codecompanion-usage-user-interface| - Workflows |codecompanion-usage-workflows| -7. Extending |codecompanion-extending| +9. Extending |codecompanion-extending| - Extending with Adapters |codecompanion-extending-extending-with-adapters| - Extending with Agentic Workflows|codecompanion-extending-extending-with-agentic-workflows| - Extending with Extensions|codecompanion-extending-extending-with-extensions| @@ -652,7 +662,244 @@ UI ~ ============================================================================== -5. Configuration *codecompanion-configuration* +5. ACP *codecompanion-acp* + +CodeCompanion implements the Agent Client Protocol (ACP) + to enable you to work with coding agents +from within Neovim. ACP is an open standard that enables structured interaction +between clients (like CodeCompanion) and AI agents, providing capabilities such +as session management, file system operations, tool execution, and permission +handling. + +This page provides a technical reference for what’s supported in +CodeCompanion and how it’s been implemented. + + +IMPLEMENTATION *codecompanion-acp-implementation* + +CodeCompanion provides comprehensive support for the ACP specification: + + ------------------------------------------------------------------------ + Feature Category Supported Details + ------------------------------ ------------------------- --------------- + Core Protocol ✅ JSON-RPC 2.0, + streaming + responses, + message + buffering + + Authentication ✅ Multiple auth + methods, + adapter-level + hooks + + Content Types ✅ Text, images, + embedded + resources + + File System ✅ Read/write text + files with line + ranges + + MCP Integration ✅ Stdio, HTTP, + and SSE + transports + + Permissions ✅ Interactive UI + with diff + preview for + tool approval + + Session Management ✅ Create, load, + and persist + sessions with + state tracking + + Session Modes ✅ Mode switching + + Session Models ✅ Select specific + models + + Tool Calls ✅ Content blocks, + file diffs, + status updates + + Agent Plans ❌ Visual display + of an agent’s + execution plan + + Terminal Operations ❌ Terminal + capabilities + not implemented + ------------------------------------------------------------------------ + +SUPPORTED ADAPTERS ~ + +Please see the |codecompanion-configuration-adapters-acp| page. + + +CLIENT CAPABILITIES ~ + +CodeCompanion advertises the following capabilities to ACP agents: + +>lua + { + fs = { + readTextFile = true, -- Read files with optional line ranges + writeTextFile = true -- Write/create files + }, + terminal = false -- Terminal operations not supported + } +< + + +CONTENT TYPES ~ + + Content Type Send to Agent Receive from Agent + -------------------- --------------- -------------------- + Text ✅ ✅ + File Diffs N/A ✅ + Images ✅ ❌ + Audio ❌ ❌ + Embedded Resources ❌ ❌ + +STATE MANAGEMENT ~ + +Unlike HTTP adapters which are stateless (sending the full conversation history +with each request), ACP adapters are stateful. The agent maintains the +conversation context, so CodeCompanion only sends new messages with each +prompt. Session IDs are tracked throughout the conversation lifecycle. + + +FILE CONTEXT HANDLING ~ + +When sending files as embedded resources to agents, CodeCompanion re-reads the +file content rather than using the chat buffer representation. This avoids +HTTP-style `` tags that are used for LLM adapters but don’t make +sense for ACP agents. + + +SLASH COMMANDS ~ + +ACP agents can advertise their own slash commands dynamically. You can access +them with `\command` in the chat buffer. CodeCompanion transforms this to +`/command` before sending your prompt to the agent. + + +MODEL SELECTION ~ + +CodeCompanion implements a `session/set_model` method that allows you to select +a model for the current session. This feature is not part of the official ACP +specification + and +is subject to change in future versions. + + +CLEANUP AND LIFECYCLE ~ + +CodeCompanion ensures clean disconnection from ACP agents by hooking into +Neovim’s `VimLeavePre` autocmd. This guarantees that agent processes are +properly terminated even if Neovim exits unexpectedly. + + +PROTOCOL VERSION *codecompanion-acp-protocol-version* + +CodeCompanion currently implements **ACP Protocol Version 1**. + +The protocol version is negotiated during initialization. If an agent selects a +different version, CodeCompanion will log a warning but continue to operate, +following the agent’s selected version. + + +CURRENT LIMITATIONS *codecompanion-acp-current-limitations* + +- **Terminal Operations**: The `terminal/*` family of methods (`terminal/create`, + `terminal/output`, `terminal/release`, etc.) are not implemented. CodeCompanion + doesn’t advertise terminal capabilities to agents. +- **Agent Plan Rendering**: Plan + updates from agents are + received and logged, but they’re not currently rendered in the chat buffer + UI. +- **Audio Content**: Audio can’t be sent or received + + +SEE ALSO *codecompanion-acp-see-also* + +- Agent Client Protocol Specification - Official ACP documentation +- |codecompanion-configuration-adapters-acp| - Setup instructions for specific agents +- |codecompanion-usage-chat-buffer-agents| - How to interact with agents in chat + + +============================================================================== +6. MCP *codecompanion-mcp* + +CodeCompanion implements the Model Context Protocol (MCP) + to enable you to connect the plugin to +external systems and applications. The plugin only implements a subset of the +full MCP specification, focusing on the features that enable developers to +enhance their coding experience. + + +USAGE *codecompanion-mcp-usage* + +To use MCP servers within CodeCompanion, refer to the +|codecompanion-usage-chat-buffer-tools-mcp| section in the chat buffer usage +section of the documentation. + +If |codecompanion-configuration-mcp-enabling-servers|, the servers will be +started when you open a chat buffer for the first time. However, you can use +the |codecompanion-usage-chat-buffer-slash-commands-mcp| to start or stop +servers manually. + + +IMPLEMENTATION *codecompanion-mcp-implementation* + + ---------------------------------------------------------------------------- + Feature Category Supported Details + ------------------------- ----------- -------------------------------------- + Transport: Stdio ✅ + + Transport: Streamable ❌ + HTTP + + Basic: Cancellation ✅ Timeout and user can cancel manually + + Basic: Progress ❌ + + Basic: Task ❌ + + Client: Roots ✅ Disabled by default + + Client: Sampling ❌ + + Client: Elicitation ❌ + + Server: Completion ❌ + + Server: Pagination ✅ + + Server: Prompts ❌ + + Server: Resources ❌ + + Server: Tools ✅ Currently only supports Text Content + + Server: Tool list changed ❌ + notification + ---------------------------------------------------------------------------- + +PROTOCOL VERSION *codecompanion-mcp-protocol-version* + +CodeCompanion currently supports MCP version **2025-11-25**. + + +SEE ALSO *codecompanion-mcp-see-also* + +- Model Context Protocol Specification - Official MCP documentation + + +============================================================================== +7. Configuration *codecompanion-configuration* ACTION PALETTE *codecompanion-configuration-action-palette* @@ -1871,6 +2118,122 @@ Please see the |codecompanion-chat-buffer-diff| on the Chat Buffer page for configuration options. +MCP SERVERS *codecompanion-configuration-mcp-servers* + +In #2549 , +CodeCompanion added support for the Model Context Protocol (MCP) +, an open-source standard for connecting AI +applications to external systems. + +You can find out which parts of the protocol CodeCompanion has implemented on +the |codecompanion-model-context-protocol| page. Currently, you can leverage +MCP servers with |codecompanion-usage-chat-buffer-index|. + + +MCP SERVERS ~ + +You can give CodeCompanion knowledge of MCP servers via the `mcp.servers` +configuration option. This is a list of server definitions, each specifying how +to connect to an MCP server + + +BASIC CONFIGURATION + + +In the example above, we’re using 1Password CLI + tool to fetch the API key. However, +you can leverage CodeCompanion’s built-in +|codecompanion-configuration-adapters-http-environment-variables| capabilities +to fetch the value from any source you like. + + +ROOTS + + + [!IMPORTANT] The `roots` feature is a hint to MCP servers. Compliant servers + use it to limit file system access, but CodeCompanion cannot enforce this. For + untrusted servers, use isolation mechanisms like containers. +Roots +allow you to specify directories that the MCP server can access. By default, +roots are disabled for security reasons. You can enable them by adding a +`roots` field to your server configuration: + + + +ENABLING SERVERS ~ + +By default, all MCP servers are enabled in CodeCompanion. This results in +servers being started when a chat buffer is opened for the first time - +remaining active until Neovim is closed. This behaviour can be changed by +setting `mcp.enabled = false`. You can also change this at an individual server +level: + + + +OVERRIDING TOOL BEHAVIOUR ~ + +An MCP server can expose multiple tools. For example, a "math" server might +provide `add`, `subtract`, `multiply`, and `divide` tools. You can override the +behaviour of individual tools using the `tool_overrides` configuration, +allowing you to customise options, output handling, system prompts, and +timeouts on a per-tool basis. + +The `tool_overrides` field is a table where keys are the **MCP tool names** +(not the prefixed names used internally by CodeCompanion): + + + +TOOL DEFAULTS + +You can set default options for all tools by setting the `tool_defaults` +option. However, note that `tool_overrides` take precedence over them: + +>lua + require("codecompanion").setup({ + mcp = { + ["math-server"] = { + cmd = { "npx", "-y", "math-mcp-server" }, + tool_defaults = { + require_approval_before = true, + }, + -- Per-tool overrides take precedence over tool_defaults + tool_overrides = { + add = { + opts = { + require_approval_before = false, + }, + }, + }, + }, + }, + }) +< + + +OVERRIDE OPTIONS + +Each tool override can include: + + ------------------------------------------------------------------------ + Option Type Description + --------------------- --------------- ---------------------------------- + opts table Tool options like + require_approval_before, + require_approval_after + + output table Custom output handlers (success, + error, prompt, rejected, + cancelled) + + system_prompt string Additional system prompt text for + this tool + + timeout number Custom timeout in milliseconds for + this tool + + enabled boolean Whether the tool is enabled + ------------------------------------------------------------------------ + RULES *codecompanion-configuration-rules* Within CodeCompanion, rules fulfil two main purposes within a chat buffer: @@ -2647,7 +3010,7 @@ You can prevent any code from being sent to the LLM with: ============================================================================== -6. Usage *codecompanion-usage* +8. Usage *codecompanion-usage* GENERAL *codecompanion-usage-general* @@ -2696,221 +3059,6 @@ When in a chat buffer, you can cycle between other chat buffers with `{` or `}`. -ACP PROTOCOL REFERENCE *codecompanion-usage-acp-protocol-reference* - -CodeCompanion implements the Agent Client Protocol (ACP) - to enable you to work with coding agents -from within Neovim. ACP is an open standard that enables structured interaction -between clients (like CodeCompanion) and AI agents, providing capabilities such -as session management, file system operations, tool execution, and permission -handling. - -This page provides a technical reference for what’s supported in -CodeCompanion and how it’s been implemented. - - -PROTOCOL SUPPORT ~ - -CodeCompanion provides comprehensive support for the ACP specification: - - ------------------------------------------------------------------------ - Feature Category Support Level Details - ------------------------------ ------------------------- --------------- - Core Protocol ✅ Full JSON-RPC 2.0, - streaming - responses, - message - buffering - - Session Management ✅ Full Create, load, - and persist - sessions with - state tracking - - Authentication ✅ Full Multiple auth - methods, - adapter-level - hooks - - File System ✅ Full Read/write text - files with line - ranges - - Permissions ✅ Full Interactive UI - with diff - preview for - tool approval - - Content Types ✅ Full Text, images, - embedded - resources - - Tool Calls ✅ Full Content blocks, - file diffs, - status updates - - Session Modes ✅ Full Mode switching - and state - management - - MCP Integration ✅ Full Stdio, HTTP, - and SSE - transports - - Agent Plans ❌ Visual display - of an agent’s - execution plan - - Terminal Operations ❌ Terminal - capabilities - not implemented - ------------------------------------------------------------------------ - -SUPPORTED ADAPTERS - -Please see the |codecompanion-configuration-adapters-acp| page. - - -CLIENT CAPABILITIES - -CodeCompanion advertises the following capabilities to ACP agents: - ->lua - { - fs = { - readTextFile = true, -- Read files with optional line ranges - writeTextFile = true -- Write/create files - }, - terminal = false -- Terminal operations not supported - } -< - - -CONTENT SUPPORT - - Content Type Send to Agent Receive from Agent - -------------------- --------------- -------------------- - Text ✅ ✅ - Images ✅ ✅ - Embedded Resources ✅ ✅ - Audio ❌ ❌ - File Diffs N/A ✅ - -SESSION UPDATES HANDLED - -CodeCompanion processes the following session update types: - -- **Message chunks**: Streamed text from agent responses -- **Thought chunks**: Agent reasoning displayed separately -- **Tool calls**: Full execution lifecycle with status tracking -- **Mode changes**: Automatic UI updates when modes switch -- **Available commands**: Dynamic command registration for completion - - -IMPLEMENTATION NOTES ~ - - -MESSAGE BUFFERING - -JSON-RPC message boundaries don’t always align with I/O boundaries. -CodeCompanion buffers stdout from the agent process and extracts complete -JSON-RPC messages line-by-line, ensuring robust parsing even with partial -reads. - - -STATE MANAGEMENT - -Unlike HTTP adapters which are stateless (sending the full conversation history -with each request), ACP adapters are stateful. The agent maintains the -conversation context, so CodeCompanion only sends new messages with each -prompt. Session IDs are tracked throughout the conversation lifecycle. - - -TOOL CALL CACHING - -Tool call state is maintained in memory to support permission requests. When an -agent requests permission for a tool call, the cached details enable features -like the diff preview UI for file edits. - - -FILE CONTEXT HANDLING - -When sending files as embedded resources to agents, CodeCompanion re-reads the -file content rather than using the chat buffer representation. This avoids -HTTP-style `` tags that are used for LLM adapters but don’t make -sense for ACP agents. - - -SLASH COMMANDS - -ACP agents can advertise their own slash commands dynamically. You can access -them with `\command` in the chat buffer. CodeCompanion transforms this to -`/command` before sending your prompt to the agent. - - -MODEL SELECTION - -CodeCompanion implements a `session/set_model` method that allows you to select -a model for the current session. This feature is not part of the official ACP -specification - and -is subject to change in future versions. - - -GRACEFUL DEGRADATION - -CodeCompanion checks an agent’s capabilities during initialization and -gracefully falls back to supported content types. For example, if an agent -doesn’t support embedded context, files are sent as plain text instead. - - -CLEANUP AND LIFECYCLE - -CodeCompanion ensures clean disconnection from ACP agents by hooking into -Neovim’s `VimLeavePre` autocmd. This guarantees that agent processes are -properly terminated even if Neovim exits unexpectedly. - - -KEY FEATURES ~ - -- **Streaming**: Real-time response streaming with chunk-by-chunk rendering -- **Permission System**: Interactive approval for file operations with diff preview -- **Session Persistence**: Resume previous conversations across Neovim sessions -- **Mode Management**: Switch between agent modes (e.g. ask, architect, code) -- **MCP Servers**: Connect agents to external tools via the Model Context Protocol -- **Slash Command Completion**: Auto-complete agent-specific commands with `\command` syntax -- **Error Handling**: Comprehensive error messages and graceful degradation - - -PROTOCOL VERSION ~ - -CodeCompanion currently implements **ACP Protocol Version 1**. - -The protocol version is negotiated during initialization. If an agent selects a -different version, CodeCompanion will log a warning but continue to operate, -following the agent’s selected version. - - -KNOWN LIMITATIONS ~ - -- **Terminal Operations**: The `terminal/*` family of methods (`terminal/create`, - `terminal/output`, `terminal/release`, etc.) are not implemented. CodeCompanion - doesn’t advertise terminal capabilities to agents. -- **Agent Plan Rendering**: Plan - updates from agents are - received and logged, but they’re not currently rendered in the chat buffer - UI. -- **Audio Content**: Audio content blocks aren’t sent in prompts, despite - capability detection. - - -SEE ALSO ~ - -- |codecompanion-configuration-adapters-acp| - Setup instructions for specific agents -- |codecompanion-usage-chat-buffer-agents| - How to interact with agents in chat -- Agent Client Protocol Specification - Official ACP documentation - - ACTION PALETTE *codecompanion-usage-action-palette* The `Action Palette` has been designed to be your entry point for the many @@ -3770,6 +3918,14 @@ In the `openai_responses` adapter, the following tools are available: - `web_search` - Allow models to search the web for the latest information before generating a response. +MCP ~ + +The MCP servers you’ve |codecompanion-configuration-mcp| in CodeCompanion +expose their own set of tools that you can use in the chat buffer. Once a +server has been started, the tools will be available to you and appear in the +completion menu, by typing `@`. They are prefixed with `mcp:`. + + SECURITY ~ CodeCompanion takes security very seriously, especially in a world of agentic @@ -3951,6 +4107,15 @@ The `rules` slash command allows you to add |codecompanion-usage-chat-buffer-rules| groups to the chat buffer. +/MCP ~ + +The `mcp` slash command allows you to start and stop +|codecompanion-configuration-mcp| servers manually from within a chat buffer. +This is applied at a global level, so starting/stopping servers in one chat +buffer will affect all other chat buffers. A `snacks.nvim` and `vim.ui.select` +provider is available for selecting which MCP servers to start/stop. + + /MODE ~ The `mode` slash command is specific to @@ -4113,6 +4278,10 @@ The events that are fired from within the plugin are: - `CodeCompanionToolFinished` - Fired when a tool has finished executing - `CodeCompanionInlineStarted` - Fired at the start of the Inline interaction - `CodeCompanionInlineFinished` - Fired at the end of the Inline interaction +- `CodeCompanionMCPServerStart` - Fired when an MCP server is started +- `CodeCompanionMCPServerReady` - Fired when an MCP server is ready for requests +- `CodeCompanionMCPServerClosed` - Fired when an MCP server is closed +- `CodeCompanionMCPServerToolsLoaded` - Fired when tools are loaded for an MCP server - `CodeCompanionRequestStarted` - Fired at the start of any API request - `CodeCompanionRequestStreaming` - Fired at the start of a streaming API request - `CodeCompanionRequestFinished` - Fired at the end of any API request @@ -4335,7 +4504,7 @@ the |codecompanion-extending-agentic-workflows| guide. ============================================================================== -7. Extending *codecompanion-extending* +9. Extending *codecompanion-extending* EXTENDING WITH ADAPTERS *codecompanion-extending-extending-with-adapters* diff --git a/doc/configuration/mcp.md b/doc/configuration/mcp.md new file mode 100644 index 000000000..3b7122aac --- /dev/null +++ b/doc/configuration/mcp.md @@ -0,0 +1,221 @@ +--- +description: Learn how to configure MCP servers within CodeCompanion.nvim +--- + +# Configuring MCP Servers + +In [#2549](https://github.com/olimorris/codecompanion.nvim/pull/2549), CodeCompanion added support for the [Model Context Protocol (MCP)](https://modelcontextprotocol.io), an open-source standard for connecting AI applications to external systems. + +You can find out which parts of the protocol CodeCompanion has implemented on the [MCP](/model-context-protocol) page. Currently, you can leverage MCP servers with [chat interactions](/usage/chat-buffer/index). + +## Configuring MCP Servers + +You can give CodeCompanion knowledge of MCP servers via the `mcp.servers` configuration option. This is a list of server definitions, each specifying how to connect to an MCP server + +### Basic Configuration + +::: code-group + +```lua [Basic Example] +require("codecompanion").setup({ + mcp = { + ["tavily-mcp"] = { + cmd = { "npx", "-y", "tavily-mcp@latest" }, + }, + }, +}) +``` + +```lua [Environment Variables] {5-7} +require("codecompanion").setup({ + mcp = { + ["tavily-mcp"] = { + cmd = { "npx", "-y", "tavily-mcp@latest" }, + env = { + TAVILY_API_KEY = "cmd:op read op://personal/Tavily_API/credential --no-newline", + }, + }, + }, +}) +``` + +::: + +In the example above, we're using [1Password CLI](https://developer.1password.com/docs/cli/) tool to fetch the API key. However, you can leverage CodeCompanion's built-in [environment variable](/configuration/adapters-http#environment-variables) capabilities to fetch the value from any source you like. + +### Roots + +> [!IMPORTANT] +> The `roots` feature is a hint to MCP servers. Compliant servers use it to limit file system access, but CodeCompanion cannot enforce this. For untrusted servers, use isolation mechanisms like containers. + +[Roots](https://modelcontextprotocol.io/specification/2025-11-25/client/roots) allow you to specify directories that the MCP server can access. By default, roots are disabled for security reasons. You can enable them by adding a `roots` field to your server configuration: + +::: code-group + +```lua [Roots] +require("codecompanion").setup({ + mcp = { + filesystem = { + cmd = { "npx", "-y", "@modelcontextprotocol/server-filesystem" }, + roots = function() + -- Return a list of names and directories as per: + -- https://modelcontextprotocol.io/specification/2025-11-25/client/roots#listing-roots + end, + }, + }, +}) +``` + +```lua [Root List Changes] +require("codecompanion").setup({ + mcp = { + filesystem = { + cmd = { "npx", "-y", "@modelcontextprotocol/server-filesystem" }, + ---@param notify fun() + register_roots_list_changes = function(notify) + -- Call `notify()` whenever the list of roots changes. + end, + }, + }, +}) +``` + +::: + +## Enabling Servers + +By default, all MCP servers are enabled in CodeCompanion. This results in servers being started when a chat buffer is opened for the first time - remaining active until Neovim is closed. This behaviour can be changed by setting `mcp.enabled = false`. You can also change this at an individual server level: + +::: code-group + +```lua [Globally] {3} +require("codecompanion").setup({ + mcp = { + enabled = false, + ["tavily-mcp"] = { + cmd = { "npx", "-y", "tavily-mcp@latest" }, + }, + }, +}) +``` + +```lua [Per Server] {3,6-8} +require("codecompanion").setup({ + mcp = { + enabled = true, + ["tavily-mcp"] = { + cmd = { "npx", "-y", "tavily-mcp@latest" }, + opts = { + enabled = false, + }, + }, + }, +}) +``` + +::: + +## Overriding Tool Behaviour + +An MCP server can expose multiple tools. For example, a "math" server might provide `add`, `subtract`, `multiply`, and `divide` tools. You can override the behaviour of individual tools using the `tool_overrides` configuration, allowing you to customise options, output handling, system prompts, and timeouts on a per-tool basis. + +The `tool_overrides` field is a table where keys are the **MCP tool names** (not the prefixed names used internally by CodeCompanion): + +::: code-group + +```lua [Requiring Approval] +require("codecompanion").setup({ + mcp = { + ["math-server"] = { + cmd = { "npx", "-y", "math-mcp-server" }, + tool_overrides = { + divide = { + opts = { + require_approval_before = true, + }, + }, + }, + }, + }, +}) +``` + +```lua [Custom Output] +require("codecompanion").setup({ + mcp = { + ["math-server"] = { + cmd = { "npx", "-y", "math-mcp-server" }, + tool_overrides = { + add = { + output = { + success = function(self, tools, cmd, stdout) + local tool_bridge = require("codecompanion.mcp.tool_bridge") + local content = stdout and stdout[#stdout] + local output = tool_bridge.format_tool_result_content(content) + local msg = string.format("%d + %d = %s", self.args.a, self.args.b, output) + tools.chat:add_tool_output(self, output, msg) + end, + }, + }, + }, + }, + }, +}) +``` + +```lua [System Prompt] +require("codecompanion").setup({ + mcp = { + ["math-server"] = { + cmd = { "npx", "-y", "math-mcp-server" }, + tool_overrides = { + multiply = { + system_prompt = "When using the multiply tool, always show your working.", + }, + }, + }, + }, +}) +``` + +::: + +### Tool Defaults + +You can set default options for all tools by setting the `tool_defaults` option. However, note that `tool_overrides` take precedence over them: + +```lua +require("codecompanion").setup({ + mcp = { + ["math-server"] = { + cmd = { "npx", "-y", "math-mcp-server" }, + tool_defaults = { + require_approval_before = true, + }, + -- Per-tool overrides take precedence over tool_defaults + tool_overrides = { + add = { + opts = { + require_approval_before = false, + }, + }, + }, + }, + }, +}) +``` + + +### Override Options + +Each tool override can include: + +| Option | Type | Description | +|--------|------|-------------| +| `opts` | `table` | Tool options like `require_approval_before`, `require_approval_after` | +| `output` | `table` | Custom output handlers (`success`, `error`, `prompt`, `rejected`, `cancelled`) | +| `system_prompt` | `string` | Additional system prompt text for this tool | +| `timeout` | `number` | Custom timeout in milliseconds for this tool | +| `enabled` | `boolean` | Whether the tool is enabled | + + diff --git a/doc/model-context-protocol.md b/doc/model-context-protocol.md new file mode 100644 index 000000000..2110c1675 --- /dev/null +++ b/doc/model-context-protocol.md @@ -0,0 +1,42 @@ +--- +description: How to leverage Model Context Protocol (MCP) servers within CodeCompanion.nvim +--- + +# Model Context Protocol (MCP) Support + +CodeCompanion implements the [Model Context Protocol (MCP)](https://modelcontextprotocol.io) to enable you to connect the plugin to external systems and applications. The plugin only implements a subset of the full MCP specification, focusing on the features that enable developers to enhance their coding experience. + +## Usage + +To use MCP servers within CodeCompanion, refer to the [tools](/usage/chat-buffer/tools#mcp) section in the chat buffer usage section of the documentation. + +If [enabled](/configuration/mcp#enabling-servers), the servers will be started when you open a chat buffer for the first time. However, you can use the [MCP slash command](/usage/chat-buffer/slash-commands#mcp) to start or stop servers manually. + +## Implementation + + +| Feature Category | Supported | Details | +|----------------------------------------|-----------|-------------------------------------------------------------| +| Transport: Stdio | ✅ | | +| Transport: Streamable HTTP | ❌ | | +| Basic: Cancellation | ✅ | Timeout and user can cancel manually | +| Basic: Progress | ❌ | | +| Basic: Task | ❌ | | +| Client: Roots | ✅ | Disabled by default | +| Client: Sampling | ❌ | | +| Client: Elicitation | ❌ | | +| Server: Completion | ❌ | | +| Server: Pagination | ✅ | | +| Server: Prompts | ❌ | | +| Server: Resources | ❌ | | +| Server: Tools | ✅ | Currently only supports Text Content | +| Server: Tool list changed notification | ❌ | | + + +## Protocol Version + +CodeCompanion currently supports MCP version **2025-11-25**. + +## See Also + +- [Model Context Protocol Specification](https://modelcontextprotocol.io/specification/2025-11-25) - Official MCP documentation diff --git a/doc/usage/chat-buffer/slash-commands.md b/doc/usage/chat-buffer/slash-commands.md index 032903e15..776d1fa50 100644 --- a/doc/usage/chat-buffer/slash-commands.md +++ b/doc/usage/chat-buffer/slash-commands.md @@ -63,6 +63,10 @@ The _image_ slash command allows you to add images into a chat buffer via remote The _rules_ slash command allows you to add [rules](/usage/chat-buffer/rules) groups to the chat buffer. +## /mcp + +The _mcp_ slash command allows you to start and stop [Model Context Protocol (MCP)](/configuration/mcp) servers manually from within a chat buffer. This is applied at a global level, so starting/stopping servers in one chat buffer will affect all other chat buffers. A _snacks.nvim_ and `vim.ui.select` provider is available for selecting which MCP servers to start/stop. + ## /mode The _mode_ slash command is specific to [ACP](/configuration/adapters-acp) adapters and allows users to switch between different agent operating modes, as per the [protocol](https://agentclientprotocol.com/protocol/session-modes) docs. diff --git a/doc/usage/chat-buffer/tools.md b/doc/usage/chat-buffer/tools.md index 8908246d4..438f03284 100644 --- a/doc/usage/chat-buffer/tools.md +++ b/doc/usage/chat-buffer/tools.md @@ -309,6 +309,10 @@ In the `openai_responses` adapter, the following tools are available: - `web_search` - Allow models to search the web for the latest information before generating a response. +## MCP + +The MCP servers you've [configured](/configuration/mcp) in CodeCompanion expose their own set of tools that you can use in the chat buffer. Once a server has been started, the tools will be available to you and appear in the completion menu, by typing `@`. They are prefixed with `mcp:`. + ## Security CodeCompanion takes security very seriously, especially in a world of agentic code development. To that end, every effort is made to ensure that LLMs are only given the information that they need to execute a tool successfully. CodeCompanion will endeavour to make sure that the full disk path to your current working directory (cwd) in Neovim is never shared. The impact of this is that the LLM can only work within the cwd when executing tools but will minimize actions that are hard to [recover from](https://www.businessinsider.com/replit-ceo-apologizes-ai-coding-tool-delete-company-database-2025-7). diff --git a/doc/usage/events.md b/doc/usage/events.md index e8c2f90c2..099ddac9b 100644 --- a/doc/usage/events.md +++ b/doc/usage/events.md @@ -29,6 +29,10 @@ The events that are fired from within the plugin are: - `CodeCompanionToolFinished` - Fired when a tool has finished executing - `CodeCompanionInlineStarted` - Fired at the start of the Inline interaction - `CodeCompanionInlineFinished` - Fired at the end of the Inline interaction +- `CodeCompanionMCPServerStart` - Fired when an MCP server is started +- `CodeCompanionMCPServerReady` - Fired when an MCP server is ready for requests +- `CodeCompanionMCPServerClosed` - Fired when an MCP server is closed +- `CodeCompanionMCPServerToolsLoaded` - Fired when tools are loaded for an MCP server - `CodeCompanionRequestStarted` - Fired at the start of any API request - `CodeCompanionRequestStreaming` - Fired at the start of a streaming API request - `CodeCompanionRequestFinished` - Fired at the end of any API request diff --git a/lua/codecompanion/config.lua b/lua/codecompanion/config.lua index c0915fdad..d3087b359 100644 --- a/lua/codecompanion/config.lua +++ b/lua/codecompanion/config.lua @@ -49,7 +49,7 @@ local defaults = { }, }, opts = { - cmd_timeout = 10e3, -- Timeout for commands that resolve env variables (milliseconds) + cmd_timeout = 20e3, -- Timeout for commands that resolve env variables (milliseconds) }, }, constants = constants, @@ -448,6 +448,14 @@ If you are providing code changes, use the insert_edit_into_file tool (if availa contains_code = false, }, }, + ["mcp"] = { + callback = "interactions.chat.slash_commands.builtin.mcp", + description = "Toggle MCP servers", + opts = { + contains_code = false, + provider = "default", -- snacks|default + }, + }, ["now"] = { callback = "interactions.chat.slash_commands.builtin.now", description = "Insert the current date and time", @@ -769,6 +777,14 @@ The user is working on a %s machine. Please respond with system specific command }, }, }, + -- MCP SERVERS ---------------------------------------------------------------- + mcp = { + enabled = true, + servers = {}, + opts = { + timeout = 30e3, -- Timeout for MCP server responses (milliseconds) + }, + }, -- PROMPT LIBRARIES --------------------------------------------------------- prompt_library = { -- Users can define prompt library items in markdown diff --git a/lua/codecompanion/interactions/chat/debug.lua b/lua/codecompanion/interactions/chat/debug.lua index c2ea26fae..73b6bc441 100644 --- a/lua/codecompanion/interactions/chat/debug.lua +++ b/lua/codecompanion/interactions/chat/debug.lua @@ -155,6 +155,17 @@ function Debug:render() table.insert(lines, '-- Following Buffer: "' .. bufname .. '" (' .. _G.codecompanion_current_context .. ")") end + -- Add MCP status + local mcp_status = require("codecompanion.mcp").get_status() + if vim.tbl_count(mcp_status) > 0 then + table.insert(lines, "") + table.insert(lines, "-- MCP Servers:") + for server, status in pairs(mcp_status) do + local is_ready = status.ready and " " or "○ " + table.insert(lines, string.format("-- %s%s (tools: %d)", is_ready, server, status.tool_count)) + end + end + -- Add settings if not config.display.chat.show_settings and adapter.type ~= "acp" then table.insert(lines, "") diff --git a/lua/codecompanion/interactions/chat/helpers/filter.lua b/lua/codecompanion/interactions/chat/helpers/filter.lua index 07b2b9e9f..024a99e85 100644 --- a/lua/codecompanion/interactions/chat/helpers/filter.lua +++ b/lua/codecompanion/interactions/chat/helpers/filter.lua @@ -59,10 +59,10 @@ function Filter.create_filter(config) end ---Get enabled items from the cache or compute them - ---@param items_config table The items configuration + ---@param items_cfg table The items configuration ---@param opts? table Options to pass to enabled functions ---@return table Map of item names to enabled status - local function get_enabled_items(items_config, opts) + local function get_enabled_items(items_cfg, opts) opts = opts or {} -- Create a cache key that includes both config and adapter state @@ -77,7 +77,7 @@ function Filter.create_filter(config) end local cache_key_data = { - config = items_config, + config = items_cfg, adapter = adapter_key, } local current_cache_key = hash.hash(cache_key_data) @@ -93,7 +93,7 @@ function Filter.create_filter(config) -- Get skip keys from config or use defaults local skip_keys = config.skip_keys or { "opts" } - for item_name, item_config in pairs(items_config) do + for item_name, item_config in pairs(items_cfg) do -- Skip special keys local should_skip = false for _, skip_key in ipairs(skip_keys) do @@ -113,35 +113,44 @@ function Filter.create_filter(config) end ---Filter configuration to only include enabled items - ---@param items_config table The items configuration - ---@param opts? table Options to pass to enabled functions - ---@return table The filtered configuration - function Filter.filter_enabled(items_config, opts) - local enabled_items = get_enabled_items(items_config, opts) - local filtered_config = vim.deepcopy(items_config) + ---@param items_cfg table + ---@param opts? table + ---@return table + function Filter.filter_enabled(items_cfg, opts) + -- Apply pre_filter hook to merge tools, dynamically as is the case with MCP servers + local merged_config = items_cfg + if config.pre_filter then + merged_config = config.pre_filter(items_cfg) + end + + local enabled_items = get_enabled_items(merged_config, opts) + local filtered_cfg = vim.deepcopy(merged_config) -- Remove disabled items for item_name, is_enabled in pairs(enabled_items) do if not is_enabled then - filtered_config[item_name] = nil + filtered_cfg[item_name] = nil end end -- Run custom post-filter logic if provided if config.post_filter then - filtered_config = config.post_filter(filtered_config, opts, enabled_items) + opts = vim.tbl_extend("force", opts or {}, { + enabled_items = enabled_items, + }) + filtered_cfg = config.post_filter(filtered_cfg, opts) end - return filtered_config + return filtered_cfg end ---Check if a specific item is enabled - ---@param item_name string The name of the item - ---@param items_config table The items configuration - ---@param opts? table Options to pass to enabled functions + ---@param item_name string + ---@param items_cfg table + ---@param opts? table ---@return boolean - function Filter.is_enabled(item_name, items_config, opts) - local enabled_items = get_enabled_items(items_config, opts) + function Filter.is_enabled(item_name, items_cfg, opts) + local enabled_items = get_enabled_items(items_cfg, opts) return enabled_items[item_name] == true end diff --git a/lua/codecompanion/interactions/chat/init.lua b/lua/codecompanion/interactions/chat/init.lua index 87592f204..bb7867aa6 100644 --- a/lua/codecompanion/interactions/chat/init.lua +++ b/lua/codecompanion/interactions/chat/init.lua @@ -526,6 +526,10 @@ function Chat.new(args) self:update_metadata() + if config.mcp.enabled then + require("codecompanion.mcp").start_servers() + end + -- Likely this hasn't been set by the time the user opens the chat buffer if not _G.codecompanion_current_context then _G.codecompanion_current_context = self.buffer_context.bufnr @@ -1452,6 +1456,10 @@ function Chat:stop() end) end + pcall(function() + require("codecompanion.mcp").cancel_requests(self.id) + end) + if self.current_request then local handle = self.current_request self.current_request = nil diff --git a/lua/codecompanion/interactions/chat/slash_commands/builtin/mcp.lua b/lua/codecompanion/interactions/chat/slash_commands/builtin/mcp.lua new file mode 100644 index 000000000..bed35232d --- /dev/null +++ b/lua/codecompanion/interactions/chat/slash_commands/builtin/mcp.lua @@ -0,0 +1,142 @@ +local config = require("codecompanion.config") +local log = require("codecompanion.utils.log") +local mcp = require("codecompanion.mcp") +local utils = require("codecompanion.utils") + +local fmt = string.format + +local CONSTANTS = { + NAME = "MCP", + PROMPT = "Toggle MCP server", + STARTED_ICON = "", + STOPPED_ICON = "○", +} + +---Build picker entries for configured MCP servers +---@return table[] +local function build_items() + local items = {} + local status = mcp.get_status() + + for name, server in pairs(status) do + local icon = server.started and CONSTANTS.STARTED_ICON or CONSTANTS.STOPPED_ICON + local activity = server.started and (server.ready and "ready" or "starting") or "stopped" + local display = fmt("%s %s (%s, tools: %d)", icon, name, activity, server.tool_count or 0) + + table.insert(items, { + name = name, + display = display, + text = display, + enabled = server.enabled, + }) + end + + return items +end + +---Toggle the selected MCP server +---@param selected { name: string } +---@return nil +local function toggle_server(selected) + local ok, result = mcp.toggle_server(selected.name) + if not ok then + return log:warn(result) + end + + local status = mcp.get_status()[selected.name] + local state = status and status.started and "started" or "stopped" + utils.notify(fmt("MCP server `%s` %s", selected.name, state)) +end + +local providers = { + ---The default provider + ---@param SlashCommand CodeCompanion.SlashCommand + ---@return nil + default = function(SlashCommand) + local items = build_items() + if #items == 0 then + return utils.notify("No MCP servers configured", vim.log.levels.WARN) + end + + vim.ui.select(items, { + kind = "codecompanion.nvim", + prompt = CONSTANTS.PROMPT, + format_item = function(item) + return item.display + end, + }, function(selected) + if not selected then + return + end + return SlashCommand:output(selected) + end) + end, + + ---The Snacks.nvim provider + ---@param SlashCommand CodeCompanion.SlashCommand + ---@return nil + snacks = function(SlashCommand) + local items = build_items() + if #items == 0 then + return utils.notify("No MCP servers configured", vim.log.levels.WARN) + end + + local snacks = require("codecompanion.providers.slash_commands.snacks") + snacks = snacks.new({ + title = CONSTANTS.PROMPT .. ": ", + output = function(selection) + return SlashCommand:output(selection) + end, + }) + + snacks.provider.picker.pick({ + title = CONSTANTS.PROMPT, + items = items, + prompt = snacks.title, + format = function(item, _) + return { { item.display } } + end, + confirm = snacks:display(), + main = { file = false, float = true }, + }) + end, +} + +---@class CodeCompanion.SlashCommand.MCP: CodeCompanion.SlashCommand +local SlashCommand = {} + +---@param args CodeCompanion.SlashCommandArgs +function SlashCommand.new(args) + local self = setmetatable({ + Chat = args.Chat, + config = args.config, + context = args.context, + }, { __index = SlashCommand }) + + return self +end + +---Is the slash command enabled? +---@return boolean|boolean,string +function SlashCommand.enabled() + if vim.tbl_isempty(config.mcp.servers or {}) then + return false, "[MCP] No servers found in your configuration" + end + return true +end + +---Execute the slash command +---@param SlashCommands CodeCompanion.SlashCommands +---@return nil +function SlashCommand:execute(SlashCommands) + return SlashCommands:set_provider(self, providers) +end + +---Output from the slash command in the chat buffer +---@param selected { name: string } +---@return nil +function SlashCommand:output(selected) + return toggle_server(selected) +end + +return SlashCommand diff --git a/lua/codecompanion/interactions/chat/slash_commands/init.lua b/lua/codecompanion/interactions/chat/slash_commands/init.lua index 339fdf084..e156bad87 100644 --- a/lua/codecompanion/interactions/chat/slash_commands/init.lua +++ b/lua/codecompanion/interactions/chat/slash_commands/init.lua @@ -47,10 +47,7 @@ end function SlashCommands:set_provider(SlashCommand, providers) if SlashCommand.config.opts and SlashCommand.config.opts.provider then if not providers[SlashCommand.config.opts.provider] then - return log:error( - "Provider for the symbols slash command could not be found: %s", - SlashCommand.config.opts.provider - ) + return log:error("Provider for the slash command could not be found: %s", SlashCommand.config.opts.provider) end return providers[SlashCommand.config.opts.provider](SlashCommand) --[[@type function]] end diff --git a/lua/codecompanion/interactions/chat/tools/filter.lua b/lua/codecompanion/interactions/chat/tools/filter.lua index ec5e10594..9058a271a 100644 --- a/lua/codecompanion/interactions/chat/tools/filter.lua +++ b/lua/codecompanion/interactions/chat/tools/filter.lua @@ -1,54 +1,101 @@ local filter = require("codecompanion.interactions.chat.helpers.filter") local log = require("codecompanion.utils.log") +---Add MCP tools from the MCP tools registry into the tools config +---@param tools_config table +---@return table +local function add_mcp_tools(tools_config) + local ok, mcp = pcall(require, "codecompanion.mcp") -- Lazy load this to avoid circular dependency + if not ok then + return tools_config + end + local mcp_tools, mcp_groups = mcp.get_registered_tools() + + local merged = vim.tbl_extend("force", {}, tools_config) + + for tool_name, tool_config in pairs(mcp_tools) do + merged[tool_name] = tool_config + end + + if not vim.tbl_isempty(mcp_groups) then + merged.groups = vim.tbl_extend("force", merged.groups or {}, mcp_groups) + end + + return merged +end + ---@class CodeCompanion.Tools.Filter local Filter = filter.create_filter({ skip_keys = { "opts", "groups" }, - post_filter = function(filtered_config, opts, enabled_items) - -- Adapter specific tool + pre_filter = add_mcp_tools, + post_filter = function(filtered_cfg, opts) + local mcp_status + + ---Determine if the MCP server for a tool has been started + local function server_started(tool_cfg) + local server = vim.tbl_get(tool_cfg, "opts", "_mcp_info", "server") + if not server then + return true + end + if not mcp_status then + mcp_status = require("codecompanion.mcp").get_status() + end + return mcp_status[server] and mcp_status[server].started or false + end + + -- Adapter specific tools if opts and opts.adapter and opts.adapter.available_tools then - for tool_name, tool_config in pairs(opts.adapter.available_tools) do + for name, cfg in pairs(opts.adapter.available_tools) do local should_show = true - if tool_config.enabled then - if type(tool_config.enabled) == "function" then - should_show = tool_config.enabled(opts.adapter) + if cfg.enabled then + if type(cfg.enabled) == "function" then + should_show = cfg.enabled(opts.adapter) else - should_show = tool_config.enabled + should_show = cfg.enabled end end -- An adapter's tool will take precedence over built-in tools if should_show then - filtered_config[tool_name] = vim.tbl_extend("force", tool_config, { + filtered_cfg[name] = vim.tbl_extend("force", cfg, { _adapter_tool = true, - _has_client_tool = tool_config.opts and tool_config.opts.client_tool and true or false, + _has_client_tool = cfg.opts and cfg.opts.client_tool and true or false, }) end end end + for name, cfg in pairs(filtered_cfg) do + if name ~= "opts" and name ~= "groups" then + if type(cfg) == "table" and not server_started(cfg) then + filtered_cfg[name] = nil + opts.enabled_items[name] = nil + log:trace("[Tool Filter] Filtered out MCP tool for stopped server: %s", name) + end + end + end + -- Filter tool groups to only include enabled ones - if filtered_config.groups then - for group_name, group_config in pairs(filtered_config.groups) do - if group_config.tools then + if filtered_cfg.groups then + for name, cfg in pairs(filtered_cfg.groups) do + if cfg.tools then local enabled_group_tools = {} - for _, tool_name in ipairs(group_config.tools) do - if enabled_items[tool_name] then + for _, tool_name in ipairs(cfg.tools) do + if opts.enabled_items[tool_name] then table.insert(enabled_group_tools, tool_name) end end - filtered_config.groups[group_name].tools = enabled_group_tools + filtered_cfg.groups[name].tools = enabled_group_tools - -- Remove group if no tools are enabled if #enabled_group_tools == 0 then - filtered_config.groups[group_name] = nil - log:trace("[Tool Filter] Filtered out group with no enabled tools: %s", group_name) + filtered_cfg.groups[name] = nil + log:trace("[Tool Filter] Filtered out group with no enabled tools: %s", name) end end end end - return filtered_config + return filtered_cfg end, }) diff --git a/lua/codecompanion/mcp/client.lua b/lua/codecompanion/mcp/client.lua new file mode 100644 index 000000000..2ae8a1a01 --- /dev/null +++ b/lua/codecompanion/mcp/client.lua @@ -0,0 +1,666 @@ +local METHODS = require("codecompanion.mcp.methods") +local config = require("codecompanion.config") +local tool_bridge = require("codecompanion.mcp.tool_bridge") + +local adapter_utils = require("codecompanion.utils.adapters") +local log = require("codecompanion.utils.log") +local utils = require("codecompanion.utils") + +local CONSTANTS = { + GRACEFUL_SHUTDOWN_TIMEOUT = 3000, + SIGTERM_TIMEOUT = 2000, -- After SIGTERM before SIGKILL + SERVER_TIMEOUT = config.mcp.opts.timeout, + + MAX_TOOLS_PER_SERVER = 100, -- Maximum tools per server to avoid infinite pagination + + JSONRPC = { -- Some of these are unusues + ERROR_PARSE = -32700, + ERROR_INVALID_REQUEST = -32600, + ERROR_METHOD_NOT_FOUND = -32601, + ERROR_INVALID_PARAMS = -32602, + ERROR_INTERNAL = -32603, + }, +} + +local last_msg_id = 0 + +---Increment and return the next unique message id used for JSON-RPC requests. +---@return number next_id +local function next_msg_id() + last_msg_id = last_msg_id + 1 + return last_msg_id +end + +---Transform static methods for easier testing +---@param class table The class with static.methods definition +---@param methods? table Optional method overrides for testing +---@return table methods Transformed methods with overrides applied +local function transform_static_methods(class, methods) + local ret = {} + for k, v in pairs(class.static.methods) do + ret[k] = (methods and methods[k]) or v.default + end + return ret +end + +---Abstraction over the IO transport to a MCP server +---@class CodeCompanion.MCP.Transport +---@field start fun(self: CodeCompanion.MCP.Transport, on_line_read: fun(line: string), on_close: fun(err?: string)) +---@field started fun(self: CodeCompanion.MCP.Transport): boolean +---@field write fun(self: CodeCompanion.MCP.Transport, lines?: string[]) +---@field stop fun(self: CodeCompanion.MCP.Transport) + +---Default Transport implementation backed by vim.system +---@class CodeCompanion.MCP.StdioTransport : CodeCompanion.MCP.Transport +---@field name string +---@field cmd string[] +---@field env? table +---@field env_replaced? table Replacement of environment variables with their actual values +---@field methods table +---@field _incomplete_line? string +---@field _on_line_read? fun(line: string) +---@field _on_close? fun(err?: string) +---@field _sysobj? vim.SystemObj +local StdioTransport = {} +StdioTransport.static = {} + +---@class CodeCompanion.MCP.StdioTransportArgs +---@field name string +---@field cfg CodeCompanion.MCP.ServerConfig +---@field methods? table Optional method overrides for testing + +-- Static methods for testing/mocking +StdioTransport.static.methods = { + defer_fn = { default = vim.defer_fn }, + job = { default = vim.system }, + schedule_wrap = { default = vim.schedule_wrap }, +} + +---Create a new StdioTransport for the given server configuration. +---@param args CodeCompanion.MCP.StdioTransportArgs +---@return CodeCompanion.MCP.StdioTransport +function StdioTransport.new(args) + local self = setmetatable({ + cmd = args.cfg.cmd, + env = args.cfg.env, + name = args.name, + methods = transform_static_methods(StdioTransport, args.methods), + }, { __index = StdioTransport }) ---@cast self CodeCompanion.MCP.StdioTransport + + return self +end + +---Start the underlying process and attach stdout/stderr callbacks. +---@param on_line_read fun(line: string) +---@param on_close fun(err?: string) +function StdioTransport:start(on_line_read, on_close) + assert(not self._sysobj, "StdioTransport: start called when already started") + self._on_line_read = on_line_read + self._on_close = on_close + + adapter_utils.get_env_vars(self) + local cmd = adapter_utils.set_env_vars(self, self.cmd) + self._sysobj = self.methods.job( + cmd, + { + env = self.env_replaced or self.env, + text = true, + stdin = true, + stdout = self.methods.schedule_wrap(function(err, data) + self:_handle_stdout(err, data) + end), + stderr = self.methods.schedule_wrap(function(err, data) + self:_handle_stderr(err, data) + end), + }, + self.methods.schedule_wrap(function(out) + self:_handle_exit(out) + end) + ) +end + +---Return whether the transport process has been started. +---@return boolean +function StdioTransport:started() + return self._sysobj ~= nil +end + +---Handle stdout stream chunks, buffer incomplete lines and deliver complete lines to the on_line_read callback. +---@param err? string +---@param data? string +function StdioTransport:_handle_stdout(err, data) + if err then + return log:debug("[MCP::Client] stdout error: %s", err) + end + if not data or data == "" then + return + end + + local combined = "" + if self._incomplete_line then + combined = self._incomplete_line .. data + self._incomplete_line = nil + else + combined = data + end + + local last_newline_pos = combined:match(".*()\n") + if last_newline_pos == nil then + self._incomplete_line = combined + return + elseif last_newline_pos < #combined then + self._incomplete_line = combined:sub(last_newline_pos + 1) + combined = combined:sub(1, last_newline_pos) + end + + for line in vim.gsplit(combined, "\n", { plain = true, trimempty = true }) do + if line ~= "" and self._on_line_read then + local ok, _ = pcall(self._on_line_read, line) + if not ok then + log:debug("[MCP::Client] on_line_read callback failed for line: %s", line) + end + end + end +end + +---Handle stderr output from the process. +---@param err? string +---@param data? string +function StdioTransport:_handle_stderr(err, data) + if err then + return log:debug("[MCP::Client] stderr error: %s", err) + end + if data then + log:debug("[MCP::Client::%s] stderr: %s", self.name, data) + end +end + +---Handle process exit and invoke the on_close callback with an optional error message. +---@param out vim.SystemCompleted The output object from vim.system containing code and signal fields. +function StdioTransport:_handle_exit(out) + local err_msg = nil + if out and (out.code ~= 0) then + err_msg = string.format("exit code %s, signal %s", tostring(out.code), tostring(out.signal)) + end + self._sysobj = nil + if self._on_close then + local ok, _ = pcall(self._on_close, err_msg) + if not ok then + log:debug("[MCP::Client] on_close callback failed") + end + end +end + +---Write lines to the process stdin. +---@param lines string[] +function StdioTransport:write(lines) + if not self._sysobj then + error("StdioTransport: write called before start") + end + self._sysobj:write(lines) +end + +---Stop the MCP server process. +function StdioTransport:stop() + if not self._sysobj then + return + end + + -- Step 1: Close stdin to signal the server to exit gracefully + pcall(function() + self._sysobj:write(nil) -- Close stdin + end) + + -- Step 2: Schedule SIGTERM if process doesn't exit within timeout + self.methods.defer_fn(function() + if self._sysobj then + pcall(function() + self._sysobj:kill(vim.uv.constants.SIGTERM) + end) + + -- Step 3: Schedule SIGKILL as last resort + self.methods.defer_fn(function() + if self._sysobj then + pcall(function() + self._sysobj:kill(vim.uv.constants.SIGKILL) + end) + end + end, CONSTANTS.SIGTERM_TIMEOUT) + end + end, CONSTANTS.GRACEFUL_SHUTDOWN_TIMEOUT) +end + +---@alias ServerRequestHandler fun(cli: CodeCompanion.MCP.Client, params: table?): "result" | "error", table +---@alias ResponseHandler fun(resp: MCP.JSONRPCResultResponse | MCP.JSONRPCErrorResponse) + +---@class CodeCompanion.MCP.ResponseHandlerEntry +---@field handler ResponseHandler The response handler callback +---@field chat_id? number The chat buffer ID that initiated this request + +---@class CodeCompanion.MCP.Client +---@field name string +---@field cfg CodeCompanion.MCP.ServerConfig +---@field ready boolean +---@field transport CodeCompanion.MCP.Transport +---@field resp_handlers table +---@field server_request_handlers table +---@field server_capabilities? table +---@field server_instructions? string +---@field methods table +local Client = {} +Client.__index = Client + +Client.static = {} +Client.static.methods = { + new_transport = { + default = function(args) + return StdioTransport.new(args) + end, + }, + json_decode = { default = vim.json.decode }, + json_encode = { default = vim.json.encode }, + schedule_wrap = { default = vim.schedule_wrap }, + defer_fn = { default = vim.defer_fn }, +} + +---@class CodeCompanion.MCP.ClientArgs +---@field name string +---@field cfg CodeCompanion.MCP.ServerConfig +---@field transport? CodeCompanion.MCP.Transport Optional transport instance for testing +---@field methods? table Optional method overrides for testing + +---Create a new MCP client instance bound to the provided server configuration. +---@param args CodeCompanion.MCP.ClientArgs +---@return CodeCompanion.MCP.Client +function Client.new(args) + local static_methods = transform_static_methods(Client, args.methods) + local transport = args.transport + or static_methods.new_transport({ name = args.name, cfg = args.cfg, methods = args.methods }) + local self = setmetatable({ + name = args.name, + cfg = args.cfg, + ready = false, + transport = transport, + resp_handlers = {}, + server_request_handlers = {}, + methods = static_methods, + }, Client) + + self.server_request_handlers = { + ["ping"] = function() + return self:_handle_server_ping() + end, + ["roots/list"] = function() + return self:_handle_server_roots_list() + end, + } + + return self +end + +---Start the client. +function Client:start() + if self.transport:started() then + return + end + log:debug("[MCP::Client::%s] Starting with command: %s", self.name, table.concat(self.cfg.cmd, " ")) + + self.transport:start(function(line) + self:_on_transport_line_read(line) + end, function(err) + self:_on_transport_close(err) + end) + utils.fire("MCPServerStart", { server = self.name }) + + self:_start_initialization() +end + +---Stop the client +---@return nil +function Client:stop() + if not self.transport:started() then + return + end + + log:debug("[MCP::Client::%s] Stopping server", self.name) + self.transport:stop() +end + +---Start the MCP initialization procedure +---@return nil +function Client:_start_initialization() + assert(self.transport:started(), "MCP Server process is not running.") + assert(not self.ready, "MCP Server is already initialized.") + + local capabilities = vim.empty_dict() + if self.cfg.roots then + capabilities.roots = { listChanged = self.cfg.register_roots_list_changed ~= nil } + end + + self:request("initialize", { + protocolVersion = "2025-11-25", + clientInfo = { + name = "CodeCompanion.nvim", + version = "NO VERSION", --MCP Spec explicitly requires a version + }, + capabilities = capabilities, + }, function(resp) + if resp.error then + log:error("[MCP::Client::%s] Initialization failed: %s", self.name, resp.error) + self:stop() + return + end + log:debug( + "[MCP::Client::%s] Initialized: version=%s, server=%s, capabilities=%s", + self.name, + resp.result.protocolVersion, + resp.result.serverInfo, + resp.result.capabilities + ) + self:notify(METHODS.InitializedNotification) + self.server_capabilities = resp.result.capabilities + self.server_instructions = resp.result.instructions + self.ready = true + if self.cfg.register_roots_list_changed then + self.cfg.register_roots_list_changed(function() + self:notify(METHODS.RootsListChangedNotification) + end) + end + utils.fire("MCPServerReady", { server = self.name }) + self:refresh_tools() + end) +end + +---Handle transport close events. +---@param err string|nil +function Client:_on_transport_close(err) + self.ready = false + for id, entry in pairs(self.resp_handlers) do + -- Notify all pending requests of the transport closure + pcall(entry.handler, { + jsonrpc = "2.0", + id = id, + error = { code = CONSTANTS.JSONRPC.ERROR_INTERNAL, message = "MCP server connection closed" }, + }) + end + self.resp_handlers = {} + utils.fire("MCPServerClosed", { server = self.name, err = err }) +end + +---Process a single JSON-RPC line received from the MCP server. +---@param line string +function Client:_on_transport_line_read(line) + if not line or line == "" then + return + end + log:debug("[MCP::Client::%s] Received: %s", self.name, line) + local ok, msg = pcall(self.methods.json_decode, line, { luanil = { object = true } }) + if not ok then + return log:debug("[MCP::Client::%s] Failed to decode: %s", self.name, line) + end + if type(msg) ~= "table" or msg.jsonrpc ~= "2.0" then + return log:debug("[MCP::Client::%s] Invalid message: %s", self.name, line) + end + if msg.id == nil then + return -- Notification already logged above + end + + if msg.method then + self:_handle_server_request(msg) + else + local entry = self.resp_handlers[msg.id] + if entry then + self.resp_handlers[msg.id] = nil + local ok, result = pcall(entry.handler, msg) + if not ok then + log:debug("[MCP::Client::%s] Response handler failed for request %s: %s", self.name, msg.id, result) + end + end + end +end + +---Handle an incoming JSON-RPC request from the MCP server. +---@param msg MCP.JSONRPCRequest +function Client:_handle_server_request(msg) + assert(self.transport:started(), "MCP Server process is not running.") + local resp = { + jsonrpc = "2.0", + id = msg.id, + } + local handler = self.server_request_handlers[msg.method] + if not handler then + resp.error = { code = CONSTANTS.JSONRPC.ERROR_METHOD_NOT_FOUND, message = "Method not found" } + else + local ok, status, body = pcall(handler, self, msg.params) + if not ok then + log:debug("[MCP::Client::%s] Handler for %s failed: %s", self.name, msg.method, status) + resp.error = { code = CONSTANTS.JSONRPC.ERROR_INTERNAL, message = status } + elseif status == "error" then + resp.error = body + elseif status == "result" then + resp.result = body + else + resp.error = { code = CONSTANTS.JSONRPC.ERROR_INTERNAL, message = "Internal server error" } + end + end + local resp_str = self.methods.json_encode(resp) + log:debug("[MCP::Client::%s] Sending: %s", self.name, resp_str) + self.transport:write({ resp_str }) +end + +---Get the server instructions, applying any overrides from the config +---@return string? +function Client:get_server_instructions() + assert(self.ready, "MCP Server is not ready.") + local override = self.cfg.server_instructions + if type(override) == "function" then + return override(self.server_instructions) + elseif type(override) == "string" then + return override + else + return self.server_instructions + end +end + +---Send a JSON-RPC notification to the MCP server. +---@param method string +---@param params? table +function Client:notify(method, params) + assert(self.transport:started(), "MCP Server process is not running.") + if params and vim.tbl_isempty(params) then + params = vim.empty_dict() + end + local notif = { + jsonrpc = "2.0", + method = method, + params = params, + } + local notif_str = self.methods.json_encode(notif) + log:debug("[MCP::Client::%s] Sending: %s", self.name, notif_str) + self.transport:write({ notif_str }) +end + +---Send a JSON-RPC request to the MCP server. +---@param method string +---@param params? table +---@param resp_handler ResponseHandler +---@param opts? table { timeout?: number, chat_id?: number } +---@return number req_id +function Client:request(method, params, resp_handler, opts) + assert(self.transport:started(), "MCP Server process is not running.") + local req_id = next_msg_id() + if params and vim.tbl_isempty(params) then + params = vim.empty_dict() + end + local req = { + jsonrpc = "2.0", + id = req_id, + method = method, + params = params, + } + if resp_handler then + self.resp_handlers[req_id] = { + handler = resp_handler, + chat_id = opts and opts.chat_id or nil, + } + end + local req_str = self.methods.json_encode(req) + log:debug("[MCP::Client::%s] Sending: %s", self.name, req_str) + self.transport:write({ req_str }) + + local timeout = opts and opts.timeout or CONSTANTS.SERVER_TIMEOUT + self.methods.defer_fn(function() + if self.resp_handlers[req_id] then + self.resp_handlers[req_id] = nil + self:cancel_request(req_id, "Request timed out") + + local timeout_msg = string.format("Request timed out after %d ms", timeout) + if resp_handler then + local ok, _ = pcall(resp_handler, { + jsonrpc = "2.0", + id = req_id, + error = { code = CONSTANTS.JSONRPC.ERROR_INTERNAL, message = timeout_msg }, + }) + if not ok then + log:debug("[MCP::Client::%s] Timeout handler failed for request %s", self.name, req_id) + end + end + end + end, timeout) + + return req_id +end + +---Handler for 'ping' server requests. +---@return "result", table +function Client:_handle_server_ping() + return "result", {} +end + +---Handler for 'roots/list' server requests. +---@return "result" | "error", table +function Client:_handle_server_roots_list() + if not self.cfg.roots then + return "error", { code = CONSTANTS.JSONRPC.ERROR_METHOD_NOT_FOUND, message = "roots capability not enabled" } + end + + local ok, roots = pcall(self.cfg.roots) + if not ok then + log:debug("[MCP::Client::%s] Roots function failed: %s", self.name, roots) + return "error", { code = CONSTANTS.JSONRPC.ERROR_INTERNAL, message = "roots function failed" } + end + + if not roots or type(roots) ~= "table" then + log:debug("[MCP::Client::%s] Roots function returned invalid result: %s", self.name, roots) + return "error", { code = CONSTANTS.JSONRPC.ERROR_INTERNAL, message = "roots function returned invalid result" } + end + + return "result", { roots = roots } +end + +---Cancel a pending request to the MCP server and notify the server of cancellation. +---@param req_id number The ID of the request to cancel +---@param reason? string The reason for cancellation +---@return nil +function Client:cancel_request(req_id, reason) + log:debug("[MCP::Client::%s] Cancelling request %s: %s", self.name, req_id, reason or "") + self.resp_handlers[req_id] = nil + self:notify(METHODS.CancelledNotification, { + requestId = req_id, + reason = reason, + }) +end + +---Cancel all pending requests for a specific chat buffer +---@param chat_id number The chat buffer ID +---@param reason? string The reason for cancellation +function Client:cancel_request_from_chat(chat_id, reason) + reason = reason or "Cancelled by user" + + for req_id, entry in pairs(self.resp_handlers) do + if entry.chat_id == chat_id then + log:debug("[MCP::Client::%s] Cancelling request %s for chat %s: %s", self.name, req_id, chat_id, reason) + self.resp_handlers[req_id] = nil + self:notify(METHODS.CancelledNotification, { + requestId = req_id, + reason = reason, + }) + end + end +end + +---Call a tool on the MCP server +---@param name string The name of the tool to call +---@param args? table The arguments to pass to the tool +---@param callback fun(ok: boolean, result_or_error: MCP.CallToolResult | string) Callback function that receives (ok, result_or_error) +---@param opts? table { timeout?: number, chat_id?: number } +---@return number req_id +function Client:call_tool(name, args, callback, opts) + assert(self.ready, "MCP Server is not ready.") + + return self:request("tools/call", { + name = name, + arguments = args, + }, function(resp) + if resp.error then + log:error( + "[MCP::Client::%s] Tool call failed for %s: [%s] %s", + self.name, + name, + resp.error.code, + resp.error.message + ) + callback(false, string.format("MCP JSONRPC error: [%s] %s", resp.error.code, resp.error.message)) + return + end + + if not resp.result or not resp.result.content then + log:debug("[MCP::Client::%s] Malformed tool response for %s", self.name, name) + callback(false, "MCP call_tool received malformed response") + return + end + local result = resp.result --[[@as MCP.CallToolResult]] + + callback(true, result) + end, opts) +end + +---Refresh the list of tools available from the MCP server. +---@return nil +function Client:refresh_tools() + assert(self.ready, "MCP Server is not ready.") + if not self.server_capabilities.tools then + log:debug("[MCP::Client::%s] Server does not support tools", self.name) + return + end + + local all_tools = {} ---@type MCP.Tool[] + local function load_tools(cursor) + self:request("tools/list", { cursor = cursor }, function(resp) + if resp.error then + log:debug("[MCP::Client::%s] tools/list failed: [%s] %s", self.name, resp.error.code, resp.error.message) + return + end + + local tools = resp.result and resp.result.tools or {} + for _, tool in ipairs(tools) do + table.insert(all_tools, tool) + end + + -- pagination handling + local next_cursor = resp.result and resp.result.nextCursor + if next_cursor then + return load_tools(next_cursor) + end + + log:debug("[MCP::Client::%s] Loaded %d tools", self.name, #all_tools) + local installed_tools = tool_bridge.setup_tools(self, all_tools) + utils.fire("MCPServerToolsLoaded", { server = self.name, tools = installed_tools }) + utils.fire("ChatRefreshCache") + end) + end + + load_tools() +end + +return Client diff --git a/lua/codecompanion/mcp/init.lua b/lua/codecompanion/mcp/init.lua new file mode 100644 index 000000000..aef4e713a --- /dev/null +++ b/lua/codecompanion/mcp/init.lua @@ -0,0 +1,242 @@ +local Client = require("codecompanion.mcp.client") +local config = require("codecompanion.config") + +local CONSTANTS = { + TOOL_PREFIX = "mcp:", +} + +local M = {} + +---@type table Dynamic registry for MCP tools (server_name -> { tools: table, groups: table }) +local tool_registry = {} + +---Return whether the server config is enabled +---@param server_cfg CodeCompanion.MCP.ServerConfig +---@return boolean +local function is_enabled(server_cfg) + return not (server_cfg.opts and server_cfg.opts.enabled == false) +end + +---Register tools from an MCP server +---@param server_name string +---@param tools table Tool configurations keyed by tool name +---@param group table Group configuration for the server's tools +---@return nil +function M.register_tools(server_name, tools, group) + tool_registry[server_name] = { + tools = tools, + group = group, + } +end + +---Unregister tools from an MCP server +---@param server_name string +---@return nil +function M.unregister_tools(server_name) + tool_registry[server_name] = nil +end + +---Get all registered MCP tools merged into a single table +---@return table tools All MCP tools +---@return table groups All MCP tool groups +function M.get_registered_tools() + local all_tools = {} + local all_groups = {} + + for server_name, registry in pairs(tool_registry) do + for tool_name, tool_config in pairs(registry.tools) do + all_tools[tool_name] = tool_config + end + if registry.group then + all_groups[CONSTANTS.TOOL_PREFIX .. server_name] = registry.group + end + end + + return all_tools, all_groups +end + +---Get tool count for a specific server +---@param server_name string +---@return number +function M.get_tool_count(server_name) + local registry = tool_registry[server_name] + if not registry then + return 0 + end + return vim.tbl_count(registry.tools) +end + +---@class CodeCompanion.MCP.ToolOverride +---@field opts? table +---@field enabled nil | boolean | fun(): boolean +---@field system_prompt? string +---@field output? table +---@field timeout? number + +---@class CodeCompanion.MCP.ServerConfig +---@field cmd string[] +---@field env? table +---@field opts? { enabled: boolean} +---@field server_instructions nil | string | fun(orig_server_instructions: string): string +---@field tool_defaults? table +---@field tool_overrides? table +---@field roots? fun(): { name?: string, uri: string }[] +---@field register_roots_list_changed? fun(notify: fun()) + +---@class CodeCompanion.MCPConfig +---@field servers? table + +---@type table +local clients = {} + +---Start all configured MCP servers if not already started +---@return nil +function M.start_servers() + local mcp_cfg = config.mcp + if vim.tbl_isempty(mcp_cfg.servers) then + return + end + + for name, cfg in pairs(mcp_cfg.servers) do + if cfg.opts and cfg.opts.enabled == false then + goto continue + end + if not clients[name] then + local client = Client.new({ name = name, cfg = cfg }) + clients[name] = client + end + ::continue:: + end + + for _, client in pairs(clients) do + client:start() + end + + vim.api.nvim_create_autocmd("VimLeavePre", { + group = vim.api.nvim_create_augroup("codecompanion.mcp.stop", { clear = true }), + callback = function() + pcall(function() + M.stop_servers() + end) + end, + }) +end + +---Stop all MCP servers +---@return nil +function M.stop_servers() + for _, client in pairs(clients) do + client:stop() + end + clients = {} +end + +---Restart all MCP servers +---@return nil +function M.restart_servers() + M.stop_servers() + M.start_servers() +end + +---Enable a configured MCP server +---@param name string +---@return boolean, boolean|string +function M.enable_server(name) + local mcp_cfg = config.mcp + local server_cfg = mcp_cfg.servers[name] + if not server_cfg then + return false, string.format("MCP server not found: %s", name) + end + + server_cfg.opts = server_cfg.opts or {} + server_cfg.opts.enabled = true + + if not clients[name] then + clients[name] = Client.new({ name = name, cfg = server_cfg }) + end + + clients[name]:start() + + return true, true +end + +---Disable a configured MCP server +---@param name string +---@return boolean, boolean|string +function M.disable_server(name) + local mcp_cfg = config.mcp + local server_cfg = mcp_cfg.servers[name] + if not server_cfg then + return false, string.format("MCP server not found: %s", name) + end + + server_cfg.opts = server_cfg.opts or {} + server_cfg.opts.enabled = false + + if clients[name] then + clients[name]:stop() + clients[name] = nil + end + + return true, false +end + +---Toggle a configured MCP server on or off +---@param name string +---@return boolean, boolean|string +function M.toggle_server(name) + local mcp_cfg = config.mcp + local server_cfg = mcp_cfg.servers[name] + if not server_cfg then + return false, string.format("MCP server not found: %s", name) + end + + local client = clients[name] + if client and client.transport:started() then + return M.disable_server(name) + end + + return M.enable_server(name) +end + +---Refresh configuration and restart servers +---This allows users to update their MCP config and apply changes without restarting Neovim +---@return nil +function M.refresh() + M.stop_servers() + M.start_servers() +end + +---Get status of all MCP servers +---@return table +function M.get_status() + local status = {} + + local mcp_cfg = config.mcp + for name, cfg in pairs(mcp_cfg.servers) do + local client = clients[name] + local ready = client and client.ready or false + + status[name] = { + ready = ready, + tool_count = M.get_tool_count(name), + started = client and client.transport:started() or false, + enabled = is_enabled(cfg), + } + end + + return status +end + +---Cancel all pending MCP requests for a specific chat buffer +---@param chat_id number +---@param reason? string +function M.cancel_requests(chat_id, reason) + for _, client in pairs(clients) do + if client.ready then + client:cancel_request_from_chat(chat_id, reason) + end + end +end + +return M diff --git a/lua/codecompanion/mcp/methods.lua b/lua/codecompanion/mcp/methods.lua new file mode 100644 index 000000000..f60295dd1 --- /dev/null +++ b/lua/codecompanion/mcp/methods.lua @@ -0,0 +1,5 @@ +return { + CancelledNotification = "notifications/cancelled", + InitializedNotification = "notifications/initialized", + RootsListChangedNotification = "notifications/roots/list_changed", +} diff --git a/lua/codecompanion/mcp/tool_bridge.lua b/lua/codecompanion/mcp/tool_bridge.lua new file mode 100644 index 000000000..dc285cc17 --- /dev/null +++ b/lua/codecompanion/mcp/tool_bridge.lua @@ -0,0 +1,205 @@ +local log = require("codecompanion.utils.log") + +local CONSTANTS = { + MESSAGES = { + TOOL_ACCESS = "I'm giving you access to tools from an MCP server", + TOOL_GROUPS = "Tools from MCP Server `%s`", + }, +} + +local fmt = string.format + +local M = {} + +---Format the output content from an MCP tool +---@param content string | MCP.ContentBlock[] +---@return string +function M.format_tool_result_content(content) + if type(content) == "table" then + if #content == 1 and content[1].type == "text" then + return content[1].text + end + return vim.inspect(content) + end + return content or "" +end + +---Default tool output callbacks that may be overridden by user config +---@class CodeCompanion.Tool.MCPToolBridge: CodeCompanion.Tools.Tool +local tool_output = { + ---@param self CodeCompanion.Tool.MCPToolBridge + ---@param tools CodeCompanion.Tools + ---@param cmd table The command that was executed + ---@param stdout table The output from the command + success = function(self, tools, cmd, stdout) + local chat = tools.chat + local output = M.format_tool_result_content(stdout and stdout[#stdout]) + local output_for_user = output + local DISPLAY_LIMIT_BYTES = 1000 + if #output_for_user > DISPLAY_LIMIT_BYTES then + local utf_offset = vim.str_utf_start(output_for_user, 1 + DISPLAY_LIMIT_BYTES) + output_for_user = output_for_user:sub(1, DISPLAY_LIMIT_BYTES + utf_offset) .. "\n\n...[truncated]" + end + local for_user = fmt( + [[MCP: %s executed successfully: +```` +%s +````]], + self.name, + output_for_user + ) + chat:add_tool_output(self, output, for_user) + end, + + ---@param self CodeCompanion.Tool.MCPToolBridge + ---@param tools CodeCompanion.Tools + ---@param cmd table + ---@param stderr table The error output from the command + error = function(self, tools, cmd, stderr) + local chat = tools.chat + local err_msg = M.format_tool_result_content(stderr and stderr[#stderr] or "") + local for_user = fmt( + [[MCP: %s failed: +```` +%s +```` +Arguments: +````%s +````]], + self.name, + err_msg, + vim.inspect(self.args) + ) + chat:add_tool_output(self, "MCP Tool execution failed:\n" .. err_msg, for_user) + end, + + ---The message which is shared with the user when asking for their approval + ---@param self CodeCompanion.Tool.MCPToolBridge + ---@param tools CodeCompanion.Tools + ---@return nil|string + prompt = function(self, tools) + return fmt("Execute the `%s` MCP tool?\nArguments:\n%s", self.name, vim.inspect(self.args)) + end, +} + +---Build a CodeCompanion tool from an MCP tool specification +---@param client CodeCompanion.MCP.Client +---@param mcp_tool MCP.Tool +---@return string? tool_name +---@return table? tool_config +function M.build(client, mcp_tool) + if mcp_tool.execution and mcp_tool.execution.taskSupport == "required" then + return log:warn( + "[MCP::Tool Bridge::%s] tool `%s` requires task execution support, which is not supported", + client.name, + mcp_tool.name + ) + end + + local prefixed_name = fmt("%s_%s", client.name, mcp_tool.name) + + -- Users can override server options via tool configuration + local override = (client.cfg.tool_overrides and client.cfg.tool_overrides[mcp_tool.name]) or {} + local output_cb = vim.tbl_deep_extend("force", tool_output, override.output or {}) + local tool_opts = vim.tbl_deep_extend("force", client.cfg.tool_defaults or {}, override.opts or {}) + + local tool = { + name = prefixed_name, + opts = tool_opts, + schema = { + type = "function", + ["function"] = { + name = prefixed_name, + description = mcp_tool.description, + parameters = mcp_tool.inputSchema, + strict = true, + }, + }, + system_prompt = override.system_prompt, + cmds = { + ---Execute the MCP tool + ---@param self CodeCompanion.Tools + ---@param args table The arguments from the LLM's tool call + ---@param input? any The output from the previous function call + ---@param output_handler function Async callback for completion + ---@return nil|table + function(self, args, input, output_handler) + local chat_id = self.chat and self.chat.id or nil + client:call_tool(mcp_tool.name, args, function(ok, result_or_error) + local output + if not ok then -- RPC failure + output = { status = "error", data = result_or_error } + else + local result = result_or_error + if result.isError then -- Tool execution error + output = { status = "error", data = result.content } + else + output = { status = "success", data = result.content } + end + end + output_handler(output) + end, { timeout = override.timeout, chat_id = chat_id }) + end, + }, + output = output_cb, + } + + local tool_cfg = { + description = mcp_tool.title or mcp_tool.name, + callback = tool, + enabled = override.enabled, + -- User should use the generated tool group instead of individual tools + visible = false, + -- `_mcp_info` marks the tool as originating from an MCP server + opts = { _mcp_info = { server = client.name } }, + } + + return prefixed_name, tool_cfg +end + +---Setup tools from an MCP server into the MCP registry +---@param client CodeCompanion.MCP.Client +---@param mcp_tools MCP.Tool[] +---@return string[] tools +function M.setup_tools(client, mcp_tools) + local mcp = require("codecompanion.mcp") + local tools = {} + local tool_configs = {} + + for _, tool in ipairs(mcp_tools) do + local name, tool_cfg = M.build(client, tool) + if name and tool_cfg then + tool_configs[name] = tool_cfg + table.insert(tools, name) + end + end + + if #tools == 0 then + log:warn("[MCP::Tool Bridge::%s] has no valid tools to configure", client.name) + return {} + end + + local server_prompt = { + fmt("%s `%s`: %s.", CONSTANTS.MESSAGES.TOOL_ACCESS, client.name, table.concat(tools, ", ")), + } + + -- The prompt should also contain instructions from the server, if any. + local server_instructions = client:get_server_instructions() + if server_instructions and server_instructions ~= "" then + table.insert(server_prompt, "Detailed instructions for this MCP server:") + table.insert(server_prompt, server_instructions) + end + + local group = { + description = string.format("Tools from MCP Server `%s`", client.name), + tools = tools, + prompt = table.concat(server_prompt, "\n"), + opts = { collapse_tools = true }, + } + + mcp.register_tools(client.name, tool_configs, group) + + return tools +end + +return M diff --git a/lua/codecompanion/types.lua b/lua/codecompanion/types.lua index dadb5e277..5476710d8 100644 --- a/lua/codecompanion/types.lua +++ b/lua/codecompanion/types.lua @@ -23,6 +23,41 @@ ---@alias ACP.availableCommands ACP.AvailableCommand[] +---@meta Model Context Protocol + +---@class MCP.JSONRPCRequest +---@field jsonrpc "2.0" +---@field id integer | string +---@field method string +---@field params table? + +---@class MCP.JSONRPCResultResponse +---@field jsonrpc "2.0" +---@field id integer | string +---@field result table? + +---@class MCP.JSONRPCErrorResponse +---@field jsonrpc "2.0" +---@field id integer | string +---@field error { code: integer, message: string, data: any? } + +---@class MCP.Tool +---@field name string +---@field inputSchema table +---@field description? string +---@field title? string +---@field execution? table + +---@class MCP.TextContent +---@field type "text" +---@field text string + +---@alias MCP.ContentBlock MCP.TextContent|any + +---@class MCP.CallToolResult +---@field isError? boolean +---@field content MCP.ContentBlock[] + ---@meta Tree-sitter ---@class vim.treesitter.LanguageTree diff --git a/scripts/panvimdoc-cleanup.lua b/scripts/panvimdoc-cleanup.lua index 7660b2095..ecd779b7f 100644 --- a/scripts/panvimdoc-cleanup.lua +++ b/scripts/panvimdoc-cleanup.lua @@ -14,16 +14,34 @@ local header_substitution = { { "^Using the%s*", "" }, { "^Using%s*", "" }, { "^Other Configuration Options", "Other Options" }, + -- Add more specific patterns for your long headers + { "^Agent Client Protocol %(ACP%) Support", "ACP" }, + { "^Model Context Protocol %(MCP%) Support", "MCP" }, + { "^Extending CodeCompanion with", "Extending with" }, + { "^Creating Your Own", "Extending with" }, + { "^Community Contributed", "Community" }, + { "Installation and Configuration", "Setup" }, + { "^The CodeCompanion", "" }, + { " in CodeCompanion", "" }, + { " for CodeCompanion", "" }, } function M.Header(el) local text = stringify(el.content) + + -- Apply all substitutions in order for _, sub in ipairs(header_substitution) do local pat, repl = sub[1], sub[2] text = text:gsub(pat, repl) end - el.content = text + -- Truncate if still too long (optional safety net) + local max_length = 80 + if #text > max_length then + text = text:sub(1, max_length - 3) .. "..." + end + + el.content = text return el end diff --git a/scripts/vimdoc.md b/scripts/vimdoc.md index 24950e724..f8690af69 100644 --- a/scripts/vimdoc.md +++ b/scripts/vimdoc.md @@ -3,6 +3,8 @@ doc/index.md doc/installation.md doc/getting-started.md doc/upgrading.md +doc/agent-client-protocol.md +doc/model-context-protocol.md ``` # Configuration @@ -12,6 +14,7 @@ doc/configuration/adapters-acp.md doc/configuration/adapters-http.md doc/configuration/chat-buffer.md doc/configuration/inline-assistant.md +doc/configuration/mcp.md doc/configuration/rules.md doc/configuration/prompt-library.md doc/configuration/system-prompt.md @@ -22,7 +25,6 @@ doc/configuration/others.md # Usage ```{.include shift-heading-level-by=1} doc/usage/introduction.md -doc/usage/acp-protocol.md doc/usage/action-palette.md doc/usage/chat-buffer/index.md doc/usage/chat-buffer/agents.md diff --git a/tests/config.lua b/tests/config.lua index 1c24ff510..d40de598d 100644 --- a/tests/config.lua +++ b/tests/config.lua @@ -249,7 +249,6 @@ return { "cmd", }, }, - ["tool_group"] = { description = "Tool Group", system_prompt = "My tool group system prompt", @@ -380,6 +379,13 @@ return { }, }, }, + mcp = { + enabled = true, + servers = {}, + opts = { + timeout = 10e3, + }, + }, prompt_library = { ["Demo"] = { strategy = "chat", diff --git a/tests/mcp/test_mcp_client.lua b/tests/mcp/test_mcp_client.lua new file mode 100644 index 000000000..9d645b47e --- /dev/null +++ b/tests/mcp/test_mcp_client.lua @@ -0,0 +1,474 @@ +local h = require("tests.helpers") + +local child = MiniTest.new_child_neovim() + +local T = MiniTest.new_set({ + hooks = { + pre_case = function() + h.child_start(child) + child.lua([[ + Client = require("codecompanion.mcp.client") + MockMCPClientTransport = require("tests.mocks.mcp_client_transport") + TRANSPORT = MockMCPClientTransport:new() + function mock_new_transport() + return TRANSPORT + end + + function read_mcp_tools() + return vim + .iter(vim.fn.readfile("tests/stubs/mcp/tools.jsonl")) + :map(function(s) + return s ~= "" and vim.json.decode(s) or nil + end) + :totable() + end + + function setup_default_initialization() + TRANSPORT:expect_jsonrpc_call("initialize", function(params) + return "result", { + protocolVersion = params.protocolVersion, + capabilities = { tools = {} }, + serverInfo = { name = "Test MCP Server", version = "1.0.0" }, + } + end) + TRANSPORT:expect_jsonrpc_notify("notifications/initialized", function() end) + end + + function setup_tool_list(tools) + TRANSPORT:expect_jsonrpc_call("tools/list", function() + return "result", { tools = tools or read_mcp_tools() } + end) + end + + function start_client_and_wait_loaded() + local tools_loaded + -- NOTE: We rely on this event to know when tools are loaded + vim.api.nvim_create_autocmd("User", { + pattern = "CodeCompanionMCPServerToolsLoaded", + once = true, + callback = function() tools_loaded = true end, + }) + + CLI = Client.new({ name = "testMcp", cfg = { cmd = { "test-mcp" } }, methods = { new_transport = mock_new_transport } }) + CLI:start() + vim.wait(1000, function() return tools_loaded end) + end + ]]) + end, + post_case = function() + h.is_true(child.lua_get("TRANSPORT:all_handlers_consumed()")) + end, + post_once = child.stop, + }, +}) + +T["MCP Client"] = MiniTest.new_set() +T["MCP Client"]["start() starts and initializes the client once"] = function() + child.lua([[ + READY = false + INIT_PARAMS = {} + + vim.api.nvim_create_autocmd("User", { + pattern = "CodeCompanionMCPServerReady", + once = true, + callback = function() READY = true end, + }) + + TRANSPORT:expect_jsonrpc_call("initialize", function(params) + table.insert(INIT_PARAMS, params) + return "result", { + protocolVersion = params.protocolVersion, + capabilities = { tools = {} }, + serverInfo = { name = "Test MCP Server", version = "1.0.0" }, + } + end) + TRANSPORT:expect_jsonrpc_notify("notifications/initialized", function() end) + + setup_tool_list() + CLI = Client.new({ name = "testMcp", cfg = { cmd = { "test-mcp" } }, methods = { new_transport = mock_new_transport } }) + CLI:start() + CLI:start() -- repeated call should be no-op + CLI:start() + vim.wait(1000, function() return READY end) + CLI:start() -- repeated call should be no-op + CLI:start() + ]]) + + h.is_true(child.lua_get("READY")) + h.eq(child.lua_get("INIT_PARAMS[1]"), { + protocolVersion = "2025-11-25", + clientInfo = { + name = "CodeCompanion.nvim", + version = "NO VERSION", + }, + capabilities = {}, + }) + h.is_true(child.lua_get("CLI.ready")) +end + +T["MCP Client"]["tools are loaded in pages"] = function() + local result = child.lua([[ + setup_default_initialization() + + local mcp_tools = read_mcp_tools() + local page_size = 2 + TRANSPORT:expect_jsonrpc_call("tools/list", function(params) + local start_idx = tonumber(params.cursor) or 1 + local end_idx = math.min(start_idx + page_size - 1, #mcp_tools) + local page_tools = {} + for i = start_idx, end_idx do + table.insert(page_tools, mcp_tools[i]) + end + local next_cursor = end_idx < #mcp_tools and tostring(end_idx + 1) or nil + return "result", { tools = page_tools, nextCursor = next_cursor } + end, { repeats = math.ceil(#mcp_tools / page_size) }) + + start_client_and_wait_loaded() + + -- Get tools from the MCP registry (not config) + local mcp = require("codecompanion.mcp") + local registered_tools, registered_groups = mcp.get_registered_tools() + local group = registered_groups["mcp:testMcp"] + local tools = vim + .iter(registered_tools) + :filter(function(_, v) + return vim.tbl_get(v, "opts", "_mcp_info", "server") == "testMcp" + end) + :fold({}, function(acc, k, v) + v = vim.deepcopy(v) + -- functions cannot cross process boundary + v.callback.cmds = nil + v.callback.output = nil + acc[k] = v + return acc + end) + return { + mcp_tools = read_mcp_tools(), + group = group, + tools = tools, + } + ]]) + + local mcp_tools = result.mcp_tools + local tools = result.tools + local group = result.group + + h.eq(vim.tbl_count(tools), #mcp_tools) + h.eq(#group.tools, #mcp_tools) + for _, mcp_tool in ipairs(mcp_tools) do + local cc_tool_name = "testMcp_" .. mcp_tool.name + h.expect_tbl_contains(cc_tool_name, group.tools) + + local cc_tool = tools[cc_tool_name] + h.expect_truthy(cc_tool) + h.eq(mcp_tool.title or mcp_tool.name, cc_tool.description) + h.is_false(cc_tool.visible) + h.eq({ + type = "function", + ["function"] = { + name = cc_tool_name, + description = mcp_tool.description, + parameters = mcp_tool.inputSchema, + strict = true, + }, + }, cc_tool.callback.schema) + end + + h.expect_contains("testMcp", group.prompt) +end + +T["MCP Client"]["can process tool calls"] = function() + local result = child.lua([[ + setup_default_initialization() + setup_tool_list() + start_client_and_wait_loaded() + + TRANSPORT:expect_jsonrpc_call("tools/call", function(params) + if params.name == "echo" then + local value = params.arguments.value + if value == nil then + return "result", { isError = true, content = { { type = "text", text = "No value" } } } + end + return "result", { content = { { type = "text", text = params.arguments.value } } } + else + return "error", { code = -32601, message = "Tool not found" } + end + end, { repeats = 3 }) + + local call_results = {} + local function append_call_result(ok, result_or_error) + table.insert(call_results, { ok, result_or_error }) + end + CLI:call_tool("echo", { value = "xxxyyyzzz" }, append_call_result) + CLI:call_tool("echo", {}, append_call_result) + CLI:call_tool("nonexistent_tool", {}, append_call_result) + vim.wait(1000, function() return #call_results == 3 end) + return call_results + ]]) + + h.eq({ + { true, { content = { { type = "text", text = "xxxyyyzzz" } } } }, + { true, { isError = true, content = { { type = "text", text = "No value" } } } }, + { false, "MCP JSONRPC error: [-32601] Tool not found" }, + }, result) +end + +T["MCP Client"]["can handle reordered tool call responses"] = function() + local result = child.lua([[ + setup_default_initialization() + setup_tool_list() + start_client_and_wait_loaded() + + local latencies = { 300, 50, 150, 400 } + for _, latency in ipairs(latencies) do + TRANSPORT:expect_jsonrpc_call("tools/call", function(params) + return "result", { content = { { type = "text", text = params.arguments.value } } } + end, { latency = latency }) + end + + local call_results = {} + local function append_call_result(ok, result_or_error) + table.insert(call_results, { ok, result_or_error }) + end + for i, latency in ipairs(latencies) do + CLI:call_tool("echo", { value = string.format("%d_%d", i, latency) }, append_call_result) + end + vim.wait(1000, function() return #call_results == #latencies end) + return call_results + ]]) + + h.eq({ + { true, { content = { { type = "text", text = "2_50" } } } }, + { true, { content = { { type = "text", text = "3_150" } } } }, + { true, { content = { { type = "text", text = "1_300" } } } }, + { true, { content = { { type = "text", text = "4_400" } } } }, + }, result) +end + +T["MCP Client"]["respects timeout option for tool calls"] = function() + local result = child.lua([[ + setup_default_initialization() + setup_tool_list() + start_client_and_wait_loaded() + + TRANSPORT:expect_jsonrpc_call("tools/call", function(params) + return "result", { content = { { type = "text", text = "fast response" } } } + end) + TRANSPORT:expect_jsonrpc_call("tools/call", function(params) + return "result", { content = { { type = "text", text = "slow response" } } } + end, { latency = 200 }) + TRANSPORT:expect_jsonrpc_call("tools/call", function(params) + return "result", { content = { { type = "text", text = "very slow response" } } } + end, { latency = 200 }) + + local call_results = {} + local function append_call_result(ok, result_or_error) + table.insert(call_results, { ok, result_or_error }) + end + + CLI:call_tool("echo", { value = "no_timeout" }, append_call_result) + CLI:call_tool("echo", { value = "short_timeout" }, append_call_result, { timeout = 100 }) + CLI:call_tool("echo", { value = "long_timeout" }, append_call_result, { timeout = 1000 }) + + vim.wait(2000, function() return #call_results == 3 end) + return call_results + ]]) + + h.is_true(result[1][1]) + h.eq(result[1][2].content[1].text, "fast response") + + h.is_false(result[2][1]) + h.expect_contains("timed out", result[2][2]) + + h.is_true(result[3][1]) + h.eq(result[3][2].content[1].text, "very slow response") +end + +T["MCP Client"]["roots capability is declared when roots config is provided"] = function() + local result = child.lua([[ + setup_default_initialization() + setup_tool_list() + + local roots = { + { uri = "file:///home/user/project1", name = "Project 1" }, + { uri = "file:///home/user/project2", name = "Project 2" }, + } + + CLI = Client.new({ + name = "testMcp", + cfg = { + cmd = { "test-mcp" }, + roots = function() return roots end, + }, + methods = { new_transport = mock_new_transport }, + }) + CLI:start() + vim.wait(1000, function() return CLI.ready end) + + local received_resp + TRANSPORT:send_request_to_client("roots/list", nil, function(status, result) + received_resp = { status, result } + end) + + vim.wait(1000, function() return received_resp ~= nil end) + return { roots = roots, received_resp = received_resp } + ]]) + + h.eq(result.received_resp[1], "result") + h.eq(result.received_resp[2], { roots = result.roots }) +end + +T["MCP Client"]["roots list changed notification is sent when roots change"] = function() + local result = child.lua([[ + setup_default_initialization() + setup_tool_list() + + local root_lists = { + {}, + { + { uri = "file:///home/user/projectA", name = "Project A" }, + }, + { + { uri = "file:///home/user/projectA", name = "Project A" }, + { uri = "file:///home/user/projectB", name = "Project B" }, + }, + { + { uri = "file:///home/user/projectC", name = "Project C" }, + }, + } + local current_roots + + local notify_roots_list_changed + CLI = Client.new({ + name = "testMcp", + cfg = { + cmd = { "test-mcp" }, + roots = function() return current_roots end, + register_roots_list_changed = function(notify) + notify_roots_list_changed = notify + end, + }, + methods = { new_transport = mock_new_transport }, + }) + CLI:start() + vim.wait(1000, function() return CLI.ready end) + + local received_resps = {} + for i = 1, #root_lists do + if current_roots ~= nil then + TRANSPORT:expect_jsonrpc_notify("roots/listChanged", function() end) + notify_roots_list_changed() + vim.wait(1000, function() return TRANSPORT:all_handlers_consumed() end) + end + current_roots = root_lists[i] + TRANSPORT:send_request_to_client("roots/list", nil, function(status, result) + received_resps[i] = { status, result } + end) + vim.wait(1000, function() return received_resps[i] ~= nil end) + end + + return { received_resps = received_resps, root_lists = root_lists } + ]]) + + for i, roots in ipairs(result.root_lists) do + h.eq(result.received_resps[i][1], "result") + h.eq(result.received_resps[i][2], { roots = roots }) + end +end + +T["MCP Client"]["transport closed automatically on initialization failure"] = function() + child.lua([[ + TRANSPORT:expect_jsonrpc_call("initialize", function(params) + return "error", { code = -32603, message = "Initialization failed" } + end) + + CLI = Client.new({ name = "testMcp", cfg = { cmd = { "test-mcp" } }, methods = { new_transport = mock_new_transport } }) + CLI:start() + vim.wait(1000, function() return TRANSPORT:all_handlers_consumed() end) + vim.wait(1000, function() return not CLI.ready end) + ]]) + + h.is_false(child.lua_get("CLI.ready")) + h.is_false(child.lua_get("TRANSPORT:started()")) +end + +T["MCP Client"]["stop() cleans up pending requests"] = function() + local call_result = child.lua([[ + setup_default_initialization() + setup_tool_list() + start_client_and_wait_loaded() + + -- initiate a SLOW tool call that won't respond before stop() + TRANSPORT:expect_jsonrpc_call("tools/call", function(params) + return "result", { content = { { type = "text", text = "slow response" } } } + end, { latency = 1000 }) + + local call_result + CLI:call_tool("echo", { value = "will be cancelled" }, function(ok, result_or_error) + call_result = { ok, result_or_error } + end) + + vim.wait(50, function() return call_result ~= nil end) + CLI:stop() + vim.wait(1000, function() return call_result ~= nil end) + return call_result + ]]) + + h.is_false(child.lua_get("CLI.ready")) + h.is_false(child.lua_get("TRANSPORT:started()")) + h.is_false(call_result[1]) + h.expect_contains("close", call_result[2]) +end + +T["MCP Client"]["cancel_request_from_chat cancels requests for specific chat"] = function() + child.lua([[ + setup_default_initialization() + setup_tool_list() + start_client_and_wait_loaded() + + -- Set up handlers for two tool calls and the cancellation notification + TRANSPORT:expect_jsonrpc_call("tools/call", function() + return "result", { content = { { type = "text", text = "response1" } } } + end) + TRANSPORT:expect_jsonrpc_call("tools/call", function() + return "result", { content = { { type = "text", text = "response2" } } } + end) + + CANCEL_PARAMS = nil + TRANSPORT:expect_jsonrpc_notify("notifications/cancelled", function(params) + CANCEL_PARAMS = params + end) + + -- Make two requests with different chat_ids + REQ_ID_1 = CLI:call_tool("echo", { value = "chat1" }, function() end, { chat_id = 1 }) + REQ_ID_2 = CLI:call_tool("echo", { value = "chat2" }, function() end, { chat_id = 2 }) + + -- Capture chat_ids from resp_handlers before cancel + CHAT_ID_1 = CLI.resp_handlers[REQ_ID_1] and CLI.resp_handlers[REQ_ID_1].chat_id + CHAT_ID_2 = CLI.resp_handlers[REQ_ID_2] and CLI.resp_handlers[REQ_ID_2].chat_id + + -- Cancel only chat 1 + CLI:cancel_request_from_chat(1, "User stopped") + + -- Check handler state immediately after cancel + HANDLER_1_REMOVED = CLI.resp_handlers[REQ_ID_1] == nil + HANDLER_2_KEPT = CLI.resp_handlers[REQ_ID_2] ~= nil + + -- Wait for all mock handlers to be consumed + vim.wait(1000, function() return TRANSPORT:all_handlers_consumed() end) + ]]) + + -- Verify chat_ids were stored correctly in handlers + h.eq(child.lua_get("CHAT_ID_1"), 1) + h.eq(child.lua_get("CHAT_ID_2"), 2) + + -- Verify only chat 1's handler was removed + h.is_true(child.lua_get("HANDLER_1_REMOVED")) + h.is_true(child.lua_get("HANDLER_2_KEPT")) + + -- Verify cancellation notification was sent with correct params + h.eq(child.lua_get("CANCEL_PARAMS.requestId"), child.lua_get("REQ_ID_1")) + h.eq(child.lua_get("CANCEL_PARAMS.reason"), "User stopped") +end + +return T diff --git a/tests/mcp/test_mcp_tools.lua b/tests/mcp/test_mcp_tools.lua new file mode 100644 index 000000000..793d76d91 --- /dev/null +++ b/tests/mcp/test_mcp_tools.lua @@ -0,0 +1,416 @@ +local h = require("tests.helpers") + +local child = MiniTest.new_child_neovim() + +local T = MiniTest.new_set({ + hooks = { + pre_case = function() + h.child_start(child) + child.lua([[ + local h = require("tests.helpers") + Client = require("codecompanion.mcp.client") + MockMCPClientTransport = require("tests.mocks.mcp_client_transport") + + MCP_TOOLS = vim + .iter(vim.fn.readfile("tests/stubs/mcp/tools.jsonl")) + :map(function(s) + if s ~= "" then + return vim.json.decode(s) + end + end) + :totable() + + MATH_MCP_TRANSPORT = MockMCPClientTransport:new() + MATH_MCP_TOOLS = vim.iter(MCP_TOOLS):filter(function(tool) + return vim.startswith(tool.name, "math_") + end):totable() + + OTHER_MCP_TRANSPORT = MockMCPClientTransport:new() + OTHER_MCP_TOOLS = vim.iter(MCP_TOOLS):filter(function(tool) + return not vim.startswith(tool.name, "math_") + end):totable() + + ---Setup expectations for MCP server initialization handshake + ---@param transport CodeCompanion.MCP.MockMCPClientTransport + ---@param tools MCP.Tool[] + ---@param server_instructions? string + local function setup_mcp_init_expectations(transport, tools, server_instructions) + transport:expect_jsonrpc_call("initialize", function(params) + return "result", { + protocolVersion = params.protocolVersion, + capabilities = { tools = {} }, + serverInfo = { name = "Test MCP Server", version = "1.0.0" }, + instructions = server_instructions or "Test MCP server instructions.", + } + end) + transport:expect_jsonrpc_notify("notifications/initialized", function(params) end) + transport:expect_jsonrpc_call("tools/list", function() + return "result", { tools = tools } + end) + end + + ---Get the transport and tools for a given server command + ---@param cmd string The first element of the server cmd array + ---@return CodeCompanion.MCP.MockMCPClientTransport, MCP.Tool[] + local function get_transport_and_tools(cmd) + if cmd == "math_mcp" then + return MATH_MCP_TRANSPORT, MATH_MCP_TOOLS + else + return OTHER_MCP_TRANSPORT, OTHER_MCP_TOOLS + end + end + + ---Create a transport factory that injects mock transports + ---@param server_instructions? string Optional custom server instructions + ---@return fun(args: CodeCompanion.MCP.StdioTransportArgs): CodeCompanion.MCP.Transport + local function create_transport_factory(server_instructions) + return function(args) + local transport, tools = get_transport_and_tools(args.cfg.cmd[1]) + setup_mcp_init_expectations(transport, tools, server_instructions) + return transport + end + end + + -- Default transport factory for tests + Client.static.methods.new_transport.default = create_transport_factory() + + local adapter = { + name = "test_adapter_for_mcp_tools", + roles = { llm = "assistant", user = "user" }, + features = {}, + opts = { tools = true }, + url = "http://0.0.0.0", + schema = { model = { default = "dummy" } }, + handlers = { + response = { + parse_chat = function(self, data, tools) + for _, tool in ipairs(data.tools or {}) do + table.insert(tools, tool) + end + return { + status = "success", + output = { role = "assistant", content = data.content } + } + end + }, + tools = { + format_calls = function(self, llm_tool_calls) + return llm_tool_calls + end, + format_response = function(self, llm_tool_call, mcp_output) + return { role = "tool", content = mcp_output } + end, + } + }, + } + + ---Create a chat buffer with MCP servers configured + ---@param mcp_cfg? CodeCompanion.MCPConfig + ---@return CodeCompanion.Chat + function create_chat(mcp_cfg) + mcp_cfg = mcp_cfg or { + servers = { + math_mcp = { cmd = { "math_mcp" } }, + other_mcp = { cmd = { "other_mcp" } }, + }, + } + local loading = vim.tbl_count(mcp_cfg.servers) + -- NOTE: We rely on this event to know when tools are loaded + vim.api.nvim_create_autocmd("User", { + pattern = "CodeCompanionMCPServerToolsLoaded", + callback = function() + loading = loading - 1 + return loading == 0 + end, + }) + local chat = h.setup_chat_buffer({ + mcp = mcp_cfg, + adapters = { + http = { [adapter.name] = adapter }, + }, + }, { name = adapter.name }) + vim.wait(1000, function() return loading == 0 end) + return chat + end + + ---Extract tool output messages from chat messages + ---@param chat_msgs table[] + ---@return string[] + function extract_tool_outputs(chat_msgs) + return vim.iter(chat_msgs):map(function(msg) + if msg.role == "tool" then + return msg.content + end + end):totable() + end + ]]) + end, + post_case = function() + h.is_true(child.lua_get("MATH_MCP_TRANSPORT:all_handlers_consumed()")) + h.is_true(child.lua_get("OTHER_MCP_TRANSPORT:all_handlers_consumed()")) + end, + post_once = child.stop, + }, +}) + +T["MCP Tools"] = MiniTest.new_set() + +T["MCP Tools"]["MCP tools can be used as CodeCompanion tools"] = function() + h.mock_http(child) + h.queue_mock_http_response(child, { + content = "Call some tools", + tools = { + { ["function"] = { name = "math_mcp_math_add", arguments = { a = 1, b = 3 } } }, + { ["function"] = { name = "math_mcp_math_mul", arguments = { a = 4, b = 2 } } }, + { ["function"] = { name = "math_mcp_math_add", arguments = { a = 2, b = -3 } } }, + }, + }) + local chat_msgs = child.lua([[ + local chat = create_chat() + MATH_MCP_TRANSPORT:expect_jsonrpc_call("tools/call", function(params) + local retval + if params.name == "math_add" then + retval = params.arguments.a + params.arguments.b + elseif params.name == "math_mul" then + retval = params.arguments.a * params.arguments.b + else + return "error", { code = -32601, message = "Unknown tool: " .. params.name } + end + return "result", { + content = { { type = "text", text = tostring(retval) } } + } + end, { repeats = 3 }) + + chat:add_buf_message({ + role = "user", + content = "@{mcp:math_mcp} Use some tools.", + }) + chat:submit() + vim.wait(1000, function() return vim.bo[chat.bufnr].modifiable end) + return chat.messages + ]]) + + local tool_output_msgs = vim + .iter(chat_msgs) + :map(function(msg) + if msg.role == "tool" then + return msg.content + end + end) + :totable() + h.eq({ "4", "8", "-1" }, tool_output_msgs) + + local llm_req = child.lua_get("_G.mock_client:get_last_request().payload") + local has_prompt = vim.iter(llm_req.messages):any(function(msg) + return msg.content:find("math_mcp") + and msg.content:find("math_mcp_math_add") + and msg.content:find("math_mcp_math_mul") + and msg.content:find("Test MCP server instructions.") + end) + h.is_true(has_prompt) + + local math_mcp_tools = child.lua_get("MATH_MCP_TOOLS") + local llm_tool_schemas = llm_req.tools[1] + h.eq(#math_mcp_tools, vim.tbl_count(llm_tool_schemas)) + for _, mcp_tool in ipairs(math_mcp_tools) do + local cc_tool_name = "math_mcp_" .. mcp_tool.name + local llm_tool_schema = llm_tool_schemas[string.format("%s", cc_tool_name)] + h.eq(llm_tool_schema.type, "function") + h.eq(llm_tool_schema["function"].name, cc_tool_name) + h.eq(llm_tool_schema["function"].description, mcp_tool.description) + h.eq(llm_tool_schema["function"].parameters, mcp_tool.inputSchema) + end +end + +T["MCP Tools"]["MCP tools should handle errors correctly"] = function() + h.mock_http(child) + h.queue_mock_http_response(child, { + content = "Should fail", + tools = { + { ["function"] = { name = "other_mcp_make_list", arguments = { count = -1, item = "y" } } }, + }, + }) + + local chat_msgs = child.lua([[ + local chat = create_chat() + OTHER_MCP_TRANSPORT:expect_jsonrpc_call("tools/call", function(params) + if params.name == "echo" then + return "error", { code = -32603, message = "test jsonrpc error" } + elseif params.name == "make_list" then + if params.arguments.count < 0 then + return "result", { + isError = true, + content = { { type = "text", text = "count must be non-negative" } }, + } + end + local list = {} + for i = 1, params.arguments.count do + table.insert(list, { type = "text", text = params.arguments.item }) + end + return "result", { content = list } + end + end) + + chat:add_buf_message({ role = "user", content = "@{mcp.other_mcp} Should have errors" }) + chat:submit() + vim.wait(1000, function() return vim.bo[chat.bufnr].modifiable end) + return chat.messages + ]]) + + local tool_output_msgs = child.lua_get("extract_tool_outputs(...)", { chat_msgs }) + h.eq({ "MCP Tool execution failed:\ncount must be non-negative" }, tool_output_msgs) +end + +T["MCP Tools"]["allows overriding tool options and behavior"] = function() + h.mock_http(child) + h.queue_mock_http_response(child, { + content = "Call some tools", + tools = { + { ["function"] = { name = "other_mcp_say_hi" } }, + { ["function"] = { name = "other_mcp_make_list", arguments = { count = 3, item = "xyz" } } }, + { ["function"] = { name = "other_mcp_echo", arguments = { value = "ECHO REQ" } } }, + }, + }) + + local result = child.lua([[ + require("tests.log") + local chat = create_chat({ + servers = { + other_mcp = { + cmd = { "other_mcp" }, + server_instructions = function(orig) + return orig .. "\nAdditional instructions for other_mcp." + end, + tool_defaults = { + require_approval_before = true, + }, + tool_overrides = { + echo = { + timeout = 100, + output = { + prompt = function(self, tools) + return "Custom confirmation prompt for echo tool: " .. self.args.value + end, + }, + }, + say_hi = { + opts = { + require_approval_before = false, + }, + system_prompt = "TEST SYSTEM PROMPT FOR SAY_HI", + }, + make_list = { + output = { + success = function(self, tools, cmd, stdout) + local output = vim.iter(stdout[#stdout]):map(function(block) + assert(block.type == "text") + return block.text + end):join(",") + tools.chat:add_tool_output(self, output) + end + }, + }, + } + }, + } + }) + + OTHER_MCP_TRANSPORT:expect_jsonrpc_call("tools/call", function(params) + assert(params.name == "say_hi") + return "result", { content = { { type = "text", text = "Hello there!" } } } + end) + OTHER_MCP_TRANSPORT:expect_jsonrpc_call("tools/call", function(params) + assert(params.name == "make_list") + local content = {} + for i = 1, params.arguments.count do + table.insert(content, { type = "text", text = params.arguments.item }) + end + return "result", { content = content } + end) + OTHER_MCP_TRANSPORT:expect_jsonrpc_call("tools/call", function(params) + assert(params.name == "echo") + return "result", { content = { { type = "text", text = params.arguments.value } } } + end, { latency = 10 * 1000 }) + + chat:add_buf_message({ role = "user", content = "@{mcp:other_mcp}" }) + + local confirmations = {} + local ui = require("codecompanion.utils.ui") + ui.confirm = function(prompt, choices) + table.insert(confirmations, prompt) + for i, choice in ipairs(choices) do + if choice:find("Allow once") then + return i + end + end + assert(false, "No 'Allow once' choice found") + end + + chat:submit() + vim.wait(1000, function() return vim.bo[chat.bufnr].modifiable end) + return { chat_msgs = chat.messages, confirmations = confirmations } + ]]) + + local has_server_instructions = vim.iter(result.chat_msgs):any(function(msg) + return msg.content:find("Test MCP server instructions.\nAdditional instructions for other_mcp.") + end) + h.is_true(has_server_instructions) + + local has_custom_tool_prompt = vim.iter(result.chat_msgs):any(function(msg) + return msg.content:find("TEST SYSTEM PROMPT FOR SAY_HI") + end) + h.is_true(has_custom_tool_prompt) + + local tool_output_msgs = child.lua_get("extract_tool_outputs(...)", { result.chat_msgs }) + h.eq(tool_output_msgs, { + "Hello there!", + "xyz,xyz,xyz", + "MCP Tool execution failed:\nMCP JSONRPC error: [-32603] Request timed out after 100 ms", + }) + + h.eq(#result.confirmations, 2) + h.expect_contains("make_list", result.confirmations[1]) + h.eq(result.confirmations[2], "Custom confirmation prompt for echo tool: ECHO REQ") +end + +T["MCP Tools"]["long output will be truncated in the chat buffer"] = function() + h.mock_http(child) + local output_prefix = "@LONG_OUTPUT_START@" + local output_suffix = "@LONG_OUTPUT_END@" + local output_elem = "\u{1F605}" + local output = output_prefix .. string.rep(output_elem, 5000) .. output_suffix + h.queue_mock_http_response(child, { + content = "Call a tool", + tools = { + { ["function"] = { name = "other_mcp_echo", arguments = { value = output } } }, + }, + }) + + local result = child.lua([[ + local chat = create_chat() + OTHER_MCP_TRANSPORT:expect_jsonrpc_call("tools/call", function(params) + return "result", { + content = { { type = "text", text = params.arguments.value } } + } + end) + + chat:add_buf_message({ role = "user", content = "@{mcp.other_mcp} Please echo a long message." }) + chat:submit() + vim.wait(1000, function() return vim.bo[chat.bufnr].modifiable end) + return { chat_msgs = chat.messages, chat_bufnr = chat.bufnr } + ]]) + + -- Chat buffer should contain truncated output (prefix .. elem * n .. truncation-mark) + local chat_buf_lines = child.api.nvim_buf_get_lines(result.chat_bufnr, 0, -1, false) + local chat_buf_content = table.concat(chat_buf_lines, "\n") + local truncated_output_regex = + string.format([=[%s\(%s\)\+[[:space:][:punct:]]*\[truncated\]]=], output_prefix, output_elem) + h.expect_truthy(vim.regex(truncated_output_regex):match_str(chat_buf_content)) + + -- LLM should receive full output + local tool_output_msgs = child.lua_get("extract_tool_outputs(...)", { result.chat_msgs }) + h.eq(#tool_output_msgs, 1) + h.eq(tool_output_msgs[1], output) +end + +return T diff --git a/tests/mocks/mcp_client_transport.lua b/tests/mocks/mcp_client_transport.lua new file mode 100644 index 000000000..9aa942760 --- /dev/null +++ b/tests/mocks/mcp_client_transport.lua @@ -0,0 +1,169 @@ +local log = require("codecompanion.utils.log") + +---A mock implementation of `Transport` +---@class CodeCompanion.MCP.MockMCPClientTransport : CodeCompanion.MCP.Transport +---@field private _started boolean +---@field private _on_line_read? fun(line: string) +---@field private _on_close? fun() +---@field private _line_handlers (fun(line: string): boolean)[] +local MockMCPClientTransport = {} +MockMCPClientTransport.__index = MockMCPClientTransport + +function MockMCPClientTransport:new() + return setmetatable({ + _started = false, + _line_handlers = {}, + }, self) +end + +function MockMCPClientTransport:start(on_line_read, on_close) + assert(not self._started, "Transport already started") + self._on_line_read = on_line_read + self._on_close = on_close + self._started = true +end + +function MockMCPClientTransport:started() + return self._started +end + +function MockMCPClientTransport:write(lines) + assert(self._started, "Transport not started") + if lines == nil then + self:stop() + return + end + vim.schedule(function() + for _, line in ipairs(lines) do + log:info("MockMCPClientTransport received line: %s", line) + assert(#self._line_handlers > 0, "No pending line handlers") + local handler = self._line_handlers[1] + local keep = handler(line) + if not keep then + table.remove(self._line_handlers, 1) + end + end + end) +end + +function MockMCPClientTransport:write_line_to_client(line, latency) + assert(self._started, "Transport not started") + vim.defer_fn(function() + log:info("MockMCPClientTransport sending line to client: %s", line) + self._on_line_read(line) + end, latency or 0) +end + +---@param handler fun(line: string): boolean handle a client written line; return true to preserve this handler for next line +---@return CodeCompanion.MCP.MockMCPClientTransport self +function MockMCPClientTransport:expect_client_write_line(handler) + table.insert(self._line_handlers, handler) + return self +end + +---@param method string +---@param handler fun(params?: table): "result"|"error", table +---@param opts? { repeats?: integer, latency?: integer } +---@return CodeCompanion.MCP.MockMCPClientTransport self +function MockMCPClientTransport:expect_jsonrpc_call(method, handler, opts) + local remaining_repeats = opts and opts.repeats or 1 + return self:expect_client_write_line(function(line) + local function get_response() + local resp = { jsonrpc = "2.0" } + local ok, req = pcall(vim.json.decode, line, { luanil = { object = true } }) + if not ok then + resp.error = { code = -32700, message = string.format("Parse error: %s", req) } + return resp + end + resp.id = req.id + if req.jsonrpc ~= "2.0" then + resp.error = { code = -32600, message = string.format("Invalid JSON-RPC version: %s", req.jsonrpc) } + return resp + end + if req.method ~= method then + resp.error = { code = -32601, message = string.format("Expected method '%s', got '%s'", method, req.method) } + return resp + end + local status, result = handler(req.params) + if status == "result" then + resp.result = result + elseif status == "error" then + resp.error = result + else + error("Handler must return 'result' or 'error'") + end + return resp + end + + self:write_line_to_client(vim.json.encode(get_response()), opts and opts.latency) + remaining_repeats = remaining_repeats - 1 + return remaining_repeats > 0 + end) +end + +---@param method string +---@param handler? fun(params?: table) +---@param opts? { repeats: integer } +---@return CodeCompanion.MCP.MockMCPClientTransport self +function MockMCPClientTransport:expect_jsonrpc_notify(method, handler, opts) + local remaining_repeats = opts and opts.repeats or 1 + return self:expect_client_write_line(function(line) + local ok, req = pcall(vim.json.decode, line, { luanil = { object = true } }) + if not ok then + log:error("Failed to parse JSON-RPC notification: %s", line) + elseif req.jsonrpc ~= "2.0" then + log:error("Invalid JSON-RPC version: %s", req.jsonrpc) + elseif req.method ~= method then + log:error("Unexpected JSON-RPC method. Expected: %s, Got: %s", method, req.method) + elseif handler then + handler(req.params) + end + remaining_repeats = remaining_repeats - 1 + return remaining_repeats > 0 + end) +end + +---@param method string +---@param params? table +---@param resp_handler fun(status: "result"|"error", result_or_error: table) +function MockMCPClientTransport:send_request_to_client(method, params, resp_handler) + assert(self:all_handlers_consumed(), "Cannot send request to client: pending line handlers exist") + local req_id = math.random(1, 1e9) + local req = { jsonrpc = "2.0", id = req_id, method = method, params = params } + self:expect_client_write_line(function(line) + local ok, resp = pcall(vim.json.decode, line, { luanil = { object = true } }) + if not ok then + log:error("Failed to parse JSON-RPC response: %s", line) + elseif resp.id ~= req_id then + log:error("Mismatched JSON-RPC response ID. Expected: %d, Got: %s", req_id, tostring(resp.id)) + elseif resp.result then + resp_handler("result", resp.result) + elseif resp.error then + resp_handler("error", resp.error) + else + log:error("Invalid JSON-RPC response: %s", line) + end + return false + end) + self:write_line_to_client(vim.json.encode(req)) +end + +function MockMCPClientTransport:all_handlers_consumed() + return #self._line_handlers == 0 +end + +function MockMCPClientTransport:stop() + vim.schedule(function() + self._started = false + self._on_close() + end) +end + +function MockMCPClientTransport:expect_transport_stop() + return self:expect_client_write_line(function(line) + assert(line == nil, "Expected transport to be stopped") + return false + end) +end + +return MockMCPClientTransport diff --git a/tests/stubs/mcp/tools.jsonl b/tests/stubs/mcp/tools.jsonl new file mode 100644 index 000000000..3b2133382 --- /dev/null +++ b/tests/stubs/mcp/tools.jsonl @@ -0,0 +1,5 @@ +{"name":"echo","description":"Echoes back the input","inputSchema":{"type":"object","properties":{"value":{"type":"string","description":"A string value to echo back"}},"required":["value"]}} +{"name":"say_hi","description":"Say Hi to you","inputSchema":{"type":"object","properties":{"name":{"type":"string","description":"Who are you?"}},"required":[]}} +{"name":"make_list","description":"Creates a list of items","inputSchema":{"type":"object","properties":{"count":{"type":"number","description":"Number of items"},"item":{"oneOf":[{"type":"string"},{"type":"number"}],"description":"The item to repeat in the list"}},"required":["count","item"]}} +{"name":"math_add","title":"Math/Add","description":"Adds two numbers","inputSchema":{"type":"object","properties":{"a":{"type":"number","description":"The first number to add"},"b":{"type":"number","description":"The second number to add"}},"required":["a","b"]}} +{"name":"math_mul","title":"Math/Mul","description":"Multiply two numbers","inputSchema":{"type":"object","properties":{"a":{"type":"number","description":"The first number to multiply"},"b":{"type":"number","description":"The second number to multiply"}},"required":["a","b"]}}