fix: serialize empty MCP tool properties as {} not null for OpenAI (#28380)

## Summary

Fixed a bug where MCP tools with an empty `properties` schema had their
tool definitions sent to OpenAI with `"properties": null`, causing
OpenAI to reject the request.

## Problem

When an MCP server reports a tool with an empty input schema, e.g.:

```json
{
  "type": "object",
  "properties": {},
  "required": []
}
```

Coder was forwarding the tool definition to OpenAI as:

```json
{
  "type": "object",
  "properties": null
}
```

OpenAI's function-calling API rejects `properties: null`, making these
tools unusable with OpenAI models.

## Root cause

The empty `properties` object collapsed to Go `nil` at two layers:

1. `agent/x/agentmcp/manager.go` `toolInputSchemaMap` stripped the
present-but-empty `"properties": {}` (guarded by `len(properties) > 0`),
so the wire copy lost it entirely.
2. `coderd/x/chatd/chatloop/chatloop.go` `buildToolDefinitions` embedded
`info.Parameters` directly into the input schema. When nil,
`encoding/json` marshals it as `null`.

## Fix

- `buildToolDefinitions` now substitutes an empty object when
`Parameters` is nil, mirroring the existing `required` guard (the sink,
protects all tool sources).
- `toolInputSchemaMap` now preserves an empty `properties` object
instead of dropping it (the upstream producer).

## Tests

Added regression tests (failing before, passing after) covering both
layers and asserting the schema serializes to
`{"type":"object","properties":{}}`:

- `TestBuildToolDefinitionsNilPropertiesBecomesEmptyObject`
- `TestToolInputSchemaMapPreservesEmptyProperties`
-
`TestWorkspaceMCPToolInfosFromResources/EmptyPropertiesYieldsEmptySchema`

---

*This PR was generated by Coder Agents.*
This commit is contained in:
Danielle Maywood
2026-08-20 15:56:58 +02:00
committed by GitHub
parent ab6601086a
commit 5480299cef
5 changed files with 108 additions and 2 deletions
+4 -1
View File
@@ -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 {
+24
View File
@@ -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()