mirror of
https://github.com/coder/coder.git
synced 2026-10-31 02:43:21 +08:00
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:
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user