fix(coderd/x/chatd): prevent nil required field in MCP tool schemas for OpenAI (#23538)

This commit is contained in:
Kyle Carberry
2026-03-24 18:29:41 -04:00
committed by GitHub
parent 367b5af173
commit 3812b504fc
3 changed files with 49 additions and 2 deletions
+5 -1
View File
@@ -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{
+7 -1
View File
@@ -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,
}
}
@@ -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) {