mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-04 23:57:54 +00:00
149 lines
5.1 KiB
Go
149 lines
5.1 KiB
Go
package mcpserver
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/GopeedLab/gopeed/internal/fetcher"
|
|
"github.com/GopeedLab/gopeed/pkg/base"
|
|
"github.com/GopeedLab/gopeed/pkg/download"
|
|
"github.com/GopeedLab/gopeed/pkg/protocol/bt"
|
|
"github.com/GopeedLab/gopeed/pkg/protocol/ed2k"
|
|
phttp "github.com/GopeedLab/gopeed/pkg/protocol/http"
|
|
"github.com/google/jsonschema-go/jsonschema"
|
|
)
|
|
|
|
func TestToolSchemaPropertiesAreObjects(t *testing.T) {
|
|
downloader := download.NewDownloader(&download.DownloaderConfig{Storage: download.NewMemStorage()})
|
|
req := httptest.NewRequest(http.MethodPost, "http://localhost/mcp", bytes.NewBufferString(
|
|
`{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}`,
|
|
))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Accept", "application/json, text/event-stream")
|
|
recorder := httptest.NewRecorder()
|
|
NewHandler(downloader).ServeHTTP(recorder, req)
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("tools/list status = %d: %s", recorder.Code, recorder.Body.String())
|
|
}
|
|
var response struct {
|
|
Result struct {
|
|
Tools []struct {
|
|
Name string `json:"name"`
|
|
InputSchema map[string]any `json:"inputSchema"`
|
|
OutputSchema map[string]any `json:"outputSchema"`
|
|
} `json:"tools"`
|
|
} `json:"result"`
|
|
}
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(response.Result.Tools) == 0 {
|
|
t.Fatal("tools/list returned no tools")
|
|
}
|
|
for _, tool := range response.Result.Tools {
|
|
t.Run(tool.Name, func(t *testing.T) {
|
|
checkSchemaProperties(t, tool.InputSchema, "inputSchema")
|
|
checkSchemaProperties(t, tool.OutputSchema, "outputSchema")
|
|
})
|
|
}
|
|
}
|
|
|
|
// Check the serialized definitions, including nested file requests and union
|
|
// branches. Boolean additionalProperties remains valid; boolean entries inside
|
|
// properties are what clients with object-only schema parsers cannot consume.
|
|
func checkSchemaProperties(t *testing.T, value any, path string) {
|
|
t.Helper()
|
|
switch value := value.(type) {
|
|
case map[string]any:
|
|
for key, child := range value {
|
|
if key == "properties" {
|
|
properties, ok := child.(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("%s.properties = %#v, want object", path, child)
|
|
}
|
|
for name, property := range properties {
|
|
if _, ok := property.(map[string]any); !ok {
|
|
t.Errorf("%s.properties.%s = %#v, want schema object", path, name, property)
|
|
}
|
|
}
|
|
}
|
|
checkSchemaProperties(t, child, path+"."+key)
|
|
}
|
|
case []any:
|
|
for i, child := range value {
|
|
checkSchemaProperties(t, child, fmt.Sprintf("%s[%d]", path, i))
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestToolOutputSchemasPreserveDynamicValues(t *testing.T) {
|
|
statsSchema := toolOutputSchema[getTaskStatsOutput]()
|
|
for _, test := range []struct {
|
|
name string
|
|
stats any
|
|
}{
|
|
{"unavailable", nil},
|
|
{"typed nil", (*fetcher.Stats)(nil)},
|
|
{"http", &fetcher.Stats{Snapshot: &phttp.Stats{Connections: []*phttp.StatsConnection{{Downloaded: 123}}}}},
|
|
{"bittorrent", &fetcher.Stats{Snapshot: &bt.StatsSnapshot{}, Runtime: &bt.StatsRuntime{}}},
|
|
{"ed2k", &fetcher.Stats{Snapshot: &ed2k.StatsSnapshot{}, Runtime: &ed2k.StatsRuntime{}}},
|
|
{"restored snapshot", &fetcher.Stats{Snapshot: json.RawMessage(`{"segmentsTotal":3,"segmentsDone":2,"segmentsFailed":0}`)}},
|
|
{"empty object", map[string]any{}},
|
|
{"nested JSON", map[string]any{"values": []any{nil, true, "text", 1.5, map[string]any{"key": "value"}}}},
|
|
{"array", []any{1, "two", false}},
|
|
{"string", "protocol value"},
|
|
{"number", 1.5},
|
|
{"boolean", true},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
validateToolOutput(t, statsSchema, &getTaskStatsOutput{Stats: test.stats})
|
|
})
|
|
}
|
|
|
|
// Output extras can be empty, null, or extension-specific. In particular,
|
|
// they need not satisfy the required settings in the download input schema.
|
|
for _, extra := range []any{nil, map[string]any{}, &phttp.ReqExtra{}, &bt.ReqExtra{},
|
|
map[string]any{"custom": []any{true, nil, 42}}, "custom value"} {
|
|
req := &base.Request{URL: "https://example.com/file", Extra: extra}
|
|
res := &base.Resource{Files: []*base.FileInfo{{Name: "file", Req: req}}}
|
|
validateToolOutput(t, toolOutputSchema[resolveTaskOutput](), &resolveTaskOutput{Res: res})
|
|
validateToolOutput(t, toolOutputSchema[getTaskOutput](), &getTaskOutput{Task: taskDetails{
|
|
Req: req, Res: res, Opts: &base.Options{Extra: extra},
|
|
}})
|
|
}
|
|
|
|
// Supplying an explicit schema must retain validation of the output envelope.
|
|
resolved, err := statsSchema.Resolve(nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, invalid := range []map[string]any{{}, {"stats": nil, "unexpected": true}} {
|
|
if err := resolved.Validate(invalid); err == nil {
|
|
t.Errorf("accepted invalid output envelope: %#v", invalid)
|
|
}
|
|
}
|
|
}
|
|
|
|
func validateToolOutput(t *testing.T, schema *jsonschema.Schema, output any) {
|
|
t.Helper()
|
|
data, err := json.Marshal(output)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var value any
|
|
if err := json.Unmarshal(data, &value); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resolved, err := schema.Resolve(nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := resolved.Validate(value); err != nil {
|
|
t.Fatalf("output %s does not match schema: %v", data, err)
|
|
}
|
|
}
|