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

+525
View File
@@ -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
}
+215
View File
@@ -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)
}
}