mirror of
https://github.com/pacnpal/Roo-Code.git
synced 2025-12-20 12:21:13 -05:00
Merge branch 'main' into main
This commit is contained in:
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Feature Request
|
||||
url: https://github.com/RooVetGit/Roo-Cline/discussions/categories/feature-requests
|
||||
about: Share and vote on feature requests for Roo Cline
|
||||
- name: Leave a Review
|
||||
url: https://marketplace.visualstudio.com/items?itemName=RooVeterinaryInc.roo-cline&ssr=false#review-details
|
||||
about: Enjoying Roo Cline? Leave a review here!
|
||||
5
.github/workflows/changeset-ai-releases.yml
vendored
5
.github/workflows/changeset-ai-releases.yml
vendored
@@ -8,8 +8,9 @@ run-name: Changeset AI Release ${{ github.actor != 'R00-B0T' && '- Create PR' ||
|
||||
# 4. Creating a GitHub release with the AI-generated notes
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed, opened, synchronize, labeled]
|
||||
# pull_request:
|
||||
# types: [closed, opened, synchronize, labeled]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REPO_PATH: ${{ github.repository }}
|
||||
|
||||
31
.github/workflows/temp-marketplace-publish.yml
vendored
Normal file
31
.github/workflows/temp-marketplace-publish.yml
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
name: Publish Extension Temporary
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
publish-extension:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
- run: |
|
||||
git config user.name github-actions
|
||||
git config user.email github-actions@github.com
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
npm install -g vsce ovsx
|
||||
npm install
|
||||
cd webview-ui
|
||||
npm install
|
||||
cd ..
|
||||
- name: Package and Publish Extension
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
run: |
|
||||
current_package_version=$(node -p "require('./package.json').version")
|
||||
npm run publish:marketplace
|
||||
echo "Successfully published version $current_package_version to VS Code Marketplace"
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -6,9 +6,5 @@ node_modules
|
||||
.DS_Store
|
||||
|
||||
# Builds
|
||||
bin
|
||||
bin/
|
||||
roo-cline-*.vsix
|
||||
|
||||
# Local prompts
|
||||
prompts
|
||||
.clinerules
|
||||
|
||||
42
CHANGELOG.md
42
CHANGELOG.md
@@ -1,10 +1,40 @@
|
||||
# Roo Cline Changelog
|
||||
|
||||
## 2.1.21
|
||||
## [2.2.7]
|
||||
|
||||
### Patch Changes
|
||||
- More fixes to search/replace diffs
|
||||
|
||||
- 8dbd019: Larger Promp Text Input
|
||||
## [2.2.6]
|
||||
|
||||
- Add a fuzzy match tolerance when applying diffs
|
||||
|
||||
## [2.2.5]
|
||||
|
||||
- Allow MCP servers to be enabled/disabled
|
||||
|
||||
## [2.2.4]
|
||||
|
||||
- Tweak the prompt to encourage diff edits when they're enabled
|
||||
|
||||
## [2.2.3]
|
||||
|
||||
- Clean up the settings screen
|
||||
|
||||
## [2.2.2]
|
||||
|
||||
- Add checkboxes to auto-approve MCP tools
|
||||
|
||||
## [2.2.1]
|
||||
|
||||
- Fix another diff editing indentation bug
|
||||
|
||||
## [2.2.0]
|
||||
|
||||
- Incorporate MCP changes from Cline 2.2.0
|
||||
|
||||
## [2.1.21]
|
||||
|
||||
- Larger text area input + ability to drag images into it
|
||||
|
||||
## [2.1.20]
|
||||
|
||||
@@ -64,6 +94,12 @@
|
||||
|
||||
- Updated extension icon and metadata
|
||||
|
||||
## [2.2.0]
|
||||
|
||||
- Add support for Model Context Protocol (MCP), enabling Cline to use custom tools like web-search tool or GitHub tool
|
||||
- Add MCP server management tab accessible via the server icon in the menu bar
|
||||
- Add ability for Cline to dynamically create new MCP servers based on user requests (e.g., "add a tool that gets the latest npm docs")
|
||||
|
||||
## [2.1.6]
|
||||
|
||||
- Roo Cline now runs in all VSCode-compatible editors
|
||||
|
||||
67
README.md
67
README.md
@@ -1,6 +1,6 @@
|
||||
# Roo-Cline
|
||||
|
||||
A fork of Cline, an autonomous coding agent, with some added experimental configuration and automation features.
|
||||
A fork of Cline, an autonomous coding agent, optimized for speed and flexibility.
|
||||
- Auto-approval capabilities for commands, write, and browser operations
|
||||
- Support for .clinerules per-project custom instructions
|
||||
- Ability to run side-by-side with Cline
|
||||
@@ -10,6 +10,15 @@ A fork of Cline, an autonomous coding agent, with some added experimental config
|
||||
- Support for copying prompts from the history screen
|
||||
- Support for editing through diffs / handling truncated full-file edits
|
||||
- Support for newer Gemini models (gemini-exp-1206 and gemini-2.0-flash-exp)
|
||||
- Support for dragging and dropping images into chats
|
||||
- Support for auto-approving MCP tools
|
||||
- Support for enabling/disabling MCP servers
|
||||
|
||||
## Disclaimer
|
||||
|
||||
**Please note** that Roo Veterinary, Inc does **not** make any representations or warranties regarding any code, models, or other tools provided or made available in connection with Roo-Cline, any associated third-party tools, or any resulting outputs. You assume **all risks** associated with the use of any such tools or outputs; such tools are provided on an **"AS IS"** and **"AS AVAILABLE"** basis. Such risks may include, without limitation, intellectual property infringement, cyber vulnerabilities or attacks, bias, inaccuracies, errors, defects, viruses, downtime, property loss or damage, and/or personal injury. You are solely responsible for your use of any such tools or outputs (including, without limitation, the legality, appropriateness, and results thereof).
|
||||
|
||||
## Demo
|
||||
|
||||
Here's an example of Roo-Cline autonomously creating a snake game with "Always approve write operations" and "Always approve browser actions" turned on:
|
||||
|
||||
@@ -100,7 +109,7 @@ Subscribe to our [Github releases](https://github.com/RooVetGit/Roo-Cline/releas
|
||||
|
||||
Meet Cline, an AI assistant that can use your **CLI** a**N**d **E**ditor.
|
||||
|
||||
Thanks to [Claude 3.5 Sonnet's agentic coding capabilities](https://www-cdn.anthropic.com/fed9cc193a14b84131812372d8d5857f8f304c52/Model_Card_Claude_3_Addendum.pdf), Cline can handle complex software development tasks step-by-step. With tools that let him create & edit files, explore large projects, use the browser, and execute terminal commands (after you grant permission), he can assist you in ways that go beyond code completion or tech support. While autonomous AI scripts traditionally run in sandboxed environments, this extension provides a human-in-the-loop GUI to approve every file change and terminal command, providing a safe and accessible way to explore the potential of agentic AI.
|
||||
Thanks to [Claude 3.5 Sonnet's agentic coding capabilities](https://www-cdn.anthropic.com/fed9cc193a14b84131812372d8d5857f8f304c52/Model_Card_Claude_3_Addendum.pdf), Cline can handle complex software development tasks step-by-step. With tools that let him create & edit files, explore large projects, use the browser, and execute terminal commands (after you grant permission), he can assist you in ways that go beyond code completion or tech support. Cline can even use the Model Context Protocol (MCP) to create new tools and extend his own capabilities. While autonomous AI scripts traditionally run in sandboxed environments, this extension provides a human-in-the-loop GUI to approve every file change and terminal command, providing a safe and accessible way to explore the potential of agentic AI.
|
||||
|
||||
1. Enter your task and add images to convert mockups into functional apps or fix bugs with screenshots.
|
||||
2. Cline starts by analyzing your file structure & source code ASTs, running regex searches, and reading relevant files to get up to speed in existing projects. By carefully managing what information is added to context, Cline can provide valuable assistance even for large, complex projects without overwhelming the context window.
|
||||
@@ -163,11 +172,55 @@ Try asking Cline to "test the app", and watch as he runs a command like `npm run
|
||||
|
||||
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
|
||||
|
||||
<img align="right" width="360" src="https://github.com/user-attachments/assets/7fdf41e6-281a-4b4b-ac19-020b838b6970">
|
||||
<img align="right" width="350" src="https://github.com/user-attachments/assets/ac0efa14-5c1f-4c26-a42d-9d7c56f5fadd">
|
||||
|
||||
### "add a tool that..."
|
||||
|
||||
Thanks to the [Model Context Protocol](https://github.com/modelcontextprotocol), Cline can extend his capabilities through custom tools. While you can use [community-made servers](https://github.com/modelcontextprotocol/servers), Cline can instead create and install tools tailored to your specific workflow. Just ask Cline to "add a tool" and he will handle everything, from creating a new MCP server to installing it into the extension. These custom tools then become part of Cline's toolkit, ready to use in future tasks.
|
||||
|
||||
- "add a tool that fetches Jira tickets": Retrieve ticket ACs and put Cline to work
|
||||
- "add a tool that manages AWS EC2s": Check server metrics and scale instances up or down
|
||||
- "add a tool that pulls the latest PagerDuty incidents": Fetch details and ask Cline to fix bugs
|
||||
|
||||
<!-- Transparent pixel to create line break after floating image -->
|
||||
|
||||
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
|
||||
|
||||
<img align="left" width="360" src="https://github.com/user-attachments/assets/7fdf41e6-281a-4b4b-ac19-020b838b6970">
|
||||
|
||||
### Add Context
|
||||
|
||||
- **`@url`:** Paste in a URL for the extension to fetch and convert to markdown, useful when you want to give Cline the latest docs
|
||||
- **`@problems`:** Add workspace errors and warnings ('Problems' panel) for Cline to fix
|
||||
- **`@file`:** Adds a file's contents so you don't have to waste API requests approving read file (+ type to search files)
|
||||
- **`@folder`:** Adds folder's files all at once to speed up your workflow even more
|
||||
**`@url`:** Paste in a URL for the extension to fetch and convert to markdown, useful when you want to give Cline the latest docs
|
||||
|
||||
**`@problems`:** Add workspace errors and warnings ('Problems' panel) for Cline to fix
|
||||
|
||||
**`@file`:** Adds a file's contents so you don't have to waste API requests approving read file (+ type to search files)
|
||||
|
||||
**`@folder`:** Adds folder's files all at once to speed up your workflow even more
|
||||
|
||||
## Contributing
|
||||
|
||||
To contribute to the project, start by exploring [open issues](https://github.com/cline/cline/issues) or checking our [feature request board](https://github.com/cline/cline/discussions/categories/feature-requests?discussions_q=is%3Aopen+category%3A%22Feature+Requests%22+sort%3Atop). We'd also love to have you join our [Discord](https://discord.gg/cline) to share ideas and connect with other contributors. If you're interested in joining the team, check out our [careers page](https://cline.bot/join-us)!
|
||||
|
||||
<details>
|
||||
<summary>Local Development Instructions</summary>
|
||||
|
||||
1. Clone the repository _(Requires [git-lfs](https://git-lfs.com/))_:
|
||||
```bash
|
||||
git clone https://github.com/cline/cline.git
|
||||
```
|
||||
2. Open the project in VSCode:
|
||||
```bash
|
||||
code cline
|
||||
```
|
||||
3. Install the necessary dependencies for the extension and webview-gui:
|
||||
```bash
|
||||
npm run install:all
|
||||
```
|
||||
4. Launch by pressing `F5` (or `Run`->`Start Debugging`) to open a new VSCode window with the extension loaded. (You may need to install the [esbuild problem matchers extension](https://marketplace.visualstudio.com/items?itemName=connor4312.esbuild-problem-matchers) if you run into issues building the project.)
|
||||
|
||||
</details>
|
||||
|
||||
## License
|
||||
|
||||
[Apache 2.0 © 2024 Cline Bot Inc.](./LICENSE)
|
||||
|
||||
@@ -5,17 +5,35 @@ module.exports = {
|
||||
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
|
||||
transform: {
|
||||
'^.+\\.tsx?$': ['ts-jest', {
|
||||
tsconfig: 'tsconfig.json'
|
||||
tsconfig: {
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "node",
|
||||
"esModuleInterop": true,
|
||||
"allowJs": true
|
||||
}
|
||||
}]
|
||||
},
|
||||
testMatch: ['**/__tests__/**/*.test.ts'],
|
||||
moduleNameMapper: {
|
||||
'^vscode$': '<rootDir>/node_modules/@types/vscode/index.d.ts'
|
||||
'^vscode$': '<rootDir>/src/__mocks__/vscode.js',
|
||||
'@modelcontextprotocol/sdk$': '<rootDir>/src/__mocks__/@modelcontextprotocol/sdk/index.js',
|
||||
'@modelcontextprotocol/sdk/(.*)': '<rootDir>/src/__mocks__/@modelcontextprotocol/sdk/$1',
|
||||
'^delay$': '<rootDir>/src/__mocks__/delay.js',
|
||||
'^p-wait-for$': '<rootDir>/src/__mocks__/p-wait-for.js',
|
||||
'^globby$': '<rootDir>/src/__mocks__/globby.js',
|
||||
'^serialize-error$': '<rootDir>/src/__mocks__/serialize-error.js',
|
||||
'^strip-ansi$': '<rootDir>/src/__mocks__/strip-ansi.js',
|
||||
'^default-shell$': '<rootDir>/src/__mocks__/default-shell.js',
|
||||
'^os-name$': '<rootDir>/src/__mocks__/os-name.js'
|
||||
},
|
||||
transformIgnorePatterns: [
|
||||
'node_modules/(?!(@modelcontextprotocol|delay|p-wait-for|globby|serialize-error|strip-ansi|default-shell|os-name)/)'
|
||||
],
|
||||
setupFiles: [],
|
||||
globals: {
|
||||
'ts-jest': {
|
||||
diagnostics: false
|
||||
diagnostics: false,
|
||||
isolatedModules: true
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
209
package-lock.json
generated
209
package-lock.json
generated
@@ -1,18 +1,19 @@
|
||||
{
|
||||
"name": "roo-cline",
|
||||
"version": "2.1.21",
|
||||
"version": "2.2.7",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "roo-cline",
|
||||
"version": "2.1.21",
|
||||
"version": "2.2.7",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/bedrock-sdk": "^0.10.2",
|
||||
"@anthropic-ai/sdk": "^0.26.0",
|
||||
"@anthropic-ai/vertex-sdk": "^0.4.1",
|
||||
"@aws-sdk/client-bedrock-runtime": "^3.706.0",
|
||||
"@google/generative-ai": "^0.18.0",
|
||||
"@modelcontextprotocol/sdk": "^1.0.1",
|
||||
"@types/clone-deep": "^4.0.4",
|
||||
"@types/pdf-parse": "^1.1.4",
|
||||
"@types/turndown": "^5.0.5",
|
||||
@@ -20,6 +21,7 @@
|
||||
"@vscode/codicons": "^0.0.36",
|
||||
"axios": "^1.7.4",
|
||||
"cheerio": "^1.0.0",
|
||||
"chokidar": "^4.0.1",
|
||||
"clone-deep": "^4.0.1",
|
||||
"default-shell": "^2.2.0",
|
||||
"delay": "^6.0.0",
|
||||
@@ -40,7 +42,8 @@
|
||||
"strip-ansi": "^7.1.0",
|
||||
"tree-sitter-wasms": "^0.1.11",
|
||||
"turndown": "^7.2.0",
|
||||
"web-tree-sitter": "^0.22.6"
|
||||
"web-tree-sitter": "^0.22.6",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@changesets/cli": "^2.27.10",
|
||||
@@ -4253,6 +4256,16 @@
|
||||
"resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz",
|
||||
"integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.0.3.tgz",
|
||||
"integrity": "sha512-2as3cX/VJ0YBHGmdv3GFyTpoM8q2gqE98zh3Vf1NwnsSY0h3mvoO07MUzfygCKkWsFjcZm4otIiqD6Xh7kiSBQ==",
|
||||
"dependencies": {
|
||||
"content-type": "^1.0.5",
|
||||
"raw-body": "^3.0.0",
|
||||
"zod": "^3.23.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
@@ -6408,6 +6421,42 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@vscode/test-cli/node_modules/chokidar": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"anymatch": "~3.1.2",
|
||||
"braces": "~3.0.2",
|
||||
"glob-parent": "~5.1.2",
|
||||
"is-binary-path": "~2.1.0",
|
||||
"is-glob": "~4.0.1",
|
||||
"normalize-path": "~3.0.0",
|
||||
"readdirp": "~3.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@vscode/test-cli/node_modules/readdirp": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"picomatch": "^2.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vscode/test-electron": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@vscode/test-electron/-/test-electron-2.4.1.tgz",
|
||||
@@ -7087,6 +7136,14 @@
|
||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/c8": {
|
||||
"version": "9.1.0",
|
||||
"resolved": "https://registry.npmjs.org/c8/-/c8-9.1.0.tgz",
|
||||
@@ -7265,27 +7322,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
|
||||
"dev": true,
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz",
|
||||
"integrity": "sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==",
|
||||
"dependencies": {
|
||||
"anymatch": "~3.1.2",
|
||||
"braces": "~3.0.2",
|
||||
"glob-parent": "~5.1.2",
|
||||
"is-binary-path": "~2.1.0",
|
||||
"is-glob": "~4.0.1",
|
||||
"normalize-path": "~3.0.0",
|
||||
"readdirp": "~3.6.0"
|
||||
"readdirp": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8.10.0"
|
||||
"node": ">= 14.16.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/chromium-bidi": {
|
||||
@@ -7543,6 +7590,14 @@
|
||||
"resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
|
||||
"integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ=="
|
||||
},
|
||||
"node_modules/content-type": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
||||
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
@@ -7807,6 +7862,14 @@
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-indent": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-6.1.0.tgz",
|
||||
@@ -9426,6 +9489,21 @@
|
||||
"entities": "^4.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
|
||||
"integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
|
||||
"dependencies": {
|
||||
"depd": "2.0.0",
|
||||
"inherits": "2.0.4",
|
||||
"setprototypeof": "1.2.0",
|
||||
"statuses": "2.0.1",
|
||||
"toidentifier": "1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/http-proxy-agent": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
|
||||
@@ -11874,6 +11952,30 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/mocha/node_modules/chokidar": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"anymatch": "~3.1.2",
|
||||
"braces": "~3.0.2",
|
||||
"glob-parent": "~5.1.2",
|
||||
"is-binary-path": "~2.1.0",
|
||||
"is-glob": "~4.0.1",
|
||||
"normalize-path": "~3.0.0",
|
||||
"readdirp": "~3.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/mocha/node_modules/cliui": {
|
||||
"version": "7.0.4",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz",
|
||||
@@ -11923,6 +12025,18 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/mocha/node_modules/readdirp": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"picomatch": "^2.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mocha/node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
@@ -13208,6 +13322,20 @@
|
||||
"safe-buffer": "^5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/raw-body": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz",
|
||||
"integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==",
|
||||
"dependencies": {
|
||||
"bytes": "3.1.2",
|
||||
"http-errors": "2.0.0",
|
||||
"iconv-lite": "0.6.3",
|
||||
"unpipe": "1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||
@@ -13321,15 +13449,15 @@
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"picomatch": "^2.2.1"
|
||||
},
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz",
|
||||
"integrity": "sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==",
|
||||
"engines": {
|
||||
"node": ">=8.10.0"
|
||||
"node": ">= 14.16.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/reflect.getprototypeof": {
|
||||
@@ -13707,6 +13835,11 @@
|
||||
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="
|
||||
},
|
||||
"node_modules/shallow-clone": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz",
|
||||
@@ -13956,6 +14089,14 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
||||
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/stdin-discarder": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.1.0.tgz",
|
||||
@@ -14410,6 +14551,14 @@
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/toidentifier": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
@@ -14705,6 +14854,14 @@
|
||||
"node": ">= 4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz",
|
||||
|
||||
26
package.json
26
package.json
@@ -3,7 +3,7 @@
|
||||
"displayName": "Roo Cline",
|
||||
"description": "A fork of Cline, an autonomous coding agent, with some added experimental configuration and automation features.",
|
||||
"publisher": "RooVeterinaryInc",
|
||||
"version": "2.1.21",
|
||||
"version": "2.2.7",
|
||||
"icon": "assets/icons/rocket.png",
|
||||
"galleryBanner": {
|
||||
"color": "#617A91",
|
||||
@@ -32,6 +32,7 @@
|
||||
"cline",
|
||||
"claude",
|
||||
"dev",
|
||||
"mcp",
|
||||
"openrouter",
|
||||
"coding",
|
||||
"agent",
|
||||
@@ -68,6 +69,11 @@
|
||||
"title": "New Task",
|
||||
"icon": "$(add)"
|
||||
},
|
||||
{
|
||||
"command": "roo-cline.mcpButtonClicked",
|
||||
"title": "MCP Servers",
|
||||
"icon": "$(server)"
|
||||
},
|
||||
{
|
||||
"command": "roo-cline.historyButtonClicked",
|
||||
"title": "History",
|
||||
@@ -97,19 +103,24 @@
|
||||
"when": "view == roo-cline.SidebarProvider"
|
||||
},
|
||||
{
|
||||
"command": "roo-cline.historyButtonClicked",
|
||||
"command": "roo-cline.mcpButtonClicked",
|
||||
"group": "navigation@2",
|
||||
"when": "view == roo-cline.SidebarProvider"
|
||||
},
|
||||
{
|
||||
"command": "roo-cline.popoutButtonClicked",
|
||||
"command": "roo-cline.historyButtonClicked",
|
||||
"group": "navigation@3",
|
||||
"when": "view == roo-cline.SidebarProvider"
|
||||
},
|
||||
{
|
||||
"command": "roo-cline.settingsButtonClicked",
|
||||
"command": "roo-cline.popoutButtonClicked",
|
||||
"group": "navigation@4",
|
||||
"when": "view == roo-cline.SidebarProvider"
|
||||
},
|
||||
{
|
||||
"command": "roo-cline.settingsButtonClicked",
|
||||
"group": "navigation@5",
|
||||
"when": "view == roo-cline.SidebarProvider"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -146,7 +157,7 @@
|
||||
"package": "npm run build:webview && npm run check-types && npm run lint && node esbuild.js --production",
|
||||
"pretest": "npm run compile-tests && npm run compile && npm run lint",
|
||||
"start:webview": "cd webview-ui && npm run start",
|
||||
"test": "vscode-test",
|
||||
"test": "jest",
|
||||
"test:webview": "cd webview-ui && npm run test",
|
||||
"prepare": "husky",
|
||||
"publish:marketplace": "vsce publish",
|
||||
@@ -184,6 +195,7 @@
|
||||
"@anthropic-ai/sdk": "^0.26.0",
|
||||
"@anthropic-ai/vertex-sdk": "^0.4.1",
|
||||
"@google/generative-ai": "^0.18.0",
|
||||
"@modelcontextprotocol/sdk": "^1.0.1",
|
||||
"@types/clone-deep": "^4.0.4",
|
||||
"@types/pdf-parse": "^1.1.4",
|
||||
"@types/turndown": "^5.0.5",
|
||||
@@ -191,6 +203,7 @@
|
||||
"@vscode/codicons": "^0.0.36",
|
||||
"axios": "^1.7.4",
|
||||
"cheerio": "^1.0.0",
|
||||
"chokidar": "^4.0.1",
|
||||
"clone-deep": "^4.0.1",
|
||||
"default-shell": "^2.2.0",
|
||||
"delay": "^6.0.0",
|
||||
@@ -211,7 +224,8 @@
|
||||
"strip-ansi": "^7.1.0",
|
||||
"tree-sitter-wasms": "^0.1.11",
|
||||
"turndown": "^7.2.0",
|
||||
"web-tree-sitter": "^0.22.6"
|
||||
"web-tree-sitter": "^0.22.6",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"lint-staged": {
|
||||
"src/**/*.{ts,tsx}": [
|
||||
|
||||
17
src/__mocks__/@modelcontextprotocol/sdk/client/index.js
Normal file
17
src/__mocks__/@modelcontextprotocol/sdk/client/index.js
Normal file
@@ -0,0 +1,17 @@
|
||||
class Client {
|
||||
constructor() {
|
||||
this.request = jest.fn()
|
||||
}
|
||||
|
||||
connect() {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
close() {
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
Client
|
||||
}
|
||||
22
src/__mocks__/@modelcontextprotocol/sdk/client/stdio.js
Normal file
22
src/__mocks__/@modelcontextprotocol/sdk/client/stdio.js
Normal file
@@ -0,0 +1,22 @@
|
||||
class StdioClientTransport {
|
||||
constructor() {
|
||||
this.start = jest.fn().mockResolvedValue(undefined)
|
||||
this.close = jest.fn().mockResolvedValue(undefined)
|
||||
this.stderr = {
|
||||
on: jest.fn()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class StdioServerParameters {
|
||||
constructor() {
|
||||
this.command = ''
|
||||
this.args = []
|
||||
this.env = {}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
StdioClientTransport,
|
||||
StdioServerParameters
|
||||
}
|
||||
24
src/__mocks__/@modelcontextprotocol/sdk/index.js
Normal file
24
src/__mocks__/@modelcontextprotocol/sdk/index.js
Normal file
@@ -0,0 +1,24 @@
|
||||
const { Client } = require('./client/index.js')
|
||||
const { StdioClientTransport, StdioServerParameters } = require('./client/stdio.js')
|
||||
const {
|
||||
CallToolResultSchema,
|
||||
ListToolsResultSchema,
|
||||
ListResourcesResultSchema,
|
||||
ListResourceTemplatesResultSchema,
|
||||
ReadResourceResultSchema,
|
||||
ErrorCode,
|
||||
McpError
|
||||
} = require('./types.js')
|
||||
|
||||
module.exports = {
|
||||
Client,
|
||||
StdioClientTransport,
|
||||
StdioServerParameters,
|
||||
CallToolResultSchema,
|
||||
ListToolsResultSchema,
|
||||
ListResourcesResultSchema,
|
||||
ListResourceTemplatesResultSchema,
|
||||
ReadResourceResultSchema,
|
||||
ErrorCode,
|
||||
McpError
|
||||
}
|
||||
51
src/__mocks__/@modelcontextprotocol/sdk/types.js
Normal file
51
src/__mocks__/@modelcontextprotocol/sdk/types.js
Normal file
@@ -0,0 +1,51 @@
|
||||
const CallToolResultSchema = {
|
||||
parse: jest.fn().mockReturnValue({})
|
||||
}
|
||||
|
||||
const ListToolsResultSchema = {
|
||||
parse: jest.fn().mockReturnValue({
|
||||
tools: []
|
||||
})
|
||||
}
|
||||
|
||||
const ListResourcesResultSchema = {
|
||||
parse: jest.fn().mockReturnValue({
|
||||
resources: []
|
||||
})
|
||||
}
|
||||
|
||||
const ListResourceTemplatesResultSchema = {
|
||||
parse: jest.fn().mockReturnValue({
|
||||
resourceTemplates: []
|
||||
})
|
||||
}
|
||||
|
||||
const ReadResourceResultSchema = {
|
||||
parse: jest.fn().mockReturnValue({
|
||||
contents: []
|
||||
})
|
||||
}
|
||||
|
||||
const ErrorCode = {
|
||||
InvalidRequest: 'InvalidRequest',
|
||||
MethodNotFound: 'MethodNotFound',
|
||||
InvalidParams: 'InvalidParams',
|
||||
InternalError: 'InternalError'
|
||||
}
|
||||
|
||||
class McpError extends Error {
|
||||
constructor(code, message) {
|
||||
super(message)
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CallToolResultSchema,
|
||||
ListToolsResultSchema,
|
||||
ListResourcesResultSchema,
|
||||
ListResourceTemplatesResultSchema,
|
||||
ReadResourceResultSchema,
|
||||
ErrorCode,
|
||||
McpError
|
||||
}
|
||||
17
src/__mocks__/McpHub.ts
Normal file
17
src/__mocks__/McpHub.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export class McpHub {
|
||||
connections = []
|
||||
isConnecting = false
|
||||
|
||||
constructor() {
|
||||
this.toggleToolAlwaysAllow = jest.fn()
|
||||
this.callTool = jest.fn()
|
||||
}
|
||||
|
||||
async toggleToolAlwaysAllow(serverName: string, toolName: string, shouldAllow: boolean): Promise<void> {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
async callTool(serverName: string, toolName: string, toolArguments?: Record<string, unknown>): Promise<any> {
|
||||
return Promise.resolve({ result: 'success' })
|
||||
}
|
||||
}
|
||||
12
src/__mocks__/default-shell.js
Normal file
12
src/__mocks__/default-shell.js
Normal file
@@ -0,0 +1,12 @@
|
||||
// Mock default shell based on platform
|
||||
const os = require('os');
|
||||
|
||||
let defaultShell;
|
||||
if (os.platform() === 'win32') {
|
||||
defaultShell = 'cmd.exe';
|
||||
} else {
|
||||
defaultShell = '/bin/bash';
|
||||
}
|
||||
|
||||
module.exports = defaultShell;
|
||||
module.exports.default = defaultShell;
|
||||
6
src/__mocks__/delay.js
Normal file
6
src/__mocks__/delay.js
Normal file
@@ -0,0 +1,6 @@
|
||||
function delay(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
module.exports = delay;
|
||||
module.exports.default = delay;
|
||||
10
src/__mocks__/globby.js
Normal file
10
src/__mocks__/globby.js
Normal file
@@ -0,0 +1,10 @@
|
||||
function globby(patterns, options) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
globby.sync = function(patterns, options) {
|
||||
return [];
|
||||
};
|
||||
|
||||
module.exports = globby;
|
||||
module.exports.default = globby;
|
||||
6
src/__mocks__/os-name.js
Normal file
6
src/__mocks__/os-name.js
Normal file
@@ -0,0 +1,6 @@
|
||||
function osName() {
|
||||
return 'macOS';
|
||||
}
|
||||
|
||||
module.exports = osName;
|
||||
module.exports.default = osName;
|
||||
20
src/__mocks__/p-wait-for.js
Normal file
20
src/__mocks__/p-wait-for.js
Normal file
@@ -0,0 +1,20 @@
|
||||
function pWaitFor(condition, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const interval = setInterval(() => {
|
||||
if (condition()) {
|
||||
clearInterval(interval);
|
||||
resolve();
|
||||
}
|
||||
}, options.interval || 20);
|
||||
|
||||
if (options.timeout) {
|
||||
setTimeout(() => {
|
||||
clearInterval(interval);
|
||||
reject(new Error('Timed out'));
|
||||
}, options.timeout);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = pWaitFor;
|
||||
module.exports.default = pWaitFor;
|
||||
25
src/__mocks__/serialize-error.js
Normal file
25
src/__mocks__/serialize-error.js
Normal file
@@ -0,0 +1,25 @@
|
||||
function serializeError(error) {
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack
|
||||
};
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
function deserializeError(errorData) {
|
||||
if (errorData && typeof errorData === 'object') {
|
||||
const error = new Error(errorData.message);
|
||||
error.name = errorData.name;
|
||||
error.stack = errorData.stack;
|
||||
return error;
|
||||
}
|
||||
return errorData;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
serializeError,
|
||||
deserializeError
|
||||
};
|
||||
7
src/__mocks__/strip-ansi.js
Normal file
7
src/__mocks__/strip-ansi.js
Normal file
@@ -0,0 +1,7 @@
|
||||
function stripAnsi(string) {
|
||||
// Simple mock that just returns the input string
|
||||
return string;
|
||||
}
|
||||
|
||||
module.exports = stripAnsi;
|
||||
module.exports.default = stripAnsi;
|
||||
57
src/__mocks__/vscode.js
Normal file
57
src/__mocks__/vscode.js
Normal file
@@ -0,0 +1,57 @@
|
||||
const vscode = {
|
||||
window: {
|
||||
showInformationMessage: jest.fn(),
|
||||
showErrorMessage: jest.fn(),
|
||||
createTextEditorDecorationType: jest.fn().mockReturnValue({
|
||||
dispose: jest.fn()
|
||||
})
|
||||
},
|
||||
workspace: {
|
||||
onDidSaveTextDocument: jest.fn()
|
||||
},
|
||||
Disposable: class {
|
||||
dispose() {}
|
||||
},
|
||||
Uri: {
|
||||
file: (path) => ({
|
||||
fsPath: path,
|
||||
scheme: 'file',
|
||||
authority: '',
|
||||
path: path,
|
||||
query: '',
|
||||
fragment: '',
|
||||
with: jest.fn(),
|
||||
toJSON: jest.fn()
|
||||
})
|
||||
},
|
||||
EventEmitter: class {
|
||||
constructor() {
|
||||
this.event = jest.fn();
|
||||
this.fire = jest.fn();
|
||||
}
|
||||
},
|
||||
ConfigurationTarget: {
|
||||
Global: 1,
|
||||
Workspace: 2,
|
||||
WorkspaceFolder: 3
|
||||
},
|
||||
Position: class {
|
||||
constructor(line, character) {
|
||||
this.line = line;
|
||||
this.character = character;
|
||||
}
|
||||
},
|
||||
Range: class {
|
||||
constructor(startLine, startCharacter, endLine, endCharacter) {
|
||||
this.start = new vscode.Position(startLine, startCharacter);
|
||||
this.end = new vscode.Position(endLine, endCharacter);
|
||||
}
|
||||
},
|
||||
ThemeColor: class {
|
||||
constructor(id) {
|
||||
this.id = id;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = vscode;
|
||||
@@ -32,18 +32,24 @@ export class OpenAiHandler implements ApiHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// Include stream_options for OpenAI Compatible providers if the checkbox is checked
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
|
||||
{ role: "system", content: systemPrompt },
|
||||
...convertToOpenAiMessages(messages),
|
||||
]
|
||||
const stream = await this.client.chat.completions.create({
|
||||
const requestOptions: OpenAI.Chat.ChatCompletionCreateParams = {
|
||||
model: this.options.openAiModelId ?? "",
|
||||
messages: openAiMessages,
|
||||
temperature: 0,
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
})
|
||||
}
|
||||
|
||||
if (this.options.includeStreamOptions ?? true) {
|
||||
requestOptions.stream_options = { include_usage: true }
|
||||
}
|
||||
|
||||
const stream = await this.client.chat.completions.create(requestOptions)
|
||||
for await (const chunk of stream) {
|
||||
const delta = chunk.choices[0]?.delta
|
||||
if (delta?.content) {
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
ClineApiReqCancelReason,
|
||||
ClineApiReqInfo,
|
||||
ClineAsk,
|
||||
ClineAskUseMcpServer,
|
||||
ClineMessage,
|
||||
ClineSay,
|
||||
ClineSayBrowserAction,
|
||||
@@ -754,7 +755,17 @@ export class Cline {
|
||||
}
|
||||
|
||||
async *attemptApiRequest(previousApiReqIndex: number): ApiStream {
|
||||
const systemPrompt = await SYSTEM_PROMPT(cwd, this.api.getModel().info.supportsComputerUse ?? false, this.diffStrategy) + await addCustomInstructions(this.customInstructions ?? '', cwd)
|
||||
// Wait for MCP servers to be connected before generating system prompt
|
||||
await pWaitFor(() => this.providerRef.deref()?.mcpHub?.isConnecting !== true, { timeout: 10_000 }).catch(() => {
|
||||
console.error("MCP servers failed to connect in time")
|
||||
})
|
||||
|
||||
const mcpHub = this.providerRef.deref()?.mcpHub
|
||||
if (!mcpHub) {
|
||||
throw new Error("MCP hub not available")
|
||||
}
|
||||
|
||||
const systemPrompt = await SYSTEM_PROMPT(cwd, this.api.getModel().info.supportsComputerUse ?? false, mcpHub, this.diffStrategy) + await addCustomInstructions(this.customInstructions ?? '', cwd)
|
||||
|
||||
// If the previous API request's total token usage is close to the context window, truncate the conversation history to free up space for the new request
|
||||
if (previousApiReqIndex >= 0) {
|
||||
@@ -893,6 +904,10 @@ export class Cline {
|
||||
return `[${block.name} for '${block.params.path}']`
|
||||
case "browser_action":
|
||||
return `[${block.name} for '${block.params.action}']`
|
||||
case "use_mcp_tool":
|
||||
return `[${block.name} for '${block.params.server_name}']`
|
||||
case "access_mcp_resource":
|
||||
return `[${block.name} for '${block.params.server_name}']`
|
||||
case "ask_followup_question":
|
||||
return `[${block.name} for '${block.params.question}']`
|
||||
case "attempt_completion":
|
||||
@@ -1634,7 +1649,162 @@ export class Cline {
|
||||
break
|
||||
}
|
||||
}
|
||||
case "use_mcp_tool": {
|
||||
const server_name: string | undefined = block.params.server_name
|
||||
const tool_name: string | undefined = block.params.tool_name
|
||||
const mcp_arguments: string | undefined = block.params.arguments
|
||||
try {
|
||||
if (block.partial) {
|
||||
const partialMessage = JSON.stringify({
|
||||
type: "use_mcp_tool",
|
||||
serverName: removeClosingTag("server_name", server_name),
|
||||
toolName: removeClosingTag("tool_name", tool_name),
|
||||
arguments: removeClosingTag("arguments", mcp_arguments),
|
||||
} satisfies ClineAskUseMcpServer)
|
||||
await this.ask("use_mcp_server", partialMessage, block.partial).catch(() => {})
|
||||
break
|
||||
} else {
|
||||
if (!server_name) {
|
||||
this.consecutiveMistakeCount++
|
||||
pushToolResult(
|
||||
await this.sayAndCreateMissingParamError("use_mcp_tool", "server_name"),
|
||||
)
|
||||
break
|
||||
}
|
||||
if (!tool_name) {
|
||||
this.consecutiveMistakeCount++
|
||||
pushToolResult(
|
||||
await this.sayAndCreateMissingParamError("use_mcp_tool", "tool_name"),
|
||||
)
|
||||
break
|
||||
}
|
||||
// arguments are optional, but if they are provided they must be valid JSON
|
||||
// if (!mcp_arguments) {
|
||||
// this.consecutiveMistakeCount++
|
||||
// pushToolResult(await this.sayAndCreateMissingParamError("use_mcp_tool", "arguments"))
|
||||
// break
|
||||
// }
|
||||
let parsedArguments: Record<string, unknown> | undefined
|
||||
if (mcp_arguments) {
|
||||
try {
|
||||
parsedArguments = JSON.parse(mcp_arguments)
|
||||
} catch (error) {
|
||||
this.consecutiveMistakeCount++
|
||||
await this.say(
|
||||
"error",
|
||||
`Cline tried to use ${tool_name} with an invalid JSON argument. Retrying...`,
|
||||
)
|
||||
pushToolResult(
|
||||
formatResponse.toolError(
|
||||
formatResponse.invalidMcpToolArgumentError(server_name, tool_name),
|
||||
),
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
this.consecutiveMistakeCount = 0
|
||||
const completeMessage = JSON.stringify({
|
||||
type: "use_mcp_tool",
|
||||
serverName: server_name,
|
||||
toolName: tool_name,
|
||||
arguments: mcp_arguments,
|
||||
} satisfies ClineAskUseMcpServer)
|
||||
const didApprove = await askApproval("use_mcp_server", completeMessage)
|
||||
if (!didApprove) {
|
||||
break
|
||||
}
|
||||
// now execute the tool
|
||||
await this.say("mcp_server_request_started") // same as browser_action_result
|
||||
const toolResult = await this.providerRef
|
||||
.deref()
|
||||
?.mcpHub?.callTool(server_name, tool_name, parsedArguments)
|
||||
|
||||
// TODO: add progress indicator and ability to parse images and non-text responses
|
||||
const toolResultPretty =
|
||||
(toolResult?.isError ? "Error:\n" : "") +
|
||||
toolResult?.content
|
||||
.map((item) => {
|
||||
if (item.type === "text") {
|
||||
return item.text
|
||||
}
|
||||
if (item.type === "resource") {
|
||||
const { blob, ...rest } = item.resource
|
||||
return JSON.stringify(rest, null, 2)
|
||||
}
|
||||
return ""
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n\n") || "(No response)"
|
||||
await this.say("mcp_server_response", toolResultPretty)
|
||||
pushToolResult(formatResponse.toolResult(toolResultPretty))
|
||||
break
|
||||
}
|
||||
} catch (error) {
|
||||
await handleError("executing MCP tool", error)
|
||||
break
|
||||
}
|
||||
}
|
||||
case "access_mcp_resource": {
|
||||
const server_name: string | undefined = block.params.server_name
|
||||
const uri: string | undefined = block.params.uri
|
||||
try {
|
||||
if (block.partial) {
|
||||
const partialMessage = JSON.stringify({
|
||||
type: "access_mcp_resource",
|
||||
serverName: removeClosingTag("server_name", server_name),
|
||||
uri: removeClosingTag("uri", uri),
|
||||
} satisfies ClineAskUseMcpServer)
|
||||
await this.ask("use_mcp_server", partialMessage, block.partial).catch(() => {})
|
||||
break
|
||||
} else {
|
||||
if (!server_name) {
|
||||
this.consecutiveMistakeCount++
|
||||
pushToolResult(
|
||||
await this.sayAndCreateMissingParamError("access_mcp_resource", "server_name"),
|
||||
)
|
||||
break
|
||||
}
|
||||
if (!uri) {
|
||||
this.consecutiveMistakeCount++
|
||||
pushToolResult(
|
||||
await this.sayAndCreateMissingParamError("access_mcp_resource", "uri"),
|
||||
)
|
||||
break
|
||||
}
|
||||
this.consecutiveMistakeCount = 0
|
||||
const completeMessage = JSON.stringify({
|
||||
type: "access_mcp_resource",
|
||||
serverName: server_name,
|
||||
uri,
|
||||
} satisfies ClineAskUseMcpServer)
|
||||
const didApprove = await askApproval("use_mcp_server", completeMessage)
|
||||
if (!didApprove) {
|
||||
break
|
||||
}
|
||||
// now execute the tool
|
||||
await this.say("mcp_server_request_started")
|
||||
const resourceResult = await this.providerRef
|
||||
.deref()
|
||||
?.mcpHub?.readResource(server_name, uri)
|
||||
const resourceResultPretty =
|
||||
resourceResult?.contents
|
||||
.map((item) => {
|
||||
if (item.text) {
|
||||
return item.text
|
||||
}
|
||||
return ""
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n\n") || "(Empty response)"
|
||||
await this.say("mcp_server_response", resourceResultPretty)
|
||||
pushToolResult(formatResponse.toolResult(resourceResultPretty))
|
||||
break
|
||||
}
|
||||
} catch (error) {
|
||||
await handleError("accessing MCP resource", error)
|
||||
break
|
||||
}
|
||||
}
|
||||
case "ask_followup_question": {
|
||||
const question: string | undefined = block.params.question
|
||||
try {
|
||||
|
||||
@@ -3,6 +3,44 @@ import { ClineProvider } from '../webview/ClineProvider';
|
||||
import { ApiConfiguration } from '../../shared/api';
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
// Mock all MCP-related modules
|
||||
jest.mock('@modelcontextprotocol/sdk/types.js', () => ({
|
||||
CallToolResultSchema: {},
|
||||
ListResourcesResultSchema: {},
|
||||
ListResourceTemplatesResultSchema: {},
|
||||
ListToolsResultSchema: {},
|
||||
ReadResourceResultSchema: {},
|
||||
ErrorCode: {
|
||||
InvalidRequest: 'InvalidRequest',
|
||||
MethodNotFound: 'MethodNotFound',
|
||||
InternalError: 'InternalError'
|
||||
},
|
||||
McpError: class McpError extends Error {
|
||||
code: string;
|
||||
constructor(code: string, message: string) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
this.name = 'McpError';
|
||||
}
|
||||
}
|
||||
}), { virtual: true });
|
||||
|
||||
jest.mock('@modelcontextprotocol/sdk/client/index.js', () => ({
|
||||
Client: jest.fn().mockImplementation(() => ({
|
||||
connect: jest.fn().mockResolvedValue(undefined),
|
||||
close: jest.fn().mockResolvedValue(undefined),
|
||||
listTools: jest.fn().mockResolvedValue({ tools: [] }),
|
||||
callTool: jest.fn().mockResolvedValue({ content: [] })
|
||||
}))
|
||||
}), { virtual: true });
|
||||
|
||||
jest.mock('@modelcontextprotocol/sdk/client/stdio.js', () => ({
|
||||
StdioClientTransport: jest.fn().mockImplementation(() => ({
|
||||
connect: jest.fn().mockResolvedValue(undefined),
|
||||
close: jest.fn().mockResolvedValue(undefined)
|
||||
}))
|
||||
}), { virtual: true });
|
||||
|
||||
// Mock fileExistsAtPath
|
||||
jest.mock('../../utils/fs', () => ({
|
||||
fileExistsAtPath: jest.fn().mockImplementation((filePath) => {
|
||||
@@ -85,7 +123,11 @@ jest.mock('vscode', () => {
|
||||
}],
|
||||
onDidCreateFiles: jest.fn(() => mockDisposable),
|
||||
onDidDeleteFiles: jest.fn(() => mockDisposable),
|
||||
onDidRenameFiles: jest.fn(() => mockDisposable)
|
||||
onDidRenameFiles: jest.fn(() => mockDisposable),
|
||||
onDidSaveTextDocument: jest.fn(() => mockDisposable),
|
||||
onDidChangeTextDocument: jest.fn(() => mockDisposable),
|
||||
onDidOpenTextDocument: jest.fn(() => mockDisposable),
|
||||
onDidCloseTextDocument: jest.fn(() => mockDisposable)
|
||||
},
|
||||
env: {
|
||||
uriScheme: 'vscode',
|
||||
|
||||
@@ -17,6 +17,8 @@ export const toolUseNames = [
|
||||
"list_files",
|
||||
"list_code_definition_names",
|
||||
"browser_action",
|
||||
"use_mcp_tool",
|
||||
"access_mcp_resource",
|
||||
"ask_followup_question",
|
||||
"attempt_completion",
|
||||
] as const
|
||||
@@ -35,6 +37,10 @@ export const toolParamNames = [
|
||||
"url",
|
||||
"coordinate",
|
||||
"text",
|
||||
"server_name",
|
||||
"tool_name",
|
||||
"arguments",
|
||||
"uri",
|
||||
"question",
|
||||
"result",
|
||||
"diff",
|
||||
@@ -86,6 +92,16 @@ export interface BrowserActionToolUse extends ToolUse {
|
||||
params: Partial<Pick<Record<ToolParamName, string>, "action" | "url" | "coordinate" | "text">>
|
||||
}
|
||||
|
||||
export interface UseMcpToolToolUse extends ToolUse {
|
||||
name: "use_mcp_tool"
|
||||
params: Partial<Pick<Record<ToolParamName, string>, "server_name" | "tool_name" | "arguments">>
|
||||
}
|
||||
|
||||
export interface AccessMcpResourceToolUse extends ToolUse {
|
||||
name: "access_mcp_resource"
|
||||
params: Partial<Pick<Record<ToolParamName, string>, "server_name" | "uri">>
|
||||
}
|
||||
|
||||
export interface AskFollowupQuestionToolUse extends ToolUse {
|
||||
name: "ask_followup_question"
|
||||
params: Partial<Pick<Record<ToolParamName, string>, "question">>
|
||||
|
||||
@@ -7,9 +7,9 @@ import { SearchReplaceDiffStrategy } from './strategies/search-replace'
|
||||
* @returns The appropriate diff strategy for the model
|
||||
*/
|
||||
export function getDiffStrategy(model: string): DiffStrategy {
|
||||
// For now, return SearchReplaceDiffStrategy for all models
|
||||
// For now, return SearchReplaceDiffStrategy for all models (with a fuzzy threshold of 0.9)
|
||||
// This architecture allows for future optimizations based on model capabilities
|
||||
return new SearchReplaceDiffStrategy()
|
||||
return new SearchReplaceDiffStrategy(0.9)
|
||||
}
|
||||
|
||||
export type { DiffStrategy }
|
||||
|
||||
@@ -1,68 +1,32 @@
|
||||
import { SearchReplaceDiffStrategy } from '../search-replace'
|
||||
|
||||
describe('SearchReplaceDiffStrategy', () => {
|
||||
let strategy: SearchReplaceDiffStrategy
|
||||
describe('exact matching', () => {
|
||||
let strategy: SearchReplaceDiffStrategy
|
||||
|
||||
beforeEach(() => {
|
||||
strategy = new SearchReplaceDiffStrategy()
|
||||
})
|
||||
|
||||
describe('applyDiff', () => {
|
||||
it('should replace matching content', () => {
|
||||
const originalContent = `function hello() {
|
||||
console.log("hello")
|
||||
}
|
||||
`
|
||||
const diffContent = `test.ts
|
||||
<<<<<<< SEARCH
|
||||
function hello() {
|
||||
console.log("hello")
|
||||
}
|
||||
=======
|
||||
function hello() {
|
||||
console.log("hello world")
|
||||
}
|
||||
>>>>>>> REPLACE`
|
||||
|
||||
const result = strategy.applyDiff(originalContent, diffContent)
|
||||
expect(result).toBe(`function hello() {
|
||||
console.log("hello world")
|
||||
}
|
||||
`)
|
||||
beforeEach(() => {
|
||||
strategy = new SearchReplaceDiffStrategy() // Default 1.0 threshold for exact matching
|
||||
})
|
||||
|
||||
it('should handle extra whitespace in search/replace blocks', () => {
|
||||
const originalContent = `function test() {
|
||||
return true;
|
||||
}
|
||||
`
|
||||
it('should replace matching content', () => {
|
||||
const originalContent = 'function hello() {\n console.log("hello")\n}\n'
|
||||
const diffContent = `test.ts
|
||||
<<<<<<< SEARCH
|
||||
|
||||
function test() {
|
||||
return true;
|
||||
function hello() {
|
||||
console.log("hello")
|
||||
}
|
||||
|
||||
=======
|
||||
function test() {
|
||||
return false;
|
||||
function hello() {
|
||||
console.log("hello world")
|
||||
}
|
||||
>>>>>>> REPLACE`
|
||||
|
||||
const result = strategy.applyDiff(originalContent, diffContent)
|
||||
expect(result).toBe(`function test() {
|
||||
return false;
|
||||
}
|
||||
`)
|
||||
expect(result).toBe('function hello() {\n console.log("hello world")\n}\n')
|
||||
})
|
||||
|
||||
it('should match content with different surrounding whitespace', () => {
|
||||
const originalContent = `
|
||||
function example() {
|
||||
return 42;
|
||||
}
|
||||
|
||||
`
|
||||
const originalContent = '\nfunction example() {\n return 42;\n}\n\n'
|
||||
const diffContent = `test.ts
|
||||
<<<<<<< SEARCH
|
||||
function example() {
|
||||
@@ -75,19 +39,11 @@ function example() {
|
||||
>>>>>>> REPLACE`
|
||||
|
||||
const result = strategy.applyDiff(originalContent, diffContent)
|
||||
expect(result).toBe(`
|
||||
function example() {
|
||||
return 43;
|
||||
}
|
||||
|
||||
`)
|
||||
expect(result).toBe('\nfunction example() {\n return 43;\n}\n\n')
|
||||
})
|
||||
|
||||
it('should match content with different indentation in search block', () => {
|
||||
const originalContent = ` function test() {
|
||||
return true;
|
||||
}
|
||||
`
|
||||
const originalContent = ' function test() {\n return true;\n }\n'
|
||||
const diffContent = `test.ts
|
||||
<<<<<<< SEARCH
|
||||
function test() {
|
||||
@@ -100,10 +56,7 @@ function test() {
|
||||
>>>>>>> REPLACE`
|
||||
|
||||
const result = strategy.applyDiff(originalContent, diffContent)
|
||||
expect(result).toBe(` function test() {
|
||||
return false;
|
||||
}
|
||||
`)
|
||||
expect(result).toBe(' function test() {\n return false;\n }\n')
|
||||
})
|
||||
|
||||
it('should handle tab-based indentation', () => {
|
||||
@@ -199,10 +152,7 @@ function test() {
|
||||
})
|
||||
|
||||
it('should return false if search content does not match', () => {
|
||||
const originalContent = `function hello() {
|
||||
console.log("hello")
|
||||
}
|
||||
`
|
||||
const originalContent = 'function hello() {\n console.log("hello")\n}\n'
|
||||
const diffContent = `test.ts
|
||||
<<<<<<< SEARCH
|
||||
function hello() {
|
||||
@@ -219,28 +169,15 @@ function hello() {
|
||||
})
|
||||
|
||||
it('should return false if diff format is invalid', () => {
|
||||
const originalContent = `function hello() {
|
||||
console.log("hello")
|
||||
}
|
||||
`
|
||||
const diffContent = `test.ts
|
||||
Invalid diff format`
|
||||
const originalContent = 'function hello() {\n console.log("hello")\n}\n'
|
||||
const diffContent = `test.ts\nInvalid diff format`
|
||||
|
||||
const result = strategy.applyDiff(originalContent, diffContent)
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle multiple lines with proper indentation', () => {
|
||||
const originalContent = `class Example {
|
||||
constructor() {
|
||||
this.value = 0
|
||||
}
|
||||
|
||||
getValue() {
|
||||
return this.value
|
||||
}
|
||||
}
|
||||
`
|
||||
const originalContent = 'class Example {\n constructor() {\n this.value = 0\n }\n\n getValue() {\n return this.value\n }\n}\n'
|
||||
const diffContent = `test.ts
|
||||
<<<<<<< SEARCH
|
||||
getValue() {
|
||||
@@ -255,18 +192,7 @@ Invalid diff format`
|
||||
>>>>>>> REPLACE`
|
||||
|
||||
const result = strategy.applyDiff(originalContent, diffContent)
|
||||
expect(result).toBe(`class Example {
|
||||
constructor() {
|
||||
this.value = 0
|
||||
}
|
||||
|
||||
getValue() {
|
||||
// Add logging
|
||||
console.log("Getting value")
|
||||
return this.value
|
||||
}
|
||||
}
|
||||
`)
|
||||
expect(result).toBe('class Example {\n constructor() {\n this.value = 0\n }\n\n getValue() {\n // Add logging\n console.log("Getting value")\n return this.value\n }\n}\n')
|
||||
})
|
||||
|
||||
it('should preserve whitespace exactly in the output', () => {
|
||||
@@ -286,223 +212,415 @@ Invalid diff format`
|
||||
expect(result).toBe(" modified\n still indented\n end\n")
|
||||
})
|
||||
|
||||
it('should handle complex refactoring with multiple functions', () => {
|
||||
const originalContent = `export async function extractTextFromFile(filePath: string): Promise<string> {
|
||||
try {
|
||||
await fs.access(filePath)
|
||||
} catch (error) {
|
||||
throw new Error(\`File not found: \${filePath}\`)
|
||||
}
|
||||
const fileExtension = path.extname(filePath).toLowerCase()
|
||||
switch (fileExtension) {
|
||||
case ".pdf":
|
||||
return extractTextFromPDF(filePath)
|
||||
case ".docx":
|
||||
return extractTextFromDOCX(filePath)
|
||||
case ".ipynb":
|
||||
return extractTextFromIPYNB(filePath)
|
||||
default:
|
||||
const isBinary = await isBinaryFile(filePath).catch(() => false)
|
||||
if (!isBinary) {
|
||||
return addLineNumbers(await fs.readFile(filePath, "utf8"))
|
||||
} else {
|
||||
throw new Error(\`Cannot read text for file type: \${fileExtension}\`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function addLineNumbers(content: string): string {
|
||||
const lines = content.split('\\n')
|
||||
const maxLineNumberWidth = String(lines.length).length
|
||||
return lines
|
||||
.map((line, index) => {
|
||||
const lineNumber = String(index + 1).padStart(maxLineNumberWidth, ' ')
|
||||
return \`\${lineNumber} | \${line}\`
|
||||
}).join('\\n')
|
||||
}`
|
||||
|
||||
it('should preserve indentation when adding new lines after existing content', () => {
|
||||
const originalContent = ' onScroll={() => updateHighlights()}'
|
||||
const diffContent = `test.ts
|
||||
<<<<<<< SEARCH
|
||||
export async function extractTextFromFile(filePath: string): Promise<string> {
|
||||
try {
|
||||
await fs.access(filePath)
|
||||
} catch (error) {
|
||||
throw new Error(\`File not found: \${filePath}\`)
|
||||
}
|
||||
const fileExtension = path.extname(filePath).toLowerCase()
|
||||
switch (fileExtension) {
|
||||
case ".pdf":
|
||||
return extractTextFromPDF(filePath)
|
||||
case ".docx":
|
||||
return extractTextFromDOCX(filePath)
|
||||
case ".ipynb":
|
||||
return extractTextFromIPYNB(filePath)
|
||||
default:
|
||||
const isBinary = await isBinaryFile(filePath).catch(() => false)
|
||||
if (!isBinary) {
|
||||
return addLineNumbers(await fs.readFile(filePath, "utf8"))
|
||||
} else {
|
||||
throw new Error(\`Cannot read text for file type: \${fileExtension}\`)
|
||||
}
|
||||
}
|
||||
}
|
||||
onScroll={() => updateHighlights()}
|
||||
=======
|
||||
onScroll={() => updateHighlights()}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
>>>>>>> REPLACE`
|
||||
|
||||
export function addLineNumbers(content: string): string {
|
||||
const lines = content.split('\\n')
|
||||
const maxLineNumberWidth = String(lines.length).length
|
||||
return lines
|
||||
.map((line, index) => {
|
||||
const lineNumber = String(index + 1).padStart(maxLineNumberWidth, ' ')
|
||||
return \`\${lineNumber} | \${line}\`
|
||||
}).join('\\n')
|
||||
const result = strategy.applyDiff(originalContent, diffContent)
|
||||
expect(result).toBe(' onScroll={() => updateHighlights()}\n onDragOver={(e) => {\n e.preventDefault()\n e.stopPropagation()\n }}')
|
||||
})
|
||||
})
|
||||
|
||||
describe('fuzzy matching', () => {
|
||||
let strategy: SearchReplaceDiffStrategy
|
||||
|
||||
beforeEach(() => {
|
||||
strategy = new SearchReplaceDiffStrategy(0.9) // 90% similarity threshold
|
||||
})
|
||||
|
||||
it('should match content with small differences (>90% similar)', () => {
|
||||
const originalContent = 'function getData() {\n const results = fetchData();\n return results.filter(Boolean);\n}\n'
|
||||
const diffContent = `test.ts
|
||||
<<<<<<< SEARCH
|
||||
function getData() {
|
||||
const result = fetchData();
|
||||
return results.filter(Boolean);
|
||||
}
|
||||
=======
|
||||
function extractLineRange(content: string, startLine?: number, endLine?: number): string {
|
||||
const lines = content.split('\\n')
|
||||
const start = startLine ? Math.max(1, startLine) : 1
|
||||
const end = endLine ? Math.min(lines.length, endLine) : lines.length
|
||||
|
||||
if (start > end || start > lines.length) {
|
||||
throw new Error(\`Invalid line range: start=\${start}, end=\${end}, total lines=\${lines.length}\`)
|
||||
}
|
||||
|
||||
return lines.slice(start - 1, end).join('\\n')
|
||||
}
|
||||
|
||||
export async function extractTextFromFile(filePath: string, startLine?: number, endLine?: number): Promise<string> {
|
||||
try {
|
||||
await fs.access(filePath)
|
||||
} catch (error) {
|
||||
throw new Error(\`File not found: \${filePath}\`)
|
||||
}
|
||||
const fileExtension = path.extname(filePath).toLowerCase()
|
||||
let content: string
|
||||
|
||||
switch (fileExtension) {
|
||||
case ".pdf": {
|
||||
const dataBuffer = await fs.readFile(filePath)
|
||||
const data = await pdf(dataBuffer)
|
||||
content = extractLineRange(data.text, startLine, endLine)
|
||||
break
|
||||
}
|
||||
case ".docx": {
|
||||
const result = await mammoth.extractRawText({ path: filePath })
|
||||
content = extractLineRange(result.value, startLine, endLine)
|
||||
break
|
||||
}
|
||||
case ".ipynb": {
|
||||
const data = await fs.readFile(filePath, "utf8")
|
||||
const notebook = JSON.parse(data)
|
||||
let extractedText = ""
|
||||
|
||||
for (const cell of notebook.cells) {
|
||||
if ((cell.cell_type === "markdown" || cell.cell_type === "code") && cell.source) {
|
||||
extractedText += cell.source.join("\\n") + "\\n"
|
||||
}
|
||||
}
|
||||
content = extractLineRange(extractedText, startLine, endLine)
|
||||
break
|
||||
}
|
||||
default: {
|
||||
const isBinary = await isBinaryFile(filePath).catch(() => false)
|
||||
if (!isBinary) {
|
||||
const fileContent = await fs.readFile(filePath, "utf8")
|
||||
content = extractLineRange(fileContent, startLine, endLine)
|
||||
} else {
|
||||
throw new Error(\`Cannot read text for file type: \${fileExtension}\`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return addLineNumbers(content, startLine)
|
||||
}
|
||||
|
||||
export function addLineNumbers(content: string, startLine: number = 1): string {
|
||||
const lines = content.split('\\n')
|
||||
const maxLineNumberWidth = String(startLine + lines.length - 1).length
|
||||
return lines
|
||||
.map((line, index) => {
|
||||
const lineNumber = String(startLine + index).padStart(maxLineNumberWidth, ' ')
|
||||
return \`\${lineNumber} | \${line}\`
|
||||
}).join('\\n')
|
||||
function getData() {
|
||||
const data = fetchData();
|
||||
return data.filter(Boolean);
|
||||
}
|
||||
>>>>>>> REPLACE`
|
||||
|
||||
const result = strategy.applyDiff(originalContent, diffContent)
|
||||
const expected = `function extractLineRange(content: string, startLine?: number, endLine?: number): string {
|
||||
const lines = content.split('\\n')
|
||||
const start = startLine ? Math.max(1, startLine) : 1
|
||||
const end = endLine ? Math.min(lines.length, endLine) : lines.length
|
||||
expect(result).toBe('function getData() {\n const data = fetchData();\n return data.filter(Boolean);\n}\n')
|
||||
})
|
||||
|
||||
if (start > end || start > lines.length) {
|
||||
throw new Error(\`Invalid line range: start=\${start}, end=\${end}, total lines=\${lines.length}\`)
|
||||
}
|
||||
it('should not match when content is too different (<90% similar)', () => {
|
||||
const originalContent = 'function processUsers(data) {\n return data.map(user => user.name);\n}\n'
|
||||
const diffContent = `test.ts
|
||||
<<<<<<< SEARCH
|
||||
function handleItems(items) {
|
||||
return items.map(item => item.username);
|
||||
}
|
||||
=======
|
||||
function processData(data) {
|
||||
return data.map(d => d.value);
|
||||
}
|
||||
>>>>>>> REPLACE`
|
||||
|
||||
return lines.slice(start - 1, end).join('\\n')
|
||||
const result = strategy.applyDiff(originalContent, diffContent)
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should match content with extra whitespace', () => {
|
||||
const originalContent = 'function sum(a, b) {\n return a + b;\n}'
|
||||
const diffContent = `test.ts
|
||||
<<<<<<< SEARCH
|
||||
function sum(a, b) {
|
||||
return a + b;
|
||||
}
|
||||
=======
|
||||
function sum(a, b) {
|
||||
return a + b + 1;
|
||||
}
|
||||
>>>>>>> REPLACE`
|
||||
|
||||
const result = strategy.applyDiff(originalContent, diffContent)
|
||||
expect(result).toBe('function sum(a, b) {\n return a + b + 1;\n}')
|
||||
})
|
||||
})
|
||||
|
||||
describe('line-constrained search', () => {
|
||||
let strategy: SearchReplaceDiffStrategy
|
||||
|
||||
beforeEach(() => {
|
||||
strategy = new SearchReplaceDiffStrategy()
|
||||
})
|
||||
|
||||
it('should find and replace within specified line range', () => {
|
||||
const originalContent = `
|
||||
function one() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
export async function extractTextFromFile(filePath: string, startLine?: number, endLine?: number): Promise<string> {
|
||||
try {
|
||||
await fs.access(filePath)
|
||||
} catch (error) {
|
||||
throw new Error(\`File not found: \${filePath}\`)
|
||||
}
|
||||
const fileExtension = path.extname(filePath).toLowerCase()
|
||||
let content: string
|
||||
|
||||
switch (fileExtension) {
|
||||
case ".pdf": {
|
||||
const dataBuffer = await fs.readFile(filePath)
|
||||
const data = await pdf(dataBuffer)
|
||||
content = extractLineRange(data.text, startLine, endLine)
|
||||
break
|
||||
}
|
||||
case ".docx": {
|
||||
const result = await mammoth.extractRawText({ path: filePath })
|
||||
content = extractLineRange(result.value, startLine, endLine)
|
||||
break
|
||||
}
|
||||
case ".ipynb": {
|
||||
const data = await fs.readFile(filePath, "utf8")
|
||||
const notebook = JSON.parse(data)
|
||||
let extractedText = ""
|
||||
|
||||
for (const cell of notebook.cells) {
|
||||
if ((cell.cell_type === "markdown" || cell.cell_type === "code") && cell.source) {
|
||||
extractedText += cell.source.join("\\n") + "\\n"
|
||||
}
|
||||
}
|
||||
content = extractLineRange(extractedText, startLine, endLine)
|
||||
break
|
||||
}
|
||||
default: {
|
||||
const isBinary = await isBinaryFile(filePath).catch(() => false)
|
||||
if (!isBinary) {
|
||||
const fileContent = await fs.readFile(filePath, "utf8")
|
||||
content = extractLineRange(fileContent, startLine, endLine)
|
||||
} else {
|
||||
throw new Error(\`Cannot read text for file type: \${fileExtension}\`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return addLineNumbers(content, startLine)
|
||||
function two() {
|
||||
return 2;
|
||||
}
|
||||
|
||||
export function addLineNumbers(content: string, startLine: number = 1): string {
|
||||
const lines = content.split('\\n')
|
||||
const maxLineNumberWidth = String(startLine + lines.length - 1).length
|
||||
return lines
|
||||
.map((line, index) => {
|
||||
const lineNumber = String(startLine + index).padStart(maxLineNumberWidth, ' ')
|
||||
return \`\${lineNumber} | \${line}\`
|
||||
}).join('\\n')
|
||||
}`
|
||||
expect(result).toBe(expected)
|
||||
function three() {
|
||||
return 3;
|
||||
}
|
||||
`.trim()
|
||||
const diffContent = `test.ts
|
||||
<<<<<<< SEARCH
|
||||
function two() {
|
||||
return 2;
|
||||
}
|
||||
=======
|
||||
function two() {
|
||||
return "two";
|
||||
}
|
||||
>>>>>>> REPLACE`
|
||||
|
||||
const result = strategy.applyDiff(originalContent, diffContent, 5, 7)
|
||||
expect(result).toBe(`function one() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
function two() {
|
||||
return "two";
|
||||
}
|
||||
|
||||
function three() {
|
||||
return 3;
|
||||
}`)
|
||||
})
|
||||
|
||||
it('should find and replace within buffer zone (5 lines before/after)', () => {
|
||||
const originalContent = `
|
||||
function one() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
function two() {
|
||||
return 2;
|
||||
}
|
||||
|
||||
function three() {
|
||||
return 3;
|
||||
}
|
||||
`.trim()
|
||||
const diffContent = `test.ts
|
||||
<<<<<<< SEARCH
|
||||
function three() {
|
||||
return 3;
|
||||
}
|
||||
=======
|
||||
function three() {
|
||||
return "three";
|
||||
}
|
||||
>>>>>>> REPLACE`
|
||||
|
||||
// Even though we specify lines 5-7, it should still find the match at lines 9-11
|
||||
// because it's within the 5-line buffer zone
|
||||
const result = strategy.applyDiff(originalContent, diffContent, 5, 7)
|
||||
expect(result).toBe(`function one() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
function two() {
|
||||
return 2;
|
||||
}
|
||||
|
||||
function three() {
|
||||
return "three";
|
||||
}`)
|
||||
})
|
||||
|
||||
it('should not find matches outside search range and buffer zone', () => {
|
||||
const originalContent = `
|
||||
function one() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
function two() {
|
||||
return 2;
|
||||
}
|
||||
|
||||
function three() {
|
||||
return 3;
|
||||
}
|
||||
|
||||
function four() {
|
||||
return 4;
|
||||
}
|
||||
|
||||
function five() {
|
||||
return 5;
|
||||
}
|
||||
`.trim()
|
||||
const diffContent = `test.ts
|
||||
<<<<<<< SEARCH
|
||||
function five() {
|
||||
return 5;
|
||||
}
|
||||
=======
|
||||
function five() {
|
||||
return "five";
|
||||
}
|
||||
>>>>>>> REPLACE`
|
||||
|
||||
// Searching around function two() (lines 5-7)
|
||||
// function five() is more than 5 lines away, so it shouldn't match
|
||||
const result = strategy.applyDiff(originalContent, diffContent, 5, 7)
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle search range at start of file', () => {
|
||||
const originalContent = `
|
||||
function one() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
function two() {
|
||||
return 2;
|
||||
}
|
||||
`.trim()
|
||||
const diffContent = `test.ts
|
||||
<<<<<<< SEARCH
|
||||
function one() {
|
||||
return 1;
|
||||
}
|
||||
=======
|
||||
function one() {
|
||||
return "one";
|
||||
}
|
||||
>>>>>>> REPLACE`
|
||||
|
||||
const result = strategy.applyDiff(originalContent, diffContent, 1, 3)
|
||||
expect(result).toBe(`function one() {
|
||||
return "one";
|
||||
}
|
||||
|
||||
function two() {
|
||||
return 2;
|
||||
}`)
|
||||
})
|
||||
|
||||
it('should handle search range at end of file', () => {
|
||||
const originalContent = `
|
||||
function one() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
function two() {
|
||||
return 2;
|
||||
}
|
||||
`.trim()
|
||||
const diffContent = `test.ts
|
||||
<<<<<<< SEARCH
|
||||
function two() {
|
||||
return 2;
|
||||
}
|
||||
=======
|
||||
function two() {
|
||||
return "two";
|
||||
}
|
||||
>>>>>>> REPLACE`
|
||||
|
||||
const result = strategy.applyDiff(originalContent, diffContent, 5, 7)
|
||||
expect(result).toBe(`function one() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
function two() {
|
||||
return "two";
|
||||
}`)
|
||||
})
|
||||
|
||||
it('should match specific instance of duplicate code using line numbers', () => {
|
||||
const originalContent = `
|
||||
function processData(data) {
|
||||
return data.map(x => x * 2);
|
||||
}
|
||||
|
||||
function unrelatedStuff() {
|
||||
console.log("hello");
|
||||
}
|
||||
|
||||
// Another data processor
|
||||
function processData(data) {
|
||||
return data.map(x => x * 2);
|
||||
}
|
||||
|
||||
function moreStuff() {
|
||||
console.log("world");
|
||||
}
|
||||
`.trim()
|
||||
const diffContent = `test.ts
|
||||
<<<<<<< SEARCH
|
||||
function processData(data) {
|
||||
return data.map(x => x * 2);
|
||||
}
|
||||
=======
|
||||
function processData(data) {
|
||||
// Add logging
|
||||
console.log("Processing data...");
|
||||
return data.map(x => x * 2);
|
||||
}
|
||||
>>>>>>> REPLACE`
|
||||
|
||||
// Target the second instance of processData
|
||||
const result = strategy.applyDiff(originalContent, diffContent, 10, 12)
|
||||
expect(result).toBe(`function processData(data) {
|
||||
return data.map(x => x * 2);
|
||||
}
|
||||
|
||||
function unrelatedStuff() {
|
||||
console.log("hello");
|
||||
}
|
||||
|
||||
// Another data processor
|
||||
function processData(data) {
|
||||
// Add logging
|
||||
console.log("Processing data...");
|
||||
return data.map(x => x * 2);
|
||||
}
|
||||
|
||||
function moreStuff() {
|
||||
console.log("world");
|
||||
}`)
|
||||
})
|
||||
|
||||
it('should search from start line to end of file when only start_line is provided', () => {
|
||||
const originalContent = `
|
||||
function one() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
function two() {
|
||||
return 2;
|
||||
}
|
||||
|
||||
function three() {
|
||||
return 3;
|
||||
}
|
||||
`.trim()
|
||||
const diffContent = `test.ts
|
||||
<<<<<<< SEARCH
|
||||
function three() {
|
||||
return 3;
|
||||
}
|
||||
=======
|
||||
function three() {
|
||||
return "three";
|
||||
}
|
||||
>>>>>>> REPLACE`
|
||||
|
||||
// Only provide start_line, should search from there to end of file
|
||||
const result = strategy.applyDiff(originalContent, diffContent, 8)
|
||||
expect(result).toBe(`function one() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
function two() {
|
||||
return 2;
|
||||
}
|
||||
|
||||
function three() {
|
||||
return "three";
|
||||
}`)
|
||||
})
|
||||
|
||||
it('should search from start of file to end line when only end_line is provided', () => {
|
||||
const originalContent = `
|
||||
function one() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
function two() {
|
||||
return 2;
|
||||
}
|
||||
|
||||
function three() {
|
||||
return 3;
|
||||
}
|
||||
`.trim()
|
||||
const diffContent = `test.ts
|
||||
<<<<<<< SEARCH
|
||||
function one() {
|
||||
return 1;
|
||||
}
|
||||
=======
|
||||
function one() {
|
||||
return "one";
|
||||
}
|
||||
>>>>>>> REPLACE`
|
||||
|
||||
// Only provide end_line, should search from start of file to there
|
||||
const result = strategy.applyDiff(originalContent, diffContent, undefined, 4)
|
||||
expect(result).toBe(`function one() {
|
||||
return "one";
|
||||
}
|
||||
|
||||
function two() {
|
||||
return 2;
|
||||
}
|
||||
|
||||
function three() {
|
||||
return 3;
|
||||
}`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getToolDescription', () => {
|
||||
let strategy: SearchReplaceDiffStrategy
|
||||
|
||||
beforeEach(() => {
|
||||
strategy = new SearchReplaceDiffStrategy()
|
||||
})
|
||||
|
||||
it('should include the current working directory', () => {
|
||||
const cwd = '/test/dir'
|
||||
const description = strategy.getToolDescription(cwd)
|
||||
@@ -517,5 +635,11 @@ export function addLineNumbers(content: string, startLine: number = 1): string {
|
||||
expect(description).toContain('<apply_diff>')
|
||||
expect(description).toContain('</apply_diff>')
|
||||
})
|
||||
|
||||
it('should document start_line and end_line parameters', () => {
|
||||
const description = strategy.getToolDescription('/test')
|
||||
expect(description).toContain('start_line: (required) The line number where the search block starts.')
|
||||
expect(description).toContain('end_line: (required) The line number where the search block ends.')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,44 +1,96 @@
|
||||
import { DiffStrategy } from "../types"
|
||||
|
||||
function levenshteinDistance(a: string, b: string): number {
|
||||
const matrix: number[][] = [];
|
||||
|
||||
// Initialize matrix
|
||||
for (let i = 0; i <= a.length; i++) {
|
||||
matrix[i] = [i];
|
||||
}
|
||||
for (let j = 0; j <= b.length; j++) {
|
||||
matrix[0][j] = j;
|
||||
}
|
||||
|
||||
// Fill matrix
|
||||
for (let i = 1; i <= a.length; i++) {
|
||||
for (let j = 1; j <= b.length; j++) {
|
||||
if (a[i-1] === b[j-1]) {
|
||||
matrix[i][j] = matrix[i-1][j-1];
|
||||
} else {
|
||||
matrix[i][j] = Math.min(
|
||||
matrix[i-1][j-1] + 1, // substitution
|
||||
matrix[i][j-1] + 1, // insertion
|
||||
matrix[i-1][j] + 1 // deletion
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return matrix[a.length][b.length];
|
||||
}
|
||||
|
||||
function getSimilarity(original: string, search: string): number {
|
||||
// Normalize strings by removing extra whitespace but preserve case
|
||||
const normalizeStr = (str: string) => str.replace(/\s+/g, ' ').trim();
|
||||
|
||||
const normalizedOriginal = normalizeStr(original);
|
||||
const normalizedSearch = normalizeStr(search);
|
||||
|
||||
if (normalizedOriginal === normalizedSearch) { return 1; }
|
||||
|
||||
// Calculate Levenshtein distance
|
||||
const distance = levenshteinDistance(normalizedOriginal, normalizedSearch);
|
||||
|
||||
// Calculate similarity ratio (0 to 1, where 1 is exact match)
|
||||
const maxLength = Math.max(normalizedOriginal.length, normalizedSearch.length);
|
||||
return 1 - (distance / maxLength);
|
||||
}
|
||||
|
||||
export class SearchReplaceDiffStrategy implements DiffStrategy {
|
||||
private fuzzyThreshold: number;
|
||||
|
||||
constructor(fuzzyThreshold?: number) {
|
||||
// Default to exact matching (1.0) unless fuzzy threshold specified
|
||||
this.fuzzyThreshold = fuzzyThreshold ?? 1.0;
|
||||
}
|
||||
|
||||
getToolDescription(cwd: string): string {
|
||||
return `## apply_diff
|
||||
Description: Request to replace existing code using search and replace blocks.
|
||||
Description: Request to replace existing code using a search and replace block.
|
||||
This tool allows for precise, surgical replaces to files by specifying exactly what content to search for and what to replace it with.
|
||||
Only use this tool when you need to replace/fix existing code.
|
||||
The tool will maintain proper indentation and formatting while making changes.
|
||||
Only a single operation is allowed per tool use.
|
||||
The SEARCH section must exactly match existing content including whitespace and indentation.
|
||||
If you're not confident in the exact content to search for, use the read_file tool first to get the exact content.
|
||||
|
||||
Parameters:
|
||||
- path: (required) The path of the file to modify (relative to the current working directory ${cwd})
|
||||
- diff: (required) The search/replace blocks defining the changes.
|
||||
- diff: (required) The search/replace block defining the changes.
|
||||
- start_line: (required) The line number where the search block starts.
|
||||
- end_line: (required) The line number where the search block ends.
|
||||
|
||||
Format:
|
||||
1. First line must be the file path
|
||||
2. Followed by search/replace blocks:
|
||||
\`\`\`
|
||||
<<<<<<< SEARCH
|
||||
[exact content to find including whitespace]
|
||||
=======
|
||||
[new content to replace with]
|
||||
>>>>>>> REPLACE
|
||||
\`\`\`
|
||||
Diff format:
|
||||
\`\`\`
|
||||
<<<<<<< SEARCH
|
||||
[exact content to find including whitespace]
|
||||
=======
|
||||
[new content to replace with]
|
||||
>>>>>>> REPLACE
|
||||
\`\`\`
|
||||
|
||||
Example:
|
||||
|
||||
Original file:
|
||||
\`\`\`
|
||||
def calculate_total(items):
|
||||
total = 0
|
||||
for item in items:
|
||||
total += item
|
||||
return total
|
||||
1 | def calculate_total(items):
|
||||
2 | total = 0
|
||||
3 | for item in items:
|
||||
4 | total += item
|
||||
5 | return total
|
||||
\`\`\`
|
||||
|
||||
Search/Replace content:
|
||||
\`\`\`
|
||||
main.py
|
||||
<<<<<<< SEARCH
|
||||
def calculate_total(items):
|
||||
total = 0
|
||||
@@ -58,10 +110,12 @@ Usage:
|
||||
<diff>
|
||||
Your search/replace content here
|
||||
</diff>
|
||||
<start_line>1</start_line>
|
||||
<end_line>5</end_line>
|
||||
</apply_diff>`
|
||||
}
|
||||
|
||||
applyDiff(originalContent: string, diffContent: string): string | false {
|
||||
applyDiff(originalContent: string, diffContent: string, startLine?: number, endLine?: number): string | false {
|
||||
// Extract the search and replace blocks
|
||||
const match = diffContent.match(/<<<<<<< SEARCH\n([\s\S]*?)\n=======\n([\s\S]*?)\n>>>>>>> REPLACE/);
|
||||
if (!match) {
|
||||
@@ -74,34 +128,42 @@ Your search/replace content here
|
||||
const lineEnding = originalContent.includes('\r\n') ? '\r\n' : '\n';
|
||||
|
||||
// Split content into lines, handling both \n and \r\n
|
||||
const searchLines = searchContent.trim().split(/\r?\n/);
|
||||
const replaceLines = replaceContent.trim().split(/\r?\n/);
|
||||
const searchLines = searchContent.split(/\r?\n/);
|
||||
const replaceLines = replaceContent.split(/\r?\n/);
|
||||
const originalLines = originalContent.split(/\r?\n/);
|
||||
|
||||
// Find the search content in the original
|
||||
let matchIndex = -1;
|
||||
// Determine search range based on provided line numbers
|
||||
let searchStartIndex = 0;
|
||||
let searchEndIndex = originalLines.length;
|
||||
|
||||
for (let i = 0; i <= originalLines.length - searchLines.length; i++) {
|
||||
let found = true;
|
||||
|
||||
for (let j = 0; j < searchLines.length; j++) {
|
||||
const originalLine = originalLines[i + j];
|
||||
const searchLine = searchLines[j];
|
||||
|
||||
// Compare lines after removing leading/trailing whitespace
|
||||
if (originalLine.trim() !== searchLine.trim()) {
|
||||
found = false;
|
||||
break;
|
||||
}
|
||||
if (startLine !== undefined || endLine !== undefined) {
|
||||
// Convert to 0-based index and add buffer
|
||||
if (startLine !== undefined) {
|
||||
searchStartIndex = Math.max(0, startLine - 6);
|
||||
}
|
||||
|
||||
if (found) {
|
||||
matchIndex = i;
|
||||
break;
|
||||
if (endLine !== undefined) {
|
||||
searchEndIndex = Math.min(originalLines.length, endLine + 5);
|
||||
}
|
||||
}
|
||||
|
||||
if (matchIndex === -1) {
|
||||
// Find the search content in the original using fuzzy matching
|
||||
let matchIndex = -1;
|
||||
let bestMatchScore = 0;
|
||||
|
||||
for (let i = searchStartIndex; i <= searchEndIndex - searchLines.length; i++) {
|
||||
// Join the lines and calculate overall similarity
|
||||
const originalChunk = originalLines.slice(i, i + searchLines.length).join('\n');
|
||||
const searchChunk = searchLines.join('\n');
|
||||
|
||||
const similarity = getSimilarity(originalChunk, searchChunk);
|
||||
if (similarity > bestMatchScore) {
|
||||
bestMatchScore = similarity;
|
||||
matchIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
// Require similarity to meet threshold
|
||||
if (matchIndex === -1 || bestMatchScore < this.fuzzyThreshold) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -121,54 +183,26 @@ Your search/replace content here
|
||||
});
|
||||
|
||||
// Apply the replacement while preserving exact indentation
|
||||
const indentedReplace = replaceLines.map((line, i) => {
|
||||
// Get the corresponding original and search indentations
|
||||
const originalIndent = originalIndents[Math.min(i, originalIndents.length - 1)];
|
||||
const searchIndent = searchIndents[Math.min(i, searchIndents.length - 1)];
|
||||
const indentedReplaceLines = replaceLines.map((line, i) => {
|
||||
// Get the matched line's exact indentation
|
||||
const matchedIndent = originalIndents[0];
|
||||
|
||||
// Get the current line's indentation
|
||||
// Get the current line's indentation relative to the search content
|
||||
const currentIndentMatch = line.match(/^[\t ]*/);
|
||||
const currentIndent = currentIndentMatch ? currentIndentMatch[0] : '';
|
||||
const searchBaseIndent = searchIndents[0] || '';
|
||||
|
||||
// If this line has the same indentation level as the search block,
|
||||
// use the original indentation. Otherwise, calculate the difference
|
||||
// and preserve the exact type of whitespace characters
|
||||
if (currentIndent.length === searchIndent.length) {
|
||||
return originalIndent + line.trim();
|
||||
} else {
|
||||
// Get the corresponding search line's indentation
|
||||
const searchLineIndex = Math.min(i, searchLines.length - 1);
|
||||
const searchLineIndent = searchIndents[searchLineIndex];
|
||||
// Calculate the relative indentation from the search content
|
||||
const relativeIndent = currentIndent.slice(searchBaseIndent.length);
|
||||
|
||||
// Get the corresponding original line's indentation
|
||||
const originalLineIndex = Math.min(i, originalIndents.length - 1);
|
||||
const originalLineIndent = originalIndents[originalLineIndex];
|
||||
|
||||
// If this line has the same indentation as its corresponding search line,
|
||||
// use the original indentation
|
||||
if (currentIndent === searchLineIndent) {
|
||||
return originalLineIndent + line.trim();
|
||||
}
|
||||
|
||||
// Otherwise, preserve the original indentation structure
|
||||
const indentChar = originalLineIndent.charAt(0) || '\t';
|
||||
const indentLevel = Math.floor(originalLineIndent.length / indentChar.length);
|
||||
|
||||
// Calculate the relative indentation from the search line
|
||||
const searchLevel = Math.floor(searchLineIndent.length / indentChar.length);
|
||||
const currentLevel = Math.floor(currentIndent.length / indentChar.length);
|
||||
const relativeLevel = currentLevel - searchLevel;
|
||||
|
||||
// Apply the relative indentation to the original level
|
||||
const targetLevel = Math.max(0, indentLevel + relativeLevel);
|
||||
return indentChar.repeat(targetLevel) + line.trim();
|
||||
}
|
||||
// Apply the matched indentation plus any relative indentation
|
||||
return matchedIndent + relativeIndent + line.trim();
|
||||
});
|
||||
|
||||
// Construct the final content
|
||||
const beforeMatch = originalLines.slice(0, matchIndex);
|
||||
const afterMatch = originalLines.slice(matchIndex + searchLines.length);
|
||||
|
||||
return [...beforeMatch, ...indentedReplace, ...afterMatch].join(lineEnding);
|
||||
return [...beforeMatch, ...indentedReplaceLines, ...afterMatch].join(lineEnding);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@ export interface DiffStrategy {
|
||||
* Apply a diff to the original content
|
||||
* @param originalContent The original file content
|
||||
* @param diffContent The diff content in the strategy's format
|
||||
* @param startLine Optional line number where the search block starts. If not provided, searches the entire file.
|
||||
* @param endLine Optional line number where the search block ends. If not provided, searches the entire file.
|
||||
* @returns The new content after applying the diff, or false if the diff could not be applied
|
||||
*/
|
||||
applyDiff(originalContent: string, diffContent: string): string | false
|
||||
applyDiff(originalContent: string, diffContent: string, startLine?: number, endLine?: number): string | false
|
||||
}
|
||||
|
||||
@@ -28,6 +28,9 @@ Otherwise, if you have not completed the task and do not need additional informa
|
||||
missingToolParameterError: (paramName: string) =>
|
||||
`Missing value for required parameter '${paramName}'. Please retry with complete response.\n\n${toolUseInstructionsReminder}`,
|
||||
|
||||
invalidMcpToolArgumentError: (serverName: string, toolName: string) =>
|
||||
`Invalid JSON argument used with ${serverName} for ${toolName}. Please retry with a properly formatted JSON argument.`,
|
||||
|
||||
toolResult: (
|
||||
text: string,
|
||||
images?: string[],
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import osName from "os-name"
|
||||
import { DiffStrategy } from "../diff/DiffStrategy"
|
||||
import defaultShell from "default-shell"
|
||||
import os from "os"
|
||||
import osName from "os-name"
|
||||
import fs from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { DiffStrategy } from "../diff/DiffStrategy"
|
||||
import { McpHub } from "../../services/mcp/McpHub"
|
||||
|
||||
export const SYSTEM_PROMPT = async (
|
||||
cwd: string,
|
||||
supportsComputerUse: boolean,
|
||||
mcpHub: McpHub,
|
||||
diffStrategy?: DiffStrategy
|
||||
) => `You are Cline, a highly skilled software engineer with extensive knowledge in many programming languages, frameworks, design patterns, and best practices.
|
||||
|
||||
@@ -141,6 +143,35 @@ Usage:
|
||||
: ""
|
||||
}
|
||||
|
||||
## use_mcp_tool
|
||||
Description: Request to use a tool provided by a connected MCP server. Each MCP server can provide multiple tools with different capabilities. Tools have defined input schemas that specify required and optional parameters.
|
||||
Parameters:
|
||||
- server_name: (required) The name of the MCP server providing the tool
|
||||
- tool_name: (required) The name of the tool to execute
|
||||
- arguments: (required) A JSON object containing the tool's input parameters, following the tool's input schema
|
||||
Usage:
|
||||
<use_mcp_tool>
|
||||
<server_name>server name here</server_name>
|
||||
<tool_name>tool name here</tool_name>
|
||||
<arguments>
|
||||
{
|
||||
"param1": "value1",
|
||||
"param2": "value2"
|
||||
}
|
||||
</arguments>
|
||||
</use_mcp_tool>
|
||||
|
||||
## access_mcp_resource
|
||||
Description: Request to access a resource provided by a connected MCP server. Resources represent data sources that can be used as context, such as files, API responses, or system information.
|
||||
Parameters:
|
||||
- server_name: (required) The name of the MCP server providing the resource
|
||||
- uri: (required) The URI identifying the specific resource to access
|
||||
Usage:
|
||||
<access_mcp_resource>
|
||||
<server_name>server name here</server_name>
|
||||
<uri>resource URI here</uri>
|
||||
</access_mcp_resource>
|
||||
|
||||
## ask_followup_question
|
||||
Description: Ask the user a question to gather additional information needed to complete the task. This tool should be used when you encounter ambiguities, need clarification, or require more details to proceed effectively. It allows for interactive problem-solving by enabling direct communication with the user. Use this tool judiciously to maintain a balance between gathering necessary information and avoiding excessive back-and-forth.
|
||||
Parameters:
|
||||
@@ -194,6 +225,26 @@ Your final result description here
|
||||
</content>
|
||||
</write_to_file>
|
||||
|
||||
## Example 3: Requesting to use an MCP tool
|
||||
|
||||
<use_mcp_tool>
|
||||
<server_name>weather-server</server_name>
|
||||
<tool_name>get_forecast</tool_name>
|
||||
<arguments>
|
||||
{
|
||||
"city": "San Francisco",
|
||||
"days": 5
|
||||
}
|
||||
</arguments>
|
||||
</use_mcp_tool>
|
||||
|
||||
## Example 4: Requesting to access an MCP resource
|
||||
|
||||
<access_mcp_resource>
|
||||
<server_name>weather-server</server_name>
|
||||
<uri>weather://san-francisco/current</uri>
|
||||
</access_mcp_resource>
|
||||
|
||||
# Tool Use Guidelines
|
||||
|
||||
1. In <thinking> tags, assess what information you already have and what information you need to proceed with the task.
|
||||
@@ -217,6 +268,413 @@ By waiting for and carefully considering the user's response after each tool use
|
||||
|
||||
====
|
||||
|
||||
MCP SERVERS
|
||||
|
||||
The Model Context Protocol (MCP) enables communication between the system and locally running MCP servers that provide additional tools and resources to extend your capabilities.
|
||||
|
||||
# Connected MCP Servers
|
||||
|
||||
When a server is connected, you can use the server's tools via the \`use_mcp_tool\` tool, and access the server's resources via the \`access_mcp_resource\` tool.
|
||||
|
||||
${
|
||||
mcpHub.getServers().length > 0
|
||||
? `${mcpHub
|
||||
.getServers()
|
||||
.filter((server) => server.status === "connected")
|
||||
.map((server) => {
|
||||
const tools = server.tools
|
||||
?.map((tool) => {
|
||||
const schemaStr = tool.inputSchema
|
||||
? ` Input Schema:
|
||||
${JSON.stringify(tool.inputSchema, null, 2).split("\n").join("\n ")}`
|
||||
: ""
|
||||
|
||||
return `- ${tool.name}: ${tool.description}\n${schemaStr}`
|
||||
})
|
||||
.join("\n\n")
|
||||
|
||||
const templates = server.resourceTemplates
|
||||
?.map((template) => `- ${template.uriTemplate} (${template.name}): ${template.description}`)
|
||||
.join("\n")
|
||||
|
||||
const resources = server.resources
|
||||
?.map((resource) => `- ${resource.uri} (${resource.name}): ${resource.description}`)
|
||||
.join("\n")
|
||||
|
||||
const config = JSON.parse(server.config)
|
||||
|
||||
return (
|
||||
`## ${server.name} (\`${config.command}${config.args && Array.isArray(config.args) ? ` ${config.args.join(" ")}` : ""}\`)` +
|
||||
(tools ? `\n\n### Available Tools\n${tools}` : "") +
|
||||
(templates ? `\n\n### Resource Templates\n${templates}` : "") +
|
||||
(resources ? `\n\n### Direct Resources\n${resources}` : "")
|
||||
)
|
||||
})
|
||||
.join("\n\n")}`
|
||||
: "(No MCP servers currently connected)"
|
||||
}
|
||||
|
||||
## Creating an MCP Server
|
||||
|
||||
The user may ask you something along the lines of "add a tool" that does some function, in other words to create an MCP server that provides tools and resources that may connect to external APIs for example. You have the ability to create an MCP server and add it to a configuration file that will then expose the tools and resources for you to use with \`use_mcp_tool\` and \`access_mcp_resource\`.
|
||||
|
||||
When creating MCP servers, it's important to understand that they operate in a non-interactive environment. The server cannot initiate OAuth flows, open browser windows, or prompt for user input during runtime. All credentials and authentication tokens must be provided upfront through environment variables in the MCP settings configuration. For example, Spotify's API uses OAuth to get a refresh token for the user, but the MCP server cannot initiate this flow. While you can walk the user through obtaining an application client ID and secret, you may have to create a separate one-time setup script (like get-refresh-token.js) that captures and logs the final piece of the puzzle: the user's refresh token (i.e. you might run the script using execute_command which would open a browser for authentication, and then log the refresh token so that you can see it in the command output for you to use in the MCP settings configuration).
|
||||
|
||||
Unless the user specifies otherwise, new MCP servers should be created in: ${await mcpHub.getMcpServersPath()}
|
||||
|
||||
### Example MCP Server
|
||||
|
||||
For example, if the user wanted to give you the ability to retrieve weather information, you could create an MCP server that uses the OpenWeather API to get weather information, add it to the MCP settings configuration file, and then notice that you now have access to new tools and resources in the system prompt that you might use to show the user your new capabilities.
|
||||
|
||||
The following example demonstrates how to build an MCP server that provides weather data functionality. While this example shows how to implement resources, resource templates, and tools, in practice you should prefer using tools since they are more flexible and can handle dynamic parameters. The resource and resource template implementations are included here mainly for demonstration purposes of the different MCP capabilities, but a real weather server would likely just expose tools for fetching weather data. (The following steps are for macOS)
|
||||
|
||||
1. Use the \`create-typescript-server\` tool to bootstrap a new project in the default MCP servers directory:
|
||||
|
||||
\`\`\`bash
|
||||
cd ${await mcpHub.getMcpServersPath()}
|
||||
npx @modelcontextprotocol/create-server weather-server
|
||||
cd weather-server
|
||||
# Install dependencies
|
||||
npm install axios
|
||||
\`\`\`
|
||||
|
||||
This will create a new project with the following structure:
|
||||
|
||||
\`\`\`
|
||||
weather-server/
|
||||
├── package.json
|
||||
{
|
||||
...
|
||||
"type": "module", // added by default, uses ES module syntax (import/export) rather than CommonJS (require/module.exports) (Important to know if you create additional scripts in this server repository like a get-refresh-token.js script)
|
||||
"scripts": {
|
||||
"build": "tsc && node -e \"require('fs').chmodSync('build/index.js', '755')\"",
|
||||
...
|
||||
}
|
||||
...
|
||||
}
|
||||
├── tsconfig.json
|
||||
└── src/
|
||||
└── weather-server/
|
||||
└── index.ts # Main server implementation
|
||||
\`\`\`
|
||||
|
||||
2. Replace \`src/index.ts\` with the following:
|
||||
|
||||
\`\`\`typescript
|
||||
#!/usr/bin/env node
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ErrorCode,
|
||||
ListResourcesRequestSchema,
|
||||
ListResourceTemplatesRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
McpError,
|
||||
ReadResourceRequestSchema,
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
import axios from 'axios';
|
||||
|
||||
const API_KEY = process.env.OPENWEATHER_API_KEY; // provided by MCP config
|
||||
if (!API_KEY) {
|
||||
throw new Error('OPENWEATHER_API_KEY environment variable is required');
|
||||
}
|
||||
|
||||
interface OpenWeatherResponse {
|
||||
main: {
|
||||
temp: number;
|
||||
humidity: number;
|
||||
};
|
||||
weather: [{ description: string }];
|
||||
wind: { speed: number };
|
||||
dt_txt?: string;
|
||||
}
|
||||
|
||||
const isValidForecastArgs = (
|
||||
args: any
|
||||
): args is { city: string; days?: number } =>
|
||||
typeof args === 'object' &&
|
||||
args !== null &&
|
||||
typeof args.city === 'string' &&
|
||||
(args.days === undefined || typeof args.days === 'number');
|
||||
|
||||
class WeatherServer {
|
||||
private server: Server;
|
||||
private axiosInstance;
|
||||
|
||||
constructor() {
|
||||
this.server = new Server(
|
||||
{
|
||||
name: 'example-weather-server',
|
||||
version: '0.1.0',
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
resources: {},
|
||||
tools: {},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
this.axiosInstance = axios.create({
|
||||
baseURL: 'http://api.openweathermap.org/data/2.5',
|
||||
params: {
|
||||
appid: API_KEY,
|
||||
units: 'metric',
|
||||
},
|
||||
});
|
||||
|
||||
this.setupResourceHandlers();
|
||||
this.setupToolHandlers();
|
||||
|
||||
// Error handling
|
||||
this.server.onerror = (error) => console.error('[MCP Error]', error);
|
||||
process.on('SIGINT', async () => {
|
||||
await this.server.close();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
// MCP Resources represent any kind of UTF-8 encoded data that an MCP server wants to make available to clients, such as database records, API responses, log files, and more. Servers define direct resources with a static URI or dynamic resources with a URI template that follows the format \`[protocol]://[host]/[path]\`.
|
||||
private setupResourceHandlers() {
|
||||
// For static resources, servers can expose a list of resources:
|
||||
this.server.setRequestHandler(ListResourcesRequestSchema, async () => ({
|
||||
resources: [
|
||||
// This is a poor example since you could use the resource template to get the same information but this demonstrates how to define a static resource
|
||||
{
|
||||
uri: \`weather://San Francisco/current\`, // Unique identifier for San Francisco weather resource
|
||||
name: \`Current weather in San Francisco\`, // Human-readable name
|
||||
mimeType: 'application/json', // Optional MIME type
|
||||
// Optional description
|
||||
description:
|
||||
'Real-time weather data for San Francisco including temperature, conditions, humidity, and wind speed',
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
// For dynamic resources, servers can expose resource templates:
|
||||
this.server.setRequestHandler(
|
||||
ListResourceTemplatesRequestSchema,
|
||||
async () => ({
|
||||
resourceTemplates: [
|
||||
{
|
||||
uriTemplate: 'weather://{city}/current', // URI template (RFC 6570)
|
||||
name: 'Current weather for a given city', // Human-readable name
|
||||
mimeType: 'application/json', // Optional MIME type
|
||||
description: 'Real-time weather data for a specified city', // Optional description
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
// ReadResourceRequestSchema is used for both static resources and dynamic resource templates
|
||||
this.server.setRequestHandler(
|
||||
ReadResourceRequestSchema,
|
||||
async (request) => {
|
||||
const match = request.params.uri.match(
|
||||
/^weather:\/\/([^/]+)\/current$/
|
||||
);
|
||||
if (!match) {
|
||||
throw new McpError(
|
||||
ErrorCode.InvalidRequest,
|
||||
\`Invalid URI format: \${request.params.uri}\`
|
||||
);
|
||||
}
|
||||
const city = decodeURIComponent(match[1]);
|
||||
|
||||
try {
|
||||
const response = await this.axiosInstance.get(
|
||||
'weather', // current weather
|
||||
{
|
||||
params: { q: city },
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
uri: request.params.uri,
|
||||
mimeType: 'application/json',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
temperature: response.data.main.temp,
|
||||
conditions: response.data.weather[0].description,
|
||||
humidity: response.data.main.humidity,
|
||||
wind_speed: response.data.wind.speed,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
throw new McpError(
|
||||
ErrorCode.InternalError,
|
||||
\`Weather API error: \${
|
||||
error.response?.data.message ?? error.message
|
||||
}\`
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/* MCP Tools enable servers to expose executable functionality to the system. Through these tools, you can interact with external systems, perform computations, and take actions in the real world.
|
||||
* - Like resources, tools are identified by unique names and can include descriptions to guide their usage. However, unlike resources, tools represent dynamic operations that can modify state or interact with external systems.
|
||||
* - While resources and tools are similar, you should prefer to create tools over resources when possible as they provide more flexibility.
|
||||
*/
|
||||
private setupToolHandlers() {
|
||||
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||
tools: [
|
||||
{
|
||||
name: 'get_forecast', // Unique identifier
|
||||
description: 'Get weather forecast for a city', // Human-readable description
|
||||
inputSchema: {
|
||||
// JSON Schema for parameters
|
||||
type: 'object',
|
||||
properties: {
|
||||
city: {
|
||||
type: 'string',
|
||||
description: 'City name',
|
||||
},
|
||||
days: {
|
||||
type: 'number',
|
||||
description: 'Number of days (1-5)',
|
||||
minimum: 1,
|
||||
maximum: 5,
|
||||
},
|
||||
},
|
||||
required: ['city'], // Array of required property names
|
||||
},
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
if (request.params.name !== 'get_forecast') {
|
||||
throw new McpError(
|
||||
ErrorCode.MethodNotFound,
|
||||
\`Unknown tool: \${request.params.name}\`
|
||||
);
|
||||
}
|
||||
|
||||
if (!isValidForecastArgs(request.params.arguments)) {
|
||||
throw new McpError(
|
||||
ErrorCode.InvalidParams,
|
||||
'Invalid forecast arguments'
|
||||
);
|
||||
}
|
||||
|
||||
const city = request.params.arguments.city;
|
||||
const days = Math.min(request.params.arguments.days || 3, 5);
|
||||
|
||||
try {
|
||||
const response = await this.axiosInstance.get<{
|
||||
list: OpenWeatherResponse[];
|
||||
}>('forecast', {
|
||||
params: {
|
||||
q: city,
|
||||
cnt: days * 8,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(response.data.list, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: \`Weather API error: \${
|
||||
error.response?.data.message ?? error.message
|
||||
}\`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async run() {
|
||||
const transport = new StdioServerTransport();
|
||||
await this.server.connect(transport);
|
||||
console.error('Weather MCP server running on stdio');
|
||||
}
|
||||
}
|
||||
|
||||
const server = new WeatherServer();
|
||||
server.run().catch(console.error);
|
||||
\`\`\`
|
||||
|
||||
(Remember: This is just an example–you may use different dependencies, break the implementation up into multiple files, etc.)
|
||||
|
||||
3. Build and compile the executable JavaScript file
|
||||
|
||||
\`\`\`bash
|
||||
npm run build
|
||||
\`\`\`
|
||||
|
||||
4. Whenever you need an environment variable such as an API key to configure the MCP server, walk the user through the process of getting the key. For example, they may need to create an account and go to a developer dashboard to generate the key. Provide step-by-step instructions and URLs to make it easy for the user to retrieve the necessary information. Then use the ask_followup_question tool to ask the user for the key, in this case the OpenWeather API key.
|
||||
|
||||
5. Install the MCP Server by adding the MCP server configuration to the settings file located at '${await mcpHub.getMcpSettingsFilePath()}'. The settings file may have other MCP servers already configured, so you would read it first and then add your new server to the existing \`mcpServers\` object.
|
||||
|
||||
IMPORTANT: Regardless of what else you see in the MCP settings file, you must default any new MCP servers you create to disabled=false and alwaysAllow=[].
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"mcpServers": {
|
||||
...,
|
||||
"weather": {
|
||||
"command": "node",
|
||||
"args": ["/path/to/weather-server/build/index.js"],
|
||||
"env": {
|
||||
"OPENWEATHER_API_KEY": "user-provided-api-key"
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
(Note: the user may also ask you to install the MCP server to the Claude desktop app, in which case you would read then modify \`~/Library/Application\ Support/Claude/claude_desktop_config.json\` on macOS for example. It follows the same format of a top level \`mcpServers\` object.)
|
||||
|
||||
6. After you have edited the MCP settings configuration file, the system will automatically run all the servers and expose the available tools and resources in the 'Connected MCP Servers' section.
|
||||
|
||||
7. Now that you have access to these new tools and resources, you may suggest ways the user can command you to invoke them - for example, with this new weather tool now available, you can invite the user to ask "what's the weather in San Francisco?"
|
||||
|
||||
## Editing MCP Servers
|
||||
|
||||
The user may ask to add tools or resources that may make sense to add to an existing MCP server (listed under 'Connected MCP Servers' above: ${
|
||||
mcpHub
|
||||
.getServers()
|
||||
.map((server) => server.name)
|
||||
.join(", ") || "(None running currently)"
|
||||
}, e.g. if it would use the same API. This would be possible if you can locate the MCP server repository on the user's system by looking at the server arguments for a filepath. You might then use list_files and read_file to explore the files in the repository, and use write_to_file ${diffStrategy ? "or apply_diff " : ""}to make changes to the files.
|
||||
|
||||
However some MCP servers may be running from installed packages rather than a local repository, in which case it may make more sense to create a new MCP server.
|
||||
|
||||
# MCP Servers Are Not Always Necessary
|
||||
|
||||
The user may not always request the use or creation of MCP servers. Instead, they might provide tasks that can be completed with existing tools. While using the MCP SDK to extend your capabilities can be useful, it's important to understand that this is just one specialized type of task you can accomplish. You should only implement MCP servers when the user explicitly requests it (e.g., "add a tool that...").
|
||||
|
||||
Remember: The MCP documentation and example provided above are to help you understand and work with existing MCP servers or create new ones when requested by the user. You already have access to tools and capabilities that can be used to accomplish a wide range of tasks.
|
||||
|
||||
====
|
||||
|
||||
CAPABILITIES
|
||||
|
||||
- You have access to tools that let you execute CLI commands on the user's computer, list files, view source code definitions, regex search${
|
||||
@@ -231,6 +689,7 @@ CAPABILITIES
|
||||
? "\n- You can use the browser_action tool to interact with websites (including html files and locally running development servers) through a Puppeteer-controlled browser when you feel it is necessary in accomplishing the user's task. This tool is particularly useful for web development tasks as it allows you to launch a browser, navigate to pages, interact with elements through clicks and keyboard input, and capture the results through screenshots and console logs. This tool may be useful at key stages of web development tasks-such as after implementing new features, making substantial changes, when troubleshooting issues, or to verify the result of your work. You can analyze the provided screenshots to ensure correct rendering or identify errors, and review console logs for runtime issues.\n - For example, if asked to add a component to a react website, you might create the necessary files, use execute_command to run the site locally, then use browser_action to launch the browser, navigate to the local server, and verify the component renders & functions correctly before closing the browser."
|
||||
: ""
|
||||
}
|
||||
- You have access to MCP servers that may provide additional tools and resources. Each server may provide different capabilities that you can use to accomplish tasks more effectively.
|
||||
|
||||
====
|
||||
|
||||
@@ -242,17 +701,16 @@ RULES
|
||||
- Before using the execute_command tool, you must first think about the SYSTEM INFORMATION context provided to understand the user's environment and tailor your commands to ensure they are compatible with their system. You must also consider if the command you need to run should be executed in a specific directory outside of the current working directory '${cwd.toPosix()}', and if so prepend with \`cd\`'ing into that directory && then executing the command (as one command since you are stuck operating from '${cwd.toPosix()}'). For example, if you needed to run \`npm install\` in a project outside of '${cwd.toPosix()}', you would need to prepend with a \`cd\` i.e. pseudocode for this would be \`cd (path to project) && (command, in this case npm install)\`.
|
||||
- When using the search_files tool, craft your regex patterns carefully to balance specificity and flexibility. Based on the user's task you may use it to find code patterns, TODO comments, function definitions, or any text-based information across the project. The results include context, so analyze the surrounding code to better understand the matches. Leverage the search_files tool in combination with other tools for more comprehensive analysis. For example, use it to find specific code patterns, then use read_file to examine the full context of interesting matches before using write_to_file to make informed changes.
|
||||
- When creating a new project (such as an app, website, or any software project), organize all new files within a dedicated project directory unless the user specifies otherwise. Use appropriate file paths when writing files, as the write_to_file tool will automatically create any necessary directories. Structure the project logically, adhering to best practices for the specific type of project being created. Unless otherwise specified, new projects should be easily run without additional setup, for example most projects can be built in HTML, CSS, and JavaScript - which you can open in a browser.
|
||||
${diffStrategy ? "- Prefer to use apply_diff over write_to_file when making changes to existing files, particularly when editing files more than 200 lines of code, as it allows you to apply specific modifications based on a set of changes provided in a diff. This is particularly useful when you need to make targeted edits or updates to a file without overwriting the entire content." : ""}
|
||||
${diffStrategy ? "- You should use apply_diff instead of write_to_file when making changes to existing files since it is much faster and easier to apply a diff than to write the entire file again. Only use write_to_file to edit files when apply_diff has failed repeatedly to apply the diff." : "- When you want to modify a file, use the write_to_file tool directly with the desired content. You do not need to display the content before using the tool."}
|
||||
- Be sure to consider the type of project (e.g. Python, JavaScript, web application) when determining the appropriate structure and files to include. Also consider what files may be most relevant to accomplishing the task, for example looking at a project's manifest file would help you understand the project's dependencies, which you could incorporate into any code you write.
|
||||
- When making changes to code, always consider the context in which the code is being used. Ensure that your changes are compatible with the existing codebase and that they follow the project's coding standards and best practices.
|
||||
- When you want to modify a file, use the write_to_file tool directly with the desired content. You do not need to display the content before using the tool.
|
||||
- Do not ask for more information than necessary. Use the tools provided to accomplish the user's request efficiently and effectively. When you've completed your task, you must use the attempt_completion tool to present the result to the user. The user may provide feedback, which you can use to make improvements and try again.
|
||||
- You are only allowed to ask the user questions using the ask_followup_question tool. Use this tool only when you need additional details to complete a task, and be sure to use a clear and concise question that will help you move forward with the task. However if you can use the available tools to avoid having to ask the user questions, you should do so. For example, if the user mentions a file that may be in an outside directory like the Desktop, you should use the list_files tool to list the files in the Desktop and check if the file they are talking about is there, rather than asking the user to provide the file path themselves.
|
||||
- When executing commands, if you don't see the expected output, assume the terminal executed the command successfully and proceed with the task. The user's terminal may be unable to stream the output back properly. If you absolutely need to see the actual terminal output, use the ask_followup_question tool to request the user to copy and paste it back to you.
|
||||
- The user may provide a file's contents directly in their message, in which case you shouldn't use the read_file tool to get the file contents again since you already have it.
|
||||
- Your goal is to try to accomplish the user's task, NOT engage in a back and forth conversation.${
|
||||
supportsComputerUse
|
||||
? '\n- The user may ask generic non-development tasks, such as "what\'s the latest news" or "look up the weather in San Diego", in which case you might use the browser_action tool to complete the task if it makes sense to do so, rather than trying to create a website or using curl to answer the question.'
|
||||
? '\n- The user may ask generic non-development tasks, such as "what\'s the latest news" or "look up the weather in San Diego", in which case you might use the browser_action tool to complete the task if it makes sense to do so, rather than trying to create a website or using curl to answer the question. However, if an available MCP server tool or resource can be used instead, you should prefer to use it over browser_action.'
|
||||
: ""
|
||||
}
|
||||
- NEVER end attempt_completion result with a question or request to engage in further conversation! Formulate the end of your result in a way that is final and does not require further input from the user.
|
||||
@@ -261,6 +719,7 @@ ${diffStrategy ? "- Prefer to use apply_diff over write_to_file when making chan
|
||||
- At the end of each user message, you will automatically receive environment_details. This information is not written by the user themselves, but is auto-generated to provide potentially relevant context about the project structure and environment. While this information can be valuable for understanding the project context, do not treat it as a direct part of the user's request or response. Use it to inform your actions and decisions, but don't assume the user is explicitly asking about or referring to this information unless they clearly do so in their message. When using environment_details, explain your actions clearly to ensure the user understands, as they may not be aware of these details.
|
||||
- Before executing commands, check the "Actively Running Terminals" section in environment_details. If present, consider how these active processes might impact your task. For example, if a local development server is already running, you wouldn't need to start it again. If no active terminals are listed, proceed with command execution as normal.
|
||||
- When using the write_to_file tool, ALWAYS provide the COMPLETE file content in your response. This is NON-NEGOTIABLE. Partial updates or placeholders like '// rest of code unchanged' are STRICTLY FORBIDDEN. You MUST include ALL parts of the file, even if they haven't been modified. Failure to do so will result in incomplete or broken code, severely impacting the user's project.
|
||||
- MCP operations should be used one at a time, similar to other tool usage. Wait for confirmation of success before proceeding with additional operations.
|
||||
- It is critical you wait for the user's response after each tool use, in order to confirm the success of the tool use. For example, if asked to make a todo app, you would create a file, wait for the user's response it was created successfully, then create another file if needed, wait for the user's response it was created successfully, etc.${
|
||||
supportsComputerUse
|
||||
? " Then if you want to test your work, you might use browser_action to launch the site, wait for the user's response confirming the site was launched along with a screenshot, then perhaps e.g., click a button to test functionality if needed, wait for the user's response confirming the button was clicked along with a screenshot of the new state, before finally closing the browser."
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import axios from "axios"
|
||||
import fs from "fs/promises"
|
||||
import os from "os"
|
||||
import pWaitFor from "p-wait-for"
|
||||
import * as path from "path"
|
||||
import * as vscode from "vscode"
|
||||
@@ -10,6 +11,7 @@ import { openFile, openImage } from "../../integrations/misc/open-file"
|
||||
import { selectImages } from "../../integrations/misc/process-images"
|
||||
import { getTheme } from "../../integrations/theme/getTheme"
|
||||
import WorkspaceTracker from "../../integrations/workspace/WorkspaceTracker"
|
||||
import { McpHub } from "../../services/mcp/McpHub"
|
||||
import { ApiProvider, ModelInfo } from "../../shared/api"
|
||||
import { findLast } from "../../shared/array"
|
||||
import { ExtensionMessage } from "../../shared/ExtensionMessage"
|
||||
@@ -65,11 +67,13 @@ type GlobalStateKey =
|
||||
| "allowedCommands"
|
||||
| "soundEnabled"
|
||||
| "diffEnabled"
|
||||
| "alwaysAllowMcp"
|
||||
|
||||
export const GlobalFileNames = {
|
||||
apiConversationHistory: "api_conversation_history.json",
|
||||
uiMessages: "ui_messages.json",
|
||||
openRouterModels: "openrouter_models.json",
|
||||
mcpSettings: "cline_mcp_settings.json",
|
||||
}
|
||||
|
||||
export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
@@ -80,7 +84,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
private view?: vscode.WebviewView | vscode.WebviewPanel
|
||||
private cline?: Cline
|
||||
private workspaceTracker?: WorkspaceTracker
|
||||
private latestAnnouncementId = "oct-28-2024" // update to some unique identifier when we add a new announcement
|
||||
mcpHub?: McpHub
|
||||
private latestAnnouncementId = "dec-10-2024" // update to some unique identifier when we add a new announcement
|
||||
|
||||
constructor(
|
||||
readonly context: vscode.ExtensionContext,
|
||||
@@ -89,6 +94,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
this.outputChannel.appendLine("ClineProvider instantiated")
|
||||
ClineProvider.activeInstances.add(this)
|
||||
this.workspaceTracker = new WorkspaceTracker(this)
|
||||
this.mcpHub = new McpHub(this)
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -112,6 +118,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
}
|
||||
this.workspaceTracker?.dispose()
|
||||
this.workspaceTracker = undefined
|
||||
this.mcpHub?.dispose()
|
||||
this.mcpHub = undefined
|
||||
this.outputChannel.appendLine("Disposed all disposables")
|
||||
ClineProvider.activeInstances.delete(this)
|
||||
}
|
||||
@@ -449,6 +457,10 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
await this.updateGlobalState("alwaysAllowBrowser", message.bool ?? undefined)
|
||||
await this.postStateToWebview()
|
||||
break
|
||||
case "alwaysAllowMcp":
|
||||
await this.updateGlobalState("alwaysAllowMcp", message.bool)
|
||||
await this.postStateToWebview()
|
||||
break
|
||||
case "askResponse":
|
||||
this.cline?.handleWebviewAskResponse(message.askResponse!, message.text, message.images)
|
||||
break
|
||||
@@ -528,6 +540,44 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
.getConfiguration('roo-cline')
|
||||
.update('allowedCommands', message.commands, vscode.ConfigurationTarget.Global);
|
||||
break;
|
||||
case "openMcpSettings": {
|
||||
const mcpSettingsFilePath = await this.mcpHub?.getMcpSettingsFilePath()
|
||||
if (mcpSettingsFilePath) {
|
||||
openFile(mcpSettingsFilePath)
|
||||
}
|
||||
break
|
||||
}
|
||||
case "restartMcpServer": {
|
||||
try {
|
||||
await this.mcpHub?.restartConnection(message.text!)
|
||||
} catch (error) {
|
||||
console.error(`Failed to retry connection for ${message.text}:`, error)
|
||||
}
|
||||
break
|
||||
}
|
||||
case "toggleToolAlwaysAllow": {
|
||||
try {
|
||||
await this.mcpHub?.toggleToolAlwaysAllow(
|
||||
message.serverName!,
|
||||
message.toolName!,
|
||||
message.alwaysAllow!
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(`Failed to toggle auto-approve for tool ${message.toolName}:`, error)
|
||||
}
|
||||
break
|
||||
}
|
||||
case "toggleMcpServer": {
|
||||
try {
|
||||
await this.mcpHub?.toggleServerDisabled(
|
||||
message.serverName!,
|
||||
message.disabled!
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(`Failed to toggle MCP server ${message.serverName}:`, error)
|
||||
}
|
||||
break
|
||||
}
|
||||
// Add more switch case statements here as more webview message commands
|
||||
// are created within the webview context (i.e. inside media/main.js)
|
||||
case "playSound":
|
||||
@@ -563,6 +613,24 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
await this.postStateToWebview()
|
||||
}
|
||||
|
||||
// MCP
|
||||
|
||||
async ensureMcpServersDirectoryExists(): Promise<string> {
|
||||
const mcpServersDir = path.join(os.homedir(), "Documents", "Cline", "MCP")
|
||||
try {
|
||||
await fs.mkdir(mcpServersDir, { recursive: true })
|
||||
} catch (error) {
|
||||
return "~/Documents/Cline/MCP" // in case creating a directory in documents fails for whatever reason (e.g. permissions) - this is fine since this path is only ever used in the system prompt
|
||||
}
|
||||
return mcpServersDir
|
||||
}
|
||||
|
||||
async ensureSettingsDirectoryExists(): Promise<string> {
|
||||
const settingsDir = path.join(this.context.globalStorageUri.fsPath, "settings")
|
||||
await fs.mkdir(settingsDir, { recursive: true })
|
||||
return settingsDir
|
||||
}
|
||||
|
||||
// Ollama
|
||||
|
||||
async getOllamaModels(baseUrl?: string) {
|
||||
@@ -852,6 +920,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
alwaysAllowWrite,
|
||||
alwaysAllowExecute,
|
||||
alwaysAllowBrowser,
|
||||
alwaysAllowMcp,
|
||||
soundEnabled,
|
||||
diffEnabled,
|
||||
taskHistory,
|
||||
@@ -869,6 +938,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
alwaysAllowWrite: alwaysAllowWrite ?? false,
|
||||
alwaysAllowExecute: alwaysAllowExecute ?? false,
|
||||
alwaysAllowBrowser: alwaysAllowBrowser ?? false,
|
||||
alwaysAllowMcp: alwaysAllowMcp ?? false,
|
||||
uriScheme: vscode.env.uriScheme,
|
||||
clineMessages: this.cline?.clineMessages || [],
|
||||
taskHistory: (taskHistory || [])
|
||||
@@ -965,6 +1035,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
alwaysAllowWrite,
|
||||
alwaysAllowExecute,
|
||||
alwaysAllowBrowser,
|
||||
alwaysAllowMcp,
|
||||
taskHistory,
|
||||
allowedCommands,
|
||||
soundEnabled,
|
||||
@@ -1001,6 +1072,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
this.getGlobalState("alwaysAllowWrite") as Promise<boolean | undefined>,
|
||||
this.getGlobalState("alwaysAllowExecute") as Promise<boolean | undefined>,
|
||||
this.getGlobalState("alwaysAllowBrowser") as Promise<boolean | undefined>,
|
||||
this.getGlobalState("alwaysAllowMcp") as Promise<boolean | undefined>,
|
||||
this.getGlobalState("taskHistory") as Promise<HistoryItem[] | undefined>,
|
||||
this.getGlobalState("allowedCommands") as Promise<string[] | undefined>,
|
||||
this.getGlobalState("soundEnabled") as Promise<boolean | undefined>,
|
||||
@@ -1055,6 +1127,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
alwaysAllowWrite: alwaysAllowWrite ?? false,
|
||||
alwaysAllowExecute: alwaysAllowExecute ?? false,
|
||||
alwaysAllowBrowser: alwaysAllowBrowser ?? false,
|
||||
alwaysAllowMcp: alwaysAllowMcp ?? false,
|
||||
taskHistory,
|
||||
allowedCommands,
|
||||
soundEnabled,
|
||||
|
||||
@@ -3,6 +3,53 @@ import * as vscode from 'vscode'
|
||||
import { ExtensionMessage, ExtensionState } from '../../../shared/ExtensionMessage'
|
||||
import { setSoundEnabled } from '../../../utils/sound'
|
||||
|
||||
// Mock delay module
|
||||
jest.mock('delay', () => {
|
||||
const delayFn = (ms: number) => Promise.resolve();
|
||||
delayFn.createDelay = () => delayFn;
|
||||
delayFn.reject = () => Promise.reject(new Error('Delay rejected'));
|
||||
delayFn.range = () => Promise.resolve();
|
||||
return delayFn;
|
||||
});
|
||||
|
||||
// Mock MCP-related modules
|
||||
jest.mock('@modelcontextprotocol/sdk/types.js', () => ({
|
||||
CallToolResultSchema: {},
|
||||
ListResourcesResultSchema: {},
|
||||
ListResourceTemplatesResultSchema: {},
|
||||
ListToolsResultSchema: {},
|
||||
ReadResourceResultSchema: {},
|
||||
ErrorCode: {
|
||||
InvalidRequest: 'InvalidRequest',
|
||||
MethodNotFound: 'MethodNotFound',
|
||||
InternalError: 'InternalError'
|
||||
},
|
||||
McpError: class McpError extends Error {
|
||||
code: string;
|
||||
constructor(code: string, message: string) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
this.name = 'McpError';
|
||||
}
|
||||
}
|
||||
}), { virtual: true });
|
||||
|
||||
jest.mock('@modelcontextprotocol/sdk/client/index.js', () => ({
|
||||
Client: jest.fn().mockImplementation(() => ({
|
||||
connect: jest.fn().mockResolvedValue(undefined),
|
||||
close: jest.fn().mockResolvedValue(undefined),
|
||||
listTools: jest.fn().mockResolvedValue({ tools: [] }),
|
||||
callTool: jest.fn().mockResolvedValue({ content: [] })
|
||||
}))
|
||||
}), { virtual: true });
|
||||
|
||||
jest.mock('@modelcontextprotocol/sdk/client/stdio.js', () => ({
|
||||
StdioClientTransport: jest.fn().mockImplementation(() => ({
|
||||
connect: jest.fn().mockResolvedValue(undefined),
|
||||
close: jest.fn().mockResolvedValue(undefined)
|
||||
}))
|
||||
}), { virtual: true });
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('vscode', () => ({
|
||||
ExtensionContext: jest.fn(),
|
||||
@@ -19,7 +66,11 @@ jest.mock('vscode', () => ({
|
||||
}),
|
||||
onDidChangeConfiguration: jest.fn().mockImplementation((callback) => ({
|
||||
dispose: jest.fn()
|
||||
}))
|
||||
})),
|
||||
onDidSaveTextDocument: jest.fn(() => ({ dispose: jest.fn() })),
|
||||
onDidChangeTextDocument: jest.fn(() => ({ dispose: jest.fn() })),
|
||||
onDidOpenTextDocument: jest.fn(() => ({ dispose: jest.fn() })),
|
||||
onDidCloseTextDocument: jest.fn(() => ({ dispose: jest.fn() }))
|
||||
},
|
||||
env: {
|
||||
uriScheme: 'vscode'
|
||||
|
||||
@@ -53,6 +53,12 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
}),
|
||||
)
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("roo-cline.mcpButtonClicked", () => {
|
||||
sidebarProvider.postMessageToWebview({ type: "action", action: "mcpButtonClicked" })
|
||||
}),
|
||||
)
|
||||
|
||||
const openClineInNewTab = async () => {
|
||||
outputChannel.appendLine("Opening Cline in new tab")
|
||||
// (this example uses webviewProvider activation event which is necessary to deserialize cached webview, but since we use retainContextWhenHidden, we don't need to use that event)
|
||||
|
||||
648
src/services/mcp/McpHub.ts
Normal file
648
src/services/mcp/McpHub.ts
Normal file
@@ -0,0 +1,648 @@
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
||||
import { StdioClientTransport, StdioServerParameters } from "@modelcontextprotocol/sdk/client/stdio.js"
|
||||
import {
|
||||
CallToolResultSchema,
|
||||
ListResourcesResultSchema,
|
||||
ListResourceTemplatesResultSchema,
|
||||
ListToolsResultSchema,
|
||||
ReadResourceResultSchema,
|
||||
} from "@modelcontextprotocol/sdk/types.js"
|
||||
import chokidar, { FSWatcher } from "chokidar"
|
||||
import delay from "delay"
|
||||
import deepEqual from "fast-deep-equal"
|
||||
import * as fs from "fs/promises"
|
||||
import * as path from "path"
|
||||
import * as vscode from "vscode"
|
||||
import { z } from "zod"
|
||||
import { ClineProvider, GlobalFileNames } from "../../core/webview/ClineProvider"
|
||||
import {
|
||||
McpResource,
|
||||
McpResourceResponse,
|
||||
McpResourceTemplate,
|
||||
McpServer,
|
||||
McpTool,
|
||||
McpToolCallResponse,
|
||||
} from "../../shared/mcp"
|
||||
import { fileExistsAtPath } from "../../utils/fs"
|
||||
import { arePathsEqual } from "../../utils/path"
|
||||
|
||||
export type McpConnection = {
|
||||
server: McpServer
|
||||
client: Client
|
||||
transport: StdioClientTransport
|
||||
}
|
||||
|
||||
// StdioServerParameters
|
||||
const AlwaysAllowSchema = z.array(z.string()).default([])
|
||||
|
||||
const StdioConfigSchema = z.object({
|
||||
command: z.string(),
|
||||
args: z.array(z.string()).optional(),
|
||||
env: z.record(z.string()).optional(),
|
||||
alwaysAllow: AlwaysAllowSchema.optional(),
|
||||
disabled: z.boolean().optional()
|
||||
})
|
||||
|
||||
const McpSettingsSchema = z.object({
|
||||
mcpServers: z.record(StdioConfigSchema)
|
||||
})
|
||||
|
||||
export class McpHub {
|
||||
private providerRef: WeakRef<ClineProvider>
|
||||
private disposables: vscode.Disposable[] = []
|
||||
private settingsWatcher?: vscode.FileSystemWatcher
|
||||
private fileWatchers: Map<string, FSWatcher> = new Map()
|
||||
connections: McpConnection[] = []
|
||||
isConnecting: boolean = false
|
||||
|
||||
constructor(provider: ClineProvider) {
|
||||
this.providerRef = new WeakRef(provider)
|
||||
this.watchMcpSettingsFile()
|
||||
this.initializeMcpServers()
|
||||
}
|
||||
|
||||
getServers(): McpServer[] {
|
||||
// Only return enabled servers
|
||||
return this.connections
|
||||
.filter((conn) => !conn.server.disabled)
|
||||
.map((conn) => conn.server)
|
||||
}
|
||||
|
||||
async getMcpServersPath(): Promise<string> {
|
||||
const provider = this.providerRef.deref()
|
||||
if (!provider) {
|
||||
throw new Error("Provider not available")
|
||||
}
|
||||
const mcpServersPath = await provider.ensureMcpServersDirectoryExists()
|
||||
return mcpServersPath
|
||||
}
|
||||
|
||||
async getMcpSettingsFilePath(): Promise<string> {
|
||||
const provider = this.providerRef.deref()
|
||||
if (!provider) {
|
||||
throw new Error("Provider not available")
|
||||
}
|
||||
const mcpSettingsFilePath = path.join(
|
||||
await provider.ensureSettingsDirectoryExists(),
|
||||
GlobalFileNames.mcpSettings,
|
||||
)
|
||||
const fileExists = await fileExistsAtPath(mcpSettingsFilePath)
|
||||
if (!fileExists) {
|
||||
await fs.writeFile(
|
||||
mcpSettingsFilePath,
|
||||
`{
|
||||
"mcpServers": {
|
||||
|
||||
}
|
||||
}`,
|
||||
)
|
||||
}
|
||||
return mcpSettingsFilePath
|
||||
}
|
||||
|
||||
private async watchMcpSettingsFile(): Promise<void> {
|
||||
const settingsPath = await this.getMcpSettingsFilePath()
|
||||
this.disposables.push(
|
||||
vscode.workspace.onDidSaveTextDocument(async (document) => {
|
||||
if (arePathsEqual(document.uri.fsPath, settingsPath)) {
|
||||
const content = await fs.readFile(settingsPath, "utf-8")
|
||||
const errorMessage =
|
||||
"Invalid MCP settings format. Please ensure your settings follow the correct JSON format."
|
||||
let config: any
|
||||
try {
|
||||
config = JSON.parse(content)
|
||||
} catch (error) {
|
||||
vscode.window.showErrorMessage(errorMessage)
|
||||
return
|
||||
}
|
||||
const result = McpSettingsSchema.safeParse(config)
|
||||
if (!result.success) {
|
||||
vscode.window.showErrorMessage(errorMessage)
|
||||
return
|
||||
}
|
||||
try {
|
||||
await this.updateServerConnections(result.data.mcpServers || {})
|
||||
} catch (error) {
|
||||
console.error("Failed to process MCP settings change:", error)
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
private async initializeMcpServers(): Promise<void> {
|
||||
try {
|
||||
const settingsPath = await this.getMcpSettingsFilePath()
|
||||
const content = await fs.readFile(settingsPath, "utf-8")
|
||||
const config = JSON.parse(content)
|
||||
await this.updateServerConnections(config.mcpServers || {})
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize MCP servers:", error)
|
||||
}
|
||||
}
|
||||
|
||||
private async connectToServer(name: string, config: StdioServerParameters): Promise<void> {
|
||||
// Remove existing connection if it exists (should never happen, the connection should be deleted beforehand)
|
||||
this.connections = this.connections.filter((conn) => conn.server.name !== name)
|
||||
|
||||
try {
|
||||
// Each MCP server requires its own transport connection and has unique capabilities, configurations, and error handling. Having separate clients also allows proper scoping of resources/tools and independent server management like reconnection.
|
||||
const client = new Client(
|
||||
{
|
||||
name: "Cline",
|
||||
version: this.providerRef.deref()?.context.extension?.packageJSON?.version ?? "1.0.0",
|
||||
},
|
||||
{
|
||||
capabilities: {},
|
||||
},
|
||||
)
|
||||
|
||||
const transport = new StdioClientTransport({
|
||||
command: config.command,
|
||||
args: config.args,
|
||||
env: {
|
||||
...config.env,
|
||||
...(process.env.PATH ? { PATH: process.env.PATH } : {}),
|
||||
// ...(process.env.NODE_PATH ? { NODE_PATH: process.env.NODE_PATH } : {}),
|
||||
},
|
||||
stderr: "pipe", // necessary for stderr to be available
|
||||
})
|
||||
|
||||
transport.onerror = async (error) => {
|
||||
console.error(`Transport error for "${name}":`, error)
|
||||
const connection = this.connections.find((conn) => conn.server.name === name)
|
||||
if (connection) {
|
||||
connection.server.status = "disconnected"
|
||||
this.appendErrorMessage(connection, error.message)
|
||||
}
|
||||
await this.notifyWebviewOfServerChanges()
|
||||
}
|
||||
|
||||
transport.onclose = async () => {
|
||||
const connection = this.connections.find((conn) => conn.server.name === name)
|
||||
if (connection) {
|
||||
connection.server.status = "disconnected"
|
||||
}
|
||||
await this.notifyWebviewOfServerChanges()
|
||||
}
|
||||
|
||||
// If the config is invalid, show an error
|
||||
if (!StdioConfigSchema.safeParse(config).success) {
|
||||
console.error(`Invalid config for "${name}": missing or invalid parameters`)
|
||||
const connection: McpConnection = {
|
||||
server: {
|
||||
name,
|
||||
config: JSON.stringify(config),
|
||||
status: "disconnected",
|
||||
error: "Invalid config: missing or invalid parameters",
|
||||
},
|
||||
client,
|
||||
transport,
|
||||
}
|
||||
this.connections.push(connection)
|
||||
return
|
||||
}
|
||||
|
||||
// valid schema
|
||||
const parsedConfig = StdioConfigSchema.parse(config)
|
||||
const connection: McpConnection = {
|
||||
server: {
|
||||
name,
|
||||
config: JSON.stringify(config),
|
||||
status: "connecting",
|
||||
disabled: parsedConfig.disabled,
|
||||
},
|
||||
client,
|
||||
transport,
|
||||
}
|
||||
this.connections.push(connection)
|
||||
|
||||
// transport.stderr is only available after the process has been started. However we can't start it separately from the .connect() call because it also starts the transport. And we can't place this after the connect call since we need to capture the stderr stream before the connection is established, in order to capture errors during the connection process.
|
||||
// As a workaround, we start the transport ourselves, and then monkey-patch the start method to no-op so that .connect() doesn't try to start it again.
|
||||
await transport.start()
|
||||
const stderrStream = transport.stderr
|
||||
if (stderrStream) {
|
||||
stderrStream.on("data", async (data: Buffer) => {
|
||||
const errorOutput = data.toString()
|
||||
console.error(`Server "${name}" stderr:`, errorOutput)
|
||||
const connection = this.connections.find((conn) => conn.server.name === name)
|
||||
if (connection) {
|
||||
// NOTE: we do not set server status to "disconnected" because stderr logs do not necessarily mean the server crashed or disconnected, it could just be informational. In fact when the server first starts up, it immediately logs "<name> server running on stdio" to stderr.
|
||||
this.appendErrorMessage(connection, errorOutput)
|
||||
// Only need to update webview right away if it's already disconnected
|
||||
if (connection.server.status === "disconnected") {
|
||||
await this.notifyWebviewOfServerChanges()
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
console.error(`No stderr stream for ${name}`)
|
||||
}
|
||||
transport.start = async () => {} // No-op now, .connect() won't fail
|
||||
|
||||
// // Set up notification handlers
|
||||
// client.setNotificationHandler(
|
||||
// // @ts-ignore-next-line
|
||||
// { method: "notifications/tools/list_changed" },
|
||||
// async () => {
|
||||
// console.log(`Tools changed for server: ${name}`)
|
||||
// connection.server.tools = await this.fetchTools(name)
|
||||
// await this.notifyWebviewOfServerChanges()
|
||||
// },
|
||||
// )
|
||||
|
||||
// client.setNotificationHandler(
|
||||
// // @ts-ignore-next-line
|
||||
// { method: "notifications/resources/list_changed" },
|
||||
// async () => {
|
||||
// console.log(`Resources changed for server: ${name}`)
|
||||
// connection.server.resources = await this.fetchResources(name)
|
||||
// connection.server.resourceTemplates = await this.fetchResourceTemplates(name)
|
||||
// await this.notifyWebviewOfServerChanges()
|
||||
// },
|
||||
// )
|
||||
|
||||
// Connect
|
||||
await client.connect(transport)
|
||||
connection.server.status = "connected"
|
||||
connection.server.error = ""
|
||||
|
||||
// Initial fetch of tools and resources
|
||||
connection.server.tools = await this.fetchToolsList(name)
|
||||
connection.server.resources = await this.fetchResourcesList(name)
|
||||
connection.server.resourceTemplates = await this.fetchResourceTemplatesList(name)
|
||||
} catch (error) {
|
||||
// Update status with error
|
||||
const connection = this.connections.find((conn) => conn.server.name === name)
|
||||
if (connection) {
|
||||
connection.server.status = "disconnected"
|
||||
this.appendErrorMessage(connection, error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private appendErrorMessage(connection: McpConnection, error: string) {
|
||||
const newError = connection.server.error ? `${connection.server.error}\n${error}` : error
|
||||
connection.server.error = newError //.slice(0, 800)
|
||||
}
|
||||
|
||||
private async fetchToolsList(serverName: string): Promise<McpTool[]> {
|
||||
try {
|
||||
const response = await this.connections
|
||||
.find((conn) => conn.server.name === serverName)
|
||||
?.client.request({ method: "tools/list" }, ListToolsResultSchema)
|
||||
|
||||
// Get always allow settings
|
||||
const settingsPath = await this.getMcpSettingsFilePath()
|
||||
const content = await fs.readFile(settingsPath, "utf-8")
|
||||
const config = JSON.parse(content)
|
||||
const alwaysAllowConfig = config.mcpServers[serverName]?.alwaysAllow || []
|
||||
|
||||
// Mark tools as always allowed based on settings
|
||||
const tools = (response?.tools || []).map(tool => ({
|
||||
...tool,
|
||||
alwaysAllow: alwaysAllowConfig.includes(tool.name)
|
||||
}))
|
||||
|
||||
console.log(`[MCP] Fetched tools for ${serverName}:`, tools)
|
||||
return tools
|
||||
} catch (error) {
|
||||
// console.error(`Failed to fetch tools for ${serverName}:`, error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchResourcesList(serverName: string): Promise<McpResource[]> {
|
||||
try {
|
||||
const response = await this.connections
|
||||
.find((conn) => conn.server.name === serverName)
|
||||
?.client.request({ method: "resources/list" }, ListResourcesResultSchema)
|
||||
return response?.resources || []
|
||||
} catch (error) {
|
||||
// console.error(`Failed to fetch resources for ${serverName}:`, error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchResourceTemplatesList(serverName: string): Promise<McpResourceTemplate[]> {
|
||||
try {
|
||||
const response = await this.connections
|
||||
.find((conn) => conn.server.name === serverName)
|
||||
?.client.request({ method: "resources/templates/list" }, ListResourceTemplatesResultSchema)
|
||||
return response?.resourceTemplates || []
|
||||
} catch (error) {
|
||||
// console.error(`Failed to fetch resource templates for ${serverName}:`, error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async deleteConnection(name: string): Promise<void> {
|
||||
const connection = this.connections.find((conn) => conn.server.name === name)
|
||||
if (connection) {
|
||||
try {
|
||||
// connection.client.removeNotificationHandler("notifications/tools/list_changed")
|
||||
// connection.client.removeNotificationHandler("notifications/resources/list_changed")
|
||||
// connection.client.removeNotificationHandler("notifications/stderr")
|
||||
// connection.client.removeNotificationHandler("notifications/stderr")
|
||||
await connection.transport.close()
|
||||
await connection.client.close()
|
||||
} catch (error) {
|
||||
console.error(`Failed to close transport for ${name}:`, error)
|
||||
}
|
||||
this.connections = this.connections.filter((conn) => conn.server.name !== name)
|
||||
}
|
||||
}
|
||||
|
||||
async updateServerConnections(newServers: Record<string, any>): Promise<void> {
|
||||
this.isConnecting = true
|
||||
this.removeAllFileWatchers()
|
||||
const currentNames = new Set(this.connections.map((conn) => conn.server.name))
|
||||
const newNames = new Set(Object.keys(newServers))
|
||||
|
||||
// Delete removed servers
|
||||
for (const name of currentNames) {
|
||||
if (!newNames.has(name)) {
|
||||
await this.deleteConnection(name)
|
||||
console.log(`Deleted MCP server: ${name}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Update or add servers
|
||||
for (const [name, config] of Object.entries(newServers)) {
|
||||
const currentConnection = this.connections.find((conn) => conn.server.name === name)
|
||||
|
||||
if (!currentConnection) {
|
||||
// New server
|
||||
try {
|
||||
this.setupFileWatcher(name, config)
|
||||
await this.connectToServer(name, config)
|
||||
} catch (error) {
|
||||
console.error(`Failed to connect to new MCP server ${name}:`, error)
|
||||
}
|
||||
} else if (!deepEqual(JSON.parse(currentConnection.server.config), config)) {
|
||||
// Existing server with changed config
|
||||
try {
|
||||
this.setupFileWatcher(name, config)
|
||||
await this.deleteConnection(name)
|
||||
await this.connectToServer(name, config)
|
||||
console.log(`Reconnected MCP server with updated config: ${name}`)
|
||||
} catch (error) {
|
||||
console.error(`Failed to reconnect MCP server ${name}:`, error)
|
||||
}
|
||||
}
|
||||
// If server exists with same config, do nothing
|
||||
}
|
||||
await this.notifyWebviewOfServerChanges()
|
||||
this.isConnecting = false
|
||||
}
|
||||
|
||||
private setupFileWatcher(name: string, config: any) {
|
||||
const filePath = config.args?.find((arg: string) => arg.includes("build/index.js"))
|
||||
if (filePath) {
|
||||
// we use chokidar instead of onDidSaveTextDocument because it doesn't require the file to be open in the editor. The settings config is better suited for onDidSave since that will be manually updated by the user or Cline (and we want to detect save events, not every file change)
|
||||
const watcher = chokidar.watch(filePath, {
|
||||
// persistent: true,
|
||||
// ignoreInitial: true,
|
||||
// awaitWriteFinish: true, // This helps with atomic writes
|
||||
})
|
||||
|
||||
watcher.on("change", () => {
|
||||
console.log(`Detected change in ${filePath}. Restarting server ${name}...`)
|
||||
this.restartConnection(name)
|
||||
})
|
||||
|
||||
this.fileWatchers.set(name, watcher)
|
||||
}
|
||||
}
|
||||
|
||||
private removeAllFileWatchers() {
|
||||
this.fileWatchers.forEach((watcher) => watcher.close())
|
||||
this.fileWatchers.clear()
|
||||
}
|
||||
|
||||
async restartConnection(serverName: string): Promise<void> {
|
||||
this.isConnecting = true
|
||||
const provider = this.providerRef.deref()
|
||||
if (!provider) {
|
||||
return
|
||||
}
|
||||
|
||||
// Get existing connection and update its status
|
||||
const connection = this.connections.find((conn) => conn.server.name === serverName)
|
||||
const config = connection?.server.config
|
||||
if (config) {
|
||||
vscode.window.showInformationMessage(`Restarting ${serverName} MCP server...`)
|
||||
connection.server.status = "connecting"
|
||||
connection.server.error = ""
|
||||
await this.notifyWebviewOfServerChanges()
|
||||
await delay(500) // artificial delay to show user that server is restarting
|
||||
try {
|
||||
await this.deleteConnection(serverName)
|
||||
// Try to connect again using existing config
|
||||
await this.connectToServer(serverName, JSON.parse(config))
|
||||
vscode.window.showInformationMessage(`${serverName} MCP server connected`)
|
||||
} catch (error) {
|
||||
console.error(`Failed to restart connection for ${serverName}:`, error)
|
||||
vscode.window.showErrorMessage(`Failed to connect to ${serverName} MCP server`)
|
||||
}
|
||||
}
|
||||
|
||||
await this.notifyWebviewOfServerChanges()
|
||||
this.isConnecting = false
|
||||
}
|
||||
|
||||
private async notifyWebviewOfServerChanges(): Promise<void> {
|
||||
// servers should always be sorted in the order they are defined in the settings file
|
||||
const settingsPath = await this.getMcpSettingsFilePath()
|
||||
const content = await fs.readFile(settingsPath, "utf-8")
|
||||
const config = JSON.parse(content)
|
||||
const serverOrder = Object.keys(config.mcpServers || {})
|
||||
await this.providerRef.deref()?.postMessageToWebview({
|
||||
type: "mcpServers",
|
||||
mcpServers: [...this.connections]
|
||||
.sort((a, b) => {
|
||||
const indexA = serverOrder.indexOf(a.server.name)
|
||||
const indexB = serverOrder.indexOf(b.server.name)
|
||||
return indexA - indexB
|
||||
})
|
||||
.map((connection) => connection.server),
|
||||
})
|
||||
}
|
||||
|
||||
// Public methods for server management
|
||||
|
||||
public async toggleServerDisabled(serverName: string, disabled: boolean): Promise<void> {
|
||||
let settingsPath: string
|
||||
try {
|
||||
settingsPath = await this.getMcpSettingsFilePath()
|
||||
|
||||
// Ensure the settings file exists and is accessible
|
||||
try {
|
||||
await fs.access(settingsPath)
|
||||
} catch (error) {
|
||||
console.error('Settings file not accessible:', error)
|
||||
throw new Error('Settings file not accessible')
|
||||
}
|
||||
const content = await fs.readFile(settingsPath, "utf-8")
|
||||
const config = JSON.parse(content)
|
||||
|
||||
// Validate the config structure
|
||||
if (!config || typeof config !== 'object') {
|
||||
throw new Error('Invalid config structure')
|
||||
}
|
||||
|
||||
if (!config.mcpServers || typeof config.mcpServers !== 'object') {
|
||||
config.mcpServers = {}
|
||||
}
|
||||
|
||||
if (config.mcpServers[serverName]) {
|
||||
// Create a new server config object to ensure clean structure
|
||||
const serverConfig = {
|
||||
...config.mcpServers[serverName],
|
||||
disabled
|
||||
}
|
||||
|
||||
// Ensure required fields exist
|
||||
if (!serverConfig.alwaysAllow) {
|
||||
serverConfig.alwaysAllow = []
|
||||
}
|
||||
|
||||
config.mcpServers[serverName] = serverConfig
|
||||
|
||||
// Write the entire config back
|
||||
const updatedConfig = {
|
||||
mcpServers: config.mcpServers
|
||||
}
|
||||
|
||||
await fs.writeFile(settingsPath, JSON.stringify(updatedConfig, null, 2))
|
||||
|
||||
const connection = this.connections.find(conn => conn.server.name === serverName)
|
||||
if (connection) {
|
||||
try {
|
||||
connection.server.disabled = disabled
|
||||
|
||||
// Only refresh capabilities if connected
|
||||
if (connection.server.status === "connected") {
|
||||
connection.server.tools = await this.fetchToolsList(serverName)
|
||||
connection.server.resources = await this.fetchResourcesList(serverName)
|
||||
connection.server.resourceTemplates = await this.fetchResourceTemplatesList(serverName)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to refresh capabilities for ${serverName}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
await this.notifyWebviewOfServerChanges()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to update server disabled state:", error)
|
||||
if (error instanceof Error) {
|
||||
console.error("Error details:", error.message, error.stack)
|
||||
}
|
||||
vscode.window.showErrorMessage(`Failed to update server state: ${error instanceof Error ? error.message : String(error)}`)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async readResource(serverName: string, uri: string): Promise<McpResourceResponse> {
|
||||
const connection = this.connections.find((conn) => conn.server.name === serverName)
|
||||
if (!connection) {
|
||||
throw new Error(`No connection found for server: ${serverName}`)
|
||||
}
|
||||
if (connection.server.disabled) {
|
||||
throw new Error(`Server "${serverName}" is disabled`)
|
||||
}
|
||||
return await connection.client.request(
|
||||
{
|
||||
method: "resources/read",
|
||||
params: {
|
||||
uri,
|
||||
},
|
||||
},
|
||||
ReadResourceResultSchema,
|
||||
)
|
||||
}
|
||||
|
||||
async callTool(
|
||||
serverName: string,
|
||||
toolName: string,
|
||||
toolArguments?: Record<string, unknown>,
|
||||
): Promise<McpToolCallResponse> {
|
||||
const connection = this.connections.find((conn) => conn.server.name === serverName)
|
||||
if (!connection) {
|
||||
throw new Error(
|
||||
`No connection found for server: ${serverName}. Please make sure to use MCP servers available under 'Connected MCP Servers'.`,
|
||||
)
|
||||
}
|
||||
if (connection.server.disabled) {
|
||||
throw new Error(`Server "${serverName}" is disabled and cannot be used`)
|
||||
}
|
||||
|
||||
return await connection.client.request(
|
||||
{
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: toolName,
|
||||
arguments: toolArguments,
|
||||
},
|
||||
},
|
||||
CallToolResultSchema,
|
||||
)
|
||||
}
|
||||
|
||||
async toggleToolAlwaysAllow(serverName: string, toolName: string, shouldAllow: boolean): Promise<void> {
|
||||
try {
|
||||
const settingsPath = await this.getMcpSettingsFilePath()
|
||||
const content = await fs.readFile(settingsPath, "utf-8")
|
||||
const config = JSON.parse(content)
|
||||
|
||||
// Initialize alwaysAllow if it doesn't exist
|
||||
if (!config.mcpServers[serverName].alwaysAllow) {
|
||||
config.mcpServers[serverName].alwaysAllow = []
|
||||
}
|
||||
|
||||
const alwaysAllow = config.mcpServers[serverName].alwaysAllow
|
||||
const toolIndex = alwaysAllow.indexOf(toolName)
|
||||
|
||||
if (shouldAllow && toolIndex === -1) {
|
||||
// Add tool to always allow list
|
||||
alwaysAllow.push(toolName)
|
||||
} else if (!shouldAllow && toolIndex !== -1) {
|
||||
// Remove tool from always allow list
|
||||
alwaysAllow.splice(toolIndex, 1)
|
||||
}
|
||||
|
||||
// Write updated config back to file
|
||||
await fs.writeFile(settingsPath, JSON.stringify(config, null, 2))
|
||||
|
||||
// Update the tools list to reflect the change
|
||||
const connection = this.connections.find(conn => conn.server.name === serverName)
|
||||
if (connection) {
|
||||
connection.server.tools = await this.fetchToolsList(serverName)
|
||||
await this.notifyWebviewOfServerChanges()
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("Failed to update always allow settings:", error)
|
||||
vscode.window.showErrorMessage("Failed to update always allow settings")
|
||||
throw error // Re-throw to ensure the error is properly handled
|
||||
}
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
this.removeAllFileWatchers()
|
||||
for (const connection of this.connections) {
|
||||
try {
|
||||
await this.deleteConnection(connection.server.name)
|
||||
} catch (error) {
|
||||
console.error(`Failed to close connection for ${connection.server.name}:`, error)
|
||||
}
|
||||
}
|
||||
this.connections = []
|
||||
if (this.settingsWatcher) {
|
||||
this.settingsWatcher.dispose()
|
||||
}
|
||||
this.disposables.forEach((d) => d.dispose())
|
||||
}
|
||||
}
|
||||
290
src/services/mcp/__tests__/McpHub.test.ts
Normal file
290
src/services/mcp/__tests__/McpHub.test.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
import type { McpHub as McpHubType } from '../McpHub'
|
||||
import type { ClineProvider } from '../../../core/webview/ClineProvider'
|
||||
import type { ExtensionContext, Uri } from 'vscode'
|
||||
import type { McpConnection } from '../McpHub'
|
||||
|
||||
const vscode = require('vscode')
|
||||
const fs = require('fs/promises')
|
||||
const { McpHub } = require('../McpHub')
|
||||
|
||||
jest.mock('vscode')
|
||||
jest.mock('fs/promises')
|
||||
jest.mock('../../../core/webview/ClineProvider')
|
||||
|
||||
describe('McpHub', () => {
|
||||
let mcpHub: McpHubType
|
||||
let mockProvider: Partial<ClineProvider>
|
||||
const mockSettingsPath = '/mock/settings/path/cline_mcp_settings.json'
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
|
||||
const mockUri: Uri = {
|
||||
scheme: 'file',
|
||||
authority: '',
|
||||
path: '/test/path',
|
||||
query: '',
|
||||
fragment: '',
|
||||
fsPath: '/test/path',
|
||||
with: jest.fn(),
|
||||
toJSON: jest.fn()
|
||||
}
|
||||
|
||||
mockProvider = {
|
||||
ensureSettingsDirectoryExists: jest.fn().mockResolvedValue('/mock/settings/path'),
|
||||
ensureMcpServersDirectoryExists: jest.fn().mockResolvedValue('/mock/settings/path'),
|
||||
postMessageToWebview: jest.fn(),
|
||||
context: {
|
||||
subscriptions: [],
|
||||
workspaceState: {} as any,
|
||||
globalState: {} as any,
|
||||
secrets: {} as any,
|
||||
extensionUri: mockUri,
|
||||
extensionPath: '/test/path',
|
||||
storagePath: '/test/storage',
|
||||
globalStoragePath: '/test/global-storage',
|
||||
environmentVariableCollection: {} as any,
|
||||
extension: {
|
||||
id: 'test-extension',
|
||||
extensionUri: mockUri,
|
||||
extensionPath: '/test/path',
|
||||
extensionKind: 1,
|
||||
isActive: true,
|
||||
packageJSON: {
|
||||
version: '1.0.0'
|
||||
},
|
||||
activate: jest.fn(),
|
||||
exports: undefined
|
||||
} as any,
|
||||
asAbsolutePath: (path: string) => path,
|
||||
storageUri: mockUri,
|
||||
globalStorageUri: mockUri,
|
||||
logUri: mockUri,
|
||||
extensionMode: 1,
|
||||
logPath: '/test/path',
|
||||
languageModelAccessInformation: {} as any
|
||||
} as ExtensionContext
|
||||
}
|
||||
|
||||
// Mock fs.readFile for initial settings
|
||||
;(fs.readFile as jest.Mock).mockResolvedValue(JSON.stringify({
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
command: 'node',
|
||||
args: ['test.js'],
|
||||
alwaysAllow: ['allowed-tool']
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
mcpHub = new McpHub(mockProvider as ClineProvider)
|
||||
})
|
||||
|
||||
describe('toggleToolAlwaysAllow', () => {
|
||||
it('should add tool to always allow list when enabling', async () => {
|
||||
const mockConfig = {
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
command: 'node',
|
||||
args: ['test.js'],
|
||||
alwaysAllow: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mock reading initial config
|
||||
;(fs.readFile as jest.Mock).mockResolvedValueOnce(JSON.stringify(mockConfig))
|
||||
|
||||
await mcpHub.toggleToolAlwaysAllow('test-server', 'new-tool', true)
|
||||
|
||||
// Verify the config was updated correctly
|
||||
const writeCall = (fs.writeFile as jest.Mock).mock.calls[0]
|
||||
const writtenConfig = JSON.parse(writeCall[1])
|
||||
expect(writtenConfig.mcpServers['test-server'].alwaysAllow).toContain('new-tool')
|
||||
})
|
||||
|
||||
it('should remove tool from always allow list when disabling', async () => {
|
||||
const mockConfig = {
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
command: 'node',
|
||||
args: ['test.js'],
|
||||
alwaysAllow: ['existing-tool']
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mock reading initial config
|
||||
;(fs.readFile as jest.Mock).mockResolvedValueOnce(JSON.stringify(mockConfig))
|
||||
|
||||
await mcpHub.toggleToolAlwaysAllow('test-server', 'existing-tool', false)
|
||||
|
||||
// Verify the config was updated correctly
|
||||
const writeCall = (fs.writeFile as jest.Mock).mock.calls[0]
|
||||
const writtenConfig = JSON.parse(writeCall[1])
|
||||
expect(writtenConfig.mcpServers['test-server'].alwaysAllow).not.toContain('existing-tool')
|
||||
})
|
||||
|
||||
it('should initialize alwaysAllow if it does not exist', async () => {
|
||||
const mockConfig = {
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
command: 'node',
|
||||
args: ['test.js']
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mock reading initial config
|
||||
;(fs.readFile as jest.Mock).mockResolvedValueOnce(JSON.stringify(mockConfig))
|
||||
|
||||
await mcpHub.toggleToolAlwaysAllow('test-server', 'new-tool', true)
|
||||
|
||||
// Verify the config was updated with initialized alwaysAllow
|
||||
const writeCall = (fs.writeFile as jest.Mock).mock.calls[0]
|
||||
const writtenConfig = JSON.parse(writeCall[1])
|
||||
expect(writtenConfig.mcpServers['test-server'].alwaysAllow).toBeDefined()
|
||||
expect(writtenConfig.mcpServers['test-server'].alwaysAllow).toContain('new-tool')
|
||||
})
|
||||
})
|
||||
|
||||
describe('server disabled state', () => {
|
||||
it('should toggle server disabled state', async () => {
|
||||
const mockConfig = {
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
command: 'node',
|
||||
args: ['test.js'],
|
||||
disabled: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mock reading initial config
|
||||
;(fs.readFile as jest.Mock).mockResolvedValueOnce(JSON.stringify(mockConfig))
|
||||
|
||||
await mcpHub.toggleServerDisabled('test-server', true)
|
||||
|
||||
// Verify the config was updated correctly
|
||||
const writeCall = (fs.writeFile as jest.Mock).mock.calls[0]
|
||||
const writtenConfig = JSON.parse(writeCall[1])
|
||||
expect(writtenConfig.mcpServers['test-server'].disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('should filter out disabled servers from getServers', () => {
|
||||
const mockConnections: McpConnection[] = [
|
||||
{
|
||||
server: {
|
||||
name: 'enabled-server',
|
||||
config: '{}',
|
||||
status: 'connected',
|
||||
disabled: false
|
||||
},
|
||||
client: {} as any,
|
||||
transport: {} as any
|
||||
},
|
||||
{
|
||||
server: {
|
||||
name: 'disabled-server',
|
||||
config: '{}',
|
||||
status: 'connected',
|
||||
disabled: true
|
||||
},
|
||||
client: {} as any,
|
||||
transport: {} as any
|
||||
}
|
||||
]
|
||||
|
||||
mcpHub.connections = mockConnections
|
||||
const servers = mcpHub.getServers()
|
||||
|
||||
expect(servers.length).toBe(1)
|
||||
expect(servers[0].name).toBe('enabled-server')
|
||||
})
|
||||
|
||||
it('should prevent calling tools on disabled servers', async () => {
|
||||
const mockConnection: McpConnection = {
|
||||
server: {
|
||||
name: 'disabled-server',
|
||||
config: '{}',
|
||||
status: 'connected',
|
||||
disabled: true
|
||||
},
|
||||
client: {
|
||||
request: jest.fn().mockResolvedValue({ result: 'success' })
|
||||
} as any,
|
||||
transport: {} as any
|
||||
}
|
||||
|
||||
mcpHub.connections = [mockConnection]
|
||||
|
||||
await expect(mcpHub.callTool('disabled-server', 'some-tool', {}))
|
||||
.rejects
|
||||
.toThrow('Server "disabled-server" is disabled and cannot be used')
|
||||
})
|
||||
|
||||
it('should prevent reading resources from disabled servers', async () => {
|
||||
const mockConnection: McpConnection = {
|
||||
server: {
|
||||
name: 'disabled-server',
|
||||
config: '{}',
|
||||
status: 'connected',
|
||||
disabled: true
|
||||
},
|
||||
client: {
|
||||
request: jest.fn()
|
||||
} as any,
|
||||
transport: {} as any
|
||||
}
|
||||
|
||||
mcpHub.connections = [mockConnection]
|
||||
|
||||
await expect(mcpHub.readResource('disabled-server', 'some/uri'))
|
||||
.rejects
|
||||
.toThrow('Server "disabled-server" is disabled')
|
||||
})
|
||||
})
|
||||
|
||||
describe('callTool', () => {
|
||||
it('should execute tool successfully', async () => {
|
||||
// Mock the connection with a minimal client implementation
|
||||
const mockConnection: McpConnection = {
|
||||
server: {
|
||||
name: 'test-server',
|
||||
config: JSON.stringify({}),
|
||||
status: 'connected' as const
|
||||
},
|
||||
client: {
|
||||
request: jest.fn().mockResolvedValue({ result: 'success' })
|
||||
} as any,
|
||||
transport: {
|
||||
start: jest.fn(),
|
||||
close: jest.fn(),
|
||||
stderr: { on: jest.fn() }
|
||||
} as any
|
||||
}
|
||||
|
||||
mcpHub.connections = [mockConnection]
|
||||
|
||||
await mcpHub.callTool('test-server', 'some-tool', {})
|
||||
|
||||
// Verify the request was made with correct parameters
|
||||
expect(mockConnection.client.request).toHaveBeenCalledWith(
|
||||
{
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'some-tool',
|
||||
arguments: {}
|
||||
}
|
||||
},
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw error if server not found', async () => {
|
||||
await expect(mcpHub.callTool('non-existent-server', 'some-tool', {}))
|
||||
.rejects
|
||||
.toThrow('No connection found for server: non-existent-server')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { ApiConfiguration, ModelInfo } from "./api"
|
||||
import { HistoryItem } from "./HistoryItem"
|
||||
import { McpServer } from "./mcp"
|
||||
|
||||
// webview will hold state
|
||||
export interface ExtensionMessage {
|
||||
@@ -16,8 +17,14 @@ export interface ExtensionMessage {
|
||||
| "invoke"
|
||||
| "partialMessage"
|
||||
| "openRouterModels"
|
||||
| "mcpServers"
|
||||
text?: string
|
||||
action?: "chatButtonClicked" | "settingsButtonClicked" | "historyButtonClicked" | "didBecomeVisible"
|
||||
action?:
|
||||
| "chatButtonClicked"
|
||||
| "mcpButtonClicked"
|
||||
| "settingsButtonClicked"
|
||||
| "historyButtonClicked"
|
||||
| "didBecomeVisible"
|
||||
invoke?: "sendMessage" | "primaryButtonClick" | "secondaryButtonClick"
|
||||
state?: ExtensionState
|
||||
images?: string[]
|
||||
@@ -26,6 +33,7 @@ export interface ExtensionMessage {
|
||||
filePaths?: string[]
|
||||
partialMessage?: ClineMessage
|
||||
openRouterModels?: Record<string, ModelInfo>
|
||||
mcpServers?: McpServer[]
|
||||
}
|
||||
|
||||
export interface ExtensionState {
|
||||
@@ -39,6 +47,7 @@ export interface ExtensionState {
|
||||
alwaysAllowWrite?: boolean
|
||||
alwaysAllowExecute?: boolean
|
||||
alwaysAllowBrowser?: boolean
|
||||
alwaysAllowMcp?: boolean
|
||||
uriScheme?: string
|
||||
allowedCommands?: string[]
|
||||
soundEnabled?: boolean
|
||||
@@ -66,6 +75,7 @@ export type ClineAsk =
|
||||
| "resume_completed_task"
|
||||
| "mistake_limit_reached"
|
||||
| "browser_action_launch"
|
||||
| "use_mcp_server"
|
||||
|
||||
export type ClineSay =
|
||||
| "task"
|
||||
@@ -83,6 +93,8 @@ export type ClineSay =
|
||||
| "browser_action"
|
||||
| "browser_action_result"
|
||||
| "command"
|
||||
| "mcp_server_request_started"
|
||||
| "mcp_server_response"
|
||||
|
||||
export interface ClineSayTool {
|
||||
tool:
|
||||
@@ -118,6 +130,14 @@ export type BrowserActionResult = {
|
||||
currentMousePosition?: string
|
||||
}
|
||||
|
||||
export interface ClineAskUseMcpServer {
|
||||
serverName: string
|
||||
type: "use_mcp_tool" | "access_mcp_resource"
|
||||
toolName?: string
|
||||
arguments?: string
|
||||
uri?: string
|
||||
}
|
||||
|
||||
export interface ClineApiReqInfo {
|
||||
request?: string
|
||||
tokensIn?: number
|
||||
|
||||
@@ -29,16 +29,26 @@ export interface WebviewMessage {
|
||||
| "cancelTask"
|
||||
| "refreshOpenRouterModels"
|
||||
| "alwaysAllowBrowser"
|
||||
| "alwaysAllowMcp"
|
||||
| "playSound"
|
||||
| "soundEnabled"
|
||||
| "diffEnabled"
|
||||
| "openMcpSettings"
|
||||
| "restartMcpServer"
|
||||
| "toggleToolAlwaysAllow"
|
||||
| "toggleMcpServer"
|
||||
text?: string
|
||||
disabled?: boolean
|
||||
askResponse?: ClineAskResponse
|
||||
apiConfiguration?: ApiConfiguration
|
||||
images?: string[]
|
||||
bool?: boolean
|
||||
commands?: string[]
|
||||
audioType?: AudioType
|
||||
// For toggleToolAutoApprove
|
||||
serverName?: string
|
||||
toolName?: string
|
||||
alwaysAllow?: boolean
|
||||
}
|
||||
|
||||
export type ClineAskResponse = "yesButtonClicked" | "noButtonClicked" | "messageResponse"
|
||||
|
||||
@@ -36,7 +36,9 @@ export interface ApiHandlerOptions {
|
||||
geminiApiKey?: string
|
||||
openAiNativeApiKey?: string
|
||||
azureApiVersion?: string
|
||||
useBedrockRuntime?: boolean // Force use of Bedrock Runtime API instead of SDK
|
||||
openRouterUseMiddleOutTransform?: boolean
|
||||
includeStreamOptions?: boolean
|
||||
setAzureApiVersion?: boolean
|
||||
}
|
||||
|
||||
export type ApiConfiguration = ApiHandlerOptions & {
|
||||
@@ -380,8 +382,16 @@ export const openAiModelInfoSaneDefaults: ModelInfo = {
|
||||
// Gemini
|
||||
// https://ai.google.dev/gemini-api/docs/models/gemini
|
||||
export type GeminiModelId = keyof typeof geminiModels
|
||||
export const geminiDefaultModelId: GeminiModelId = "gemini-1.5-flash-002"
|
||||
export const geminiDefaultModelId: GeminiModelId = "gemini-2.0-flash-exp"
|
||||
export const geminiModels = {
|
||||
"gemini-2.0-flash-exp": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 1_048_576,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
},
|
||||
"gemini-1.5-flash-002": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 1_048_576,
|
||||
@@ -430,14 +440,6 @@ export const geminiModels = {
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
},
|
||||
"gemini-2.0-flash-exp": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 1_048_576,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
},
|
||||
} as const satisfies Record<string, ModelInfo>
|
||||
|
||||
// OpenAI Native
|
||||
|
||||
66
src/shared/mcp.ts
Normal file
66
src/shared/mcp.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
export type McpServer = {
|
||||
name: string
|
||||
config: string
|
||||
status: "connected" | "connecting" | "disconnected"
|
||||
error?: string
|
||||
tools?: McpTool[]
|
||||
resources?: McpResource[]
|
||||
resourceTemplates?: McpResourceTemplate[]
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export type McpTool = {
|
||||
name: string
|
||||
description?: string
|
||||
inputSchema?: object
|
||||
alwaysAllow?: boolean
|
||||
}
|
||||
|
||||
export type McpResource = {
|
||||
uri: string
|
||||
name: string
|
||||
mimeType?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export type McpResourceTemplate = {
|
||||
uriTemplate: string
|
||||
name: string
|
||||
description?: string
|
||||
mimeType?: string
|
||||
}
|
||||
|
||||
export type McpResourceResponse = {
|
||||
_meta?: Record<string, any>
|
||||
contents: Array<{
|
||||
uri: string
|
||||
mimeType?: string
|
||||
text?: string
|
||||
blob?: string
|
||||
}>
|
||||
}
|
||||
|
||||
export type McpToolCallResponse = {
|
||||
_meta?: Record<string, any>
|
||||
content: Array<
|
||||
| {
|
||||
type: "text"
|
||||
text: string
|
||||
}
|
||||
| {
|
||||
type: "image"
|
||||
data: string
|
||||
mimeType: string
|
||||
}
|
||||
| {
|
||||
type: "resource"
|
||||
resource: {
|
||||
uri: string
|
||||
mimeType?: string
|
||||
text?: string
|
||||
blob?: string
|
||||
}
|
||||
}
|
||||
>
|
||||
isError?: boolean
|
||||
}
|
||||
4423
webview-ui/package-lock.json
generated
4423
webview-ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -7,11 +7,13 @@ import SettingsView from "./components/settings/SettingsView"
|
||||
import WelcomeView from "./components/welcome/WelcomeView"
|
||||
import { ExtensionStateContextProvider, useExtensionState } from "./context/ExtensionStateContext"
|
||||
import { vscode } from "./utils/vscode"
|
||||
import McpView from "./components/mcp/McpView"
|
||||
|
||||
const AppContent = () => {
|
||||
const { didHydrateState, showWelcome, shouldShowAnnouncement } = useExtensionState()
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const [showHistory, setShowHistory] = useState(false)
|
||||
const [showMcp, setShowMcp] = useState(false)
|
||||
const [showAnnouncement, setShowAnnouncement] = useState(false)
|
||||
|
||||
const handleMessage = useCallback((e: MessageEvent) => {
|
||||
@@ -22,14 +24,22 @@ const AppContent = () => {
|
||||
case "settingsButtonClicked":
|
||||
setShowSettings(true)
|
||||
setShowHistory(false)
|
||||
setShowMcp(false)
|
||||
break
|
||||
case "historyButtonClicked":
|
||||
setShowSettings(false)
|
||||
setShowHistory(true)
|
||||
setShowMcp(false)
|
||||
break
|
||||
case "mcpButtonClicked":
|
||||
setShowSettings(false)
|
||||
setShowHistory(false)
|
||||
setShowMcp(true)
|
||||
break
|
||||
case "chatButtonClicked":
|
||||
setShowSettings(false)
|
||||
setShowHistory(false)
|
||||
setShowMcp(false)
|
||||
break
|
||||
}
|
||||
break
|
||||
@@ -57,13 +67,15 @@ const AppContent = () => {
|
||||
<>
|
||||
{showSettings && <SettingsView onDone={() => setShowSettings(false)} />}
|
||||
{showHistory && <HistoryView onDone={() => setShowHistory(false)} />}
|
||||
{showMcp && <McpView onDone={() => setShowMcp(false)} />}
|
||||
{/* Do not conditionally load ChatView, it's expensive and there's state we don't want to lose (user input, disableInput, askResponse promise, etc.) */}
|
||||
<ChatView
|
||||
showHistoryView={() => {
|
||||
setShowSettings(false)
|
||||
setShowMcp(false)
|
||||
setShowHistory(true)
|
||||
}}
|
||||
isHidden={showSettings || showHistory}
|
||||
isHidden={showSettings || showHistory || showMcp}
|
||||
showAnnouncement={showAnnouncement}
|
||||
hideAnnouncement={() => {
|
||||
setShowAnnouncement(false)
|
||||
|
||||
@@ -30,20 +30,36 @@ const Announcement = ({ version, hideAnnouncement }: AnnouncementProps) => {
|
||||
<span className="codicon codicon-close"></span>
|
||||
</VSCodeButton>
|
||||
<h3 style={{ margin: "0 0 8px" }}>
|
||||
🎉{" "}New in v{minorVersion}
|
||||
🎉{" "}New in Cline v{minorVersion}
|
||||
</h3>
|
||||
<p style={{ margin: "5px 0px", fontWeight: "bold" }}>Add custom tools to Cline using MCP!</p>
|
||||
<p style={{ margin: "5px 0px" }}>
|
||||
Cline now uses Anthropic's new{" "}
|
||||
<VSCodeLink
|
||||
href="https://www.anthropic.com/news/3-5-models-and-computer-use"
|
||||
style={{ display: "inline" }}>
|
||||
"Computer Use"
|
||||
</VSCodeLink>{" "}
|
||||
feature to launch a browser, click, type, and scroll. This gives him more autonomy in runtime debugging,
|
||||
end-to-end testing, and even general web use. Try asking "Look up the weather in Colorado" to see it in
|
||||
action, or{" "}
|
||||
<VSCodeLink href="https://x.com/sdrzn/status/1850880547825823989" style={{ display: "inline" }}>
|
||||
see a full demo here.
|
||||
The Model Context Protocol allows agents like Cline to plug and play custom tools,{" "}
|
||||
<VSCodeLink href="https://github.com/modelcontextprotocol/servers" style={{ display: "inline" }}>
|
||||
e.g. a web-search tool or GitHub tool.
|
||||
</VSCodeLink>
|
||||
</p>
|
||||
<p style={{ margin: "5px 0px" }}>
|
||||
You can add and configure MCP servers by clicking the new{" "}
|
||||
<span className="codicon codicon-server" style={{ fontSize: "10px" }}></span> icon in the menu bar.
|
||||
</p>
|
||||
<p style={{ margin: "5px 0px" }}>
|
||||
To take things a step further, Cline also has the ability to create custom tools for himself. Just say
|
||||
"add a tool that..." and watch as he builds and installs new capabilities specific to{" "}
|
||||
<i>your workflow</i>. For example:
|
||||
<ul style={{ margin: "4px 0 6px 20px", padding: 0 }}>
|
||||
<li>"...fetches Jira tickets": Get ticket ACs and put Cline to work</li>
|
||||
<li>"...manages AWS EC2s": Check server metrics and scale up or down</li>
|
||||
<li>"...pulls PagerDuty incidents": Pulls details to help Cline fix bugs</li>
|
||||
</ul>
|
||||
Cline handles everything from creating the MCP server to installing it in the extension, ready to use in
|
||||
future tasks. The servers are saved to <code>~/Documents/Cline/MCP</code> so you can easily share them
|
||||
with others too.{" "}
|
||||
</p>
|
||||
<p style={{ margin: "5px 0px" }}>
|
||||
Try it yourself by asking Cline to "add a tool that gets the latest npm docs", or
|
||||
<VSCodeLink href="https://x.com/sdrzn/status/1867271665086074969" style={{ display: "inline" }}>
|
||||
see a demo of MCP in action here.
|
||||
</VSCodeLink>
|
||||
</p>
|
||||
{/*<ul style={{ margin: "0 0 8px", paddingLeft: "12px" }}>
|
||||
@@ -93,6 +109,14 @@ const Announcement = ({ version, hideAnnouncement }: AnnouncementProps) => {
|
||||
environments)
|
||||
</li>
|
||||
</ul>*/}
|
||||
<div
|
||||
style={{
|
||||
height: "1px",
|
||||
background: "var(--vscode-foreground)",
|
||||
opacity: 0.1,
|
||||
margin: "8px 0",
|
||||
}}
|
||||
/>
|
||||
<p style={{ margin: "0" }}>
|
||||
Join
|
||||
<VSCodeLink style={{ display: "inline" }} href="https://discord.gg/cline">
|
||||
|
||||
@@ -2,13 +2,22 @@ import { VSCodeBadge, VSCodeProgressRing } from "@vscode/webview-ui-toolkit/reac
|
||||
import deepEqual from "fast-deep-equal"
|
||||
import React, { memo, useEffect, useMemo, useRef } from "react"
|
||||
import { useSize } from "react-use"
|
||||
import { ClineApiReqInfo, ClineMessage, ClineSayTool } from "../../../../src/shared/ExtensionMessage"
|
||||
import {
|
||||
ClineApiReqInfo,
|
||||
ClineAskUseMcpServer,
|
||||
ClineMessage,
|
||||
ClineSayTool,
|
||||
} from "../../../../src/shared/ExtensionMessage"
|
||||
import { COMMAND_OUTPUT_STRING } from "../../../../src/shared/combineCommandSequences"
|
||||
import { useExtensionState } from "../../context/ExtensionStateContext"
|
||||
import { findMatchingResourceOrTemplate } from "../../utils/mcp"
|
||||
import { vscode } from "../../utils/vscode"
|
||||
import CodeAccordian, { removeLeadingNonAlphanumeric } from "../common/CodeAccordian"
|
||||
import CodeBlock, { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock"
|
||||
import MarkdownBlock from "../common/MarkdownBlock"
|
||||
import Thumbnails from "../common/Thumbnails"
|
||||
import McpResourceRow from "../mcp/McpResourceRow"
|
||||
import McpToolRow from "../mcp/McpToolRow"
|
||||
import { highlightMentions } from "./TaskHeader"
|
||||
|
||||
interface ChatRowProps {
|
||||
@@ -67,6 +76,7 @@ export const ChatRowContent = ({
|
||||
lastModifiedMessage,
|
||||
isLast,
|
||||
}: ChatRowContentProps) => {
|
||||
const { mcpServers } = useExtensionState()
|
||||
const [cost, apiReqCancelReason, apiReqStreamingFailedMessage] = useMemo(() => {
|
||||
if (message.text != null && message.say === "api_req_started") {
|
||||
const info: ClineApiReqInfo = JSON.parse(message.text)
|
||||
@@ -81,6 +91,9 @@ export const ChatRowContent = ({
|
||||
: undefined
|
||||
const isCommandExecuting =
|
||||
isLast && lastModifiedMessage?.ask === "command" && lastModifiedMessage?.text?.includes(COMMAND_OUTPUT_STRING)
|
||||
|
||||
const isMcpServerResponding = isLast && lastModifiedMessage?.say === "mcp_server_request_started"
|
||||
|
||||
const type = message.type === "ask" ? message.ask : message.say
|
||||
|
||||
const normalColor = "var(--vscode-foreground)"
|
||||
@@ -117,6 +130,21 @@ export const ChatRowContent = ({
|
||||
Cline wants to execute this command:
|
||||
</span>,
|
||||
]
|
||||
case "use_mcp_server":
|
||||
const mcpServerUse = JSON.parse(message.text || "{}") as ClineAskUseMcpServer
|
||||
return [
|
||||
isMcpServerResponding ? (
|
||||
<ProgressIndicator />
|
||||
) : (
|
||||
<span
|
||||
className="codicon codicon-server"
|
||||
style={{ color: normalColor, marginBottom: "-1.5px" }}></span>
|
||||
),
|
||||
<span style={{ color: normalColor, fontWeight: "bold" }}>
|
||||
Cline wants to {mcpServerUse.type === "use_mcp_tool" ? "use a tool" : "access a resource"} on
|
||||
the <code>{mcpServerUse.serverName}</code> MCP server:
|
||||
</span>,
|
||||
]
|
||||
case "completion_result":
|
||||
return [
|
||||
<span
|
||||
@@ -181,7 +209,15 @@ export const ChatRowContent = ({
|
||||
default:
|
||||
return [null, null]
|
||||
}
|
||||
}, [type, cost, apiRequestFailedMessage, isCommandExecuting, apiReqCancelReason])
|
||||
}, [
|
||||
type,
|
||||
cost,
|
||||
apiRequestFailedMessage,
|
||||
isCommandExecuting,
|
||||
apiReqCancelReason,
|
||||
isMcpServerResponding,
|
||||
message.text,
|
||||
])
|
||||
|
||||
const headerStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
@@ -618,6 +654,28 @@ export const ChatRowContent = ({
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
case "mcp_server_response":
|
||||
return (
|
||||
<>
|
||||
<div style={{ paddingTop: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: "4px",
|
||||
opacity: 0.8,
|
||||
fontSize: "12px",
|
||||
textTransform: "uppercase",
|
||||
}}>
|
||||
Response
|
||||
</div>
|
||||
<CodeAccordian
|
||||
code={message.text}
|
||||
language="json"
|
||||
isExpanded={true}
|
||||
onToggleExpand={onToggleExpand}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<>
|
||||
@@ -717,6 +775,81 @@ export const ChatRowContent = ({
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
case "use_mcp_server":
|
||||
const useMcpServer = JSON.parse(message.text || "{}") as ClineAskUseMcpServer
|
||||
const server = mcpServers.find((server) => server.name === useMcpServer.serverName)
|
||||
return (
|
||||
<>
|
||||
<div style={headerStyle}>
|
||||
{icon}
|
||||
{title}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
background: "var(--vscode-textCodeBlock-background)",
|
||||
borderRadius: "3px",
|
||||
padding: "8px 10px",
|
||||
marginTop: "8px",
|
||||
}}>
|
||||
{useMcpServer.type === "access_mcp_resource" && (
|
||||
<McpResourceRow
|
||||
item={{
|
||||
// Use the matched resource/template details, with fallbacks
|
||||
...(findMatchingResourceOrTemplate(
|
||||
useMcpServer.uri || "",
|
||||
server?.resources,
|
||||
server?.resourceTemplates,
|
||||
) || {
|
||||
name: "",
|
||||
mimeType: "",
|
||||
description: "",
|
||||
}),
|
||||
// Always use the actual URI from the request
|
||||
uri: useMcpServer.uri || "",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{useMcpServer.type === "use_mcp_tool" && (
|
||||
<>
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<McpToolRow
|
||||
tool={{
|
||||
name: useMcpServer.toolName || "",
|
||||
description:
|
||||
server?.tools?.find((tool) => tool.name === useMcpServer.toolName)
|
||||
?.description || "",
|
||||
alwaysAllow: server?.tools?.find((tool) => tool.name === useMcpServer.toolName)
|
||||
?.alwaysAllow || false,
|
||||
}}
|
||||
serverName={useMcpServer.serverName}
|
||||
/>
|
||||
</div>
|
||||
{useMcpServer.arguments && useMcpServer.arguments !== "{}" && (
|
||||
<div style={{ marginTop: "8px" }}>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: "4px",
|
||||
opacity: 0.8,
|
||||
fontSize: "12px",
|
||||
textTransform: "uppercase",
|
||||
}}>
|
||||
Arguments
|
||||
</div>
|
||||
<CodeAccordian
|
||||
code={useMcpServer.arguments}
|
||||
language="json"
|
||||
isExpanded={true}
|
||||
onToggleExpand={onToggleExpand}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
case "completion_result":
|
||||
if (message.text) {
|
||||
return (
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
ClineSayTool,
|
||||
ExtensionMessage,
|
||||
} from "../../../../src/shared/ExtensionMessage"
|
||||
import { McpServer, McpTool } from "../../../../src/shared/mcp"
|
||||
import { findLast } from "../../../../src/shared/array"
|
||||
import { combineApiRequests } from "../../../../src/shared/combineApiRequests"
|
||||
import { combineCommandSequences } from "../../../../src/shared/combineCommandSequences"
|
||||
@@ -36,7 +37,7 @@ interface ChatViewProps {
|
||||
export const MAX_IMAGES_PER_MESSAGE = 20 // Anthropic limits to 20 images
|
||||
|
||||
const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryView }: ChatViewProps) => {
|
||||
const { version, clineMessages: messages, taskHistory, apiConfiguration, alwaysAllowBrowser, alwaysAllowReadOnly, alwaysAllowWrite, alwaysAllowExecute, allowedCommands } = useExtensionState()
|
||||
const { version, clineMessages: messages, taskHistory, apiConfiguration, mcpServers, alwaysAllowBrowser, alwaysAllowReadOnly, alwaysAllowWrite, alwaysAllowExecute, alwaysAllowMcp, allowedCommands } = useExtensionState()
|
||||
|
||||
//const task = messages.length > 0 ? (messages[0].say === "task" ? messages[0] : undefined) : undefined) : undefined
|
||||
const task = useMemo(() => messages.at(0), [messages]) // leaving this less safe version here since if the first message is not a task, then the extension is in a bad state and needs to be debugged (see Cline.abort)
|
||||
@@ -156,6 +157,13 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
|
||||
setPrimaryButtonText("Proceed While Running")
|
||||
setSecondaryButtonText(undefined)
|
||||
break
|
||||
case "use_mcp_server":
|
||||
setTextAreaDisabled(isPartial)
|
||||
setClineAsk("use_mcp_server")
|
||||
setEnableButtons(!isPartial)
|
||||
setPrimaryButtonText("Approve")
|
||||
setSecondaryButtonText("Reject")
|
||||
break
|
||||
case "completion_result":
|
||||
// extension waiting for feedback. but we can just present a new task button
|
||||
playSoundOnMessage("celebration")
|
||||
@@ -205,6 +213,8 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
|
||||
case "browser_action":
|
||||
case "browser_action_result":
|
||||
case "command_output":
|
||||
case "mcp_server_request_started":
|
||||
case "mcp_server_response":
|
||||
case "completion_result":
|
||||
case "tool":
|
||||
break
|
||||
@@ -273,6 +283,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
|
||||
case "browser_action_launch":
|
||||
case "command": // user can provide feedback to a tool or command use
|
||||
case "command_output": // user can send input to command stdin
|
||||
case "use_mcp_server":
|
||||
case "completion_result": // if this happens then the user has feedback for the completion result
|
||||
case "resume_task":
|
||||
case "resume_completed_task":
|
||||
@@ -314,6 +325,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
|
||||
case "command_output":
|
||||
case "tool":
|
||||
case "browser_action_launch":
|
||||
case "use_mcp_server":
|
||||
case "resume_task":
|
||||
case "mistake_limit_reached":
|
||||
vscode.postMessage({ type: "askResponse", askResponse: "yesButtonClicked" })
|
||||
@@ -348,6 +360,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
|
||||
case "command":
|
||||
case "tool":
|
||||
case "browser_action_launch":
|
||||
case "use_mcp_server":
|
||||
// responds to the API with a "This operation failed" and lets it try again
|
||||
vscode.postMessage({ type: "askResponse", askResponse: "noButtonClicked" })
|
||||
break
|
||||
@@ -463,6 +476,8 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
|
||||
return false
|
||||
}
|
||||
break
|
||||
case "mcp_server_request_started":
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
@@ -753,6 +768,19 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
|
||||
return false
|
||||
}
|
||||
|
||||
const isMcpToolAlwaysAllowed = () => {
|
||||
const lastMessage = messages.at(-1)
|
||||
if (lastMessage?.type === "ask" && lastMessage.ask === "use_mcp_server" && lastMessage.text) {
|
||||
const mcpServerUse = JSON.parse(lastMessage.text) as { type: string; serverName: string; toolName: string }
|
||||
if (mcpServerUse.type === "use_mcp_tool") {
|
||||
const server = mcpServers?.find((s: McpServer) => s.name === mcpServerUse.serverName)
|
||||
const tool = server?.tools?.find((t: McpTool) => t.name === mcpServerUse.toolName)
|
||||
return tool?.alwaysAllow || false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const isAllowedCommand = () => {
|
||||
const lastMessage = messages.at(-1)
|
||||
if (lastMessage?.type === "ask" && lastMessage.text) {
|
||||
@@ -774,11 +802,12 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
|
||||
(alwaysAllowBrowser && clineAsk === "browser_action_launch") ||
|
||||
(alwaysAllowReadOnly && clineAsk === "tool" && isReadOnlyToolAction()) ||
|
||||
(alwaysAllowWrite && clineAsk === "tool" && isWriteToolAction()) ||
|
||||
(alwaysAllowExecute && clineAsk === "command" && isAllowedCommand())
|
||||
(alwaysAllowExecute && clineAsk === "command" && isAllowedCommand()) ||
|
||||
(alwaysAllowMcp && clineAsk === "use_mcp_server" && isMcpToolAlwaysAllowed())
|
||||
) {
|
||||
handlePrimaryButtonClick()
|
||||
}
|
||||
}, [clineAsk, enableButtons, handlePrimaryButtonClick, alwaysAllowBrowser, alwaysAllowReadOnly, alwaysAllowWrite, alwaysAllowExecute, messages, allowedCommands])
|
||||
}, [clineAsk, enableButtons, handlePrimaryButtonClick, alwaysAllowBrowser, alwaysAllowReadOnly, alwaysAllowWrite, alwaysAllowExecute, alwaysAllowMcp, messages, allowedCommands, mcpServers])
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -824,7 +853,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
|
||||
I can handle complex software development tasks step-by-step. With tools that let me create
|
||||
& edit files, explore complex projects, use the browser, and execute terminal commands
|
||||
(after you grant permission), I can assist you in ways that go beyond code completion or
|
||||
tech support.
|
||||
tech support. I can even use MCP to create new tools and extend my own capabilities.
|
||||
</p>
|
||||
</div>
|
||||
{taskHistory.length > 0 && <HistoryPreview showHistoryView={showHistoryView} />}
|
||||
|
||||
@@ -10,6 +10,46 @@ interface MarkdownBlockProps {
|
||||
markdown?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom remark plugin that converts plain URLs in text into clickable links
|
||||
*
|
||||
* The original bug: We were converting text nodes into paragraph nodes,
|
||||
* which broke the markdown structure because text nodes should remain as text nodes
|
||||
* within their parent elements (like paragraphs, list items, etc.).
|
||||
* This caused the entire content to disappear because the structure became invalid.
|
||||
*/
|
||||
const remarkUrlToLink = () => {
|
||||
return (tree: any) => {
|
||||
// Visit all "text" nodes in the markdown AST (Abstract Syntax Tree)
|
||||
visit(tree, "text", (node: any, index, parent) => {
|
||||
const urlRegex = /https?:\/\/[^\s<>)"]+/g
|
||||
const matches = node.value.match(urlRegex)
|
||||
if (!matches) return
|
||||
|
||||
const parts = node.value.split(urlRegex)
|
||||
const children: any[] = []
|
||||
|
||||
parts.forEach((part: string, i: number) => {
|
||||
if (part) children.push({ type: "text", value: part })
|
||||
if (matches[i]) {
|
||||
children.push({
|
||||
type: "link",
|
||||
url: matches[i],
|
||||
children: [{ type: "text", value: matches[i] }],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Fix: Instead of converting the node to a paragraph (which broke things),
|
||||
// we replace the original text node with our new nodes in the parent's children array.
|
||||
// This preserves the document structure while adding our links.
|
||||
if (parent) {
|
||||
parent.children.splice(index, 1, ...children)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const StyledMarkdown = styled.div`
|
||||
pre {
|
||||
background-color: ${CODE_BLOCK_BG_COLOR};
|
||||
@@ -88,6 +128,15 @@ const StyledMarkdown = styled.div`
|
||||
p {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
a {
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const StyledPre = styled.pre<{ theme: any }>`
|
||||
@@ -111,6 +160,7 @@ const MarkdownBlock = memo(({ markdown }: MarkdownBlockProps) => {
|
||||
const { theme } = useExtensionState()
|
||||
const [reactContent, setMarkdown] = useRemark({
|
||||
remarkPlugins: [
|
||||
remarkUrlToLink,
|
||||
() => {
|
||||
return (tree) => {
|
||||
visit(tree, "code", (node: any) => {
|
||||
|
||||
59
webview-ui/src/components/mcp/McpResourceRow.tsx
Normal file
59
webview-ui/src/components/mcp/McpResourceRow.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { McpResource, McpResourceTemplate } from "../../../../src/shared/mcp"
|
||||
|
||||
type McpResourceRowProps = {
|
||||
item: McpResource | McpResourceTemplate
|
||||
}
|
||||
|
||||
const McpResourceRow = ({ item }: McpResourceRowProps) => {
|
||||
const hasUri = "uri" in item
|
||||
const uri = hasUri ? item.uri : item.uriTemplate
|
||||
|
||||
return (
|
||||
<div
|
||||
key={uri}
|
||||
style={{
|
||||
padding: "3px 0",
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
marginBottom: "4px",
|
||||
}}>
|
||||
<span className={`codicon codicon-symbol-file`} style={{ marginRight: "6px" }} />
|
||||
<span style={{ fontWeight: 500, wordBreak: "break-all" }}>{uri}</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
opacity: 0.8,
|
||||
margin: "4px 0",
|
||||
}}>
|
||||
{item.name && item.description
|
||||
? `${item.name}: ${item.description}`
|
||||
: !item.name && item.description
|
||||
? item.description
|
||||
: !item.description && item.name
|
||||
? item.name
|
||||
: "No description"}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
}}>
|
||||
<span style={{ opacity: 0.8 }}>Returns </span>
|
||||
<code
|
||||
style={{
|
||||
color: "var(--vscode-textPreformat-foreground)",
|
||||
background: "var(--vscode-textPreformat-background)",
|
||||
padding: "1px 4px",
|
||||
borderRadius: "3px",
|
||||
}}>
|
||||
{item.mimeType || "Unknown"}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default McpResourceRow
|
||||
116
webview-ui/src/components/mcp/McpToolRow.tsx
Normal file
116
webview-ui/src/components/mcp/McpToolRow.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react"
|
||||
import { McpTool } from "../../../../src/shared/mcp"
|
||||
import { vscode } from "../../utils/vscode"
|
||||
|
||||
type McpToolRowProps = {
|
||||
tool: McpTool
|
||||
serverName?: string
|
||||
alwaysAllowMcp?: boolean
|
||||
}
|
||||
|
||||
const McpToolRow = ({ tool, serverName, alwaysAllowMcp }: McpToolRowProps) => {
|
||||
const handleAlwaysAllowChange = () => {
|
||||
if (!serverName) return;
|
||||
|
||||
vscode.postMessage({
|
||||
type: "toggleToolAlwaysAllow",
|
||||
serverName,
|
||||
toolName: tool.name,
|
||||
alwaysAllow: !tool.alwaysAllow
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tool.name}
|
||||
style={{
|
||||
padding: "3px 0",
|
||||
}}>
|
||||
<div
|
||||
data-testid="tool-row-container"
|
||||
style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
<div style={{ display: "flex", alignItems: "center" }}>
|
||||
<span className="codicon codicon-symbol-method" style={{ marginRight: "6px" }}></span>
|
||||
<span style={{ fontWeight: 500 }}>{tool.name}</span>
|
||||
</div>
|
||||
{serverName && alwaysAllowMcp && (
|
||||
<VSCodeCheckbox
|
||||
checked={tool.alwaysAllow}
|
||||
onChange={handleAlwaysAllowChange}
|
||||
data-tool={tool.name}>
|
||||
Always allow
|
||||
</VSCodeCheckbox>
|
||||
)}
|
||||
</div>
|
||||
{tool.description && (
|
||||
<div
|
||||
style={{
|
||||
marginLeft: "0px",
|
||||
marginTop: "4px",
|
||||
opacity: 0.8,
|
||||
fontSize: "12px",
|
||||
}}>
|
||||
{tool.description}
|
||||
</div>
|
||||
)}
|
||||
{tool.inputSchema &&
|
||||
"properties" in tool.inputSchema &&
|
||||
Object.keys(tool.inputSchema.properties as Record<string, any>).length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: "8px",
|
||||
fontSize: "12px",
|
||||
border: "1px solid color-mix(in srgb, var(--vscode-descriptionForeground) 30%, transparent)",
|
||||
borderRadius: "3px",
|
||||
padding: "8px",
|
||||
}}>
|
||||
<div
|
||||
style={{ marginBottom: "4px", opacity: 0.8, fontSize: "11px", textTransform: "uppercase" }}>
|
||||
Parameters
|
||||
</div>
|
||||
{Object.entries(tool.inputSchema.properties as Record<string, any>).map(
|
||||
([paramName, schema]) => {
|
||||
const isRequired =
|
||||
tool.inputSchema &&
|
||||
"required" in tool.inputSchema &&
|
||||
Array.isArray(tool.inputSchema.required) &&
|
||||
tool.inputSchema.required.includes(paramName)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={paramName}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
marginTop: "4px",
|
||||
}}>
|
||||
<code
|
||||
style={{
|
||||
color: "var(--vscode-textPreformat-foreground)",
|
||||
marginRight: "8px",
|
||||
}}>
|
||||
{paramName}
|
||||
{isRequired && (
|
||||
<span style={{ color: "var(--vscode-errorForeground)" }}>*</span>
|
||||
)}
|
||||
</code>
|
||||
<span
|
||||
style={{
|
||||
opacity: 0.8,
|
||||
overflowWrap: "break-word",
|
||||
wordBreak: "break-word",
|
||||
}}>
|
||||
{schema.description || "No description"}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default McpToolRow
|
||||
360
webview-ui/src/components/mcp/McpView.tsx
Normal file
360
webview-ui/src/components/mcp/McpView.tsx
Normal file
@@ -0,0 +1,360 @@
|
||||
import {
|
||||
VSCodeButton,
|
||||
VSCodeLink,
|
||||
VSCodePanels,
|
||||
VSCodePanelTab,
|
||||
VSCodePanelView,
|
||||
} from "@vscode/webview-ui-toolkit/react"
|
||||
import { useState } from "react"
|
||||
import { vscode } from "../../utils/vscode"
|
||||
import { useExtensionState } from "../../context/ExtensionStateContext"
|
||||
import { McpServer } from "../../../../src/shared/mcp"
|
||||
import McpToolRow from "./McpToolRow"
|
||||
import McpResourceRow from "./McpResourceRow"
|
||||
|
||||
type McpViewProps = {
|
||||
onDone: () => void
|
||||
}
|
||||
|
||||
const McpView = ({ onDone }: McpViewProps) => {
|
||||
const { mcpServers: servers, alwaysAllowMcp } = useExtensionState()
|
||||
// const [servers, setServers] = useState<McpServer[]>([
|
||||
// // Add some mock servers for testing
|
||||
// {
|
||||
// name: "local-tools",
|
||||
// config: JSON.stringify({
|
||||
// mcpServers: {
|
||||
// "local-tools": {
|
||||
// command: "npx",
|
||||
// args: ["-y", "@modelcontextprotocol/server-tools"],
|
||||
// },
|
||||
// },
|
||||
// }),
|
||||
// status: "connected",
|
||||
// tools: [
|
||||
// {
|
||||
// name: "execute_command",
|
||||
// description: "Run a shell command on the local system",
|
||||
// },
|
||||
// {
|
||||
// name: "read_file",
|
||||
// description: "Read contents of a file from the filesystem",
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
// {
|
||||
// name: "postgres-db",
|
||||
// config: JSON.stringify({
|
||||
// mcpServers: {
|
||||
// "postgres-db": {
|
||||
// command: "npx",
|
||||
// args: ["-y", "@modelcontextprotocol/server-postgres", "postgresql://localhost/mydb"],
|
||||
// },
|
||||
// },
|
||||
// }),
|
||||
// status: "disconnected",
|
||||
// error: "Failed to connect to database: Connection refused",
|
||||
// },
|
||||
// {
|
||||
// name: "github-tools",
|
||||
// config: JSON.stringify({
|
||||
// mcpServers: {
|
||||
// "github-tools": {
|
||||
// command: "npx",
|
||||
// args: ["-y", "@modelcontextprotocol/server-github"],
|
||||
// },
|
||||
// },
|
||||
// }),
|
||||
// status: "connecting",
|
||||
// resources: [
|
||||
// {
|
||||
// uri: "github://repo/issues",
|
||||
// name: "Repository Issues",
|
||||
// },
|
||||
// {
|
||||
// uri: "github://repo/pulls",
|
||||
// name: "Pull Requests",
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
// ])
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: "10px 17px 10px 20px",
|
||||
}}>
|
||||
<h3 style={{ color: "var(--vscode-foreground)", margin: 0 }}>MCP Servers</h3>
|
||||
<VSCodeButton onClick={onDone}>Done</VSCodeButton>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "0 20px" }}>
|
||||
<div
|
||||
style={{
|
||||
color: "var(--vscode-foreground)",
|
||||
fontSize: "13px",
|
||||
marginBottom: "20px",
|
||||
marginTop: "5px",
|
||||
}}>
|
||||
The{" "}
|
||||
<VSCodeLink href="https://github.com/modelcontextprotocol" style={{ display: "inline" }}>
|
||||
Model Context Protocol
|
||||
</VSCodeLink>{" "}
|
||||
enables communication with locally running MCP servers that provide additional tools and resources
|
||||
to extend Cline's capabilities. You can use{" "}
|
||||
<VSCodeLink href="https://github.com/modelcontextprotocol/servers" style={{ display: "inline" }}>
|
||||
community-made servers
|
||||
</VSCodeLink>{" "}
|
||||
or ask Cline to create new tools specific to your workflow (e.g., "add a tool that gets the latest
|
||||
npm docs").
|
||||
</div>
|
||||
|
||||
{/* Server List */}
|
||||
{servers.length > 0 && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
|
||||
{servers.map((server) => (
|
||||
<ServerRow key={server.name} server={server} alwaysAllowMcp={alwaysAllowMcp} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Settings Button */}
|
||||
<div style={{ marginTop: "10px", width: "100%" }}>
|
||||
<VSCodeButton
|
||||
appearance="secondary"
|
||||
style={{ width: "100%" }}
|
||||
onClick={() => {
|
||||
vscode.postMessage({ type: "openMcpSettings" })
|
||||
}}>
|
||||
<span className="codicon codicon-edit" style={{ marginRight: "6px" }}></span>
|
||||
Edit MCP Settings
|
||||
</VSCodeButton>
|
||||
</div>
|
||||
|
||||
{/* Bottom padding */}
|
||||
<div style={{ height: "20px" }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Server Row Component
|
||||
const ServerRow = ({ server, alwaysAllowMcp }: { server: McpServer, alwaysAllowMcp?: boolean }) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
|
||||
const getStatusColor = () => {
|
||||
switch (server.status) {
|
||||
case "connected":
|
||||
return "var(--vscode-testing-iconPassed)"
|
||||
case "connecting":
|
||||
return "var(--vscode-charts-yellow)"
|
||||
case "disconnected":
|
||||
return "var(--vscode-testing-iconFailed)"
|
||||
}
|
||||
}
|
||||
|
||||
const handleRowClick = () => {
|
||||
if (!server.error) {
|
||||
setIsExpanded(!isExpanded)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRestart = () => {
|
||||
vscode.postMessage({
|
||||
type: "restartMcpServer",
|
||||
text: server.name,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: "10px" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
padding: "8px",
|
||||
background: "var(--vscode-textCodeBlock-background)",
|
||||
cursor: server.error ? "default" : "pointer",
|
||||
borderRadius: isExpanded || server.error ? "4px 4px 0 0" : "4px",
|
||||
opacity: server.disabled ? 0.6 : 1,
|
||||
}}
|
||||
onClick={handleRowClick}>
|
||||
{!server.error && (
|
||||
<span
|
||||
className={`codicon codicon-chevron-${isExpanded ? "down" : "right"}`}
|
||||
style={{ marginRight: "8px" }}
|
||||
/>
|
||||
)}
|
||||
<span style={{ flex: 1 }}>{server.name}</span>
|
||||
<div
|
||||
style={{ display: "flex", alignItems: "center", marginRight: "8px" }}
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
<div
|
||||
role="switch"
|
||||
aria-checked={!server.disabled}
|
||||
tabIndex={0}
|
||||
style={{
|
||||
width: "20px",
|
||||
height: "10px",
|
||||
backgroundColor: server.disabled ?
|
||||
"var(--vscode-titleBar-inactiveForeground)" :
|
||||
"var(--vscode-button-background)",
|
||||
borderRadius: "5px",
|
||||
position: "relative",
|
||||
cursor: "pointer",
|
||||
transition: "background-color 0.2s",
|
||||
opacity: server.disabled ? 0.4 : 0.8,
|
||||
}}
|
||||
onClick={() => {
|
||||
vscode.postMessage({
|
||||
type: "toggleMcpServer",
|
||||
serverName: server.name,
|
||||
disabled: !server.disabled
|
||||
});
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
vscode.postMessage({
|
||||
type: "toggleMcpServer",
|
||||
serverName: server.name,
|
||||
disabled: !server.disabled
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
width: "6px",
|
||||
height: "6px",
|
||||
backgroundColor: "var(--vscode-titleBar-activeForeground)",
|
||||
borderRadius: "50%",
|
||||
position: "absolute",
|
||||
top: "2px",
|
||||
left: server.disabled ? "2px" : "12px",
|
||||
transition: "left 0.2s",
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
width: "8px",
|
||||
height: "8px",
|
||||
borderRadius: "50%",
|
||||
background: getStatusColor(),
|
||||
marginLeft: "8px",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{server.error ? (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "13px",
|
||||
background: "var(--vscode-textCodeBlock-background)",
|
||||
borderRadius: "0 0 4px 4px",
|
||||
width: "100%",
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
color: "var(--vscode-testing-iconFailed)",
|
||||
marginBottom: "8px",
|
||||
padding: "0 10px",
|
||||
overflowWrap: "break-word",
|
||||
wordBreak: "break-word",
|
||||
}}>
|
||||
{server.error}
|
||||
</div>
|
||||
<VSCodeButton
|
||||
appearance="secondary"
|
||||
onClick={handleRestart}
|
||||
disabled={server.status === "connecting"}
|
||||
style={{ width: "calc(100% - 20px)", margin: "0 10px 10px 10px" }}>
|
||||
{server.status === "connecting" ? "Retrying..." : "Retry Connection"}
|
||||
</VSCodeButton>
|
||||
</div>
|
||||
) : (
|
||||
isExpanded && (
|
||||
<div
|
||||
style={{
|
||||
background: "var(--vscode-textCodeBlock-background)",
|
||||
padding: "0 10px 10px 10px",
|
||||
fontSize: "13px",
|
||||
borderRadius: "0 0 4px 4px",
|
||||
}}>
|
||||
<VSCodePanels>
|
||||
<VSCodePanelTab id="tools">Tools ({server.tools?.length || 0})</VSCodePanelTab>
|
||||
<VSCodePanelTab id="resources">
|
||||
Resources (
|
||||
{[...(server.resourceTemplates || []), ...(server.resources || [])].length || 0})
|
||||
</VSCodePanelTab>
|
||||
|
||||
<VSCodePanelView id="tools-view">
|
||||
{server.tools && server.tools.length > 0 ? (
|
||||
<div
|
||||
style={{ display: "flex", flexDirection: "column", gap: "8px", width: "100%" }}>
|
||||
{server.tools.map((tool) => (
|
||||
<McpToolRow
|
||||
key={tool.name}
|
||||
tool={tool}
|
||||
serverName={server.name}
|
||||
alwaysAllowMcp={alwaysAllowMcp}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ padding: "10px 0", color: "var(--vscode-descriptionForeground)" }}>
|
||||
No tools found
|
||||
</div>
|
||||
)}
|
||||
</VSCodePanelView>
|
||||
|
||||
<VSCodePanelView id="resources-view">
|
||||
{(server.resources && server.resources.length > 0) ||
|
||||
(server.resourceTemplates && server.resourceTemplates.length > 0) ? (
|
||||
<div
|
||||
style={{ display: "flex", flexDirection: "column", gap: "8px", width: "100%" }}>
|
||||
{[...(server.resourceTemplates || []), ...(server.resources || [])].map(
|
||||
(item) => (
|
||||
<McpResourceRow
|
||||
key={"uriTemplate" in item ? item.uriTemplate : item.uri}
|
||||
item={item}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ padding: "10px 0", color: "var(--vscode-descriptionForeground)" }}>
|
||||
No resources found
|
||||
</div>
|
||||
)}
|
||||
</VSCodePanelView>
|
||||
</VSCodePanels>
|
||||
|
||||
<VSCodeButton
|
||||
appearance="secondary"
|
||||
onClick={handleRestart}
|
||||
disabled={server.status === "connecting"}
|
||||
style={{ width: "calc(100% - 14px)", margin: "0 7px 3px 7px" }}>
|
||||
{server.status === "connecting" ? "Restarting..." : "Restart Server"}
|
||||
</VSCodeButton>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default McpView
|
||||
107
webview-ui/src/components/mcp/__tests__/McpToolRow.test.tsx
Normal file
107
webview-ui/src/components/mcp/__tests__/McpToolRow.test.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import React from 'react'
|
||||
import { render, fireEvent, screen } from '@testing-library/react'
|
||||
import McpToolRow from '../McpToolRow'
|
||||
import { vscode } from '../../../utils/vscode'
|
||||
|
||||
jest.mock('../../../utils/vscode', () => ({
|
||||
vscode: {
|
||||
postMessage: jest.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
describe('McpToolRow', () => {
|
||||
const mockTool = {
|
||||
name: 'test-tool',
|
||||
description: 'A test tool',
|
||||
alwaysAllow: false
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders tool name and description', () => {
|
||||
render(<McpToolRow tool={mockTool} />)
|
||||
|
||||
expect(screen.getByText('test-tool')).toBeInTheDocument()
|
||||
expect(screen.getByText('A test tool')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show always allow checkbox when serverName is not provided', () => {
|
||||
render(<McpToolRow tool={mockTool} />)
|
||||
|
||||
expect(screen.queryByText('Always allow')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows always allow checkbox when serverName is provided', () => {
|
||||
render(<McpToolRow tool={mockTool} serverName="test-server" />)
|
||||
|
||||
expect(screen.getByText('Always allow')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('sends message to toggle always allow when checkbox is clicked', () => {
|
||||
render(<McpToolRow tool={mockTool} serverName="test-server" />)
|
||||
|
||||
const checkbox = screen.getByRole('checkbox')
|
||||
fireEvent.click(checkbox)
|
||||
|
||||
expect(vscode.postMessage).toHaveBeenCalledWith({
|
||||
type: 'toggleToolAlwaysAllow',
|
||||
serverName: 'test-server',
|
||||
toolName: 'test-tool',
|
||||
alwaysAllow: true
|
||||
})
|
||||
})
|
||||
|
||||
it('reflects always allow state in checkbox', () => {
|
||||
const alwaysAllowedTool = {
|
||||
...mockTool,
|
||||
alwaysAllow: true
|
||||
}
|
||||
|
||||
render(<McpToolRow tool={alwaysAllowedTool} serverName="test-server" />)
|
||||
|
||||
const checkbox = screen.getByRole('checkbox')
|
||||
expect(checkbox).toBeChecked()
|
||||
})
|
||||
|
||||
it('prevents event propagation when clicking the checkbox', () => {
|
||||
const mockStopPropagation = jest.fn()
|
||||
render(<McpToolRow tool={mockTool} serverName="test-server" />)
|
||||
|
||||
const container = screen.getByTestId('tool-row-container')
|
||||
fireEvent.click(container, {
|
||||
stopPropagation: mockStopPropagation
|
||||
})
|
||||
|
||||
expect(mockStopPropagation).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('displays input schema parameters when provided', () => {
|
||||
const toolWithSchema = {
|
||||
...mockTool,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
param1: {
|
||||
type: 'string',
|
||||
description: 'First parameter'
|
||||
},
|
||||
param2: {
|
||||
type: 'number',
|
||||
description: 'Second parameter'
|
||||
}
|
||||
},
|
||||
required: ['param1']
|
||||
}
|
||||
}
|
||||
|
||||
render(<McpToolRow tool={toolWithSchema} serverName="test-server" />)
|
||||
|
||||
expect(screen.getByText('Parameters')).toBeInTheDocument()
|
||||
expect(screen.getByText('param1')).toBeInTheDocument()
|
||||
expect(screen.getByText('param2')).toBeInTheDocument()
|
||||
expect(screen.getByText('First parameter')).toBeInTheDocument()
|
||||
expect(screen.getByText('Second parameter')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -445,6 +445,24 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage }:
|
||||
placeholder={"Enter Model ID..."}>
|
||||
<span style={{ fontWeight: 500 }}>Model ID</span>
|
||||
</VSCodeTextField>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<VSCodeCheckbox
|
||||
checked={apiConfiguration?.includeStreamOptions ?? true}
|
||||
onChange={(e: any) => {
|
||||
const isChecked = e.target.checked
|
||||
setApiConfiguration({
|
||||
...apiConfiguration,
|
||||
includeStreamOptions: isChecked
|
||||
})
|
||||
}}>
|
||||
Include stream options
|
||||
</VSCodeCheckbox>
|
||||
<span
|
||||
className="codicon codicon-info"
|
||||
title="Stream options are for { include_usage: true }. Some providers may not support this option."
|
||||
style={{ marginLeft: '5px', cursor: 'help' }}
|
||||
></span>
|
||||
</div>
|
||||
<VSCodeCheckbox
|
||||
checked={azureApiVersionSelected}
|
||||
onChange={(e: any) => {
|
||||
|
||||
@@ -25,6 +25,8 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
|
||||
setAlwaysAllowExecute,
|
||||
alwaysAllowBrowser,
|
||||
setAlwaysAllowBrowser,
|
||||
alwaysAllowMcp,
|
||||
setAlwaysAllowMcp,
|
||||
soundEnabled,
|
||||
setSoundEnabled,
|
||||
diffEnabled,
|
||||
@@ -50,6 +52,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
|
||||
vscode.postMessage({ type: "alwaysAllowWrite", bool: alwaysAllowWrite })
|
||||
vscode.postMessage({ type: "alwaysAllowExecute", bool: alwaysAllowExecute })
|
||||
vscode.postMessage({ type: "alwaysAllowBrowser", bool: alwaysAllowBrowser })
|
||||
vscode.postMessage({ type: "alwaysAllowMcp", bool: alwaysAllowMcp })
|
||||
vscode.postMessage({ type: "allowedCommands", commands: allowedCommands ?? [] })
|
||||
vscode.postMessage({ type: "soundEnabled", bool: soundEnabled })
|
||||
vscode.postMessage({ type: "diffEnabled", bool: diffEnabled })
|
||||
@@ -142,6 +145,20 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 5 }}>
|
||||
<VSCodeCheckbox checked={diffEnabled} onChange={(e: any) => setDiffEnabled(e.target.checked)}>
|
||||
<span style={{ fontWeight: "500" }}>Enable editing through diffs</span>
|
||||
</VSCodeCheckbox>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
marginTop: "5px",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
When enabled, Cline will be able to edit files more quickly and will automatically reject truncated full-file writes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 5 }}>
|
||||
<VSCodeCheckbox
|
||||
checked={alwaysAllowReadOnly}
|
||||
@@ -159,155 +176,135 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 5 }}>
|
||||
<VSCodeCheckbox
|
||||
checked={alwaysAllowWrite}
|
||||
onChange={(e: any) => setAlwaysAllowWrite(e.target.checked)}>
|
||||
<span style={{ fontWeight: "500" }}>Always approve write operations</span>
|
||||
</VSCodeCheckbox>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
marginTop: "5px",
|
||||
padding: "8px",
|
||||
border: "1px solid var(--vscode-errorBorder)",
|
||||
borderRadius: "4px",
|
||||
color: "var(--vscode-errorForeground)",
|
||||
}}>
|
||||
⚠️ WARNING: When enabled, Cline will automatically create and edit files without requiring approval. This is potentially very dangerous and could lead to unwanted system modifications or security risks. Enable only if you fully trust the AI and understand the risks.
|
||||
<div style={{ marginBottom: 5, border: "2px solid var(--vscode-errorForeground)", borderRadius: "4px", padding: "10px" }}>
|
||||
<h4 style={{ fontWeight: 500, margin: "0 0 10px 0", color: "var(--vscode-errorForeground)" }}>⚠️ High-Risk Auto-Approve Settings</h4>
|
||||
<p style={{ fontSize: "12px", marginBottom: 15, color: "var(--vscode-descriptionForeground)" }}>
|
||||
The following settings allow Cline to automatically perform potentially dangerous operations without requiring approval.
|
||||
Enable these settings only if you fully trust the AI and understand the associated security risks.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 5 }}>
|
||||
<VSCodeCheckbox
|
||||
checked={alwaysAllowBrowser}
|
||||
onChange={(e: any) => setAlwaysAllowBrowser(e.target.checked)}>
|
||||
<span style={{ fontWeight: "500" }}>Always approve browser actions</span>
|
||||
</VSCodeCheckbox>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
marginTop: "5px",
|
||||
padding: "8px",
|
||||
backgroundColor: "var(--vscode-errorBackground)",
|
||||
border: "1px solid var(--vscode-errorBorder)",
|
||||
borderRadius: "4px",
|
||||
color: "var(--vscode-errorForeground)",
|
||||
}}>
|
||||
⚠️ WARNING: When enabled, Cline will automatically perform browser actions without requiring approval. This is potentially very dangerous and could lead to unwanted system modifications or security risks. Enable only if you fully trust the AI and understand the risks.<br/><br/>NOTE: The checkbox only applies when the model supports computer use.
|
||||
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 5 }}>
|
||||
<VSCodeCheckbox
|
||||
checked={alwaysAllowExecute}
|
||||
onChange={(e: any) => setAlwaysAllowExecute(e.target.checked)}>
|
||||
<span style={{ fontWeight: "500" }}>Always approve allowed execute operations</span>
|
||||
</VSCodeCheckbox>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
marginTop: "5px",
|
||||
padding: "8px",
|
||||
backgroundColor: "var(--vscode-errorBackground)",
|
||||
border: "1px solid var(--vscode-errorBorder)",
|
||||
borderRadius: "4px",
|
||||
color: "var(--vscode-errorForeground)",
|
||||
}}>
|
||||
⚠️ WARNING: When enabled, Cline will automatically execute allowed terminal commands without requiring approval. This is potentially very dangerous and could lead to unwanted system modifications or security risks. Enable only if you fully trust the AI and understand the risks.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{alwaysAllowExecute && (
|
||||
<div style={{ marginBottom: 5 }}>
|
||||
<div style={{ marginBottom: "10px" }}>
|
||||
<span style={{ fontWeight: "500" }}>Allowed Auto-Execute Commands</span>
|
||||
<p style={{
|
||||
fontSize: "12px",
|
||||
marginTop: "5px",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
Command prefixes that can be auto-executed when "Always approve execute operations" is enabled.
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', gap: '5px', marginTop: '10px' }}>
|
||||
<VSCodeTextField
|
||||
value={commandInput}
|
||||
onInput={(e: any) => setCommandInput(e.target.value)}
|
||||
placeholder="Enter command prefix (e.g., 'git ')"
|
||||
style={{ flexGrow: 1 }}
|
||||
/>
|
||||
<VSCodeButton onClick={handleAddCommand}>
|
||||
Add
|
||||
</VSCodeButton>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
marginTop: '10px',
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '5px'
|
||||
}}>
|
||||
{(allowedCommands ?? []).map((cmd, index) => (
|
||||
<div key={index} style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '5px',
|
||||
backgroundColor: 'var(--vscode-button-secondaryBackground)',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid var(--vscode-button-secondaryBorder)',
|
||||
height: '24px'
|
||||
}}>
|
||||
<span>{cmd}</span>
|
||||
<VSCodeButton
|
||||
appearance="icon"
|
||||
style={{
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
height: '20px',
|
||||
width: '20px',
|
||||
minWidth: '20px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
onClick={() => {
|
||||
const newCommands = (allowedCommands ?? []).filter((_, i) => i !== index)
|
||||
setAllowedCommands(newCommands)
|
||||
vscode.postMessage({
|
||||
type: "allowedCommands",
|
||||
commands: newCommands
|
||||
})
|
||||
}}
|
||||
>
|
||||
<span className="codicon codicon-close" />
|
||||
</VSCodeButton>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<VSCodeCheckbox
|
||||
checked={alwaysAllowWrite}
|
||||
onChange={(e: any) => setAlwaysAllowWrite(e.target.checked)}>
|
||||
<span style={{ fontWeight: "500" }}>Always approve write operations</span>
|
||||
</VSCodeCheckbox>
|
||||
<p style={{ fontSize: "12px", marginTop: "5px", color: "var(--vscode-descriptionForeground)" }}>
|
||||
Automatically create and edit files without requiring approval
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: 5 }}>
|
||||
<VSCodeCheckbox
|
||||
checked={alwaysAllowBrowser}
|
||||
onChange={(e: any) => setAlwaysAllowBrowser(e.target.checked)}>
|
||||
<span style={{ fontWeight: "500" }}>Always approve browser actions</span>
|
||||
</VSCodeCheckbox>
|
||||
<p style={{ fontSize: "12px", marginTop: "5px", color: "var(--vscode-descriptionForeground)" }}>
|
||||
Automatically perform browser actions without requiring approval<br/>
|
||||
Note: Only applies when the model supports computer use
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 5 }}>
|
||||
<VSCodeCheckbox
|
||||
checked={alwaysAllowMcp}
|
||||
onChange={(e: any) => {
|
||||
setAlwaysAllowMcp(e.target.checked)
|
||||
vscode.postMessage({ type: "alwaysAllowMcp", bool: e.target.checked })
|
||||
}}>
|
||||
<span style={{ fontWeight: "500" }}>Always approve MCP tools</span>
|
||||
</VSCodeCheckbox>
|
||||
<p style={{ fontSize: "12px", marginTop: "5px", color: "var(--vscode-descriptionForeground)" }}>
|
||||
Enable auto-approval of individual MCP tools in the MCP Servers view (requires both this setting and the tool's individual "Always allow" checkbox)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 5 }}>
|
||||
<VSCodeCheckbox
|
||||
checked={alwaysAllowExecute}
|
||||
onChange={(e: any) => setAlwaysAllowExecute(e.target.checked)}>
|
||||
<span style={{ fontWeight: "500" }}>Always approve allowed execute operations</span>
|
||||
</VSCodeCheckbox>
|
||||
<p style={{ fontSize: "12px", marginTop: "5px", color: "var(--vscode-descriptionForeground)" }}>
|
||||
Automatically execute allowed terminal commands without requiring approval
|
||||
</p>
|
||||
|
||||
{alwaysAllowExecute && (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<span style={{ fontWeight: "500" }}>Allowed Auto-Execute Commands</span>
|
||||
<p style={{
|
||||
fontSize: "12px",
|
||||
marginTop: "5px",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
Command prefixes that can be auto-executed when "Always approve execute operations" is enabled.
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', gap: '5px', marginTop: '10px' }}>
|
||||
<VSCodeTextField
|
||||
value={commandInput}
|
||||
onInput={(e: any) => setCommandInput(e.target.value)}
|
||||
placeholder="Enter command prefix (e.g., 'git ')"
|
||||
style={{ flexGrow: 1 }}
|
||||
/>
|
||||
<VSCodeButton onClick={handleAddCommand}>
|
||||
Add
|
||||
</VSCodeButton>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
marginTop: '10px',
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '5px'
|
||||
}}>
|
||||
{(allowedCommands ?? []).map((cmd, index) => (
|
||||
<div key={index} style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '5px',
|
||||
backgroundColor: 'var(--vscode-button-secondaryBackground)',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid var(--vscode-button-secondaryBorder)',
|
||||
height: '24px'
|
||||
}}>
|
||||
<span>{cmd}</span>
|
||||
<VSCodeButton
|
||||
appearance="icon"
|
||||
style={{
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
height: '20px',
|
||||
width: '20px',
|
||||
minWidth: '20px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
onClick={() => {
|
||||
const newCommands = (allowedCommands ?? []).filter((_, i) => i !== index)
|
||||
setAllowedCommands(newCommands)
|
||||
vscode.postMessage({
|
||||
type: "allowedCommands",
|
||||
commands: newCommands
|
||||
})
|
||||
}}
|
||||
>
|
||||
<span className="codicon codicon-close" />
|
||||
</VSCodeButton>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 5 }}>
|
||||
<h4 style={{ fontWeight: 500, marginBottom: 10 }}>Experimental Features</h4>
|
||||
|
||||
<div style={{ marginBottom: 5 }}>
|
||||
<VSCodeCheckbox checked={diffEnabled} onChange={(e: any) => setDiffEnabled(e.target.checked)}>
|
||||
<span style={{ fontWeight: "500" }}>Enable editing through diffs</span>
|
||||
</VSCodeCheckbox>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
marginTop: "5px",
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
When enabled, Cline will be able to apply diffs to make changes to files and will automatically reject truncated full-file edits.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 5 }}>
|
||||
<VSCodeCheckbox checked={soundEnabled} onChange={(e: any) => setSoundEnabled(e.target.checked)}>
|
||||
<span style={{ fontWeight: "500" }}>Enable sound effects</span>
|
||||
|
||||
@@ -31,7 +31,8 @@ const WelcomeView = () => {
|
||||
Claude 3.5 Sonnet's agentic coding capabilities
|
||||
</VSCodeLink>{" "}
|
||||
and access to tools that let me create & edit files, explore complex projects, use the browser, and
|
||||
execute terminal commands (with your permission, of course).
|
||||
execute terminal commands (with your permission, of course). I can even use MCP to create new tools and
|
||||
extend my own capabilities.
|
||||
</p>
|
||||
|
||||
<b>To get started, this extension needs an API provider for Claude 3.5 Sonnet.</b>
|
||||
|
||||
@@ -10,12 +10,14 @@ import {
|
||||
import { vscode } from "../utils/vscode"
|
||||
import { convertTextMateToHljs } from "../utils/textMateToHljs"
|
||||
import { findLastIndex } from "../../../src/shared/array"
|
||||
import { McpServer } from "../../../src/shared/mcp"
|
||||
|
||||
export interface ExtensionStateContextType extends ExtensionState {
|
||||
didHydrateState: boolean
|
||||
showWelcome: boolean
|
||||
theme: any
|
||||
openRouterModels: Record<string, ModelInfo>
|
||||
mcpServers: McpServer[]
|
||||
filePaths: string[]
|
||||
setApiConfiguration: (config: ApiConfiguration) => void
|
||||
setCustomInstructions: (value?: string) => void
|
||||
@@ -23,6 +25,7 @@ export interface ExtensionStateContextType extends ExtensionState {
|
||||
setAlwaysAllowWrite: (value: boolean) => void
|
||||
setAlwaysAllowExecute: (value: boolean) => void
|
||||
setAlwaysAllowBrowser: (value: boolean) => void
|
||||
setAlwaysAllowMcp: (value: boolean) => void
|
||||
setShowAnnouncement: (value: boolean) => void
|
||||
setAllowedCommands: (value: string[]) => void
|
||||
setSoundEnabled: (value: boolean) => void
|
||||
@@ -48,6 +51,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
|
||||
const [openRouterModels, setOpenRouterModels] = useState<Record<string, ModelInfo>>({
|
||||
[openRouterDefaultModelId]: openRouterDefaultModelInfo,
|
||||
})
|
||||
const [mcpServers, setMcpServers] = useState<McpServer[]>([])
|
||||
|
||||
const handleMessage = useCallback((event: MessageEvent) => {
|
||||
const message: ExtensionMessage = event.data
|
||||
@@ -104,6 +108,10 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
|
||||
})
|
||||
break
|
||||
}
|
||||
case "mcpServers": {
|
||||
setMcpServers(message.mcpServers ?? [])
|
||||
break
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -119,6 +127,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
|
||||
showWelcome,
|
||||
theme,
|
||||
openRouterModels,
|
||||
mcpServers,
|
||||
filePaths,
|
||||
setApiConfiguration: (value) => setState((prevState) => ({ ...prevState, apiConfiguration: value })),
|
||||
setCustomInstructions: (value) => setState((prevState) => ({ ...prevState, customInstructions: value })),
|
||||
@@ -126,6 +135,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
|
||||
setAlwaysAllowWrite: (value) => setState((prevState) => ({ ...prevState, alwaysAllowWrite: value })),
|
||||
setAlwaysAllowExecute: (value) => setState((prevState) => ({ ...prevState, alwaysAllowExecute: value })),
|
||||
setAlwaysAllowBrowser: (value) => setState((prevState) => ({ ...prevState, alwaysAllowBrowser: value })),
|
||||
setAlwaysAllowMcp: (value) => setState((prevState) => ({ ...prevState, alwaysAllowMcp: value })),
|
||||
setShowAnnouncement: (value) => setState((prevState) => ({ ...prevState, shouldShowAnnouncement: value })),
|
||||
setAllowedCommands: (value) => setState((prevState) => ({ ...prevState, allowedCommands: value })),
|
||||
setSoundEnabled: (value) => setState((prevState) => ({ ...prevState, soundEnabled: value })),
|
||||
|
||||
45
webview-ui/src/utils/mcp.ts
Normal file
45
webview-ui/src/utils/mcp.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { McpResource, McpResourceTemplate } from "../../../src/shared/mcp"
|
||||
|
||||
/**
|
||||
* Matches a URI against an array of URI templates and returns the matching template
|
||||
* @param uri The URI to match
|
||||
* @param templates Array of URI templates to match against
|
||||
* @returns The matching template or undefined if no match is found
|
||||
*/
|
||||
export function findMatchingTemplate(
|
||||
uri: string,
|
||||
templates: McpResourceTemplate[] = [],
|
||||
): McpResourceTemplate | undefined {
|
||||
return templates.find((template) => {
|
||||
// Convert template to regex pattern
|
||||
const pattern = String(template.uriTemplate)
|
||||
// First escape special regex characters
|
||||
.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||
// Then replace {param} with ([^/]+) to match any non-slash characters
|
||||
// We need to use \{ and \} because we just escaped them
|
||||
.replace(/\\\{([^}]+)\\\}/g, "([^/]+)")
|
||||
|
||||
const regex = new RegExp(`^${pattern}$`)
|
||||
return regex.test(uri)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds either an exact resource match or a matching template for a given URI
|
||||
* @param uri The URI to find a match for
|
||||
* @param resources Array of concrete resources
|
||||
* @param templates Array of resource templates
|
||||
* @returns The matching resource, template, or undefined
|
||||
*/
|
||||
export function findMatchingResourceOrTemplate(
|
||||
uri: string,
|
||||
resources: McpResource[] = [],
|
||||
templates: McpResourceTemplate[] = [],
|
||||
): McpResource | McpResourceTemplate | undefined {
|
||||
// First try to find an exact resource match
|
||||
const exactMatch = resources.find((resource) => resource.uri === uri)
|
||||
if (exactMatch) return exactMatch
|
||||
|
||||
// If no exact match, try to find a matching template
|
||||
return findMatchingTemplate(uri, templates)
|
||||
}
|
||||
Reference in New Issue
Block a user