feat: add MCP support (#1471)

This commit is contained in:
Levi authored and GitHub committed 2026-09-05 20:21:39 +08:00
1 parent 39cd9034cd
commit f2145de571
83 files changed
+3119 -363

No files matched your search

+1
View File
@@ -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
}
+30 -5
View File
@@ -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)
}
+2
View File
@@ -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"`
+51 -10
View File
@@ -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
+107
View File
@@ -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) {