Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changesets/fix_openai_incompatibility.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
### fix: generate openAI-compatible json schemas for list types - @DaleSeo PR #272

The MCP server is generating JSON schemas that don't match OpenAI's function calling specification. It puts `oneOf` at the array level instead of using `items` to define the JSON schemas for the GraphQL list types. While some other LLMs are more flexible about this, it technically violates the [JSON Schema specification](https://json-schema.org/understanding-json-schema/reference/array) that OpenAI strictly follows.

This PR updates the list type handling logic to move `oneOf` inside `items` for GraphQL list types.
199 changes: 113 additions & 86 deletions crates/apollo-mcp-server/src/operations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1044,25 +1044,36 @@ fn type_to_schema(
custom_scalar_map,
definitions,
);
let items_schema = if list_type.is_non_null() {
inner_type_schema
} else {
Schema::Object(SchemaObject {
subschemas: Some(Box::new(SubschemaValidation {
one_of: Some(vec![
inner_type_schema,
Schema::Object(SchemaObject {
instance_type: Some(SingleOrVec::Single(Box::new(
InstanceType::Null,
))),
..Default::default()
}),
]),
..Default::default()
})),
..Default::default()
})
};

schema_factory(
None,
Some(InstanceType::Array),
None,
list_type.is_non_null().then(|| ArrayValidation {
items: Some(SingleOrVec::Single(Box::new(inner_type_schema.clone()))),
..Default::default()
}),
(!list_type.is_non_null()).then(|| SubschemaValidation {
one_of: Some(vec![
inner_type_schema,
Schema::Object(SchemaObject {
instance_type: Some(SingleOrVec::Single(Box::new(InstanceType::Null))),
..Default::default()
}),
]),
Some(ArrayValidation {
items: Some(SingleOrVec::Single(Box::new(items_schema))),
..Default::default()
}),
None,
None,
)
}
}
Expand Down Expand Up @@ -1556,7 +1567,7 @@ mod tests {
.unwrap();
let tool = Tool::from(operation);

insta::assert_debug_snapshot!(tool, @r###"
insta::assert_debug_snapshot!(tool, @r#"
Tool {
name: "QueryName",
description: Some(
Expand All @@ -1570,14 +1581,16 @@ mod tests {
"properties": Object {
"id": Object {
"type": String("array"),
"oneOf": Array [
Object {
"type": String("string"),
},
Object {
"type": String("null"),
},
],
"items": Object {
"oneOf": Array [
Object {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like all our array test currently only have scalars, could we have one that s a list of input objects? (one for nullable and one for non nullable)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added tests for input object lists.

"type": String("string"),
},
Object {
"type": String("null"),
},
],
},
},
},
},
Expand All @@ -1593,8 +1606,8 @@ mod tests {
},
),
}
"###);
insta::assert_snapshot!(serde_json::to_string_pretty(&serde_json::json!(tool.input_schema)).unwrap(), @r###"
"#);
insta::assert_snapshot!(serde_json::to_string_pretty(&serde_json::json!(tool.input_schema)).unwrap(), @r#"
{
"type": "object",
"required": [
Expand All @@ -1603,18 +1616,20 @@ mod tests {
"properties": {
"id": {
"type": "array",
"oneOf": [
{
"type": "string"
},
{
"type": "null"
}
]
"items": {
"oneOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
}
}
}
"###);
"#);
Comment on lines -1617 to +1636
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not blocking, but what caused the change in all these snapshots to use the single #?

Copy link
Contributor Author

@DaleSeo DaleSeo Aug 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test code was written before Insta v1.40.0. It used to use ### for inline snapshots but now it uses # by default. See #272 (comment)

}

#[test]
Expand Down Expand Up @@ -1708,7 +1723,7 @@ mod tests {
.unwrap();
let tool = Tool::from(operation);

insta::assert_debug_snapshot!(tool, @r###"
insta::assert_debug_snapshot!(tool, @r#"
Tool {
name: "QueryName",
description: Some(
Expand All @@ -1719,14 +1734,16 @@ mod tests {
"properties": Object {
"id": Object {
"type": String("array"),
"oneOf": Array [
Object {
"type": String("string"),
},
Object {
"type": String("null"),
},
],
"items": Object {
"oneOf": Array [
Object {
"type": String("string"),
},
Object {
"type": String("null"),
},
],
},
},
},
},
Expand All @@ -1742,25 +1759,27 @@ mod tests {
},
),
}
"###);
insta::assert_snapshot!(serde_json::to_string_pretty(&serde_json::json!(tool.input_schema)).unwrap(), @r###"
"#);
insta::assert_snapshot!(serde_json::to_string_pretty(&serde_json::json!(tool.input_schema)).unwrap(), @r#"
{
"type": "object",
"properties": {
"id": {
"type": "array",
"oneOf": [
{
"type": "string"
},
{
"type": "null"
}
]
"items": {
"oneOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
}
}
}
"###);
"#);
}

#[test]
Expand Down Expand Up @@ -1848,7 +1867,7 @@ mod tests {
.unwrap();
let tool = Tool::from(operation);

insta::assert_debug_snapshot!(tool, @r###"
insta::assert_debug_snapshot!(tool, @r#"
Tool {
name: "QueryName",
description: Some(
Expand All @@ -1859,22 +1878,26 @@ mod tests {
"properties": Object {
"id": Object {
"type": String("array"),
"oneOf": Array [
Object {
"type": String("array"),
"oneOf": Array [
Object {
"type": String("string"),
},
Object {
"type": String("null"),
"items": Object {
"oneOf": Array [
Object {
"type": String("array"),
"items": Object {
"oneOf": Array [
Object {
"type": String("string"),
},
Object {
"type": String("null"),
},
],
},
],
},
Object {
"type": String("null"),
},
],
},
Object {
"type": String("null"),
},
],
},
},
},
},
Expand All @@ -1890,33 +1913,37 @@ mod tests {
},
),
}
"###);
insta::assert_snapshot!(serde_json::to_string_pretty(&serde_json::json!(tool.input_schema)).unwrap(), @r###"
"#);
insta::assert_snapshot!(serde_json::to_string_pretty(&serde_json::json!(tool.input_schema)).unwrap(), @r#"
{
"type": "object",
"properties": {
"id": {
"type": "array",
"oneOf": [
{
"type": "array",
"oneOf": [
{
"type": "string"
},
{
"type": "null"
"items": {
"oneOf": [
{
"type": "array",
"items": {
"oneOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
]
},
{
"type": "null"
}
]
},
{
"type": "null"
}
]
}
}
}
}
"###);
"#);
}

#[test]
Expand Down