diff --git a/coderd/x/chatd/chatloop/chatloop.go b/coderd/x/chatd/chatloop/chatloop.go index 1203e354eb..1e72ded6a5 100644 --- a/coderd/x/chatd/chatloop/chatloop.go +++ b/coderd/x/chatd/chatloop/chatloop.go @@ -968,7 +968,11 @@ func buildToolDefinitions(tools []fantasy.AgentTool, activeTools []string, provi inputSchema := map[string]any{ "type": "object", "properties": info.Parameters, - "required": info.Required, + } + // Only include "required" when non-empty so that a nil slice + // never serializes to null, which OpenAI rejects. + if len(info.Required) > 0 { + inputSchema["required"] = info.Required } schema.Normalize(inputSchema) prepared = append(prepared, fantasy.FunctionTool{ diff --git a/coderd/x/chatd/mcpclient/mcpclient.go b/coderd/x/chatd/mcpclient/mcpclient.go index 481f46c604..d350a9f7d5 100644 --- a/coderd/x/chatd/mcpclient/mcpclient.go +++ b/coderd/x/chatd/mcpclient/mcpclient.go @@ -429,11 +429,17 @@ func newMCPTool( } func (t *mcpToolWrapper) Info() fantasy.ToolInfo { + // Ensure Required is never nil so that it serializes to [] instead + // of null. OpenAI rejects null for the JSON Schema "required" field. + required := t.required + if required == nil { + required = []string{} + } return fantasy.ToolInfo{ Name: t.prefixedName, Description: t.description, Parameters: t.parameters, - Required: t.required, + Required: required, Parallel: true, } } diff --git a/coderd/x/chatd/mcpclient/mcpclient_test.go b/coderd/x/chatd/mcpclient/mcpclient_test.go index 36c3da3cc2..0cf36852bb 100644 --- a/coderd/x/chatd/mcpclient/mcpclient_test.go +++ b/coderd/x/chatd/mcpclient/mcpclient_test.go @@ -361,6 +361,43 @@ func TestConnectAll_ToolInfoParameters(t *testing.T) { assert.Contains(t, info.Required, "input") } +// TestConnectAll_NilRequiredBecomesEmptySlice verifies that a tool +// whose inputSchema omits "required" produces an empty slice instead +// of nil. A nil slice serializes to JSON null, which OpenAI rejects +// with "None is not of type 'array'". +func TestConnectAll_NilRequiredBecomesEmptySlice(t *testing.T) { + t.Parallel() + ctx := context.Background() + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + + // noRequiredTool defines a tool with no required parameters. + noRequiredTool := mcpserver.ServerTool{ + Tool: mcp.NewTool("optional_only", + mcp.WithDescription("A tool with no required fields"), + mcp.WithString("note", mcp.Description("An optional note")), + ), + Handler: func(_ context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return mcp.NewToolResultText("ok"), nil + }, + } + + ts := newTestMCPServer(t, noRequiredTool) + cfg := makeConfig("srv", ts.URL) + tools, cleanup := mcpclient.ConnectAll(ctx, logger, []database.MCPServerConfig{cfg}, nil) + t.Cleanup(cleanup) + require.Len(t, tools, 1) + + info := tools[0].Info() + // Required must be a non-nil empty slice, not nil. + require.NotNil(t, info.Required, "Required should never be nil") + assert.Empty(t, info.Required, "Required should be empty for tools without required fields") + + // Verify it serializes to [] not null. + bs, err := json.Marshal(info.Required) + require.NoError(t, err) + assert.Equal(t, "[]", string(bs)) +} + // TestConnectAll_APIKeyAuth verifies that api_key auth sends the // configured header and value on every request. func TestConnectAll_APIKeyAuth(t *testing.T) {