mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-08 01:20:27 +00:00
feat: add MCP support (#1471)
This commit is contained in:
83 files changed
+3119
-363
No files matched your search
+5
-4
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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