Explorar o código

Fix get_all_pages outputSchema to satisfy MCP spec

The MCP spec requires outputSchema.type to be the string "object" when a
structured output schema is declared. get_all_pages returned a bare
[]PageListItem, and the SDK inferred type ["null","array"], which strict
clients (e.g. pydantic-based ones) reject at tools/list time with a
ValidationError, leaving the server with no tools.

Wrap the list in {"pages": [...]} so the inferred schema is an object.
Add a regression test asserting every declared outputSchema.type is
"object" on the wire.
Paul Klumpp hai 6 días
pai
achega
4c11bbb2bf
Modificáronse 3 ficheiros con 55 adicións e 10 borrados
  1. 1 1
      README.md
  2. 13 5
      internal/server/tools.go
  3. 41 4
      internal/server/tools_test.go

+ 1 - 1
README.md

@@ -221,7 +221,7 @@ The four read tools are always available. Editing is disabled by default; set
 | `search_pages` | `query` (required), `path`, `locale` | Full-text search. Returns `results`, `suggestions`, and `totalHits`. |
 | `get_page_by_id` | `id` (required) | Full page by numeric ID, or `null`. |
 | `get_page_by_path` | `path`, `locale` (both required) | Full page by path and locale, or `null`. |
-| `get_all_pages` | `limit`, `locale`, `tags` | List pages with optional filters. |
+| `get_all_pages` | `limit`, `locale`, `tags` | List pages with optional filters. Returns `{"pages": [...]}`. |
 | `create_page` | `title`, `content`, `description`, `path` (required); `editor`, `isPublished`, `isPrivate`, `locale`, `tags` | Create a page. |
 | `edit_page` | `id`, `title`, `content`, `description`, `path` (required); `editor`, `isPublished`, `isPrivate`, `locale`, `tags` | Update a page. |
 

+ 13 - 5
internal/server/tools.go

@@ -87,12 +87,20 @@ type listPagesInput struct {
 	Tags   []string `json:"tags,omitempty" jsonschema:"optional tags to filter by"`
 }
 
-func (h *handlers) listPages(ctx context.Context, _ *mcp.CallToolRequest, in listPagesInput) (*mcp.CallToolResult, []wikijs.PageListItem, error) {
+// listPagesOutput wraps the page list in an object. The MCP spec requires
+// outputSchema.type to be the string "object" whenever a structured output
+// schema is declared, so this tool must not return a bare array: strict
+// clients reject the array-typed schema at tools/list time.
+type listPagesOutput struct {
+	Pages []wikijs.PageListItem `json:"pages" jsonschema:"matching pages"`
+}
+
+func (h *handlers) listPages(ctx context.Context, _ *mcp.CallToolRequest, in listPagesInput) (*mcp.CallToolResult, listPagesOutput, error) {
 	if in.Limit != nil && *in.Limit <= 0 {
-		return nil, nil, errors.New("limit must be a positive integer")
+		return nil, listPagesOutput{}, errors.New("limit must be a positive integer")
 	}
 	if err := validateLocale(in.Locale, false); err != nil {
-		return nil, nil, err
+		return nil, listPagesOutput{}, err
 	}
 
 	pages, err := h.wiki.ListPages(ctx, wikijs.ListOptions{
@@ -101,9 +109,9 @@ func (h *handlers) listPages(ctx context.Context, _ *mcp.CallToolRequest, in lis
 		Tags:   in.Tags,
 	})
 	if err != nil {
-		return nil, nil, err
+		return nil, listPagesOutput{}, err
 	}
-	return nil, pages, nil
+	return nil, listPagesOutput{Pages: pages}, nil
 }
 
 type createPageInput struct {

+ 41 - 4
internal/server/tools_test.go

@@ -185,6 +185,41 @@ func TestToolListWithoutEdit(t *testing.T) {
 	}
 }
 
+// TestListToolsOutputSchemasAreObjects guards against the MCP spec violation
+// that made strict clients (for example pydantic-based ones) reject the whole
+// tools/list response: a tool whose outputSchema.type is not the string
+// "object" is invalid whenever an output schema is declared. The check runs
+// against the wire format, which is what strict clients validate.
+func TestListToolsOutputSchemasAreObjects(t *testing.T) {
+	cs, _ := newTestSession(t, true)
+
+	res, err := cs.ListTools(context.Background(), nil)
+	if err != nil {
+		t.Fatalf("ListTools() error = %v", err)
+	}
+	if len(res.Tools) == 0 {
+		t.Fatal("no tools listed")
+	}
+	for _, tool := range res.Tools {
+		if tool.OutputSchema == nil {
+			continue // optional per the MCP spec
+		}
+		raw, err := json.Marshal(tool.OutputSchema)
+		if err != nil {
+			t.Fatalf("marshal outputSchema of %q: %v", tool.Name, err)
+		}
+		var schema struct {
+			Type json.RawMessage `json:"type"`
+		}
+		if err := json.Unmarshal(raw, &schema); err != nil {
+			t.Fatalf("unmarshal outputSchema of %q: %v", tool.Name, err)
+		}
+		if string(schema.Type) != `"object"` {
+			t.Errorf("tool %q outputSchema.type = %s, want \"object\"", tool.Name, schema.Type)
+		}
+	}
+}
+
 func TestToolListWithEdit(t *testing.T) {
 	cs, _ := newTestSession(t, true)
 
@@ -349,12 +384,14 @@ func TestListPagesTool(t *testing.T) {
 	if res.IsError {
 		t.Fatalf("IsError = true: %s", toolText(t, res))
 	}
-	var pages []wikijs.PageListItem
-	if err := json.Unmarshal([]byte(toolText(t, res)), &pages); err != nil {
+	var out struct {
+		Pages []wikijs.PageListItem `json:"pages"`
+	}
+	if err := json.Unmarshal([]byte(toolText(t, res)), &out); err != nil {
 		t.Fatalf("decode pages: %v", err)
 	}
-	if len(pages) != 1 || pages[0].ID != 1 {
-		t.Errorf("pages = %+v", pages)
+	if len(out.Pages) != 1 || out.Pages[0].ID != 1 {
+		t.Errorf("pages = %+v", out.Pages)
 	}
 
 	req := fake.lastContaining(t, "query GetAllPages")