mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-09 10:00:27 +00:00
feat: add MCP support (#1471)
This commit is contained in:
83 files changed
+3119
-363
No files matched your search
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user