fix(mcp): describe dynamic outputs with explicit schemas (#1626)

This commit is contained in:
Levi authored and GitHub committed 2026-10-28 22:32:52 +08:00
1 parent 91edba9af6
commit be0fb05edd
3 files changed
+225 -13

No files matched your search

+148
View File
@@ -0,0 +1,148 @@
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)
}
}
+47 -13
View File
@@ -190,6 +190,37 @@ func downloadInputSchema[T any]() *jsonschema.Schema {
return schema
}
// toolOutputSchema preserves arbitrary JSON values in protocol statistics and
// task metadata. Unlike the boolean schema inferred for any, these object-form
// schemas can also be parsed by clients that do not support boolean schemas in
// properties. Output metadata may come from REST, extensions, or persisted tasks,
// so it must not inherit the narrower download input restrictions.
func toolOutputSchema[T any]() *jsonschema.Schema {
schema, err := jsonschema.For[T](&jsonschema.ForOptions{
TypeSchemas: map[reflect.Type]*jsonschema.Schema{
// Request.Labels is serialized as null when the map is nil.
reflect.TypeFor[map[string]string](): {
Types: []string{"object", "null"},
AdditionalProperties: &jsonschema.Schema{Type: "string"},
},
reflect.TypeFor[any](): {
AnyOf: []*jsonschema.Schema{
{Type: "object"},
{Type: "array"},
{Type: "string"},
{Type: "number"},
{Type: "boolean"},
{Type: "null"},
},
},
},
})
if err != nil {
panic(fmt.Sprintf("mcpserver: build tool output schema: %v", err))
}
return schema
}
func (o *downloadOptions) baseOptions() *base.Options {
if o == nil {
return nil
@@ -319,11 +350,12 @@ func registerTools(server *mcp.Server, downloader *download.Downloader) {
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},
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](),
OutputSchema: toolOutputSchema[resolveTaskOutput](),
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")
@@ -403,10 +435,11 @@ func registerTools(server *mcp.Server, downloader *download.Downloader) {
})
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},
Name: "get_task",
Title: "Get task",
Description: "Get one Gopeed task including its request, resource, options, and current progress.",
OutputSchema: toolOutputSchema[getTaskOutput](),
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 {
@@ -442,10 +475,11 @@ func registerTools(server *mcp.Server, downloader *download.Downloader) {
})
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},
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.",
OutputSchema: toolOutputSchema[getTaskStatsOutput](),
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
+30
View File
@@ -192,6 +192,36 @@ func TestStreamableHTTPTools(t *testing.T) {
if tasks, ok := structured["tasks"].([]any); !ok || len(tasks) != 0 {
t.Fatalf("tasks = %#v, want empty array", structured["tasks"])
}
result, err = session.CallTool(context.Background(), &mcp.CallToolParams{
Name: "create_task",
Arguments: map[string]any{
"req": map[string]any{
"url": downloadServer.URL,
"extra": map[string]any{"header": map[string]any{"X-Gopeed-Test": "mcp-object"}},
},
"opts": map[string]any{"path": t.TempDir()},
},
})
if err != nil {
t.Fatal(err)
}
if result.IsError {
t.Fatalf("create_task returned tool error: %+v", result.Content)
}
created := result.StructuredContent.(map[string]any)
for _, name := range []string{"get_task", "get_task_stats"} {
result, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: name,
Arguments: map[string]any{"id": created["id"]},
})
if err != nil {
t.Fatalf("%s: %v", name, err)
}
if result.IsError {
t.Fatalf("%s returned tool error: %+v", name, result.Content)
}
}
}
func nestedSchemaProperty(t *testing.T, schema map[string]any, path ...string) map[string]any {