diff --git a/agent/x/agentmcp/manager.go b/agent/x/agentmcp/manager.go index 80197cff577..16d2cc4635c 100644 --- a/agent/x/agentmcp/manager.go +++ b/agent/x/agentmcp/manager.go @@ -1038,7 +1038,10 @@ func toolInputSchemaMap(schema any) map[string]any { if typ, ok := m["type"].(string); ok && typ != "" { out["type"] = typ } - if properties, ok := m["properties"].(map[string]any); ok && len(properties) > 0 { + // Preserve an empty "properties" object: dropping it collapses the + // schema to nil by the time the tool definition is rebuilt, and a + // nil properties serializes to JSON null, which OpenAI rejects. + if properties, ok := m["properties"].(map[string]any); ok { out["properties"] = properties } if required, ok := m["required"].([]any); ok && len(required) > 0 { diff --git a/agent/x/agentmcp/manager_internal_test.go b/agent/x/agentmcp/manager_internal_test.go index 75472a926a9..a0f43ead41d 100644 --- a/agent/x/agentmcp/manager_internal_test.go +++ b/agent/x/agentmcp/manager_internal_test.go @@ -22,6 +22,30 @@ import ( "github.com/coder/quartz" ) +// TestToolInputSchemaMapPreservesEmptyProperties verifies that a tool +// schema with an empty "properties" object (for example +// {"type": "object", "properties": {}}) keeps "properties" in the wire +// copy. Dropping it collapses the schema to nil by the time coderd +// rebuilds the tool definition, and a nil properties serializes to JSON +// null, which OpenAI rejects with "None is not of type 'object'". +func TestToolInputSchemaMapPreservesEmptyProperties(t *testing.T) { + t.Parallel() + + out := toolInputSchemaMap(map[string]any{ + "type": "object", + "properties": map[string]any{}, + }) + require.NotNil(t, out, "schema must not collapse to nil") + properties, ok := out["properties"].(map[string]any) + require.True(t, ok, "properties must be preserved as a map, got %T", out["properties"]) + require.NotNil(t, properties, "properties must not be nil") + + // Verify it serializes to {} not null or absent. + bs, err := json.Marshal(out) + require.NoError(t, err) + require.JSONEq(t, `{"type":"object","properties":{}}`, string(bs)) +} + func TestSplitToolName(t *testing.T) { t.Parallel() diff --git a/coderd/x/chatd/chatloop/chatloop.go b/coderd/x/chatd/chatloop/chatloop.go index e7586520f1e..c785277748f 100644 --- a/coderd/x/chatd/chatloop/chatloop.go +++ b/coderd/x/chatd/chatloop/chatloop.go @@ -1628,9 +1628,16 @@ func buildToolDefinitions(tools []fantasy.AgentTool, activeTools []string, provi continue } + // Substitute an empty object for nil properties so that a tool + // with no parameters never serializes "properties" to null, + // which OpenAI rejects. + properties := info.Parameters + if properties == nil { + properties = map[string]any{} + } inputSchema := map[string]any{ "type": "object", - "properties": info.Parameters, + "properties": properties, } // Only include "required" when non-empty so that a nil slice // never serializes to null, which OpenAI rejects. diff --git a/coderd/x/chatd/chatloop/chatloop_internal_test.go b/coderd/x/chatd/chatloop/chatloop_internal_test.go index 96825127c9f..91d8bd0ff66 100644 --- a/coderd/x/chatd/chatloop/chatloop_internal_test.go +++ b/coderd/x/chatd/chatloop/chatloop_internal_test.go @@ -2,6 +2,7 @@ package chatloop import ( "context" + "encoding/json" "iter" "testing" @@ -81,6 +82,52 @@ func TestProcessStepStreamPersistsRedactedThinkingOnEnd(t *testing.T) { require.Equal(t, "redacted-payload", metadata.RedactedData) } +// staticParametersTool returns a fixed ToolInfo, letting a test control +// Parameters directly. fantasy.NewAgentTool always generates a non-nil +// Parameters map, so it cannot reproduce the nil Parameters that MCP tool +// wrappers report for a schema with an empty "properties" object. +type staticParametersTool struct { + fantasy.AgentTool + info fantasy.ToolInfo +} + +func (t staticParametersTool) Info() fantasy.ToolInfo { return t.info } + +func (staticParametersTool) ProviderOptions() fantasy.ProviderOptions { return nil } + +// TestBuildToolDefinitionsNilPropertiesBecomesEmptyObject verifies that a +// tool whose input schema has no properties (for example an MCP tool +// reporting {"type": "object", "properties": {}}) still serializes +// "properties" as an empty JSON object. A nil Parameters map would serialize +// to null, which OpenAI rejects with "Invalid schema for function ... None is +// not of type 'object'". +func TestBuildToolDefinitionsNilPropertiesBecomesEmptyObject(t *testing.T) { + t.Parallel() + + tool := staticParametersTool{ + info: fantasy.ToolInfo{ + Name: "document_graphql_schema", + Description: "Run a GraphQL query", + Parameters: nil, + }, + } + + defs := buildToolDefinitions([]fantasy.AgentTool{tool}, nil, nil) + require.Len(t, defs, 1) + + ft, ok := defs[0].(fantasy.FunctionTool) + require.True(t, ok, "expected a fantasy.FunctionTool") + + properties, ok := ft.InputSchema["properties"].(map[string]any) + require.True(t, ok, "properties must be a map, got %T", ft.InputSchema["properties"]) + require.NotNil(t, properties, "properties must not be nil") + + // Verify it serializes to {} not null. + bs, err := json.Marshal(ft.InputSchema) + require.NoError(t, err) + require.JSONEq(t, `{"type":"object","properties":{}}`, string(bs)) +} + func TestFlushActiveStatePreservesEmptySignedReasoning(t *testing.T) { t.Parallel() diff --git a/coderd/x/chatd/context_prompt_internal_test.go b/coderd/x/chatd/context_prompt_internal_test.go index ff5184ad14e..96738f67831 100644 --- a/coderd/x/chatd/context_prompt_internal_test.go +++ b/coderd/x/chatd/context_prompt_internal_test.go @@ -735,6 +735,31 @@ func TestWorkspaceMCPToolInfosFromResources(t *testing.T) { require.Equal(t, "playwright__navigate", infos[0].Name) }) + t.Run("EmptyPropertiesYieldsEmptySchema", func(t *testing.T) { + t.Parallel() + + // An MCP tool reporting {"type": "object", "properties": {}} must + // produce a non-nil empty Schema. A nil Schema serializes to JSON + // null downstream, which OpenAI rejects. + schema := mustStruct(t, map[string]any{ + "type": "object", + "properties": map[string]any{}, + }) + resources := []database.ChatContextResource{ + mcpServerResource(t, "github", &agentproto.MCPServerBody{ + ServerName: "github", + Tools: []*agentproto.MCPTool{ + {Name: "document_graphql_schema", Description: "Run a GraphQL query", InputSchema: schema}, + }, + }, database.WorkspaceAgentContextResourceStatusOk), + } + + infos := workspaceMCPToolInfosFromResources(resources) + require.Len(t, infos, 1) + require.NotNil(t, infos[0].Schema, "Schema must not be nil for an empty properties object") + require.Empty(t, infos[0].Schema) + }) + t.Run("NoMCPServersYieldsNil", func(t *testing.T) { t.Parallel()