diff --git a/README.md b/README.md
index 8b021002..b74a1c76 100644
--- a/README.md
+++ b/README.md
@@ -35,14 +35,33 @@ Visit ✈ [Official Website](https://gopeed.com)
- 🗣️ **Available in 20+ languages** — including English, Simplified and Traditional Chinese, Japanese, Korean, and many more.
- 🌐 **Browser integration** — send downloads from Chrome, Edge, Firefox, and other compatible browsers directly to Gopeed.
- 🧩 **JavaScript extensions** — add support for video platforms, AI model hubs, cloud storage services, and other download sources.
+- 🤖 **AI integration** — use Gopeed's MCP endpoint to connect compatible AI agents and create, inspect, or manage downloads with natural language.
- 🔌 **Automation-ready** — integrate with Gopeed through its REST API, CLI, authenticated web UI, webhooks, and post-download scripts.
- 🛠️ **Built-in essentials** — customize headers and the User-Agent, use proxies and GitHub mirrors, receive notifications, and extract archives automatically.
+## 🤖 AI Integration
+
+Connect Gopeed to an AI agent and manage downloads with natural language. For example, you can say:
+
+> Download the latest Gopeed client for Windows.
+
+| Tool | Description |
+| --- | --- |
+| `resolve_task` | Resolve a download URL or URI and return its resource metadata and files before creating a task. |
+| `create_task` | Create and start a task from a resolved resource ID or a direct download request. |
+| `list_tasks` | List tasks, optionally filtering them by ID or status. |
+| `get_task` | Get the request, resource, options, and current progress for one task. |
+| `get_task_status` | Get lightweight runtime status and per-file progress for one task. |
+| `get_task_stats` | Get protocol-specific statistics, including HTTP connections or BitTorrent peers and seeding data. |
+| `pause_task` | Pause a task. |
+| `continue_task` | Continue a paused or failed task. |
+| `delete_task` | Delete a task, optionally deleting its downloaded files. |
+
## ⬇️ Download
### 🧪 Gopeed 2.0.0 Beta
-Gopeed 2.0.0 is currently in public beta. It introduces the new 2.0.0 experience, but some features may still be incomplete or unstable. Please try it and report any issues you encounter.
+Gopeed 2.0.0 is currently in public beta, introducing a redesigned interface, a native communication architecture that connects desktop and mobile clients directly to the Go core through FFI, a more consistent cross-platform experience, improved task management, more flexible API support, and MCP-based AI agent integration. Some features may still be incomplete or unstable, so please try it and report any issues you encounter.
- [Download Gopeed 2.0.0 Beta 1](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.1)
diff --git a/README_ja-JP.md b/README_ja-JP.md
index a8276799..30fb6082 100644
--- a/README_ja-JP.md
+++ b/README_ja-JP.md
@@ -35,14 +35,33 @@ Gopeed(**Go Speed** の略)は、Go と Flutter で開発された高速で
- 🗣️ **20 以上の UI 言語** — 日本語、英語、簡体字・繁体字中国語、韓国語など多くの言語に対応。
- 🌐 **ブラウザー連携** — Chrome、Edge、Firefox などのダウンロードを Gopeed に送信できます。
- 🧩 **JavaScript 拡張** — 動画サイト、AI モデルハブ、クラウドストレージなどのダウンロード元を追加できます。
+- 🤖 **AI 連携** — MCP インターフェースを通じて対応する AI Agent と連携し、自然言語でダウンロードタスクを作成・確認・管理できます。
- 🔌 **オープンなインターフェース** — REST API、CLI、認証付き Web UI、Webhook、完了後スクリプトに対応。
- 🛠️ **便利な内蔵機能** — カスタム Header/User-Agent、Proxy、GitHub ミラー、通知、自動展開。
+## 🤖 AI 連携
+
+Gopeed を AI Agent と連携すると、自然言語でダウンロードを管理できます。たとえば、AI Agent に次のように依頼できます:
+
+> 最新の Gopeed Windows クライアントをダウンロードして
+
+| Tool | 説明 |
+| --- | --- |
+| `resolve_task` | ダウンロード URL または URI を解析し、タスク作成前にリソース情報とファイル一覧を返します。 |
+| `create_task` | 解析済みのリソース ID または直接のダウンロードリクエストからタスクを作成して開始します。 |
+| `list_tasks` | タスクを一覧表示し、必要に応じて ID または状態で絞り込みます。 |
+| `get_task` | 一つのタスクについて、リクエスト、リソース、オプション、現在の進捗を取得します。 |
+| `get_task_status` | 一つのタスクの簡易的な実行状態とファイル別の進捗を取得します。 |
+| `get_task_stats` | HTTP 接続数や BitTorrent の Peer・シード情報など、プロトコル固有の統計を取得します。 |
+| `pause_task` | タスクを一時停止します。 |
+| `continue_task` | 一時停止または失敗したタスクを再開します。 |
+| `delete_task` | タスクを削除し、必要に応じてダウンロード済みファイルも削除します。 |
+
## ⬇️ インストール
### 🧪 Gopeed 2.0.0 Beta
-Gopeed 2.0.0 は現在パブリックベータ版です。新しい 2.0.0 のユーザー体験を先行してお試しいただけますが、一部の機能はまだ不完全または不安定な場合があります。ぜひお試しいただき、問題があればフィードバックをお寄せください。
+Gopeed 2.0.0 は現在パブリックベータ版です。刷新されたインターフェース、デスクトップおよびモバイルから FFI で Go コアへ直接接続するネイティブ通信アーキテクチャ、より統一されたクロスプラットフォーム体験、強化されたタスク管理、より柔軟な API、MCP による AI Agent 連携機能を導入しています。一部の機能はまだ不完全または不安定な場合がありますので、ぜひお試しいただき、問題があればフィードバックをお寄せください。
- [Gopeed 2.0.0 Beta 1 をダウンロード](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.1)
diff --git a/README_vi-VN.md b/README_vi-VN.md
index b3c1786f..f508f657 100644
--- a/README_vi-VN.md
+++ b/README_vi-VN.md
@@ -35,14 +35,33 @@ Truy cập ✈ [Trang web chính thức](https://gopeed.com)
- 🗣️ **Hơn 20 ngôn ngữ giao diện** — gồm tiếng Việt, tiếng Anh, tiếng Trung giản thể và phồn thể, tiếng Nhật, tiếng Hàn cùng nhiều ngôn ngữ khác.
- 🌐 **Tích hợp trình duyệt** — gửi tải xuống từ Chrome, Edge, Firefox và các trình duyệt tương thích sang Gopeed.
- 🧩 **Tiện ích JavaScript** — thêm nguồn tải từ nền tảng video, kho mô hình AI, lưu trữ đám mây và nhiều dịch vụ khác.
+- 🤖 **Tích hợp AI** — cung cấp giao diện MCP để kết nối với các AI Agent tương thích và tạo, kiểm tra hoặc quản lý tác vụ tải xuống bằng ngôn ngữ tự nhiên.
- 🔌 **Giao diện mở** — REST API, CLI, Web UI có xác thực, webhook và script sau khi tải.
- 🛠️ **Công cụ tích hợp** — Header/User-Agent tùy chỉnh, proxy, mirror GitHub, thông báo và tự động giải nén.
+## 🤖 Tích hợp AI
+
+Sau khi kết nối Gopeed với AI Agent, bạn có thể quản lý tải xuống bằng ngôn ngữ tự nhiên. Ví dụ, hãy nói với AI Agent:
+
+> Hãy tải xuống phiên bản Gopeed mới nhất dành cho Windows
+
+| Tool | Mô tả |
+| --- | --- |
+| `resolve_task` | Phân tích URL hoặc URI tải xuống và trả về thông tin tài nguyên cùng danh sách tệp trước khi tạo tác vụ. |
+| `create_task` | Tạo và bắt đầu tác vụ từ ID tài nguyên đã phân tích hoặc từ yêu cầu tải xuống trực tiếp. |
+| `list_tasks` | Liệt kê tác vụ và có thể lọc theo ID hoặc trạng thái. |
+| `get_task` | Lấy yêu cầu, tài nguyên, tùy chọn và tiến độ hiện tại của một tác vụ. |
+| `get_task_status` | Lấy trạng thái chạy rút gọn và tiến độ của từng tệp trong một tác vụ. |
+| `get_task_stats` | Lấy số liệu thống kê theo giao thức, gồm kết nối HTTP hoặc Peer và dữ liệu seed của BitTorrent. |
+| `pause_task` | Tạm dừng tác vụ. |
+| `continue_task` | Tiếp tục tác vụ đã tạm dừng hoặc bị lỗi. |
+| `delete_task` | Xóa tác vụ và có thể xóa cả các tệp đã tải xuống. |
+
## ⬇️ Tải về
### 🧪 Gopeed 2.0.0 Beta
-Gopeed 2.0.0 hiện đang trong giai đoạn beta công khai. Phiên bản này mang đến trải nghiệm 2.0.0 mới, nhưng một số tính năng có thể vẫn chưa hoàn thiện hoặc chưa ổn định. Hãy dùng thử và gửi phản hồi cho chúng tôi nếu bạn gặp vấn đề.
+Gopeed 2.0.0 hiện đang trong giai đoạn beta công khai, với giao diện được thiết kế lại, kiến trúc giao tiếp native kết nối trực tiếp ứng dụng desktop và di động với lõi Go qua FFI, trải nghiệm đa nền tảng nhất quán hơn, khả năng quản lý tác vụ được cải thiện, API linh hoạt hơn và khả năng tích hợp AI Agent qua MCP. Một số tính năng có thể vẫn chưa hoàn thiện hoặc chưa ổn định, vì vậy hãy dùng thử và gửi phản hồi nếu bạn gặp vấn đề.
- [Tải Gopeed 2.0.0 Beta 1](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.1)
diff --git a/README_zh-CN.md b/README_zh-CN.md
index e314ed78..2f70a3ce 100644
--- a/README_zh-CN.md
+++ b/README_zh-CN.md
@@ -35,14 +35,33 @@ Gopeed(全称 **Go Speed**,也叫“够快下载器”)是一款使用 Go
- 🗣️ **20+ 种界面语言** — 支持简体中文、繁体中文、英语、日语、韩语等多种语言。
- 🌐 **浏览器接管** — 支持 Chrome、Edge、Firefox 等兼容浏览器,将下载任务直接发送到 Gopeed。
- 🧩 **JavaScript 扩展** — 扩展视频网站、AI 模型站、网盘等更多下载源。
+- 🤖 **AI 接入** — 开放 MCP 接口,可接入 AI Agent,通过自然语言创建、查询和管理下载任务。
- 🔌 **开放接口** — 通过 REST API、CLI、带身份认证的 Web UI、Webhook 和下载后脚本进行自动化。
- 🛠️ **常用内置能力** — 自定义 Header 与 User-Agent、代理、GitHub 镜像、通知和自动解压。
+## 🤖 AI 接入
+
+将 Gopeed 接入 AI Agent 后,即可通过自然语言管理下载。例如,直接对 AI Agent 说:
+
+> 帮我下载最新的 Gopeed Windows 客户端
+
+| Tool | 功能说明 |
+| --- | --- |
+| `resolve_task` | 解析下载 URL 或 URI,在创建任务前返回资源信息及文件列表。 |
+| `create_task` | 使用已解析的资源 ID 或直接下载请求创建并启动任务。 |
+| `list_tasks` | 查询任务列表,并可按任务 ID 或状态筛选。 |
+| `get_task` | 获取单个任务的请求、资源、选项及当前进度等完整信息。 |
+| `get_task_status` | 获取单个任务的轻量运行状态和各文件下载进度。 |
+| `get_task_stats` | 获取协议相关统计信息,包括 HTTP 连接或 BitTorrent Peer 与做种数据。 |
+| `pause_task` | 暂停任务。 |
+| `continue_task` | 继续已暂停或失败的任务。 |
+| `delete_task` | 删除任务,并可选择同时删除已下载文件。 |
+
## ⬇️ 下载
### 🧪 Gopeed 2.0.0 Beta
-Gopeed 2.0.0 目前处于公开 Beta 测试阶段,带来了全新的 2.0.0 使用体验,但部分功能仍可能不够完善或稳定。欢迎提前体验,并向我们反馈使用过程中遇到的问题。
+Gopeed 2.0.0 目前处于公开 Beta 测试阶段,引入了全新界面、通过 FFI 直连 Go 核心的原生通信架构、更一致的跨平台体验、更完善的任务管理、更灵活的 API,以及用于 AI Agent 接入的 MCP 能力。部分功能仍可能不够完善或稳定,欢迎提前体验并向我们反馈问题。
- [下载 Gopeed 2.0.0 Beta 1](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.1)
diff --git a/README_zh-TW.md b/README_zh-TW.md
index 979c0147..6b981653 100644
--- a/README_zh-TW.md
+++ b/README_zh-TW.md
@@ -35,14 +35,33 @@ Gopeed(**Go Speed** 的縮寫)是一款以 Go 與 Flutter 開發的高速、
- 🗣️ **20+ 種介面語言** — 支援繁體中文、簡體中文、英文、日文、韓文等多種語言。
- 🌐 **瀏覽器整合** — 可接管 Chrome、Edge、Firefox 等相容瀏覽器的下載。
- 🧩 **JavaScript 擴充** — 新增影音網站、AI 模型平台、雲端儲存等下載來源。
+- 🤖 **AI 整合** — 提供 MCP 介面,可串接相容的 AI Agent,以自然語言建立、查詢及管理下載任務。
- 🔌 **開放介面** — 支援 REST API、CLI、認證 Web UI、Webhook 及下載後腳本。
- 🛠️ **實用內建能力** — 自訂 Header/User-Agent、Proxy、GitHub 鏡像、通知及自動解壓縮。
+## 🤖 AI 整合
+
+將 Gopeed 與 AI Agent 串接後,即可透過自然語言管理下載。例如,直接對 AI Agent 說:
+
+> 幫我下載最新的 Gopeed Windows 用戶端
+
+| Tool | 功能說明 |
+| --- | --- |
+| `resolve_task` | 解析下載 URL 或 URI,在建立任務前回傳資源資訊及檔案清單。 |
+| `create_task` | 使用已解析的資源 ID 或直接下載請求建立並啟動任務。 |
+| `list_tasks` | 查詢任務清單,並可依任務 ID 或狀態篩選。 |
+| `get_task` | 取得單一任務的請求、資源、選項及目前進度等完整資訊。 |
+| `get_task_status` | 取得單一任務的簡要執行狀態及各檔案下載進度。 |
+| `get_task_stats` | 取得協定相關統計資訊,包括 HTTP 連線或 BitTorrent Peer 與做種資料。 |
+| `pause_task` | 暫停任務。 |
+| `continue_task` | 繼續已暫停或失敗的任務。 |
+| `delete_task` | 刪除任務,並可選擇同時刪除已下載檔案。 |
+
## ⬇️ 下載
### 🧪 Gopeed 2.0.0 Beta
-Gopeed 2.0.0 目前處於公開 Beta 測試階段,帶來全新的 2.0.0 使用體驗,但部分功能仍可能不夠完善或穩定。歡迎搶先體驗,並向我們回報使用過程中遇到的問題。
+Gopeed 2.0.0 目前處於公開 Beta 測試階段,引入了全新介面、透過 FFI 直連 Go 核心的原生通訊架構、更一致的跨平台體驗、更完善的任務管理、更靈活的 API,以及透過 MCP 串接 AI Agent 的能力。部分功能仍可能不夠完善或穩定,歡迎搶先體驗並向我們回報問題。
- [下載 Gopeed 2.0.0 Beta 1](https://github.com/GopeedLab/gopeed/releases/tag/v2.0.0-beta.1)
diff --git a/cmd/web/flags.go b/cmd/web/flags.go
index 22f5179a..981860bb 100644
--- a/cmd/web/flags.go
+++ b/cmd/web/flags.go
@@ -18,6 +18,7 @@ type args struct {
Username *string `json:"username"`
Password *string `json:"password"`
ApiToken *string `json:"apiToken"`
+ MCPEnable bool `json:"mcpEnable"`
StorageDir *string `json:"storageDir"`
WhiteDownloadDirs []string `json:"whiteDownloadDirs"`
// DownloadConfig when the first time to start the server, it will be configured as initial value
@@ -43,13 +44,21 @@ func parse() *args {
func loadCliArgs() *args {
cfg := &args{}
cfg.Address = flag.String("A", "0.0.0.0", "Bind Address")
+ flag.StringVar(cfg.Address, "address", "0.0.0.0", "Bind Address")
cfg.Port = flag.Int("P", 9999, "Bind Port")
+ flag.IntVar(cfg.Port, "port", 9999, "Bind Port")
cfg.Username = flag.String("u", "gopeed", "Web Authentication Username")
+ flag.StringVar(cfg.Username, "username", "gopeed", "Web Authentication Username")
cfg.Password = flag.String("p", "", "Web Authentication Password, if no password is set, web authentication will not be enabled")
+ flag.StringVar(cfg.Password, "password", "", "Web Authentication Password, if no password is set, web authentication will not be enabled")
cfg.ApiToken = flag.String("T", "", "API token, it must be configured when using HTTP API in the case of enabling web authentication")
+ flag.StringVar(cfg.ApiToken, "api-token", "", "API token, it must be configured when using HTTP API in the case of enabling web authentication")
+ flag.BoolVar(&cfg.MCPEnable, "mcp-enable", false, "Enable MCP endpoint")
cfg.StorageDir = flag.String("d", "", "Storage directory")
- whiteDownloadDirs := flag.String("w", "", "White download directories, comma-separated")
+ flag.StringVar(cfg.StorageDir, "storage-dir", "", "Storage directory")
+ whiteDownloadDirs := flag.String("white-download-dirs", "", "White download directories, comma-separated")
cfg.configPath = flag.String("c", "./config.json", "Config file path")
+ flag.StringVar(cfg.configPath, "config", "./config.json", "Config file path")
flag.Parse()
// Parse white download directories from comma-separated string
@@ -68,21 +77,23 @@ func loadCliArgs() *args {
func overrideWithCliArgs(cfg *args, cliConfig *args) {
flag.Visit(func(f *flag.Flag) {
switch f.Name {
- case "A":
+ case "A", "address":
cfg.Address = cliConfig.Address
- case "P":
+ case "P", "port":
cfg.Port = cliConfig.Port
- case "u":
+ case "u", "username":
cfg.Username = cliConfig.Username
- case "p":
+ case "p", "password":
cfg.Password = cliConfig.Password
- case "T":
+ case "T", "api-token":
cfg.ApiToken = cliConfig.ApiToken
- case "d":
+ case "mcp-enable":
+ cfg.MCPEnable = cliConfig.MCPEnable
+ case "d", "storage-dir":
cfg.StorageDir = cliConfig.StorageDir
- case "w":
+ case "white-download-dirs":
cfg.WhiteDownloadDirs = cliConfig.WhiteDownloadDirs
- case "c":
+ case "c", "config":
cfg.configPath = cliConfig.configPath
}
})
@@ -194,6 +205,10 @@ func loadEnvVars(cfg *args) {
}
field.Set(reflect.ValueOf(dirs))
}
+ } else if field.Kind() == reflect.Bool {
+ if boolVal, err := strconv.ParseBool(envValue); err == nil {
+ field.SetBool(boolVal)
+ }
}
}
}
diff --git a/cmd/web/flags_test.go b/cmd/web/flags_test.go
index 89e48fb7..33e206c3 100644
--- a/cmd/web/flags_test.go
+++ b/cmd/web/flags_test.go
@@ -1,7 +1,9 @@
package main
import (
+ "flag"
"fmt"
+ "io"
"os"
"path/filepath"
"reflect"
@@ -10,6 +12,50 @@ import (
"github.com/GopeedLab/gopeed/pkg/base"
)
+func TestLoadCliArgsLongNames(t *testing.T) {
+ originalCommandLine := flag.CommandLine
+ originalArgs := os.Args
+ t.Cleanup(func() {
+ flag.CommandLine = originalCommandLine
+ os.Args = originalArgs
+ })
+
+ flags := flag.NewFlagSet("gopeed-web-test", flag.ContinueOnError)
+ flags.SetOutput(io.Discard)
+ flag.CommandLine = flags
+ os.Args = []string{
+ "gopeed-web",
+ "--address", "127.0.0.2",
+ "--port", "8080",
+ "--username", "admin",
+ "--password", "password",
+ "--api-token", "token",
+ "--mcp-enable",
+ "--storage-dir", "/tmp/gopeed",
+ "--white-download-dirs", "/tmp/one,/tmp/two",
+ "--config", "/tmp/gopeed.json",
+ }
+
+ cfg := loadCliArgs()
+ if *cfg.Address != "127.0.0.2" || *cfg.Port != 8080 || *cfg.Username != "admin" ||
+ *cfg.Password != "password" || *cfg.ApiToken != "token" || !cfg.MCPEnable ||
+ *cfg.StorageDir != "/tmp/gopeed" || !reflect.DeepEqual(cfg.WhiteDownloadDirs, []string{"/tmp/one", "/tmp/two"}) ||
+ *cfg.configPath != "/tmp/gopeed.json" {
+ t.Fatalf("unexpected long-name CLI config: %+v", cfg)
+ }
+
+ for _, name := range []string{"A", "P", "u", "p", "T", "d", "c"} {
+ if flags.Lookup(name) == nil {
+ t.Errorf("legacy short flag %q is missing", name)
+ }
+ }
+ for _, name := range []string{"M", "w"} {
+ if flags.Lookup(name) != nil {
+ t.Errorf("new option unexpectedly has short flag %q", name)
+ }
+ }
+}
+
func TestSetDefaults(t *testing.T) {
// Create mock CLI configuration with command line default values
cliDefaults := &args{
@@ -165,14 +211,15 @@ func TestOverrideWithCliArgs(t *testing.T) {
t.Run("flag override documentation", func(t *testing.T) {
// Document the expected behavior for each flag:
flagBehaviors := map[string]string{
- "A": "Should override cfg.Address with cliConfig.Address",
- "P": "Should override cfg.Port with cliConfig.Port",
- "u": "Should override cfg.Username with cliConfig.Username",
- "p": "Should override cfg.Password with cliConfig.Password",
- "T": "Should override cfg.ApiToken with cliConfig.ApiToken",
- "d": "Should override cfg.StorageDir with cliConfig.StorageDir",
- "w": "Should override cfg.WhiteDownloadDirs with cliConfig.WhiteDownloadDirs",
- "c": "Should override cfg.configPath with cliConfig.configPath",
+ "A/address": "Should override cfg.Address with cliConfig.Address",
+ "P/port": "Should override cfg.Port with cliConfig.Port",
+ "u/username": "Should override cfg.Username with cliConfig.Username",
+ "p/password": "Should override cfg.Password with cliConfig.Password",
+ "T/api-token": "Should override cfg.ApiToken with cliConfig.ApiToken",
+ "mcp-enable": "Should override cfg.MCPEnable with cliConfig.MCPEnable",
+ "d/storage-dir": "Should override cfg.StorageDir with cliConfig.StorageDir",
+ "white-download-dirs": "Should override cfg.WhiteDownloadDirs with cliConfig.WhiteDownloadDirs",
+ "c/config": "Should override cfg.configPath with cliConfig.configPath",
}
t.Log("Expected flag override behaviors:")
@@ -187,21 +234,23 @@ func TestOverrideWithCliArgs(t *testing.T) {
mockFlagVisit := func(config *args, cliConfig *args, flagName string) {
// Simulate the switch statement in overrideWithCliArgs
switch flagName {
- case "A":
+ case "A", "address":
config.Address = cliConfig.Address
- case "P":
+ case "P", "port":
config.Port = cliConfig.Port
- case "u":
+ case "u", "username":
config.Username = cliConfig.Username
- case "p":
+ case "p", "password":
config.Password = cliConfig.Password
- case "T":
+ case "T", "api-token":
config.ApiToken = cliConfig.ApiToken
- case "d":
+ case "mcp-enable":
+ config.MCPEnable = cliConfig.MCPEnable
+ case "d", "storage-dir":
config.StorageDir = cliConfig.StorageDir
- case "w":
+ case "white-download-dirs":
config.WhiteDownloadDirs = cliConfig.WhiteDownloadDirs
- case "c":
+ case "c", "config":
config.configPath = cliConfig.configPath
default:
t.Errorf("Unknown flag: %s", flagName)
@@ -285,6 +334,20 @@ func TestOverrideWithCliArgs(t *testing.T) {
}
},
},
+ {
+ flagName: "mcp-enable",
+ setupConfig: func() *args {
+ return &args{MCPEnable: false}
+ },
+ setupCliConfig: func() *args {
+ return &args{MCPEnable: true}
+ },
+ verify: func(t *testing.T, cfg *args) {
+ if !cfg.MCPEnable {
+ t.Error("MCPEnable flag override failed: got false, want true")
+ }
+ },
+ },
{
flagName: "d",
setupConfig: func() *args {
@@ -300,7 +363,7 @@ func TestOverrideWithCliArgs(t *testing.T) {
},
},
{
- flagName: "w",
+ flagName: "white-download-dirs",
setupConfig: func() *args {
return &args{WhiteDownloadDirs: []string{"/original/dir1", "/original/dir2"}}
},
@@ -402,6 +465,7 @@ func TestLoadConfigFile(t *testing.T) {
"username": "testuser",
"password": "testpass",
"apiToken": "testtoken",
+ "mcpEnable": true,
"storageDir": "/test/storage",
"downloadConfig": {
"downloadDir": "/test/downloads",
@@ -415,6 +479,7 @@ func TestLoadConfigFile(t *testing.T) {
Username: stringPtr("testuser"),
Password: stringPtr("testpass"),
ApiToken: stringPtr("testtoken"),
+ MCPEnable: true,
StorageDir: stringPtr("/test/storage"),
DownloadConfig: &base.DownloaderStoreConfig{
DownloadDir: "/test/downloads",
@@ -476,7 +541,7 @@ func TestLoadEnvVars(t *testing.T) {
originalEnv := make(map[string]string)
envKeys := []string{
"GOPEED_ADDRESS", "GOPEED_PORT", "GOPEED_USERNAME",
- "GOPEED_PASSWORD", "GOPEED_APITOKEN", "GOPEED_STORAGEDIR",
+ "GOPEED_PASSWORD", "GOPEED_APITOKEN", "GOPEED_MCPENABLE", "GOPEED_STORAGEDIR",
"GOPEED_DOWNLOADCONFIG", "GOPEED_WHITEDOWNLOADDIRS",
}
for _, key := range envKeys {
@@ -508,6 +573,7 @@ func TestLoadEnvVars(t *testing.T) {
"GOPEED_USERNAME": "envuser",
"GOPEED_PASSWORD": "envpass",
"GOPEED_APITOKEN": "envtoken",
+ "GOPEED_MCPENABLE": "true",
"GOPEED_STORAGEDIR": "/env/storage",
},
expected: &args{
@@ -516,6 +582,7 @@ func TestLoadEnvVars(t *testing.T) {
Username: stringPtr("envuser"),
Password: stringPtr("envpass"),
ApiToken: stringPtr("envtoken"),
+ MCPEnable: true,
StorageDir: stringPtr("/env/storage"),
},
},
diff --git a/cmd/web/main.go b/cmd/web/main.go
index 43273eff..5a31909d 100644
--- a/cmd/web/main.go
+++ b/cmd/web/main.go
@@ -50,6 +50,7 @@ func main() {
StorageDir: storageDir,
WhiteDownloadDirs: args.WhiteDownloadDirs,
ApiToken: *args.ApiToken,
+ MCPEnable: args.MCPEnable,
DownloadConfig: args.DownloadConfig,
ProductionMode: true,
WebEnable: true,
diff --git a/go.mod b/go.mod
index 785e2142..13db5774 100644
--- a/go.mod
+++ b/go.mod
@@ -16,6 +16,7 @@ require (
github.com/matoous/go-nanoid/v2 v2.0.0
github.com/mattn/go-ieproxy v0.0.12
github.com/mholt/archives v0.1.5
+ github.com/modelcontextprotocol/go-sdk v1.7.0
github.com/monkeyWie/goed2k v0.0.0-20260317100435-7a7575cf2447
github.com/pkg/errors v0.9.1
github.com/rs/dnscache v0.0.0-20230804202142-fc85eb664529
@@ -37,6 +38,7 @@ require (
github.com/felixge/fgprof v0.9.5 // indirect
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
+ github.com/google/jsonschema-go v0.4.3 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
@@ -52,21 +54,25 @@ require (
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.51.0 // indirect
github.com/refraction-networking/utls v1.6.7 // indirect
+ github.com/segmentio/asm v1.1.3 // indirect
+ github.com/segmentio/encoding v0.5.4 // indirect
github.com/sorairolake/lzip-go v0.3.8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/ulikunitz/xz v0.5.15 // indirect
+ github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.uber.org/automaxprocs v1.6.0 // indirect
go.uber.org/mock v0.5.1 // indirect
go4.org v0.0.0-20230225012048-214862532bf5 // indirect
- golang.org/x/sync v0.19.0 // indirect
+ golang.org/x/oauth2 v0.35.0 // indirect
+ golang.org/x/sync v0.20.0 // indirect
)
require (
dario.cat/mergo v1.0.0 // indirect
github.com/Microsoft/go-winio v0.6.1
github.com/ProtonMail/go-crypto v0.0.0-20230717121422-5aa5874ade95 // indirect
- github.com/RoaringBitmap/roaring v1.9.4 // indirect
+ github.com/RoaringBitmap/roaring v1.9.4
github.com/acomagu/bufpipe v1.0.4 // indirect
github.com/alecthomas/atomic v0.1.0-alpha2 // indirect
github.com/anacrolix/chansync v0.7.0 // indirect
@@ -147,13 +153,13 @@ require (
go.opentelemetry.io/otel v1.39.0 // indirect
go.opentelemetry.io/otel/metric v1.39.0 // indirect
go.opentelemetry.io/otel/trace v1.39.0 // indirect
- golang.org/x/crypto v0.46.0 // indirect
- golang.org/x/mod v0.31.0 // indirect
- golang.org/x/net v0.48.0 // indirect
- golang.org/x/sys v0.40.0 // indirect
- golang.org/x/text v0.33.0
- golang.org/x/time v0.14.0 // indirect
- golang.org/x/tools v0.40.0 // indirect
+ golang.org/x/crypto v0.48.0 // indirect
+ golang.org/x/mod v0.33.0 // indirect
+ golang.org/x/net v0.50.0
+ golang.org/x/sys v0.41.0 // indirect
+ golang.org/x/text v0.34.0
+ golang.org/x/time v0.15.0 // indirect
+ golang.org/x/tools v0.42.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
lukechampine.com/blake3 v1.4.1 // indirect
modernc.org/libc v1.67.4 // indirect
diff --git a/go.sum b/go.sum
index 7b328f6d..9c4680f4 100644
--- a/go.sum
+++ b/go.sum
@@ -23,10 +23,6 @@ filippo.io/edwards25519 v1.0.0-rc.1 h1:m0VOOB23frXZvAOK44usCgLWvtsxIoMCTBGJZlpmG
filippo.io/edwards25519 v1.0.0-rc.1/go.mod h1:N1IkdkCkiLB6tki+MYJoSx2JTY9NUlxZE7eHn5EwJns=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
-github.com/GopeedLab/webview_go v0.0.0-20260416010719-fc58e7d53769 h1:iuMR7aHvckis8PKYUDXDa8elcmf34GksWpaYak1EYKw=
-github.com/GopeedLab/webview_go v0.0.0-20260416010719-fc58e7d53769/go.mod h1:FvV19lu9rQ9NcbczN5y8pbWecURRRbWa8tbfFARBDa0=
-github.com/GopeedLab/webview_go v0.0.0-20260423023243-b3f067ccab2f h1:yu//jtofUpHZB+IpB1xIH8z4o4JsFJ6pMvgA1Ww/ElY=
-github.com/GopeedLab/webview_go v0.0.0-20260423023243-b3f067ccab2f/go.mod h1:FvV19lu9rQ9NcbczN5y8pbWecURRRbWa8tbfFARBDa0=
github.com/GopeedLab/webview_go v0.0.0-20260423085439-7a2f88b6e9b5 h1:86LVTaNDM/j/uTAD2HedkXNO9LIQlt642VAdbU/0D/M=
github.com/GopeedLab/webview_go v0.0.0-20260423085439-7a2f88b6e9b5/go.mod h1:FvV19lu9rQ9NcbczN5y8pbWecURRRbWa8tbfFARBDa0=
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
@@ -248,6 +244,8 @@ github.com/gobwas/ws v1.2.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/K
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
+github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
+github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@@ -283,6 +281,8 @@ github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
+github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
@@ -388,6 +388,8 @@ github.com/minio/minlz v1.0.1 h1:OUZUzXcib8diiX+JYxyRLIdomyZYzHct6EShOKtQY2A=
github.com/minio/minlz v1.0.1/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec=
github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8=
+github.com/modelcontextprotocol/go-sdk v1.7.0 h1:yqjY2dsbKAC0LSuWZVBMrHgiG8ukXv6NRo0JiALay44=
+github.com/modelcontextprotocol/go-sdk v1.7.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
@@ -511,6 +513,10 @@ github.com/rs/zerolog v1.31.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWR
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 h1:GHRpF1pTW19a8tTFrMLUcfWwyC0pnifVo2ClaLq+hP8=
github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8=
+github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
+github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
+github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
+github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
@@ -564,6 +570,8 @@ github.com/xiaoqidun/setft v0.0.0-20220310121541-be86327699ad h1:QtuWk6dsrNXc/ug
github.com/xiaoqidun/setft v0.0.0-20220310121541-be86327699ad/go.mod h1:Jj8p9bgKGTPQ+M8CdUMS9p7Mmdoxa3OAcAjJQBu0CcI=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
+github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
+github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
@@ -600,8 +608,8 @@ golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0
golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
-golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
-golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
+golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
+golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -634,8 +642,8 @@ golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
golang.org/x/mod v0.6.0-dev.0.20211013180041-c96bc1413d57/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
-golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
-golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
+golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
+golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -663,13 +671,15 @@ golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
-golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
-golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
+golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
+golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
+golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
+golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -679,8 +689,8 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
-golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
+golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -720,8 +730,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
-golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
+golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
@@ -729,8 +739,8 @@ golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
-golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
-golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
+golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
+golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -743,12 +753,12 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
-golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
-golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
+golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
+golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
-golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
-golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
+golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
+golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -777,8 +787,8 @@ golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapK
golang.org/x/tools v0.1.8-0.20211029000441-d6a9af8af023/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
-golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
-golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
+golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
+golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
diff --git a/pkg/base/model.go b/pkg/base/model.go
index 26417845..46bcace2 100644
--- a/pkg/base/model.go
+++ b/pkg/base/model.go
@@ -282,10 +282,11 @@ func (cfg *DownloaderStoreConfig) Merge(beforeCfg *DownloaderStoreConfig) *Downl
}
type APIServerConfig struct {
- Enable bool `json:"enable"`
- Network string `json:"network"`
- Address string `json:"address"`
- Token string `json:"token"`
+ Enable bool `json:"enable"`
+ MCPEnable bool `json:"mcpEnable"`
+ Network string `json:"network"`
+ Address string `json:"address"`
+ Token string `json:"token"`
}
func (cfg *APIServerConfig) Init() *APIServerConfig {
diff --git a/pkg/mcpserver/server.go b/pkg/mcpserver/server.go
new file mode 100644
index 00000000..62ca0a0c
--- /dev/null
+++ b/pkg/mcpserver/server.go
@@ -0,0 +1,525 @@
+// Package mcpserver exposes Gopeed's task operations over the Model Context
+// Protocol.
+package mcpserver
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "reflect"
+ "strings"
+ "time"
+
+ "github.com/GopeedLab/gopeed/pkg/base"
+ "github.com/GopeedLab/gopeed/pkg/download"
+ "github.com/google/jsonschema-go/jsonschema"
+ "github.com/modelcontextprotocol/go-sdk/mcp"
+)
+
+const developmentVersion = "dev"
+
+const serverInstructions = "Gopeed is a download manager for HTTP, HTTPS, BitTorrent, magnet, and ED2K resources. " +
+ "When the user asks to download a resource and provides a supported URL or URI, use create_task. " +
+ "Use resolve_task first when the user needs resource metadata or wants to select files before creating the task. " +
+ "If the user has not provided a concrete URL or URI, ask for one instead of inventing it."
+
+// NewHandler returns a stateless Streamable HTTP MCP handler.
+func NewHandler(downloader *download.Downloader) http.Handler {
+ server := NewServer(downloader)
+ handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server {
+ return server
+ }, &mcp.StreamableHTTPOptions{
+ Stateless: true,
+ JSONResponse: true,
+ })
+ return http.NewCrossOriginProtection().Handler(handler)
+}
+
+// NewServer builds an MCP server backed by downloader.
+func NewServer(downloader *download.Downloader) *mcp.Server {
+ if downloader == nil {
+ panic("mcpserver: nil downloader")
+ }
+
+ version := base.Version
+ if version == "" {
+ version = developmentVersion
+ }
+ server := mcp.NewServer(&mcp.Implementation{Name: "gopeed", Version: version}, &mcp.ServerOptions{
+ Instructions: serverInstructions,
+ })
+ registerTools(server, downloader)
+ return server
+}
+
+type requestExtra map[string]any
+
+type httpRequestExtra struct {
+ Method string `json:"method,omitempty" jsonschema:"Optional HTTP method; defaults to GET"`
+ Header map[string]string `json:"header,omitempty" jsonschema:"Optional HTTP request headers"`
+ Body string `json:"body,omitempty" jsonschema:"Optional HTTP request body"`
+}
+
+type btRequestExtra struct {
+ Trackers []string `json:"trackers" jsonschema:"BitTorrent tracker URLs to add to the task"`
+}
+
+type httpTaskExtra struct {
+ Connections int `json:"connections,omitempty" jsonschema:"Number of HTTP download connections"`
+ AutoTorrent *bool `json:"autoTorrent,omitempty" jsonschema:"Automatically create a BitTorrent task after downloading a torrent file"`
+ DeleteTorrentAfterDownload *bool `json:"deleteTorrentAfterDownload,omitempty" jsonschema:"Delete the torrent file after automatically creating its BitTorrent task"`
+ AutoExtract *bool `json:"autoExtract,omitempty" jsonschema:"Automatically extract the downloaded archive"`
+ ArchivePassword string `json:"archivePassword,omitempty" jsonschema:"Password for an encrypted archive"`
+ DeleteAfterExtract bool `json:"deleteAfterExtract,omitempty" jsonschema:"Delete archive files after successful extraction"`
+}
+
+// downloadRequest is the MCP-facing form of base.Request. The REST model uses
+// any for Extra, which produces a boolean JSON Schema and is misread as a
+// string by some MCP clients. The MCP schema replaces it with a oneOf union of
+// the supported protocol-specific request objects.
+type downloadRequest struct {
+ URL string `json:"url" jsonschema:"Download URL or URI. Supports HTTP, HTTPS, magnet, BitTorrent torrent sources, and ED2K links"`
+ RawURL string `json:"rawUrl,omitempty" jsonschema:"Original user-provided URL or URI when it differs from url"`
+ Extra requestExtra `json:"extra,omitempty" jsonschema:"Optional protocol-specific request settings"`
+ Labels map[string]string `json:"labels,omitempty" jsonschema:"Optional labels to attach to the download task"`
+ Proxy *base.RequestProxy `json:"proxy,omitempty" jsonschema:"Optional proxy settings for this download request"`
+ SkipVerifyCert bool `json:"skipVerifyCert,omitempty" jsonschema:"Skip TLS certificate verification for this request"`
+}
+
+func (r *downloadRequest) baseRequest() (*base.Request, error) {
+ if r == nil {
+ return nil, nil
+ }
+ if err := r.validateExtra(); err != nil {
+ return nil, err
+ }
+ return &base.Request{
+ RawURL: r.RawURL,
+ URL: r.URL,
+ Extra: r.Extra,
+ Labels: r.Labels,
+ Proxy: r.Proxy,
+ SkipVerifyCert: r.SkipVerifyCert,
+ }, nil
+}
+
+func (r *downloadRequest) validateExtra() error {
+ if len(r.Extra) == 0 {
+ return nil
+ }
+ if _, isBTExtra := r.Extra["trackers"]; isBTExtra {
+ if !isBitTorrentRequest(r.URL) {
+ return fmt.Errorf("req.extra contains BitTorrent settings, but req.url is not a magnet or torrent resource")
+ }
+ return nil
+ }
+
+ scheme := strings.ToUpper(strings.SplitN(r.URL, ":", 2)[0])
+ if scheme != "HTTP" && scheme != "HTTPS" {
+ return fmt.Errorf("req.extra contains HTTP settings, but req.url is not an HTTP or HTTPS URL")
+ }
+ return nil
+}
+
+func isBitTorrentRequest(rawURL string) bool {
+ upperURL := strings.ToUpper(rawURL)
+ path := strings.SplitN(upperURL, "?", 2)[0]
+ return strings.HasPrefix(upperURL, "MAGNET:") ||
+ strings.HasSuffix(path, ".TORRENT") ||
+ strings.HasPrefix(upperURL, "DATA:APPLICATION/X-BITTORRENT;BASE64,")
+}
+
+// downloadOptions is the MCP-facing form of base.Options. Only HTTP currently
+// defines protocol-specific task options, so Extra has one concrete schema.
+type downloadOptions struct {
+ Name string `json:"name,omitempty" jsonschema:"Optional output file or directory name"`
+ Path string `json:"path,omitempty" jsonschema:"Optional download directory"`
+ AsDefaultPath bool `json:"asDefaultPath,omitempty" jsonschema:"Save path as the default download directory after task creation"`
+ SelectFiles []int `json:"selectFiles,omitempty" jsonschema:"Optional zero-based file indexes to download from a multi-file resource"`
+ Extra *httpTaskExtra `json:"extra,omitempty" jsonschema:"Optional HTTP task settings"`
+}
+
+func requestExtraSchema() *jsonschema.Schema {
+ httpSchema, err := jsonschema.For[httpRequestExtra](nil)
+ if err != nil {
+ panic(fmt.Sprintf("mcpserver: build HTTP request extra schema: %v", err))
+ }
+ httpSchema.Title = "HTTP request settings"
+ httpSchema.Description = "Protocol-specific request settings for HTTP and HTTPS downloads. Include at least one setting."
+ httpSchema.AnyOf = []*jsonschema.Schema{
+ {Required: []string{"method"}},
+ {Required: []string{"header"}},
+ {Required: []string{"body"}},
+ }
+
+ btSchema, err := jsonschema.For[btRequestExtra](nil)
+ if err != nil {
+ panic(fmt.Sprintf("mcpserver: build BitTorrent request extra schema: %v", err))
+ }
+ btSchema.Title = "BitTorrent request settings"
+ btSchema.Description = "Protocol-specific request settings for magnet and BitTorrent resources."
+ // A slice is nullable in Go, but an explicitly supplied trackers property
+ // should be an array in the MCP contract.
+ btSchema.Properties["trackers"].Types = nil
+ btSchema.Properties["trackers"].Type = "array"
+
+ return &jsonschema.Schema{
+ Type: "object",
+ Description: "Protocol-specific request settings. Must match exactly one of the HTTP or BitTorrent schemas.",
+ OneOf: []*jsonschema.Schema{httpSchema, btSchema},
+ }
+}
+
+func downloadInputSchema[T any]() *jsonschema.Schema {
+ schema, err := jsonschema.For[T](&jsonschema.ForOptions{
+ TypeSchemas: map[reflect.Type]*jsonschema.Schema{
+ reflect.TypeFor[requestExtra](): requestExtraSchema(),
+ },
+ })
+ if err != nil {
+ panic(fmt.Sprintf("mcpserver: build tool input schema: %v", err))
+ }
+ return schema
+}
+
+func (o *downloadOptions) baseOptions() *base.Options {
+ if o == nil {
+ return nil
+ }
+ return &base.Options{
+ Name: o.Name,
+ Path: o.Path,
+ AsDefaultPath: o.AsDefaultPath,
+ SelectFiles: o.SelectFiles,
+ Extra: o.Extra,
+ }
+}
+
+type resolveTaskInput struct {
+ Req *downloadRequest `json:"req" jsonschema:"Download request to resolve"`
+ Opts *downloadOptions `json:"opts,omitempty" jsonschema:"Optional download settings used while resolving"`
+}
+
+type resolveTaskOutput struct {
+ ID string `json:"id,omitempty"`
+ Res *base.Resource `json:"res"`
+}
+
+type createTaskInput struct {
+ RID string `json:"rid,omitempty" jsonschema:"Optional resource ID returned by resolve_task"`
+ Req *downloadRequest `json:"req,omitempty" jsonschema:"Direct download request; required when rid is omitted"`
+ Opts *downloadOptions `json:"opts,omitempty" jsonschema:"Optional task settings"`
+}
+
+type createTaskOutput struct {
+ ID string `json:"id"`
+}
+
+type taskIDInput struct {
+ ID string `json:"id" jsonschema:"Gopeed task ID"`
+}
+
+type deleteTaskInput struct {
+ ID string `json:"id" jsonschema:"Gopeed task ID"`
+ Force bool `json:"force,omitempty" jsonschema:"Also delete downloaded files from disk"`
+}
+
+type listTasksInput struct {
+ IDs []string `json:"id,omitempty" jsonschema:"Only return tasks with these IDs"`
+ Statuses []string `json:"status,omitempty" jsonschema:"Only return tasks in these states: ready, running, pause, wait, error, or done"`
+ NotStatuses []string `json:"notStatus,omitempty" jsonschema:"Exclude tasks in these states: ready, running, pause, wait, error, or done"`
+}
+
+type TaskSummary struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Protocol string `json:"protocol"`
+ Status base.Status `json:"status"`
+ Uploading bool `json:"uploading"`
+ Downloaded int64 `json:"downloaded"`
+ Total int64 `json:"total"`
+ Speed int64 `json:"speed"`
+ Uploaded int64 `json:"uploaded"`
+ UploadSpeed int64 `json:"uploadSpeed"`
+ ExtractStatus download.ExtractStatus `json:"extractStatus,omitempty"`
+ ExtractProgress int `json:"extractProgress,omitempty"`
+ CreatedAt time.Time `json:"createdAt"`
+ UpdatedAt time.Time `json:"updatedAt"`
+}
+
+type taskDetails struct {
+ TaskSummary
+ Req *base.Request `json:"req,omitempty"`
+ Res *base.Resource `json:"res,omitempty"`
+ Opts *base.Options `json:"opts,omitempty"`
+}
+
+type listTasksOutput struct {
+ Tasks []TaskSummary `json:"tasks"`
+}
+
+type getTaskOutput struct {
+ Task taskDetails `json:"task"`
+}
+
+type getTaskStatusOutput struct {
+ Status *download.TaskRuntimeStatus `json:"status"`
+}
+
+type getTaskStatsOutput struct {
+ Stats any `json:"stats"`
+}
+
+type taskActionOutput struct {
+ ID string `json:"id"`
+ Success bool `json:"success"`
+}
+
+func registerTools(server *mcp.Server, downloader *download.Downloader) {
+ closedWorld := false
+ openWorld := true
+ nondestructive := false
+ destructive := true
+
+ mcp.AddTool(server, &mcp.Tool{
+ Name: "resolve_task",
+ Title: "Resolve task",
+ Description: "Resolve an HTTP, HTTPS, BitTorrent, magnet, or ED2K download request and return its resource metadata and files. Use this before create_task when the user wants to inspect the resource or select files.",
+ InputSchema: downloadInputSchema[resolveTaskInput](),
+ Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true, OpenWorldHint: &openWorld},
+ }, func(_ context.Context, _ *mcp.CallToolRequest, input *resolveTaskInput) (*mcp.CallToolResult, *resolveTaskOutput, error) {
+ if input.Req == nil {
+ return nil, nil, fmt.Errorf("req is required")
+ }
+ req, err := input.Req.baseRequest()
+ if err != nil {
+ return nil, nil, err
+ }
+ result, err := downloader.Resolve(req, input.Opts.baseOptions())
+ if err != nil {
+ return nil, nil, err
+ }
+ return nil, &resolveTaskOutput{ID: result.ID, Res: result.Res}, nil
+ })
+
+ mcp.AddTool(server, &mcp.Tool{
+ Name: "create_task",
+ Title: "Create task",
+ Description: "Create and start a Gopeed download task. Use this when the user asks to download an HTTP or HTTPS URL, a BitTorrent torrent, a magnet URI, or an ED2K link. Accepts either a resource ID from resolve_task or a direct request.",
+ InputSchema: downloadInputSchema[createTaskInput](),
+ Annotations: &mcp.ToolAnnotations{DestructiveHint: &nondestructive, OpenWorldHint: &openWorld},
+ }, func(_ context.Context, _ *mcp.CallToolRequest, input *createTaskInput) (*mcp.CallToolResult, *createTaskOutput, error) {
+ var (
+ id string
+ err error
+ )
+ switch {
+ case input.RID != "":
+ id, err = downloader.Create(input.RID)
+ case input.Req != nil:
+ var req *base.Request
+ req, err = input.Req.baseRequest()
+ if err == nil {
+ id, err = downloader.CreateDirect(req, input.Opts.baseOptions())
+ }
+ default:
+ return nil, nil, fmt.Errorf("rid or req is required")
+ }
+ if err != nil {
+ return nil, nil, err
+ }
+ return nil, &createTaskOutput{ID: id}, nil
+ })
+
+ mcp.AddTool(server, &mcp.Tool{
+ Name: "list_tasks",
+ Title: "List tasks",
+ Description: "List Gopeed tasks, optionally filtered by ID or task status.",
+ Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true, OpenWorldHint: &closedWorld},
+ }, func(_ context.Context, _ *mcp.CallToolRequest, input *listTasksInput) (*mcp.CallToolResult, *listTasksOutput, error) {
+ statuses, err := parseStatuses(input.Statuses)
+ if err != nil {
+ return nil, nil, err
+ }
+ notStatuses, err := parseStatuses(input.NotStatuses)
+ if err != nil {
+ return nil, nil, err
+ }
+ tasks := downloader.GetTasksByFilter(&download.TaskFilter{
+ IDs: input.IDs,
+ Statuses: statuses,
+ NotStatuses: notStatuses,
+ })
+ result := make([]TaskSummary, 0, len(tasks))
+ for _, task := range tasks {
+ summary, err := summarizeTask(downloader, task)
+ if err != nil {
+ return nil, nil, err
+ }
+ result = append(result, summary)
+ }
+ return nil, &listTasksOutput{Tasks: result}, nil
+ })
+
+ mcp.AddTool(server, &mcp.Tool{
+ Name: "get_task",
+ Title: "Get task",
+ Description: "Get one Gopeed task including its request, resource, options, and current progress.",
+ Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true, OpenWorldHint: &closedWorld},
+ }, func(_ context.Context, _ *mcp.CallToolRequest, input *taskIDInput) (*mcp.CallToolResult, *getTaskOutput, error) {
+ task, err := requireTask(downloader, input.ID)
+ if err != nil {
+ return nil, nil, err
+ }
+ summary, err := summarizeTask(downloader, task)
+ if err != nil {
+ return nil, nil, err
+ }
+ details := taskDetails{TaskSummary: summary}
+ if task.Meta != nil {
+ details.Req = task.Meta.Req
+ details.Res = task.Meta.Res
+ details.Opts = task.Meta.Opts
+ }
+ return nil, &getTaskOutput{Task: details}, nil
+ })
+
+ mcp.AddTool(server, &mcp.Tool{
+ Name: "get_task_status",
+ Title: "Get task status",
+ Description: "Get the lightweight runtime status and per-file progress for one Gopeed task.",
+ Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true, OpenWorldHint: &closedWorld},
+ }, func(_ context.Context, _ *mcp.CallToolRequest, input *taskIDInput) (*mcp.CallToolResult, *getTaskStatusOutput, error) {
+ if err := validateTaskID(input.ID); err != nil {
+ return nil, nil, err
+ }
+ status, err := downloader.RuntimeStatus(input.ID)
+ if err != nil {
+ return nil, nil, err
+ }
+ return nil, &getTaskStatusOutput{Status: status}, nil
+ })
+
+ mcp.AddTool(server, &mcp.Tool{
+ Name: "get_task_stats",
+ Title: "Get task statistics",
+ Description: "Get protocol-specific statistics for one Gopeed task, such as HTTP connections or BitTorrent peers and seeding data.",
+ Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true, OpenWorldHint: &closedWorld},
+ }, func(_ context.Context, _ *mcp.CallToolRequest, input *taskIDInput) (*mcp.CallToolResult, *getTaskStatsOutput, error) {
+ if err := validateTaskID(input.ID); err != nil {
+ return nil, nil, err
+ }
+ stats, err := downloader.Stats(input.ID)
+ if err != nil {
+ return nil, nil, err
+ }
+ return nil, &getTaskStatsOutput{Stats: stats}, nil
+ })
+
+ registerTaskAction(server, downloader, "pause_task", "Pause task", "Pause one Gopeed task.", &nondestructive, &closedWorld, downloader.Pause)
+ registerTaskAction(server, downloader, "continue_task", "Continue task", "Continue a paused or failed Gopeed task.", &nondestructive, &openWorld, downloader.Continue)
+
+ mcp.AddTool(server, &mcp.Tool{
+ Name: "delete_task",
+ Title: "Delete task",
+ Description: "Delete one Gopeed task. Downloaded files are preserved unless force is true.",
+ Annotations: &mcp.ToolAnnotations{DestructiveHint: &destructive, IdempotentHint: true, OpenWorldHint: &closedWorld},
+ }, func(_ context.Context, _ *mcp.CallToolRequest, input *deleteTaskInput) (*mcp.CallToolResult, *taskActionOutput, error) {
+ if _, err := requireTask(downloader, input.ID); err != nil {
+ return nil, nil, err
+ }
+ if err := downloader.Delete(&download.TaskFilter{IDs: []string{input.ID}}, input.Force); err != nil {
+ return nil, nil, err
+ }
+ return nil, &taskActionOutput{ID: input.ID, Success: true}, nil
+ })
+}
+
+func registerTaskAction(
+ server *mcp.Server,
+ downloader *download.Downloader,
+ name string,
+ title string,
+ description string,
+ destructiveHint *bool,
+ openWorldHint *bool,
+ action func(*download.TaskFilter) error,
+) {
+ mcp.AddTool(server, &mcp.Tool{
+ Name: name,
+ Title: title,
+ Description: description,
+ Annotations: &mcp.ToolAnnotations{
+ DestructiveHint: destructiveHint,
+ IdempotentHint: true,
+ OpenWorldHint: openWorldHint,
+ },
+ }, func(_ context.Context, _ *mcp.CallToolRequest, input *taskIDInput) (*mcp.CallToolResult, *taskActionOutput, error) {
+ if _, err := requireTask(downloader, input.ID); err != nil {
+ return nil, nil, err
+ }
+ if err := action(&download.TaskFilter{IDs: []string{input.ID}}); err != nil {
+ return nil, nil, err
+ }
+ return nil, &taskActionOutput{ID: input.ID, Success: true}, nil
+ })
+}
+
+func requireTask(downloader *download.Downloader, id string) (*download.Task, error) {
+ if err := validateTaskID(id); err != nil {
+ return nil, err
+ }
+ task := downloader.GetTask(id)
+ if task == nil {
+ return nil, fmt.Errorf("task %q not found", id)
+ }
+ return task, nil
+}
+
+func validateTaskID(id string) error {
+ if strings.TrimSpace(id) == "" {
+ return fmt.Errorf("id is required")
+ }
+ return nil
+}
+
+func summarizeTask(downloader *download.Downloader, task *download.Task) (TaskSummary, error) {
+ status, err := downloader.RuntimeStatus(task.ID)
+ if err != nil {
+ return TaskSummary{}, err
+ }
+ summary := TaskSummary{
+ ID: task.ID,
+ Protocol: task.Protocol,
+ Status: status.Status,
+ Uploading: task.Uploading,
+ Downloaded: status.Downloaded,
+ Total: status.Total,
+ Speed: status.Speed,
+ Uploaded: status.Uploaded,
+ UploadSpeed: status.UploadSpeed,
+ ExtractStatus: status.ExtractStatus,
+ ExtractProgress: status.ExtractProgress,
+ CreatedAt: task.CreatedAt,
+ UpdatedAt: task.UpdatedAt,
+ }
+ if task.Meta != nil && task.Meta.Res != nil && len(task.Meta.Res.Files) > 0 {
+ summary.Name = task.Name()
+ }
+ return summary, nil
+}
+
+func parseStatuses(values []string) ([]base.Status, error) {
+ statuses := make([]base.Status, 0, len(values))
+ for _, value := range values {
+ status := base.Status(value)
+ switch status {
+ case base.DownloadStatusReady, base.DownloadStatusRunning, base.DownloadStatusPause,
+ base.DownloadStatusWait, base.DownloadStatusError, base.DownloadStatusDone:
+ statuses = append(statuses, status)
+ default:
+ return nil, fmt.Errorf("invalid task status %q", value)
+ }
+ }
+ return statuses, nil
+}
diff --git a/pkg/mcpserver/server_test.go b/pkg/mcpserver/server_test.go
new file mode 100644
index 00000000..56d208e1
--- /dev/null
+++ b/pkg/mcpserver/server_test.go
@@ -0,0 +1,215 @@
+package mcpserver
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "reflect"
+ "testing"
+
+ "github.com/GopeedLab/gopeed/pkg/download"
+ "github.com/modelcontextprotocol/go-sdk/mcp"
+)
+
+func TestStreamableHTTPTools(t *testing.T) {
+ downloadServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if got := r.Header.Get("X-Gopeed-Test"); got != "mcp-object" {
+ t.Errorf("download request header = %q, want %q", got, "mcp-object")
+ }
+ _, _ = w.Write([]byte("ok"))
+ }))
+ defer downloadServer.Close()
+
+ downloader := download.NewDownloader(&download.DownloaderConfig{
+ Storage: download.NewMemStorage(),
+ StorageDir: t.TempDir(),
+ })
+ if err := downloader.Setup(); err != nil {
+ t.Fatal(err)
+ }
+ defer downloader.Close()
+
+ httpServer := httptest.NewServer(NewHandler(downloader))
+ defer httpServer.Close()
+
+ client := mcp.NewClient(&mcp.Implementation{Name: "gopeed-test", Version: "1.0.0"}, nil)
+ session, err := client.Connect(context.Background(), &mcp.StreamableClientTransport{Endpoint: httpServer.URL}, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer session.Close()
+
+ tools, err := session.ListTools(context.Background(), nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ got := make([]string, 0, len(tools.Tools))
+ var getTaskTool *mcp.Tool
+ var resolveTaskTool *mcp.Tool
+ var createTaskTool *mcp.Tool
+ for _, tool := range tools.Tools {
+ got = append(got, tool.Name)
+ switch tool.Name {
+ case "get_task":
+ getTaskTool = tool
+ case "resolve_task":
+ resolveTaskTool = tool
+ case "create_task":
+ createTaskTool = tool
+ }
+ }
+ want := []string{
+ "continue_task",
+ "create_task",
+ "delete_task",
+ "get_task",
+ "get_task_stats",
+ "get_task_status",
+ "list_tasks",
+ "pause_task",
+ "resolve_task",
+ }
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("tool names = %v, want %v", got, want)
+ }
+ if getTaskTool == nil {
+ t.Fatal("get_task tool not found")
+ }
+ if resolveTaskTool == nil || createTaskTool == nil {
+ t.Fatal("download tools not found")
+ }
+ if instructions := session.InitializeResult().Instructions; instructions != serverInstructions {
+ t.Fatalf("server instructions = %q, want %q", instructions, serverInstructions)
+ }
+ for _, tool := range []*mcp.Tool{resolveTaskTool, createTaskTool} {
+ inputSchema, err := json.Marshal(tool.InputSchema)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if bytes.Contains(inputSchema, []byte(`"extra":true`)) {
+ t.Fatalf("%s input schema contains unconstrained boolean schema for extra: %s", tool.Name, inputSchema)
+ }
+ var schema map[string]any
+ if err := json.Unmarshal(inputSchema, &schema); err != nil {
+ t.Fatal(err)
+ }
+ reqExtra := nestedSchemaProperty(t, schema, "req", "extra")
+ branches, ok := reqExtra["oneOf"].([]any)
+ if reqExtra["type"] != "object" || !ok || len(branches) != 2 {
+ t.Fatalf("%s req.extra schema = %#v, want an object with two oneOf branches", tool.Name, reqExtra)
+ }
+ optsExtra := nestedSchemaProperty(t, schema, "opts", "extra")
+ if !schemaIncludesType(optsExtra, "object") || optsExtra["additionalProperties"] != false {
+ t.Fatalf("%s opts.extra schema = %#v, want concrete HTTP task settings", tool.Name, optsExtra)
+ }
+ }
+ outputSchema, err := json.Marshal(getTaskTool.OutputSchema)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, property := range [][]byte{[]byte(`"task"`), []byte(`"id"`), []byte(`"req"`), []byte(`"res"`), []byte(`"opts"`)} {
+ if !bytes.Contains(outputSchema, property) {
+ t.Fatalf("get_task output schema %s does not contain property %s", outputSchema, property)
+ }
+ }
+
+ resolveResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
+ Name: "resolve_task",
+ Arguments: map[string]any{
+ "req": map[string]any{
+ "url": downloadServer.URL,
+ "extra": map[string]any{
+ "method": "GET",
+ "header": map[string]any{"X-Gopeed-Test": "mcp-object"},
+ },
+ },
+ },
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if resolveResult.IsError {
+ t.Fatalf("resolve_task returned tool error: %+v", resolveResult.Content)
+ }
+
+ invalidExtraResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
+ Name: "resolve_task",
+ Arguments: map[string]any{
+ "req": map[string]any{
+ "url": downloadServer.URL,
+ "extra": map[string]any{
+ "trackers": []any{"udp://tracker.example.com:80"},
+ },
+ },
+ },
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !invalidExtraResult.IsError {
+ t.Fatal("resolve_task accepted BitTorrent settings for an HTTP URL")
+ }
+
+ result, err := session.CallTool(context.Background(), &mcp.CallToolParams{
+ Name: "list_tasks",
+ Arguments: map[string]any{},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if result.IsError {
+ t.Fatalf("list_tasks returned tool error: %+v", result.Content)
+ }
+ structured, ok := result.StructuredContent.(map[string]any)
+ if !ok {
+ t.Fatalf("structured content type = %T", result.StructuredContent)
+ }
+ if tasks, ok := structured["tasks"].([]any); !ok || len(tasks) != 0 {
+ t.Fatalf("tasks = %#v, want empty array", structured["tasks"])
+ }
+}
+
+func nestedSchemaProperty(t *testing.T, schema map[string]any, path ...string) map[string]any {
+ t.Helper()
+ current := schema
+ for _, name := range path {
+ properties, ok := current["properties"].(map[string]any)
+ if !ok {
+ t.Fatalf("schema has no properties while resolving %v: %#v", path, current)
+ }
+ current, ok = properties[name].(map[string]any)
+ if !ok {
+ t.Fatalf("schema property %q is missing while resolving %v: %#v", name, path, properties)
+ }
+ }
+ return current
+}
+
+func schemaIncludesType(schema map[string]any, want string) bool {
+ switch value := schema["type"].(type) {
+ case string:
+ return value == want
+ case []any:
+ for _, item := range value {
+ if item == want {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+func TestStreamableHTTPRejectsCrossOriginRequest(t *testing.T) {
+ req := httptest.NewRequest(http.MethodPost, "http://localhost/mcp", nil)
+ req.Header.Set("Origin", "https://example.com")
+ recorder := httptest.NewRecorder()
+
+ downloader := download.NewDownloader(&download.DownloaderConfig{Storage: download.NewMemStorage()})
+ NewHandler(downloader).ServeHTTP(recorder, req)
+
+ if recorder.Code != http.StatusForbidden {
+ t.Fatalf("status = %d, want %d", recorder.Code, http.StatusForbidden)
+ }
+}
diff --git a/pkg/rest/api_server_manager.go b/pkg/rest/api_server_manager.go
index 01e1e2a5..030bed88 100644
--- a/pkg/rest/api_server_manager.go
+++ b/pkg/rest/api_server_manager.go
@@ -196,6 +196,7 @@ func (m *apiServerManager) stateLocked(desired *base.APIServerConfig) *model.API
state.Enabled = desired.Enable
}
if m.activeConfig != nil {
+ state.MCPEnabled = m.activeConfig.MCPEnable
state.Network = m.activeConfig.Network
state.Address = m.activeConfig.Address
}
diff --git a/pkg/rest/invoke_test.go b/pkg/rest/invoke_test.go
index 6bafc4da..1d9dfe2e 100644
--- a/pkg/rest/invoke_test.go
+++ b/pkg/rest/invoke_test.go
@@ -5,6 +5,7 @@ import (
"fmt"
"net/http"
"path/filepath"
+ "strings"
"testing"
"github.com/GopeedLab/gopeed/pkg/base"
@@ -45,10 +46,11 @@ func TestAPIServerLifecycleUsesPersistedConfigWithoutRestartingCore(t *testing.T
}
putAPIServerConfig(t, &base.APIServerConfig{
- Enable: true,
- Network: "tcp",
- Address: "127.0.0.1:0",
- Token: "secret",
+ Enable: true,
+ MCPEnable: true,
+ Network: "tcp",
+ Address: "127.0.0.1:0",
+ Token: "secret",
})
state, err := StartAPIServer()
if err != nil {
@@ -58,6 +60,9 @@ func TestAPIServerLifecycleUsesPersistedConfigWithoutRestartingCore(t *testing.T
if port == 0 {
t.Fatal("enabling the REST API did not open a TCP listener")
}
+ if !state.MCPEnabled {
+ t.Fatal("persisted MCP endpoint setting was not applied")
+ }
request, err := http.NewRequest(http.MethodGet, fmt.Sprintf("http://127.0.0.1:%d/api/v1/info", port), nil)
if err != nil {
@@ -73,10 +78,30 @@ func TestAPIServerLifecycleUsesPersistedConfigWithoutRestartingCore(t *testing.T
t.Fatalf("REST API returned status %d", response.StatusCode)
}
+ mcpRequest, err := http.NewRequest(
+ http.MethodPost,
+ fmt.Sprintf("http://127.0.0.1:%d/mcp", port),
+ strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"gopeed-test","version":"1.0.0"}}}`),
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ mcpRequest.Header.Set("Accept", "application/json, text/event-stream")
+ mcpRequest.Header.Set("Content-Type", "application/json")
+ mcpRequest.Header.Set("Authorization", "Bearer secret")
+ mcpResponse, err := http.DefaultClient.Do(mcpRequest)
+ if err != nil {
+ t.Fatal(err)
+ }
+ mcpResponse.Body.Close()
+ if mcpResponse.StatusCode != http.StatusOK {
+ t.Fatalf("MCP endpoint returned status %d", mcpResponse.StatusCode)
+ }
+
putAPIServerConfig(t, &base.APIServerConfig{Enable: false})
if state, err := StopAPIServer(); err != nil {
t.Fatal(err)
- } else if state.Running || state.RunningPort != 0 || state.PendingApply {
+ } else if state.Running || state.MCPEnabled || state.RunningPort != 0 || state.PendingApply {
t.Fatalf("unexpected stopped API server state: %#v", state)
}
diff --git a/pkg/rest/model/server.go b/pkg/rest/model/server.go
index 6f05434b..441a0ffd 100644
--- a/pkg/rest/model/server.go
+++ b/pkg/rest/model/server.go
@@ -19,6 +19,7 @@ type StartConfig struct {
Network string `json:"network"`
Address string `json:"address"`
ApiEnable *bool `json:"apiEnable,omitempty"`
+ MCPEnable bool `json:"mcpEnable"`
RefreshInterval int `json:"refreshInterval"`
Storage Storage `json:"storage"`
StorageDir string `json:"storageDir"`
@@ -84,6 +85,7 @@ type WebAuth struct {
// persisted APIServerConfig remains the desired state.
type APIServerState struct {
Enabled bool `json:"enabled"`
+ MCPEnabled bool `json:"mcpEnabled"`
Running bool `json:"running"`
Network string `json:"network"`
Address string `json:"address"`
diff --git a/pkg/rest/server.go b/pkg/rest/server.go
index 6cff2c0d..a62562ba 100644
--- a/pkg/rest/server.go
+++ b/pkg/rest/server.go
@@ -2,6 +2,7 @@ package rest
import (
"crypto/rand"
+ "crypto/subtle"
"encoding/base64"
"encoding/json"
"fmt"
@@ -19,6 +20,7 @@ import (
goapi "github.com/GopeedLab/gopeed/pkg/api"
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/download"
+ "github.com/GopeedLab/gopeed/pkg/mcpserver"
"github.com/GopeedLab/gopeed/pkg/rest/model"
"github.com/GopeedLab/gopeed/pkg/util"
"github.com/gorilla/handlers"
@@ -119,6 +121,7 @@ func Start(startCfg *model.StartConfig) (port int, err error) {
apiConfig.Init()
}
startCfg.ApiEnable = &apiConfig.Enable
+ startCfg.MCPEnable = apiConfig.MCPEnable
startCfg.Network = apiConfig.Network
startCfg.Address = apiConfig.Address
startCfg.ApiToken = apiConfig.Token
@@ -130,10 +133,11 @@ func Start(startCfg *model.StartConfig) (port int, err error) {
return 0, nil
}
config := &base.APIServerConfig{
- Enable: true,
- Network: startCfg.Network,
- Address: startCfg.Address,
- Token: startCfg.ApiToken,
+ Enable: true,
+ MCPEnable: startCfg.MCPEnable,
+ Network: startCfg.Network,
+ Address: startCfg.Address,
+ Token: startCfg.ApiToken,
}
port, startErr := apiServer.startWithConfigLocked(startCfg, config)
if startErr != nil && startCfg.NativeMode {
@@ -213,6 +217,13 @@ func buildServer(startCfg *model.StartConfig, continueOnStartup bool) (*http.Ser
})
}
r.Path("/api/web/proxy").HandlerFunc(DoProxy)
+ if startCfg.MCPEnable {
+ r.Path("/mcp").Handler(mcpserver.NewHandler(Downloader))
+ } else {
+ r.Path("/mcp").Handler(http.NotFoundHandler())
+ }
+ // Reserve the endpoint namespace so the web UI catch-all cannot serve it.
+ r.PathPrefix("/mcp/").Handler(http.NotFoundHandler())
enableApiToken := startCfg.ApiToken != ""
enableWebAuth := startCfg.WebEnable && startCfg.WebAuth != nil
@@ -260,17 +271,17 @@ func buildServer(startCfg *model.StartConfig, continueOnStartup bool) (*http.Ser
r.Use(func(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- protectedPath := strings.HasPrefix(r.URL.Path, "/api/") || isProtectedWebFilePath(r.URL.Path)
+ protectedPath := strings.HasPrefix(r.URL.Path, "/api/") || isProtectedWebFilePath(r.URL.Path) ||
+ (startCfg.MCPEnable && isMCPPath(r.URL.Path))
if r.URL.Path == "/api/web/login" || !protectedPath {
h.ServeHTTP(w, r)
return
}
if enableApiToken {
- apiTokenHeader := r.Header["X-Api-Token"]
// If an API token header is set, validate it before other authentication methods.
- if len(apiTokenHeader) > 0 {
- if apiTokenHeader[0] == startCfg.ApiToken {
+ if provided, valid := validateAPITokenHeaders(r, startCfg.ApiToken); provided {
+ if valid {
h.ServeHTTP(w, r)
return
}
@@ -314,9 +325,10 @@ func buildServer(startCfg *model.StartConfig, continueOnStartup bool) (*http.Ser
})
server := &http.Server{Handler: handlers.CORS(
- handlers.AllowedHeaders([]string{"Content-Type", "Authorization", "X-Api-Token", "X-Target-Uri"}),
+ handlers.AllowedHeaders([]string{"Accept", "Content-Type", "Authorization", "X-Api-Token", "X-Target-Uri", "Mcp-Method", "Mcp-Name", "Mcp-Protocol-Version", "Mcp-Session-Id", "Last-Event-ID"}),
handlers.AllowedMethods([]string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}),
handlers.AllowedOrigins([]string{"*"}),
+ handlers.ExposedHeaders([]string{"Mcp-Session-Id"}),
handlers.AllowCredentials(),
)(r)}
return server, listener, nil
@@ -328,6 +340,7 @@ func startConfigForAPIServer(config *base.APIServerConfig) *model.StartConfig {
Network: config.Network,
Address: config.Address,
ApiEnable: &enabled,
+ MCPEnable: config.MCPEnable,
ApiToken: config.Token,
NativeMode: true,
Storage: model.StorageMem,
@@ -346,10 +359,34 @@ func cloneAPIServerConfig(config *base.APIServerConfig) *base.APIServerConfig {
func sameAPIServerConfig(left, right *base.APIServerConfig) bool {
return left != nil && right != nil &&
- left.Enable == right.Enable && left.Network == right.Network &&
+ left.Enable == right.Enable && left.MCPEnable == right.MCPEnable && left.Network == right.Network &&
left.Address == right.Address && left.Token == right.Token
}
+func validateAPITokenHeaders(r *http.Request, expected string) (provided bool, valid bool) {
+ for _, token := range r.Header.Values("X-Api-Token") {
+ provided = true
+ if constantTimeTokenEqual(strings.TrimSpace(token), expected) {
+ valid = true
+ }
+ }
+ for _, authorization := range r.Header.Values("Authorization") {
+ parts := strings.Fields(authorization)
+ if len(parts) == 0 || !strings.EqualFold(parts[0], "Bearer") {
+ continue
+ }
+ provided = true
+ if len(parts) == 2 && constantTimeTokenEqual(parts[1], expected) {
+ valid = true
+ }
+ }
+ return provided, valid
+}
+
+func constantTimeTokenEqual(actual, expected string) bool {
+ return len(actual) == len(expected) && subtle.ConstantTimeCompare([]byte(actual), []byte(expected)) == 1
+}
+
func initializeCore(startCfg *model.StartConfig) error {
if APIService != nil {
return nil
@@ -385,6 +422,10 @@ func isProtectedWebFilePath(urlPath string) bool {
return urlPath == "/fs" || strings.HasPrefix(urlPath, "/fs/")
}
+func isMCPPath(urlPath string) bool {
+ return urlPath == "/mcp" || strings.HasPrefix(urlPath, "/mcp/")
+}
+
func requestUsesHTTPS(r *http.Request) bool {
if r.TLS != nil {
return true
diff --git a/pkg/rest/server_test.go b/pkg/rest/server_test.go
index 35d6abed..65dd55b2 100644
--- a/pkg/rest/server_test.go
+++ b/pkg/rest/server_test.go
@@ -780,6 +780,7 @@ func TestApiToken(t *testing.T) {
var cfg = &model.StartConfig{}
cfg.Init()
cfg.ApiToken = "123456"
+ cfg.MCPEnable = true
fileListener := doStart(cfg)
defer func() {
if err := fileListener.Close(); err != nil {
@@ -800,6 +801,112 @@ func TestApiToken(t *testing.T) {
t.Errorf("TestApiToken() got = %v, want %v", status, http.StatusOK)
}
+ status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{
+ "Authorization": "Bearer " + cfg.ApiToken,
+ }, nil)
+ if status != http.StatusOK {
+ t.Errorf("Bearer API token status = %v, want %v", status, http.StatusOK)
+ }
+
+ status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{
+ "Authorization": "bearer " + cfg.ApiToken,
+ }, nil)
+ if status != http.StatusOK {
+ t.Errorf("case-insensitive Bearer API token status = %v, want %v", status, http.StatusOK)
+ }
+
+ status, _ = doHttpRequest0(http.MethodGet, "/api/v1/config", map[string]string{
+ "Authorization": "Bearer invalid",
+ }, nil)
+ if status != http.StatusUnauthorized {
+ t.Errorf("invalid Bearer API token status = %v, want %v", status, http.StatusUnauthorized)
+ }
+
+ request, err := http.NewRequest(http.MethodGet, fmt.Sprintf("http://127.0.0.1:%d/api/v1/config", restPort), nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ request.Header.Set("X-Api-Token", "invalid")
+ request.Header.Set("Authorization", "Bearer "+cfg.ApiToken)
+ response, err := http.DefaultClient.Do(request)
+ if err != nil {
+ t.Fatal(err)
+ }
+ response.Body.Close()
+ if response.StatusCode != http.StatusOK {
+ t.Errorf("one valid token header status = %v, want %v", response.StatusCode, http.StatusOK)
+ }
+
+ mcpInitialize := map[string]any{
+ "jsonrpc": "2.0",
+ "id": 1,
+ "method": "initialize",
+ "params": map[string]any{
+ "protocolVersion": "2025-11-25",
+ "capabilities": map[string]any{},
+ "clientInfo": map[string]any{"name": "gopeed-test", "version": "1.0.0"},
+ },
+ }
+ status, _ = doHttpRequest0(http.MethodPost, "/mcp", map[string]string{
+ "Accept": "application/json, text/event-stream",
+ "Content-Type": "application/json",
+ }, mcpInitialize)
+ if status != http.StatusUnauthorized {
+ t.Errorf("unauthenticated MCP status = %v, want %v", status, http.StatusUnauthorized)
+ }
+ status, _ = doHttpRequest0(http.MethodPost, "/mcp", map[string]string{
+ "Accept": "application/json, text/event-stream",
+ "Content-Type": "application/json",
+ "Authorization": "Bearer " + cfg.ApiToken,
+ }, mcpInitialize)
+ if status != http.StatusOK {
+ t.Errorf("Bearer-authenticated MCP status = %v, want %v", status, http.StatusOK)
+ }
+
+}
+
+func TestMCPRouteToggle(t *testing.T) {
+ initialize := map[string]any{
+ "jsonrpc": "2.0",
+ "id": 1,
+ "method": "initialize",
+ "params": map[string]any{
+ "protocolVersion": "2025-11-25",
+ "capabilities": map[string]any{},
+ "clientInfo": map[string]any{
+ "name": "gopeed-test",
+ "version": "1.0.0",
+ },
+ },
+ }
+ headers := map[string]string{
+ "Accept": "application/json, text/event-stream",
+ "Content-Type": "application/json",
+ }
+
+ t.Run("disabled", func(t *testing.T) {
+ cfg := (&model.StartConfig{Storage: model.StorageMem, ApiToken: "secret"}).Init()
+ fileListener := doStart(cfg)
+ defer fileListener.Close()
+ defer Stop()
+
+ status, _ := doHttpRequest0(http.MethodPost, "/mcp", headers, initialize)
+ if status != http.StatusNotFound {
+ t.Fatalf("disabled MCP status = %d, want %d", status, http.StatusNotFound)
+ }
+ })
+
+ t.Run("enabled", func(t *testing.T) {
+ cfg := (&model.StartConfig{Storage: model.StorageMem, MCPEnable: true}).Init()
+ fileListener := doStart(cfg)
+ defer fileListener.Close()
+ defer Stop()
+
+ status, _ := doHttpRequest0(http.MethodPost, "/mcp", headers, initialize)
+ if status != http.StatusOK {
+ t.Fatalf("enabled MCP status = %d, want %d", status, http.StatusOK)
+ }
+ })
}
func TestBuildServerRejectsWebAPITokenWithoutWebAuth(t *testing.T) {
diff --git a/ui/flutter/assets/agent_icons/LICENSE b/ui/flutter/assets/agent_icons/LICENSE
new file mode 100644
index 00000000..1dd53d2a
--- /dev/null
+++ b/ui/flutter/assets/agent_icons/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2023 LobeHub
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/ui/flutter/assets/agent_icons/NOTICE.md b/ui/flutter/assets/agent_icons/NOTICE.md
new file mode 100644
index 00000000..982a7f25
--- /dev/null
+++ b/ui/flutter/assets/agent_icons/NOTICE.md
@@ -0,0 +1,13 @@
+# Agent icon assets
+
+The SVG assets in this directory are sourced from
+[`@lobehub/icons-static-svg` 1.94.0](https://www.npmjs.com/package/@lobehub/icons-static-svg),
+published by the [Lobe Icons](https://github.com/lobehub/lobe-icons) project under the MIT License.
+
+Product mappings used by Gopeed:
+
+- `deepseek.svg` represents DeepSeek Harness.
+- `zcode.svg` is the Z.ai brand icon used for ZCode.
+- `codebuddy.svg` is the CodeBuddy brand icon used for WorkBuddy because Lobe Icons does not currently include a dedicated WorkBuddy icon.
+
+Product names and logos may be trademarks of their respective owners. Their appearance here identifies compatible MCP clients and does not imply endorsement.
diff --git a/ui/flutter/assets/agent_icons/claude_code.svg b/ui/flutter/assets/agent_icons/claude_code.svg
new file mode 100644
index 00000000..d55522ce
--- /dev/null
+++ b/ui/flutter/assets/agent_icons/claude_code.svg
@@ -0,0 +1 @@
+
diff --git a/ui/flutter/assets/agent_icons/cline.svg b/ui/flutter/assets/agent_icons/cline.svg
new file mode 100644
index 00000000..d7fb67b5
--- /dev/null
+++ b/ui/flutter/assets/agent_icons/cline.svg
@@ -0,0 +1 @@
+
diff --git a/ui/flutter/assets/agent_icons/codebuddy.svg b/ui/flutter/assets/agent_icons/codebuddy.svg
new file mode 100644
index 00000000..04b3e823
--- /dev/null
+++ b/ui/flutter/assets/agent_icons/codebuddy.svg
@@ -0,0 +1 @@
+
diff --git a/ui/flutter/assets/agent_icons/codex.svg b/ui/flutter/assets/agent_icons/codex.svg
new file mode 100644
index 00000000..9ce43929
--- /dev/null
+++ b/ui/flutter/assets/agent_icons/codex.svg
@@ -0,0 +1 @@
+
diff --git a/ui/flutter/assets/agent_icons/cursor.svg b/ui/flutter/assets/agent_icons/cursor.svg
new file mode 100644
index 00000000..79b44c5e
--- /dev/null
+++ b/ui/flutter/assets/agent_icons/cursor.svg
@@ -0,0 +1 @@
+
diff --git a/ui/flutter/assets/agent_icons/deepseek.svg b/ui/flutter/assets/agent_icons/deepseek.svg
new file mode 100644
index 00000000..52eec25c
--- /dev/null
+++ b/ui/flutter/assets/agent_icons/deepseek.svg
@@ -0,0 +1 @@
+
diff --git a/ui/flutter/assets/agent_icons/gemini_cli.svg b/ui/flutter/assets/agent_icons/gemini_cli.svg
new file mode 100644
index 00000000..1b163748
--- /dev/null
+++ b/ui/flutter/assets/agent_icons/gemini_cli.svg
@@ -0,0 +1 @@
+
diff --git a/ui/flutter/assets/agent_icons/github_copilot.svg b/ui/flutter/assets/agent_icons/github_copilot.svg
new file mode 100644
index 00000000..e152afe5
--- /dev/null
+++ b/ui/flutter/assets/agent_icons/github_copilot.svg
@@ -0,0 +1 @@
+
diff --git a/ui/flutter/assets/agent_icons/opencode.svg b/ui/flutter/assets/agent_icons/opencode.svg
new file mode 100644
index 00000000..5c1bb853
--- /dev/null
+++ b/ui/flutter/assets/agent_icons/opencode.svg
@@ -0,0 +1 @@
+
diff --git a/ui/flutter/assets/agent_icons/pi.svg b/ui/flutter/assets/agent_icons/pi.svg
new file mode 100644
index 00000000..04e9e042
--- /dev/null
+++ b/ui/flutter/assets/agent_icons/pi.svg
@@ -0,0 +1 @@
+
diff --git a/ui/flutter/assets/agent_icons/qoder.svg b/ui/flutter/assets/agent_icons/qoder.svg
new file mode 100644
index 00000000..f316f8bb
--- /dev/null
+++ b/ui/flutter/assets/agent_icons/qoder.svg
@@ -0,0 +1 @@
+
diff --git a/ui/flutter/assets/agent_icons/trae.svg b/ui/flutter/assets/agent_icons/trae.svg
new file mode 100644
index 00000000..dbf31a65
--- /dev/null
+++ b/ui/flutter/assets/agent_icons/trae.svg
@@ -0,0 +1 @@
+
diff --git a/ui/flutter/assets/agent_icons/windsurf.svg b/ui/flutter/assets/agent_icons/windsurf.svg
new file mode 100644
index 00000000..8128948c
--- /dev/null
+++ b/ui/flutter/assets/agent_icons/windsurf.svg
@@ -0,0 +1 @@
+
diff --git a/ui/flutter/assets/agent_icons/zcode.svg b/ui/flutter/assets/agent_icons/zcode.svg
new file mode 100644
index 00000000..1a47fd72
--- /dev/null
+++ b/ui/flutter/assets/agent_icons/zcode.svg
@@ -0,0 +1 @@
+
diff --git a/ui/flutter/lib/api/model/downloader_config.dart b/ui/flutter/lib/api/model/downloader_config.dart
index b871bb2c..39b4bbee 100644
--- a/ui/flutter/lib/api/model/downloader_config.dart
+++ b/ui/flutter/lib/api/model/downloader_config.dart
@@ -32,11 +32,18 @@ class DownloaderConfig {
@JsonSerializable()
class ApiServerConfig {
bool enable;
+ bool mcpEnable;
String network;
String address;
String token;
- ApiServerConfig({this.enable = false, this.network = 'tcp', this.address = '127.0.0.1:9999', this.token = ''});
+ ApiServerConfig({
+ this.enable = false,
+ this.mcpEnable = false,
+ this.network = 'tcp',
+ this.address = '127.0.0.1:9999',
+ this.token = '',
+ });
factory ApiServerConfig.fromJson(Map? json) =>
json == null ? ApiServerConfig() : _$ApiServerConfigFromJson(json);
diff --git a/ui/flutter/lib/api/model/downloader_config.g.dart b/ui/flutter/lib/api/model/downloader_config.g.dart
index a4ecba0b..9c82c3ce 100644
--- a/ui/flutter/lib/api/model/downloader_config.g.dart
+++ b/ui/flutter/lib/api/model/downloader_config.g.dart
@@ -39,6 +39,7 @@ Map _$DownloaderConfigToJson(DownloaderConfig instance) => json) => ApiServerConfig(
enable: json['enable'] as bool? ?? false,
+ mcpEnable: json['mcpEnable'] as bool? ?? false,
network: json['network'] as String? ?? 'tcp',
address: json['address'] as String? ?? '127.0.0.1:9999',
token: json['token'] as String? ?? '',
@@ -46,6 +47,7 @@ ApiServerConfig _$ApiServerConfigFromJson(Map json) => ApiServe
Map _$ApiServerConfigToJson(ApiServerConfig instance) => {
'enable': instance.enable,
+ 'mcpEnable': instance.mcpEnable,
'network': instance.network,
'address': instance.address,
'token': instance.token,
diff --git a/ui/flutter/lib/app/application/app_runtime_controller.dart b/ui/flutter/lib/app/application/app_runtime_controller.dart
index 5dfbfbf4..76dedd3e 100644
--- a/ui/flutter/lib/app/application/app_runtime_controller.dart
+++ b/ui/flutter/lib/app/application/app_runtime_controller.dart
@@ -13,6 +13,7 @@ import '../../core/common/start_config.dart';
import '../../core/common/task_event.dart';
import '../../core/entry/app_initializer.dart';
import '../../core/libgopeed_boot.dart';
+import '../../core/network/gopeed/gopeed_transport.dart';
import '../../features/auth/application/web_auth_controller.dart';
import '../../l10n/l10n.dart';
import '../../util/log_util.dart';
@@ -64,11 +65,22 @@ class AppRuntimeController extends AsyncNotifier {
if (current == null) return;
final config = await ref.read(gopeedServiceProvider).getConfig();
final apiState = kIsWeb ? current.apiServerState : (await LibgopeedBoot.instance.getApiServerState()).state;
- final startConfig = _copyStartConfig(current.startConfig)
- ..apiEnable = config.api.enable
- ..network = config.api.network
- ..address = config.api.address
- ..apiToken = config.api.token;
+ final startConfig = _copyStartConfig(current.startConfig);
+ if (kIsWeb) {
+ // The Web client connection may point at a separate development server
+ // from the page itself. Downloader config describes the remote process
+ // and must not replace that client-side endpoint after reload.
+ startConfig
+ ..apiEnable = true
+ ..mcpEnable = config.api.mcpEnable;
+ } else {
+ startConfig
+ ..apiEnable = config.api.enable
+ ..mcpEnable = config.api.mcpEnable
+ ..network = config.api.network
+ ..address = config.api.address
+ ..apiToken = config.api.token;
+ }
state = AsyncValue.data(
AppRuntimeState(
startConfig: startConfig,
@@ -112,6 +124,7 @@ class AppRuntimeController extends AsyncNotifier {
var runningPort = 0;
var apiServerState = const ApiServerState(
enabled: false,
+ mcpEnabled: false,
running: false,
network: '',
address: '',
@@ -141,7 +154,7 @@ class AppRuntimeController extends AsyncNotifier {
if (kIsWeb) {
final webAuth = ref.read(webAuthControllerProvider);
- api.init('tcp', Uri.base.authority, '', onUnauthorized: webAuth.requireLogin);
+ api.init('tcp', startConfig.address, '', onUnauthorized: webAuth.requireLogin);
} else {
api.init(startConfig.network, _runningAddress(startConfig, runningPort), startConfig.apiToken);
}
@@ -150,6 +163,7 @@ class AppRuntimeController extends AsyncNotifier {
if (!kIsWeb) {
startConfig
..apiEnable = config.api.enable
+ ..mcpEnable = config.api.mcpEnable
..network = config.api.network
..address = config.api.address
..apiToken = config.api.token;
@@ -206,7 +220,7 @@ class AppRuntimeController extends AsyncNotifier {
if (kIsWeb) {
cfg
..network = 'tcp'
- ..address = Uri.base.authority;
+ ..address = webApiBaseUrl(Uri.base);
_defaultStartConfig = cfg;
return cfg;
}
@@ -404,6 +418,7 @@ class AppRuntimeController extends AsyncNotifier {
final downloaderConfig = await ref.read(gopeedServiceProvider).getConfig();
downloaderConfig.api
..enable = nextConfig.apiEnable
+ ..mcpEnable = nextConfig.mcpEnable
..network = nextConfig.network
..address = nextConfig.address
..token = nextConfig.apiToken;
@@ -459,6 +474,7 @@ class AppRuntimeController extends AsyncNotifier {
..network = config.network
..address = config.address
..apiEnable = config.apiEnable
+ ..mcpEnable = config.mcpEnable
..storage = config.storage
..storageDir = config.storageDir
..refreshInterval = config.refreshInterval
diff --git a/ui/flutter/lib/core/common/api_server_state.dart b/ui/flutter/lib/core/common/api_server_state.dart
index 7fa4a0c1..e09d1317 100644
--- a/ui/flutter/lib/core/common/api_server_state.dart
+++ b/ui/flutter/lib/core/common/api_server_state.dart
@@ -1,6 +1,7 @@
class ApiServerState {
const ApiServerState({
required this.enabled,
+ required this.mcpEnabled,
required this.running,
required this.network,
required this.address,
@@ -10,6 +11,7 @@ class ApiServerState {
});
final bool enabled;
+ final bool mcpEnabled;
final bool running;
final String network;
final String address;
@@ -20,6 +22,7 @@ class ApiServerState {
factory ApiServerState.fromJson(Map json) {
return ApiServerState(
enabled: json['enabled'] as bool? ?? false,
+ mcpEnabled: json['mcpEnabled'] as bool? ?? false,
running: json['running'] as bool? ?? false,
network: json['network'] as String? ?? '',
address: json['address'] as String? ?? '',
@@ -50,6 +53,7 @@ class ApiServerOperationResult {
state: rawState == null
? const ApiServerState(
enabled: false,
+ mcpEnabled: false,
running: false,
network: '',
address: '',
diff --git a/ui/flutter/lib/core/common/start_config.dart b/ui/flutter/lib/core/common/start_config.dart
index 3a7c6aec..ed442eab 100644
--- a/ui/flutter/lib/core/common/start_config.dart
+++ b/ui/flutter/lib/core/common/start_config.dart
@@ -20,6 +20,7 @@ class StartConfig {
late String network;
late String address;
bool apiEnable = false;
+ bool mcpEnable = false;
late String storage;
late String storageDir;
late int refreshInterval;
diff --git a/ui/flutter/lib/core/common/start_config.g.dart b/ui/flutter/lib/core/common/start_config.g.dart
index 866eb000..5d64420d 100644
--- a/ui/flutter/lib/core/common/start_config.g.dart
+++ b/ui/flutter/lib/core/common/start_config.g.dart
@@ -23,6 +23,7 @@ StartConfig _$StartConfigFromJson(Map json) => StartConfig()
..network = json['network'] as String
..address = json['address'] as String
..apiEnable = json['apiEnable'] as bool
+ ..mcpEnable = json['mcpEnable'] as bool? ?? false
..storage = json['storage'] as String
..storageDir = json['storageDir'] as String
..refreshInterval = (json['refreshInterval'] as num).toInt()
@@ -38,6 +39,7 @@ Map _$StartConfigToJson(StartConfig instance) =>
'network': instance.network,
'address': instance.address,
'apiEnable': instance.apiEnable,
+ 'mcpEnable': instance.mcpEnable,
'storage': instance.storage,
'storageDir': instance.storageDir,
'refreshInterval': instance.refreshInterval,
diff --git a/ui/flutter/lib/core/entry/libgopeed_boot_browser.dart b/ui/flutter/lib/core/entry/libgopeed_boot_browser.dart
index 04f18448..0ee384a2 100644
--- a/ui/flutter/lib/core/entry/libgopeed_boot_browser.dart
+++ b/ui/flutter/lib/core/entry/libgopeed_boot_browser.dart
@@ -43,6 +43,7 @@ class LibgopeedBootBrowser implements LibgopeedBoot {
const _unsupportedApiServerResult = ApiServerOperationResult(
state: ApiServerState(
enabled: false,
+ mcpEnabled: false,
running: false,
network: '',
address: '',
diff --git a/ui/flutter/lib/core/network/gopeed/entry/gopeed_transport_web.dart b/ui/flutter/lib/core/network/gopeed/entry/gopeed_transport_web.dart
index 0fca3167..fddb9474 100644
--- a/ui/flutter/lib/core/network/gopeed/entry/gopeed_transport_web.dart
+++ b/ui/flutter/lib/core/network/gopeed/entry/gopeed_transport_web.dart
@@ -8,7 +8,9 @@ class RestGopeedTransport implements GopeedTransport {
RestGopeedTransport(GopeedTransportConfig config)
: _dio = Dio(
BaseOptions(
- baseUrl: '${Uri.base.origin}/',
+ // Keep release requests relative so deployments under a reverse
+ // proxy path continue to work, matching the legacy Web client.
+ baseUrl: webTransportBaseUrl(config.address, Uri.base),
contentType: Headers.jsonContentType,
sendTimeout: const Duration(seconds: 5),
connectTimeout: const Duration(seconds: 5),
diff --git a/ui/flutter/lib/core/network/gopeed/gopeed_transport.dart b/ui/flutter/lib/core/network/gopeed/gopeed_transport.dart
index 9aed7d29..aa2848ac 100644
--- a/ui/flutter/lib/core/network/gopeed/gopeed_transport.dart
+++ b/ui/flutter/lib/core/network/gopeed/gopeed_transport.dart
@@ -1,4 +1,5 @@
import 'package:dio/dio.dart';
+import 'package:flutter/foundation.dart';
import 'entry/gopeed_transport_stub.dart'
if (dart.library.html) 'entry/gopeed_transport_web.dart'
@@ -18,6 +19,44 @@ class GopeedTransportConfig {
final void Function()? onUnauthorized;
}
+const defaultWebApiPort = 9999;
+
+/// Resolves the API base URL used by Flutter Web.
+///
+/// This preserves the legacy behavior: debug builds connect to the local
+/// backend on port 9999, while release builds use the page's server origin.
+String webApiBaseUrl(Uri pageUri, {bool? debugMode}) {
+ final useDebugPort = debugMode ?? kDebugMode;
+ if (useDebugPort) return 'http://127.0.0.1:$defaultWebApiPort/';
+ return '${pageUri.origin}/';
+}
+
+/// Returns the Dio base URL, preserving relative requests in release builds.
+String webTransportBaseUrl(String address, Uri pageUri, {bool? debugMode}) {
+ final useDebugPort = debugMode ?? kDebugMode;
+ return useDebugPort ? normalizeWebApiBaseUrl(address, pageUri) : '';
+}
+
+/// Normalizes either an absolute API URL or a host:port address.
+String normalizeWebApiBaseUrl(String address, Uri pageUri) {
+ var value = address.trim();
+ if (value.isEmpty) return '${pageUri.origin}/';
+
+ final parsed = Uri.tryParse(value);
+ if (parsed != null && parsed.hasScheme && parsed.host.isNotEmpty) {
+ value = Uri(
+ scheme: parsed.scheme,
+ userInfo: parsed.userInfo,
+ host: parsed.host,
+ port: parsed.hasPort ? parsed.port : null,
+ path: parsed.path,
+ ).toString();
+ } else {
+ value = '${pageUri.scheme}://${value.replaceAll(RegExp(r'/+$'), '')}';
+ }
+ return value.endsWith('/') ? value : '$value/';
+}
+
abstract interface class GopeedTransport {
Future request(String path, {String method = 'GET', dynamic data, Map? queryParameters});
diff --git a/ui/flutter/lib/core/window/app_window_chrome.dart b/ui/flutter/lib/core/window/app_window_chrome.dart
index d670096e..bd7e2807 100644
--- a/ui/flutter/lib/core/window/app_window_chrome.dart
+++ b/ui/flutter/lib/core/window/app_window_chrome.dart
@@ -10,7 +10,14 @@ abstract final class AppWindowChrome {
static bool get usesCustomChrome =>
!kIsWeb && (defaultTargetPlatform == TargetPlatform.windows || defaultTargetPlatform == TargetPlatform.linux);
- static bool get reservesHeaderInset => isDesktopWindow;
+ static bool get reservesHeaderInset => reservesHeaderInsetFor(web: kIsWeb, platform: defaultTargetPlatform);
+
+ static bool reservesHeaderInsetFor({required bool web, required TargetPlatform platform}) {
+ return web ||
+ platform == TargetPlatform.windows ||
+ platform == TargetPlatform.macOS ||
+ platform == TargetPlatform.linux;
+ }
static bool get clipsRoundedCorners => !kIsWeb && defaultTargetPlatform == TargetPlatform.windows;
}
diff --git a/ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart b/ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart
index cc462018..9c2f57ab 100644
--- a/ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart
+++ b/ui/flutter/lib/features/extensions/presentation/pages/extensions_page.dart
@@ -696,14 +696,11 @@ class _Toolbar extends StatelessWidget {
Expanded(
child: Row(
children: [
- Expanded(
- child: Align(
- alignment: Alignment.centerLeft,
- child: ConstrainedBox(
- key: const ValueKey('extension-search-field-container'),
- constraints: const BoxConstraints(maxWidth: 240),
- child: search,
- ),
+ Flexible(
+ child: ConstrainedBox(
+ key: const ValueKey('extension-search-field-container'),
+ constraints: const BoxConstraints(maxWidth: 240),
+ child: search,
),
),
const SizedBox(width: 10),
diff --git a/ui/flutter/lib/features/home/presentation/widgets/tasks_top_bar.dart b/ui/flutter/lib/features/home/presentation/widgets/tasks_top_bar.dart
index 569588c6..25063d30 100644
--- a/ui/flutter/lib/features/home/presentation/widgets/tasks_top_bar.dart
+++ b/ui/flutter/lib/features/home/presentation/widgets/tasks_top_bar.dart
@@ -60,7 +60,7 @@ class TasksTopBar extends StatelessWidget {
const SizedBox(width: 16),
ConstrainedBox(
key: const ValueKey('tasks-create-button-container'),
- constraints: const BoxConstraints(maxWidth: 190),
+ constraints: const BoxConstraints(minWidth: 120, maxWidth: 190),
child: _AddTaskButton(onPressed: onAddTask),
),
const SizedBox(width: 16),
@@ -139,8 +139,14 @@ class _AddTaskButton extends StatelessWidget {
Widget build(BuildContext context) {
return AppPrimaryButton(
onPressed: onPressed,
- leading: const Icon(Icons.add),
- child: Text(context.l10n.create, maxLines: 1, overflow: TextOverflow.ellipsis),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const Icon(Icons.add),
+ const SizedBox(width: AppDesignTokens.space8),
+ Flexible(child: Text(context.l10n.create, maxLines: 1, overflow: TextOverflow.ellipsis)),
+ ],
+ ),
);
}
}
diff --git a/ui/flutter/lib/features/settings/presentation/pages/settings_page.dart b/ui/flutter/lib/features/settings/presentation/pages/settings_page.dart
index c6aecf80..89d16495 100644
--- a/ui/flutter/lib/features/settings/presentation/pages/settings_page.dart
+++ b/ui/flutter/lib/features/settings/presentation/pages/settings_page.dart
@@ -16,6 +16,7 @@ import '../../../../app/application/app_platform_controller.dart';
import '../../../../app/application/app_runtime_controller.dart';
import '../../../../app/application/location_keep_alive.dart';
import '../../../../core/common/start_config.dart';
+import '../../../../core/network/gopeed/gopeed_transport.dart';
import '../../../../core/utils/breakpoints.dart';
import '../../../../core/window/app_window_chrome.dart';
import '../../../../shared/theme/app_design_tokens.dart';
@@ -23,6 +24,7 @@ import '../../../../shared/theme/app_palette.dart';
import '../../../../shared/widgets/app_choice_segmented_control.dart';
import '../../../../shared/widgets/app_loading_button.dart';
import '../../../../shared/widgets/app_path_picker_field.dart';
+import '../../../../shared/widgets/app_tooltip.dart';
import '../../../../shared/widgets/app_toast.dart';
import '../../../../shared/widgets/responsive_menu_layout.dart';
import '../../../../l10n/l10n.dart';
@@ -34,6 +36,7 @@ import '../../../home/presentation/widgets/primary_rail.dart';
import '../../application/settings_controller.dart';
import '../widgets/app_update_dialog.dart';
import '../widgets/download_categories_setting.dart';
+import '../widgets/mcp_agent_setup.dart';
import '../widgets/settings_language_select.dart';
import '../widgets/settings_content_frame.dart';
import '../widgets/settings_item.dart';
@@ -42,6 +45,11 @@ import '../widgets/theme_settings_control.dart';
enum _SettingsSection { basic, downloads, advanced }
+String webApiDisplayAddress(Uri pageUri) {
+ final host = pageUri.host.toLowerCase() == 'localhost' ? '127.0.0.1' : pageUri.host;
+ return Uri(host: host, port: pageUri.hasPort ? pageUri.port : null).authority;
+}
+
class SettingsPage extends ConsumerStatefulWidget {
const SettingsPage({super.key, this.sectionKey});
@@ -69,7 +77,6 @@ class _SettingsPageState extends ConsumerState {
final _proxyUserController = TextEditingController();
final _proxyPasswordController = TextEditingController();
final _customTrackersController = TextEditingController();
- final _apiAddressController = TextEditingController();
final _apiHostController = TextEditingController();
final _apiPortController = TextEditingController();
final _apiTokenController = TextEditingController();
@@ -82,7 +89,6 @@ class _SettingsPageState extends ConsumerState {
bool _syncingControllers = false;
bool _syncingStartControllers = false;
bool _savingStartConfig = false;
- String? _apiNetworkDraft;
List get _textControllers => [
_downloadDirController,
@@ -105,7 +111,6 @@ class _SettingsPageState extends ConsumerState {
];
List get _startTextControllers => [
- _apiAddressController,
_apiHostController,
_apiPortController,
_apiTokenController,
@@ -149,7 +154,9 @@ class _SettingsPageState extends ConsumerState {
_ErrorState(error: error, onRetry: () => ref.read(settingsControllerProvider.notifier).reload()),
data: (state) {
_syncConfig(state.config);
- if (runtimeState != null) {
+ if (kIsWeb) {
+ _syncWebStartConfig(state.config.api, runtimeState);
+ } else if (runtimeState != null) {
_syncStartConfig(runtimeState.startConfig);
}
return ResponsiveMenuLayout<_SettingsSection>(
@@ -279,6 +286,7 @@ class _SettingsPageState extends ConsumerState {
) {
final apiConfigDirty = _startConfig != null && _startDraftSignature() != _loadedStartSignature;
final apiServerVisualState = _apiServerVisualState(runtimeState);
+ final palette = AppPalette.of(context);
switch (section) {
case _SettingsSection.basic:
return _SettingsSectionStack(
@@ -678,6 +686,90 @@ class _SettingsPageState extends ConsumerState {
case _SettingsSection.advanced:
return _SettingsSectionStack(
children: [
+ _SettingsBlock(
+ title: 'API',
+ child: _SettingsGroup(
+ children: [
+ SettingsItem(
+ title: context.l10n.apiServerRuntimeStatus,
+ subtitle: _apiServerRuntimeDetails(runtimeState),
+ child: _ApiServerStatusLight(
+ key: const ValueKey('api-server-runtime-status'),
+ state: apiServerVisualState,
+ label: switch (apiServerVisualState) {
+ _ApiServerVisualState.loading => context.l10n.readingRuntimeStatus,
+ _ApiServerVisualState.running => context.l10n.apiServerRunning,
+ _ApiServerVisualState.stopped => context.l10n.apiServerStopped,
+ _ApiServerVisualState.failed => context.l10n.apiServerFailed,
+ },
+ ),
+ ),
+ SettingsItem(
+ title: context.l10n.listenAddress,
+ child: _HostPortControl(
+ hostController: _apiHostController,
+ portController: _apiPortController,
+ hostKey: const ValueKey('api-host-input'),
+ portKey: const ValueKey('api-port-input'),
+ enabled: !kIsWeb,
+ ),
+ ),
+ if (!kIsWeb)
+ SettingsItem(
+ title: context.l10n.apiToken,
+ child: _TextSettingControl(controller: _apiTokenController, obscureText: true),
+ ),
+ McpSettingsItem(
+ enabled: _startConfig?.mcpEnable ?? false,
+ readOnly: kIsWeb,
+ onChanged: (value) => setState(() => _startConfig?.mcpEnable = value),
+ onOpenAgentSetup: () => unawaited(_openMcpAgentSetup(runtimeState)),
+ ),
+ Align(
+ alignment: Alignment.centerRight,
+ child: Wrap(
+ spacing: 8,
+ runSpacing: 8,
+ alignment: WrapAlignment.end,
+ children: [
+ if (kIsWeb)
+ AppTooltip(
+ message: context.l10n.webApiConfigReadOnlyHelp,
+ child: Padding(
+ key: const ValueKey('web-api-config-help'),
+ padding: const EdgeInsets.all(8),
+ child: Icon(Icons.help_outline, size: 17, color: palette.textMuted),
+ ),
+ ),
+ AppLoadingButton(
+ key: const ValueKey('toggle-api-server-button'),
+ onPressed: kIsWeb || runtimeState == null ? null : _toggleApiServer,
+ loading: _savingStartConfig,
+ icon: Icon(
+ kIsWeb || (runtimeState?.apiServerState.running ?? false)
+ ? Icons.stop_circle_outlined
+ : Icons.play_circle_outline,
+ ),
+ child: Text(
+ kIsWeb || (runtimeState?.apiServerState.running ?? false)
+ ? context.l10n.stopApiServer
+ : context.l10n.startApiServer,
+ ),
+ ),
+ AppLoadingButton(
+ key: const ValueKey('save-api-config-button'),
+ onPressed: kIsWeb || runtimeState == null || !apiConfigDirty ? null : _saveApiServerConfig,
+ loading: _savingStartConfig,
+ icon: const Icon(Icons.save_outlined),
+ variant: AppLoadingButtonVariant.primary,
+ child: Text(context.l10n.save),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
_SettingsBlock(
title: context.l10n.network,
child: _SettingsGroup(
@@ -762,94 +854,6 @@ class _SettingsPageState extends ConsumerState {
],
),
),
- if (!kIsWeb)
- _SettingsBlock(
- title: 'API',
- child: _SettingsGroup(
- children: [
- SettingsItem(
- title: context.l10n.apiServerRuntimeStatus,
- subtitle: _apiServerRuntimeDetails(runtimeState),
- child: _ApiServerStatusLight(
- key: const ValueKey('api-server-runtime-status'),
- state: apiServerVisualState,
- label: switch (apiServerVisualState) {
- _ApiServerVisualState.loading => context.l10n.readingRuntimeStatus,
- _ApiServerVisualState.running => context.l10n.apiServerRunning,
- _ApiServerVisualState.stopped => context.l10n.apiServerStopped,
- _ApiServerVisualState.failed => context.l10n.apiServerFailed,
- },
- ),
- ),
- SettingsItem(
- title: context.l10n.protocol,
- child: AppChoiceSegmentedControl(
- value: _apiNetworkValue,
- buttonKeyPrefix: 'settings-choice',
- options: [
- const AppChoiceOption(value: 'tcp', label: 'TCP', icon: Icons.lan_outlined),
- if (Util.supportUnixSocket())
- const AppChoiceOption(value: 'unix', label: 'Unix Socket', icon: Icons.cable_outlined),
- ],
- onChanged: _changeApiNetwork,
- ),
- ),
- if (_apiNetworkValue == 'unix')
- SettingsItem(
- title: context.l10n.socketPath,
- child: _TextSettingControl(controller: _apiAddressController),
- )
- else
- SettingsItem(
- title: context.l10n.listenAddress,
- child: _HostPortControl(
- hostController: _apiHostController,
- portController: _apiPortController,
- hostKey: const ValueKey('api-host-input'),
- portKey: const ValueKey('api-port-input'),
- ),
- ),
- if (_apiNetworkValue == 'tcp')
- SettingsItem(
- title: context.l10n.apiToken,
- child: _TextSettingControl(controller: _apiTokenController, obscureText: true),
- ),
- Align(
- alignment: Alignment.centerRight,
- child: Wrap(
- spacing: 8,
- runSpacing: 8,
- alignment: WrapAlignment.end,
- children: [
- AppLoadingButton(
- key: const ValueKey('toggle-api-server-button'),
- onPressed: runtimeState == null ? null : _toggleApiServer,
- loading: _savingStartConfig,
- icon: Icon(
- runtimeState?.apiServerState.running ?? false
- ? Icons.stop_circle_outlined
- : Icons.play_circle_outline,
- ),
- child: Text(
- runtimeState?.apiServerState.running ?? false
- ? context.l10n.stopApiServer
- : context.l10n.startApiServer,
- ),
- ),
- AppLoadingButton(
- key: const ValueKey('save-api-config-button'),
- onPressed: runtimeState == null || !apiConfigDirty ? null : _saveApiServerConfig,
- loading: _savingStartConfig,
- icon: const Icon(Icons.save_outlined),
- variant: AppLoadingButtonVariant.primary,
- child: Text(context.l10n.save),
- ),
- ],
- ),
- ),
- ],
- ),
- ),
_SettingsBlock(
title: context.l10n.developer,
child: _SettingsGroup(
@@ -905,8 +909,14 @@ class _SettingsPageState extends ConsumerState {
subtitle: _logDirectoryLabel,
child: shad.SecondaryButton(
onPressed: kIsWeb ? null : _openLogDirectory,
- leading: const Icon(Icons.folder_open_outlined),
- child: Text(context.l10n.open),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const Icon(Icons.folder_open_outlined),
+ const SizedBox(width: AppDesignTokens.space8),
+ Text(context.l10n.open),
+ ],
+ ),
),
),
],
@@ -957,15 +967,14 @@ class _SettingsPageState extends ConsumerState {
_startConfig!.address == config.address &&
_startConfig!.apiToken == config.apiToken) {
_startConfig!.apiEnable = config.apiEnable;
+ _startConfig!.mcpEnable = config.mcpEnable;
_loadedStartSignature = signature;
return;
}
_loadedStartSignature = signature;
_startConfig = _copyStartConfig(config);
- _apiNetworkDraft = config.network == 'unix' && Util.supportUnixSocket() ? 'unix' : 'tcp';
_syncingStartControllers = true;
- _apiAddressController.text = _startConfig!.address;
- if (_apiNetworkDraft == 'tcp' && _looksLikeTcpAddress(_startConfig!.address)) {
+ if (config.network == 'tcp' && _looksLikeTcpAddress(_startConfig!.address)) {
final apiAddress = _splitHostPort(_startConfig!.address);
_apiHostController.text = apiAddress.host;
_apiPortController.text = apiAddress.port;
@@ -977,6 +986,19 @@ class _SettingsPageState extends ConsumerState {
_syncingStartControllers = false;
}
+ void _syncWebStartConfig(ApiServerConfig config, AppRuntimeState? runtimeState) {
+ final webConfig = StartConfig()
+ ..network = 'tcp'
+ ..address = webApiDisplayAddress(_webApiUri(runtimeState))
+ ..apiEnable = true
+ ..mcpEnable = config.mcpEnable
+ ..storage = ''
+ ..storageDir = ''
+ ..refreshInterval = 0
+ ..apiToken = config.token;
+ _syncStartConfig(webConfig);
+ }
+
void _scheduleTextSave() {
if (_syncingControllers || _config == null) {
return;
@@ -1055,10 +1077,8 @@ class _SettingsPageState extends ConsumerState {
}
void _applyStartControllers(StartConfig config) {
- config.network = _apiNetworkDraft ?? config.network;
- config.address = config.network == 'unix'
- ? _apiAddressController.text.trim()
- : _joinHostPort(_apiHostController.text, _apiPortController.text);
+ config.network = 'tcp';
+ config.address = _joinHostPort(_apiHostController.text, _apiPortController.text);
config.apiToken = _apiTokenController.text.trim();
}
@@ -1184,10 +1204,6 @@ class _SettingsPageState extends ConsumerState {
Future _validateStartConfig(StartConfig candidate) async {
final l10n = context.l10n;
- if (candidate.network == 'unix') {
- return candidate.address.trim().isEmpty ? l10n.socketPathRequired : null;
- }
-
final address = _splitHostPort(candidate.address);
if (address.host.isEmpty) return l10n.serverRequired;
final port = int.tryParse(address.port);
@@ -1196,29 +1212,6 @@ class _SettingsPageState extends ConsumerState {
return null;
}
- void _changeApiNetwork(String value) {
- final config = _startConfig;
- if (config == null || _apiNetworkDraft == value) return;
- setState(() {
- _apiNetworkDraft = value;
- _syncingStartControllers = true;
- if (value == 'tcp' && (_apiHostController.text.isEmpty || _apiPortController.text.isEmpty)) {
- if (_looksLikeTcpAddress(config.address)) {
- final address = _splitHostPort(config.address);
- _apiHostController.text = address.host;
- _apiPortController.text = address.port;
- } else {
- _apiHostController.text = '127.0.0.1';
- _apiPortController.text = '9999';
- }
- }
- if (value == 'unix' && (_apiAddressController.text.isEmpty || _looksLikeTcpAddress(_apiAddressController.text))) {
- _apiAddressController.text = '${Util.getStorageDir()}/$unixSocketPath';
- }
- _syncingStartControllers = false;
- });
- }
-
Future _runAction(Future Function() action) async {
try {
await action();
@@ -1422,15 +1415,12 @@ class _SettingsPageState extends ConsumerState {
}
String _startSignature(StartConfig config) {
- return '${config.apiEnable}|${config.network}|${config.address}|${config.apiToken}';
+ return '${config.apiEnable}|${config.mcpEnable}|${config.network}|${config.address}|${config.apiToken}';
}
String _startDraftSignature() {
- final network = _apiNetworkDraft ?? _startConfig?.network ?? 'tcp';
- final address = network == 'unix'
- ? _apiAddressController.text.trim()
- : _joinHostPort(_apiHostController.text, _apiPortController.text);
- return '${_startConfig?.apiEnable ?? false}|$network|$address|${_apiTokenController.text.trim()}';
+ final address = _joinHostPort(_apiHostController.text, _apiPortController.text);
+ return '${_startConfig?.apiEnable ?? false}|${_startConfig?.mcpEnable ?? false}|tcp|$address|${_apiTokenController.text.trim()}';
}
StartConfig _copyStartConfig(StartConfig config) {
@@ -1438,6 +1428,7 @@ class _SettingsPageState extends ConsumerState {
..network = config.network
..address = config.address
..apiEnable = config.apiEnable
+ ..mcpEnable = config.mcpEnable
..storage = config.storage
..storageDir = config.storageDir
..refreshInterval = config.refreshInterval
@@ -1445,14 +1436,6 @@ class _SettingsPageState extends ConsumerState {
..webViewRpcConfig = config.webViewRpcConfig;
}
- String get _apiNetworkValue {
- final network = _apiNetworkDraft ?? _startConfig?.network ?? 'tcp';
- if (network == 'unix' && Util.supportUnixSocket()) {
- return 'unix';
- }
- return 'tcp';
- }
-
String get _proxyMode {
final proxy = _config?.proxy;
if (proxy == null || !proxy.enable) return 'none';
@@ -1506,6 +1489,40 @@ class _SettingsPageState extends ConsumerState {
}
}
+ Future _openMcpAgentSetup(AppRuntimeState? runtimeState) async {
+ final mcpRunning = kIsWeb
+ ? (_startConfig?.mcpEnable ?? false)
+ : runtimeState?.apiServerState.running == true && runtimeState?.apiServerState.mcpEnabled == true;
+ await showMcpAgentSetupDialog(
+ context,
+ endpoint: _mcpEndpoint(runtimeState),
+ apiToken: _apiTokenController.text.trim(),
+ mcpRunning: mcpRunning,
+ allowManualApiTokenTemplate: kIsWeb,
+ );
+ }
+
+ String _mcpEndpoint(AppRuntimeState? runtimeState) {
+ if (kIsWeb) {
+ return webMcpEndpoint(_webApiUri(runtimeState));
+ }
+
+ final apiState = runtimeState?.apiServerState;
+ final address = apiState != null && apiState.running && apiState.network == 'tcp'
+ ? apiState.runningAddress()
+ : _joinHostPort(_apiHostController.text, _apiPortController.text);
+ final parts = _splitHostPort(address);
+ var host = parts.host;
+ if (host.startsWith('[') && host.endsWith(']')) {
+ host = host.substring(1, host.length - 1);
+ }
+ if (host == '0.0.0.0' || host == '::') {
+ host = host == '::' ? '::1' : '127.0.0.1';
+ }
+ final port = int.tryParse(parts.port);
+ return Uri(scheme: 'http', host: host, port: port, path: '/mcp').toString();
+ }
+
bool _looksLikeTcpAddress(String address) {
final separator = address.lastIndexOf(':');
if (separator <= 0 || separator == address.length - 1) {
@@ -1556,10 +1573,14 @@ class _SettingsPageState extends ConsumerState {
String? _apiServerRuntimeDetails(AppRuntimeState? runtimeState) {
if (runtimeState == null) return null;
+ if (kIsWeb) {
+ final apiUri = _webApiUri(runtimeState);
+ return '${apiUri.scheme}://${webApiDisplayAddress(apiUri)}';
+ }
final apiState = runtimeState.apiServerState;
final details = [];
if (apiState.running) {
- details.add('${apiState.network}://${apiState.runningAddress()}');
+ details.add('http://${apiState.runningAddress()}');
}
if (apiState.lastError.isNotEmpty) {
details.add(apiState.lastError);
@@ -1569,8 +1590,14 @@ class _SettingsPageState extends ConsumerState {
return details.isEmpty ? null : details.join('\n');
}
+ Uri _webApiUri(AppRuntimeState? runtimeState) {
+ final address = runtimeState?.startConfig.address ?? '';
+ return Uri.parse(normalizeWebApiBaseUrl(address, Uri.base));
+ }
+
_ApiServerVisualState _apiServerVisualState(AppRuntimeState? runtimeState) {
if (runtimeState == null) return _ApiServerVisualState.loading;
+ if (kIsWeb) return _ApiServerVisualState.running;
final apiState = runtimeState.apiServerState;
if (apiState.running) return _ApiServerVisualState.running;
if (apiState.lastError.isNotEmpty) return _ApiServerVisualState.failed;
@@ -1875,12 +1902,14 @@ class _HostPortControl extends StatelessWidget {
required this.portController,
required this.hostKey,
required this.portKey,
+ this.enabled = true,
});
final TextEditingController hostController;
final TextEditingController portController;
final Key hostKey;
final Key portKey;
+ final bool enabled;
@override
Widget build(BuildContext context) {
@@ -1890,7 +1919,12 @@ class _HostPortControl extends StatelessWidget {
child: Row(
children: [
Expanded(
- child: shad.TextField(key: hostKey, controller: hostController, hintText: context.l10n.server),
+ child: shad.TextField(
+ key: hostKey,
+ controller: hostController,
+ hintText: context.l10n.server,
+ enabled: enabled,
+ ),
),
const SizedBox(width: AppDesignTokens.space8),
SizedBox(
@@ -1903,6 +1937,7 @@ class _HostPortControl extends StatelessWidget {
step: 1,
decimalPlaces: 0,
hintText: context.l10n.port,
+ enabled: enabled,
),
),
],
@@ -1920,6 +1955,7 @@ class _NumberInput extends StatefulWidget {
this.fieldKey,
this.max,
this.hintText,
+ this.enabled = true,
});
final TextEditingController controller;
@@ -1929,6 +1965,7 @@ class _NumberInput extends StatefulWidget {
final int decimalPlaces;
final Key? fieldKey;
final String? hintText;
+ final bool enabled;
@override
State<_NumberInput> createState() => _NumberInputState();
@@ -1993,6 +2030,7 @@ class _NumberInputState extends State<_NumberInput> {
? const TextInputType.numberWithOptions(decimal: true)
: TextInputType.number,
inputFormatters: formatters,
+ enabled: widget.enabled,
features: [
shad.InputFeature.spinner(
step: widget.step,
@@ -2130,8 +2168,14 @@ class _BrowserExtensionLinks extends StatelessWidget {
shad.SecondaryButton(
key: ValueKey('browser-extension-${link.label.toLowerCase()}'),
onPressed: () => onOpen(link.uri),
- leading: const Icon(Icons.open_in_new, size: 15),
- child: Text(link.label),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const Icon(Icons.open_in_new, size: 15),
+ const SizedBox(width: AppDesignTokens.space8),
+ Text(link.label),
+ ],
+ ),
),
],
);
diff --git a/ui/flutter/lib/features/settings/presentation/widgets/download_categories_setting.dart b/ui/flutter/lib/features/settings/presentation/widgets/download_categories_setting.dart
index e5112a2c..4f93d0ae 100644
--- a/ui/flutter/lib/features/settings/presentation/widgets/download_categories_setting.dart
+++ b/ui/flutter/lib/features/settings/presentation/widgets/download_categories_setting.dart
@@ -64,8 +64,14 @@ class DownloadCategoriesControl extends StatelessWidget {
child: shad.SecondaryButton(
key: const ValueKey('add-download-category'),
onPressed: onAdd,
- leading: const Icon(Icons.add, size: 17),
- child: Text(context.l10n.add),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const Icon(Icons.add, size: 17),
+ const SizedBox(width: AppDesignTokens.space8),
+ Text(context.l10n.add),
+ ],
+ ),
),
),
],
diff --git a/ui/flutter/lib/features/settings/presentation/widgets/mcp_agent_setup.dart b/ui/flutter/lib/features/settings/presentation/widgets/mcp_agent_setup.dart
new file mode 100644
index 00000000..3ed0360e
--- /dev/null
+++ b/ui/flutter/lib/features/settings/presentation/widgets/mcp_agent_setup.dart
@@ -0,0 +1,748 @@
+import 'dart:convert';
+
+import 'package:flutter/material.dart' show Icons, Scrollbar, ScrollbarOrientation, SelectableText;
+import 'package:flutter/widgets.dart';
+import 'package:flutter_svg/flutter_svg.dart';
+import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
+
+import '../../../../l10n/l10n.dart';
+import '../../../../shared/theme/app_design_tokens.dart';
+import '../../../../shared/theme/app_palette.dart';
+import '../../../../shared/widgets/app_copy_icon_button.dart';
+import '../../../../shared/widgets/app_tooltip.dart';
+import 'settings_item.dart';
+
+class McpSettingsItem extends StatelessWidget {
+ const McpSettingsItem({
+ super.key,
+ required this.enabled,
+ required this.readOnly,
+ required this.onChanged,
+ required this.onOpenAgentSetup,
+ });
+
+ final bool enabled;
+ final bool readOnly;
+ final ValueChanged onChanged;
+ final VoidCallback onOpenAgentSetup;
+
+ @override
+ Widget build(BuildContext context) {
+ final palette = AppPalette.of(context);
+ return SettingsItem(
+ title: 'MCP',
+ subtitle: context.l10n.mcpDescription,
+ subtitleAction: _McpAgentLink(onPressed: onOpenAgentSetup),
+ titleTrailing: Icon(
+ key: const ValueKey('mcp-ai-icon'),
+ Icons.auto_awesome_outlined,
+ size: 14,
+ color: palette.brandProgress,
+ ),
+ child: shad.Switch(
+ key: const ValueKey('mcp-endpoint-switch'),
+ value: enabled,
+ enabled: !readOnly,
+ onChanged: onChanged,
+ ),
+ );
+ }
+}
+
+class _McpAgentLink extends StatefulWidget {
+ const _McpAgentLink({required this.onPressed});
+
+ final VoidCallback onPressed;
+
+ @override
+ State<_McpAgentLink> createState() => _McpAgentLinkState();
+}
+
+class _McpAgentLinkState extends State<_McpAgentLink> {
+ bool _hovered = false;
+
+ @override
+ Widget build(BuildContext context) {
+ final palette = AppPalette.of(context);
+ final color = _hovered ? palette.brandProgress : palette.brand;
+ return Semantics(
+ link: true,
+ label: context.l10n.connectAgent,
+ button: true,
+ child: MouseRegion(
+ cursor: SystemMouseCursors.click,
+ onEnter: (_) => setState(() => _hovered = true),
+ onExit: (_) => setState(() => _hovered = false),
+ child: GestureDetector(
+ key: const ValueKey('open-mcp-agent-setup'),
+ behavior: HitTestBehavior.opaque,
+ onTap: widget.onPressed,
+ child: Padding(
+ padding: const EdgeInsets.symmetric(vertical: 2),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text(
+ context.l10n.connectAgent,
+ style: TextStyle(
+ color: color,
+ fontSize: 12,
+ fontWeight: FontWeight.w600,
+ decoration: TextDecoration.underline,
+ decorationColor: color,
+ decorationThickness: _hovered ? 1.5 : 1,
+ ),
+ ),
+ const SizedBox(width: 3),
+ Icon(Icons.arrow_forward, size: 13, color: color),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+enum _McpAgent {
+ codex,
+ claudeCode,
+ cursor,
+ githubCopilot,
+ windsurf,
+ geminiCli,
+ cline,
+ openCode,
+ trae,
+ qoder,
+ workBuddy,
+ zCode,
+ deepSeekHarness,
+ pi,
+}
+
+extension on _McpAgent {
+ String get label => switch (this) {
+ _McpAgent.codex => 'Codex',
+ _McpAgent.claudeCode => 'Claude Code',
+ _McpAgent.cursor => 'Cursor',
+ _McpAgent.githubCopilot => 'GitHub Copilot',
+ _McpAgent.windsurf => 'Windsurf',
+ _McpAgent.geminiCli => 'Gemini CLI',
+ _McpAgent.cline => 'Cline',
+ _McpAgent.openCode => 'OpenCode',
+ _McpAgent.trae => 'TRAE',
+ _McpAgent.qoder => 'Qoder',
+ _McpAgent.workBuddy => 'WorkBuddy',
+ _McpAgent.zCode => 'ZCode',
+ _McpAgent.deepSeekHarness => 'DeepSeek Harness',
+ _McpAgent.pi => 'Pi',
+ };
+
+ String get iconAsset => switch (this) {
+ _McpAgent.codex => 'assets/agent_icons/codex.svg',
+ _McpAgent.claudeCode => 'assets/agent_icons/claude_code.svg',
+ _McpAgent.cursor => 'assets/agent_icons/cursor.svg',
+ _McpAgent.githubCopilot => 'assets/agent_icons/github_copilot.svg',
+ _McpAgent.windsurf => 'assets/agent_icons/windsurf.svg',
+ _McpAgent.geminiCli => 'assets/agent_icons/gemini_cli.svg',
+ _McpAgent.cline => 'assets/agent_icons/cline.svg',
+ _McpAgent.openCode => 'assets/agent_icons/opencode.svg',
+ _McpAgent.trae => 'assets/agent_icons/trae.svg',
+ _McpAgent.qoder => 'assets/agent_icons/qoder.svg',
+ _McpAgent.workBuddy => 'assets/agent_icons/codebuddy.svg',
+ _McpAgent.zCode => 'assets/agent_icons/zcode.svg',
+ _McpAgent.deepSeekHarness => 'assets/agent_icons/deepseek.svg',
+ _McpAgent.pi => 'assets/agent_icons/pi.svg',
+ };
+
+ bool get usesThemeTint => switch (this) {
+ _McpAgent.cursor ||
+ _McpAgent.githubCopilot ||
+ _McpAgent.windsurf ||
+ _McpAgent.cline ||
+ _McpAgent.openCode ||
+ _McpAgent.zCode ||
+ _McpAgent.pi => true,
+ _ => false,
+ };
+}
+
+String webMcpEndpoint(Uri pageUri) {
+ return Uri(
+ scheme: pageUri.scheme,
+ host: pageUri.host,
+ port: pageUri.hasPort ? pageUri.port : null,
+ path: '/mcp',
+ ).toString();
+}
+
+Future showMcpAgentSetupDialog(
+ BuildContext context, {
+ required String endpoint,
+ required String apiToken,
+ required bool mcpRunning,
+ bool allowManualApiTokenTemplate = false,
+}) async {
+ var selectedAgent = _McpAgent.codex;
+ var useManualApiTokenTemplate = false;
+ final overlay = const shad.DialogOverlayHandler().show(
+ context: context,
+ alignment: Alignment.center,
+ barrierDismissable: true,
+ builder: (dialogContext) => StatefulBuilder(
+ builder: (dialogContext, setDialogState) {
+ final palette = AppPalette.of(dialogContext);
+ final screenSize = MediaQuery.sizeOf(dialogContext);
+ final dialogWidth = (screenSize.width - 80).clamp(220.0, 620.0);
+ final contentMaxHeight = (screenSize.height - 140).clamp(120.0, 460.0);
+ final templateToken = allowManualApiTokenTemplate && useManualApiTokenTemplate ? '' : '';
+ final displayToken = allowManualApiTokenTemplate ? templateToken : _maskedToken(apiToken);
+ final clipboardToken = allowManualApiTokenTemplate ? templateToken : apiToken;
+ final displaySnippet = _agentSnippet(selectedAgent, endpoint: endpoint, apiToken: displayToken);
+ final clipboardSnippet = _agentSnippet(selectedAgent, endpoint: endpoint, apiToken: clipboardToken);
+
+ return shad.AlertDialog(
+ key: const ValueKey('mcp-agent-setup-dialog'),
+ padding: const EdgeInsets.all(AppDesignTokens.space24),
+ title: SizedBox(
+ width: dialogWidth,
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Container(
+ width: 34,
+ height: 34,
+ decoration: BoxDecoration(
+ color: palette.brandSoft,
+ borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius),
+ ),
+ alignment: Alignment.center,
+ child: Icon(Icons.auto_awesome_outlined, size: 19, color: palette.brandProgress),
+ ),
+ const SizedBox(width: AppDesignTokens.space12),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(dialogContext.l10n.connectMcpAgent),
+ const SizedBox(height: 4),
+ Text(
+ dialogContext.l10n.mcpAgentDialogDescription,
+ style: TextStyle(color: palette.textSecondary, fontSize: 12, fontWeight: FontWeight.w400),
+ ),
+ ],
+ ),
+ ),
+ AppTooltip(
+ message: dialogContext.l10n.close,
+ child: shad.IconButton.ghost(
+ key: const ValueKey('close-mcp-agent-setup'),
+ size: shad.ButtonSize.xSmall,
+ onPressed: () => shad.closeOverlay(dialogContext),
+ icon: const Icon(Icons.close, size: 18),
+ ),
+ ),
+ ],
+ ),
+ ),
+ content: SizedBox(
+ width: dialogWidth,
+ child: ConstrainedBox(
+ constraints: BoxConstraints(maxHeight: contentMaxHeight),
+ child: SingleChildScrollView(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ if (!mcpRunning) ...[
+ Container(
+ key: const ValueKey('mcp-not-running-hint'),
+ padding: const EdgeInsets.symmetric(
+ horizontal: AppDesignTokens.space12,
+ vertical: AppDesignTokens.space8,
+ ),
+ decoration: BoxDecoration(
+ color: palette.brandSoft,
+ borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius),
+ border: Border.all(color: palette.brandTrack),
+ ),
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Icon(Icons.info_outline, size: 16, color: palette.brandProgress),
+ const SizedBox(width: AppDesignTokens.space8),
+ Expanded(
+ child: Text(
+ dialogContext.l10n.mcpNotRunningHint,
+ style: TextStyle(color: palette.textSecondary, fontSize: 12, height: 1.4),
+ ),
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(height: AppDesignTokens.space16),
+ ],
+ _McpAgentGrid(
+ selected: selectedAgent,
+ onSelected: (value) => setDialogState(() => selectedAgent = value),
+ ),
+ const SizedBox(height: AppDesignTokens.space16),
+ Row(
+ children: [
+ _McpAgentLogo(agent: selectedAgent, size: 20),
+ const SizedBox(width: AppDesignTokens.space8),
+ Expanded(
+ child: Row(
+ children: [
+ Flexible(
+ child: Text(
+ '${selectedAgent.label} · MCP',
+ style: TextStyle(
+ color: palette.textPrimary,
+ fontSize: 13,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ ),
+ if (allowManualApiTokenTemplate) ...[
+ const SizedBox(width: 2),
+ _McpApiTokenTemplateHint(
+ key: ValueKey('mcp-api-token-template-help-$useManualApiTokenTemplate'),
+ onUseTemplate: () => setDialogState(() => useManualApiTokenTemplate = true),
+ ),
+ ],
+ ],
+ ),
+ ),
+ AppCopyIconButton(key: const ValueKey('copy-mcp-agent-snippet'), text: clipboardSnippet),
+ ],
+ ),
+ const SizedBox(height: AppDesignTokens.space8),
+ _McpSnippetBox(snippet: displaySnippet),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+ },
+ ),
+ );
+ await overlay.future;
+}
+
+class _McpApiTokenTemplateHint extends StatelessWidget {
+ const _McpApiTokenTemplateHint({super.key, required this.onUseTemplate});
+
+ final VoidCallback onUseTemplate;
+
+ @override
+ Widget build(BuildContext context) {
+ final palette = AppPalette.of(context);
+ return shad.HoverCard(
+ wait: const Duration(milliseconds: 180),
+ debounce: const Duration(milliseconds: 350),
+ popoverAlignment: Alignment.topLeft,
+ anchorAlignment: Alignment.bottomLeft,
+ popoverOffset: const Offset(0, 6),
+ hoverBuilder: (hoverContext) {
+ final hoverPalette = AppPalette.of(hoverContext);
+ return Container(
+ key: const ValueKey('mcp-api-token-template-hint'),
+ width: 288,
+ padding: const EdgeInsets.all(AppDesignTokens.space12),
+ decoration: BoxDecoration(
+ color: hoverPalette.cardBg,
+ borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius),
+ border: Border.all(color: hoverPalette.border),
+ boxShadow: [
+ BoxShadow(
+ color: const Color(0xFF000000).withValues(alpha: 0.16),
+ blurRadius: 16,
+ offset: const Offset(0, 6),
+ ),
+ ],
+ ),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ hoverContext.l10n.webMcpApiTokenHint,
+ style: TextStyle(color: hoverPalette.textSecondary, fontSize: 12, height: 1.45),
+ ),
+ const SizedBox(height: 6),
+ Semantics(
+ link: true,
+ button: true,
+ label: hoverContext.l10n.useMcpApiTokenTemplate,
+ child: MouseRegion(
+ cursor: SystemMouseCursors.click,
+ child: GestureDetector(
+ key: const ValueKey('use-mcp-api-token-template'),
+ behavior: HitTestBehavior.opaque,
+ onTap: onUseTemplate,
+ child: Text(
+ hoverContext.l10n.useMcpApiTokenTemplate,
+ style: TextStyle(
+ color: hoverPalette.brand,
+ fontSize: 12,
+ fontWeight: FontWeight.w600,
+ decoration: TextDecoration.underline,
+ decorationColor: hoverPalette.brand,
+ ),
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ },
+ child: Semantics(
+ label: context.l10n.webMcpApiTokenHint,
+ child: SizedBox(
+ key: const ValueKey('mcp-api-token-template-help'),
+ width: 28,
+ height: 28,
+ child: Icon(Icons.help_outline, size: 16, color: palette.textMuted),
+ ),
+ ),
+ );
+ }
+}
+
+class _McpAgentGrid extends StatelessWidget {
+ const _McpAgentGrid({required this.selected, required this.onSelected});
+
+ final _McpAgent selected;
+ final ValueChanged<_McpAgent> onSelected;
+
+ @override
+ Widget build(BuildContext context) {
+ return LayoutBuilder(
+ builder: (context, constraints) {
+ final columns = switch (constraints.maxWidth) {
+ >= 520 => 8,
+ >= 380 => 6,
+ >= 260 => 5,
+ _ => 4,
+ };
+ return GridView.builder(
+ shrinkWrap: true,
+ physics: const NeverScrollableScrollPhysics(),
+ gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
+ crossAxisCount: columns,
+ crossAxisSpacing: AppDesignTokens.space8,
+ mainAxisSpacing: AppDesignTokens.space8,
+ ),
+ itemCount: _McpAgent.values.length,
+ itemBuilder: (context, index) {
+ final agent = _McpAgent.values[index];
+ return _McpAgentButton(
+ key: ValueKey('mcp-agent-${_agentKey(agent)}'),
+ agent: agent,
+ selected: agent == selected,
+ onPressed: () => onSelected(agent),
+ );
+ },
+ );
+ },
+ );
+ }
+}
+
+class _McpAgentButton extends StatefulWidget {
+ const _McpAgentButton({super.key, required this.agent, required this.selected, required this.onPressed});
+
+ final _McpAgent agent;
+ final bool selected;
+ final VoidCallback onPressed;
+
+ @override
+ State<_McpAgentButton> createState() => _McpAgentButtonState();
+}
+
+class _McpAgentButtonState extends State<_McpAgentButton> {
+ bool _hovered = false;
+
+ @override
+ Widget build(BuildContext context) {
+ final palette = AppPalette.of(context);
+ final background = widget.selected
+ ? palette.brandSoft
+ : _hovered
+ ? palette.cardHoverBg
+ : palette.cardBg;
+ return Semantics(
+ button: true,
+ selected: widget.selected,
+ label: widget.agent.label,
+ child: AppTooltip(
+ message: widget.agent.label,
+ child: MouseRegion(
+ cursor: SystemMouseCursors.click,
+ onEnter: (_) => setState(() => _hovered = true),
+ onExit: (_) => setState(() => _hovered = false),
+ child: GestureDetector(
+ behavior: HitTestBehavior.opaque,
+ onTap: widget.onPressed,
+ child: AnimatedContainer(
+ duration: const Duration(milliseconds: 140),
+ curve: Curves.easeOut,
+ decoration: BoxDecoration(
+ color: background,
+ borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius + 2),
+ border: Border.all(
+ color: widget.selected ? palette.brandProgress : palette.border,
+ width: widget.selected ? 1.5 : 1,
+ ),
+ ),
+ alignment: Alignment.center,
+ child: _McpAgentLogo(agent: widget.agent, size: 28),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+class _McpAgentLogo extends StatelessWidget {
+ const _McpAgentLogo({required this.agent, required this.size});
+
+ final _McpAgent agent;
+ final double size;
+
+ @override
+ Widget build(BuildContext context) {
+ final palette = AppPalette.of(context);
+ return SvgPicture.asset(
+ agent.iconAsset,
+ width: size,
+ height: size,
+ fit: BoxFit.contain,
+ excludeFromSemantics: true,
+ colorFilter: agent.usesThemeTint ? ColorFilter.mode(palette.textPrimary, BlendMode.srcIn) : null,
+ );
+ }
+}
+
+class _McpSnippetBox extends StatefulWidget {
+ const _McpSnippetBox({required this.snippet});
+
+ final String snippet;
+
+ @override
+ State<_McpSnippetBox> createState() => _McpSnippetBoxState();
+}
+
+class _McpSnippetBoxState extends State<_McpSnippetBox> {
+ final _horizontalController = ScrollController();
+ final _verticalController = ScrollController();
+
+ @override
+ void didUpdateWidget(covariant _McpSnippetBox oldWidget) {
+ super.didUpdateWidget(oldWidget);
+ if (widget.snippet == oldWidget.snippet) return;
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (_horizontalController.hasClients) _horizontalController.jumpTo(0);
+ if (_verticalController.hasClients) _verticalController.jumpTo(0);
+ });
+ }
+
+ @override
+ void dispose() {
+ _horizontalController.dispose();
+ _verticalController.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final palette = AppPalette.of(context);
+ return Container(
+ key: const ValueKey('mcp-agent-snippet'),
+ height: 176,
+ width: double.infinity,
+ padding: const EdgeInsets.all(14),
+ clipBehavior: Clip.hardEdge,
+ decoration: BoxDecoration(
+ color: palette.surfaceSoft,
+ borderRadius: BorderRadius.circular(6),
+ border: Border.all(color: palette.border),
+ ),
+ child: LayoutBuilder(
+ builder: (context, constraints) {
+ final contentHeight = (constraints.maxHeight - AppDesignTokens.space12).clamp(0.0, double.infinity);
+ return Scrollbar(
+ key: const ValueKey('mcp-snippet-horizontal-scrollbar'),
+ controller: _horizontalController,
+ thumbVisibility: true,
+ scrollbarOrientation: ScrollbarOrientation.bottom,
+ notificationPredicate: (notification) => notification.metrics.axis == Axis.horizontal,
+ child: Padding(
+ padding: const EdgeInsets.only(bottom: AppDesignTokens.space12),
+ child: SingleChildScrollView(
+ controller: _horizontalController,
+ scrollDirection: Axis.horizontal,
+ child: SizedBox(
+ height: contentHeight,
+ child: Scrollbar(
+ key: const ValueKey('mcp-snippet-vertical-scrollbar'),
+ controller: _verticalController,
+ thumbVisibility: true,
+ scrollbarOrientation: ScrollbarOrientation.right,
+ notificationPredicate: (notification) => notification.metrics.axis == Axis.vertical,
+ child: SingleChildScrollView(
+ controller: _verticalController,
+ child: IntrinsicWidth(
+ child: ConstrainedBox(
+ constraints: BoxConstraints(minWidth: constraints.maxWidth),
+ child: SelectableText(
+ widget.snippet,
+ style: TextStyle(
+ color: palette.textPrimary,
+ fontSize: 12,
+ height: 1.55,
+ fontFamily: 'monospace',
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ },
+ ),
+ );
+ }
+}
+
+String _agentKey(_McpAgent agent) => switch (agent) {
+ _McpAgent.cursor => 'cursor',
+ _McpAgent.claudeCode => 'claude-code',
+ _McpAgent.codex => 'codex',
+ _McpAgent.githubCopilot => 'github-copilot',
+ _McpAgent.windsurf => 'windsurf',
+ _McpAgent.geminiCli => 'gemini-cli',
+ _McpAgent.cline => 'cline',
+ _McpAgent.openCode => 'opencode',
+ _McpAgent.trae => 'trae',
+ _McpAgent.qoder => 'qoder',
+ _McpAgent.workBuddy => 'workbuddy',
+ _McpAgent.zCode => 'zcode',
+ _McpAgent.deepSeekHarness => 'deepseek-harness',
+ _McpAgent.pi => 'pi',
+};
+
+String _agentSnippet(_McpAgent agent, {required String endpoint, required String apiToken}) {
+ return switch (agent) {
+ _McpAgent.cursor => _mcpServersSnippet(endpoint: endpoint, apiToken: apiToken),
+ _McpAgent.codex => _codexSnippet(endpoint: endpoint, apiToken: apiToken),
+ _McpAgent.claudeCode =>
+ apiToken.isNotEmpty
+ ? 'claude mcp add --transport http gopeed $endpoint \\\n'
+ " --header ${_shellSingleQuote('Authorization: Bearer $apiToken')}"
+ : 'claude mcp add --transport http gopeed $endpoint',
+ _McpAgent.githubCopilot => _mcpServersSnippet(
+ rootKey: 'servers',
+ type: 'http',
+ endpoint: endpoint,
+ apiToken: apiToken,
+ ),
+ _McpAgent.windsurf => _mcpServersSnippet(urlKey: 'serverUrl', endpoint: endpoint, apiToken: apiToken),
+ _McpAgent.geminiCli => _mcpServersSnippet(urlKey: 'httpUrl', endpoint: endpoint, apiToken: apiToken),
+ _McpAgent.cline => _mcpServersSnippet(
+ type: 'streamableHttp',
+ endpoint: endpoint,
+ apiToken: apiToken,
+ extra: const {'disabled': false, 'autoApprove':