Repository: langchain4j/langchain4j (2026-07-16T17:37:56Z)

Issue:
[BUG] langchain4j-mcp: $ref outside $defs becomes "#/$defs/#/..." — malformed uri-reference rejected by OpenAI
**Describe the bug**

`ToolSpecificationHelper.extractReferenceKey()` strips a leading `#/$defs/` or `#/definitions/` and otherwise returns the `$ref` **unchanged**, so a JSON pointer such as `#/properties/body/properties/from` becomes the *reference key* of a `JsonReferenceSchema`:

```java
private static String extractReferenceKey(String ref) {
    if (ref.startsWith("#/$defs/")) {
        return ref.substring("#/$defs/".length());
    }
    if (ref.startsWith("#/definitions/")) {
        return ref.substring("#/definitions/".length());
    }
    return ref;   // <-- raw JSON pointer falls through unchanged
}
```

```java
if (node.has("$ref")) {
    return JsonReferenceSchema.builder()
            .reference(extractReferenceKey(node.get("$ref").asText()))
            .build();
}
```

`JsonSchemaElementUtils` then serializes it as `"$ref": "#/$defs/" + reference`:

```java
} else if (jsonSchemaElement instanceof JsonReferenceSchema) {
    String reference = ((JsonReferenceSchema) jsonSchemaElement).reference();
    if (reference != null) {
        map.put("$ref", "#/$defs/" + reference);
    }
```

which yields `#/$defs/#/properties/body/properties/from`. Per RFC 3986 a URI may contain **at most one `#`**, so the result can never be a valid uri-reference, and the provider rejects the request.

This also contradicts `JsonReferenceSchema`'s own contract:

> "When used, the `JsonObjectSchema#definitions()` of the root JSON schema element should contain an entry with a key equal to the `#reference()` of this `JsonReferenceSchema`."

i.e. `reference()` is expected to be a bare `definitions()` key, never a pointer.

**Impact:** a single affected tool fails the **entire** chat request, so every other tool in that request fails with it.

**Regression:** on `1.12.2` there is no `$ref` branch, and the same MCP schema produces **no** `$ref` at all — requests succeed. The `$ref` branch was added in #4793 (closing #3105), which appears to have anticipated only `$defs`-style refs. Verified still present on `main`.

**Log and Stack trace**

```
dev.langchain4j.exception.InvalidRequestException: {
  "error": {
    "message": "Invalid schema for function 'send-chat-message': '#/$defs/#/properties/body/properties/from/properties/application' is not a 'uri-reference'.",
    "type": "invalid_request_error",
    "param": "tools[1].function.parameters",
    "code": "invalid_function_parameters"
  }
}
    at dev.langchain4j.internal.ExceptionMapper$DefaultExceptionMapper.mapHttpStatusCode(ExceptionMapper.java:71)
    at dev.langchain4j.model.openai.OpenAiStreamingChatModel.lambda$doChat$2(OpenAiStreamingChatModel.java:173)
```

**To Reproduce**

An MCP server advertises a tool whose `inputSchema` contains a `$ref` pointing into the schema body (i.e. not at `#/$defs/...`):

```json
{
  "name": "send-chat-message",
  "description": "Send a message to a Teams chat",
  "inputSchema": {
    "type": "object",
    "properties": {
      "chatId": { "type": "string" },
      "body": {
        "type": "object",
        "properties": {
          "from": {
            "type": "object",
            "properties": {
              "application": { "$ref": "#/properties/body/properties/from/properties/application" }
            }
          }
        }
      }
    },
    "required": ["chatId"]
  }
}
```

Then:

```java
McpToolProvider provider = McpToolProvider.builder().mcpClients(mcpClient).build();
ToolSpecification spec = provider.provideTools(null).tools().keySet().stream()
        .filter(s -> s.name().equals("send-chat-message")).findFirst().orElseThrow();

System.out.println(JsonSchemaElementUtils.toMap(spec.parameters(), false));
// $ref = #/$defs/#/properties/body/properties/from/properties/application   <-- two '#'
```

Observed on `1.16.3` and on `main`. On `1.12.2` the same schema emits no `$ref`.

**Expected behavior**

`extractReferenceKey` should not turn a JSON pointer into a `$defs` key. A `$ref` that does not target `#/$defs/<name>` or `#/definitions/<name>` should be resolved, inlined, or dropped — but never concatenated onto `#/$defs/`, since that cannot produce a valid URI for any input.

**Please complete the following information:**
 - LangChain4j version: `1.16.3` (also reproduced on `main`)
 - LLM(s) used: OpenAI (via `OpenAiStreamingChatModel`)
 - Java version: 21
 - Spring Boot version (if applicable): n/a

**Additional context**

This shape is common in the wild rather than exotic: MCP servers generated with `zod-to-json-schema` default to `$refStrategy: 'root'` and therefore emit body pointers instead of `$defs`. For example `Softeria/ms-365-mcp-server` advertises ~1190 such refs across 64 of its 311 tools (Softeria/ms-365-mcp-server#458).

Worth noting for whichever fix is chosen: OpenAI permits `$ref` only to **top-level** `$defs` entries, so rewriting a body pointer into a valid `#/$defs/<name>` would require synthesizing definitions. In the ms-365 case the pointers are additionally dangling (they do not resolve against the tool's own schema — Softeria/ms-365-mcp-server#181), so resolution is not always possible. The malformed concatenation, however, happens regardless of whether the target resolves.



Discussion:

Patch:
diff --git a/langchain4j-mcp/src/main/java/dev/langchain4j/mcp/client/ToolSpecificationHelper.java b/langchain4j-mcp/src/main/java/dev/langchain4j/mcp/client/ToolSpecificationHelper.java
index 6fa3c6b0db1..ed58824a089 100644
--- a/langchain4j-mcp/src/main/java/dev/langchain4j/mcp/client/ToolSpecificationHelper.java
+++ b/langchain4j-mcp/src/main/java/dev/langchain4j/mcp/client/ToolSpecificationHelper.java
@@ -84,11 +84,11 @@ static JsonSchemaElement jsonNodeToJsonSchemaElement(JsonNode node) {
             }
             return anyOf.build();
         }
-        // Handle $ref (JSON Schema reference)
         if (node.has("$ref")) {
-            return JsonReferenceSchema.builder()
-                    .reference(extractReferenceKey(node.get("$ref").asText()))
-                    .build();
+            String referenceKey = extractReferenceKey(node.get("$ref").asText());
+            if (referenceKey != null) {
+                return JsonReferenceSchema.builder().reference(referenceKey).build();
+            }
         }
         JsonNode typeNode = node.get("type");
         // If no type is specified, default to object schema
@@ -243,8 +243,10 @@ private static JsonSchemaElement toTypeElement(JsonNode node) {
     }
 
     /**
-     * Extracts the reference key from a JSON Schema $ref value.
-     * For example, "#/$defs/Foo" returns "Foo", "#/definitions/Bar" returns "Bar".
+     * Extracts the definition key from a JSON Schema $ref that targets a definition.
+     * For example, "#/$defs/Foo" returns "Foo" and "#/definitions/Bar" returns "Bar".
+     * Returns {@code null} for any other $ref (for example a pointer into the schema body),
+     * which is not a definition key.
      */
     private static String extractReferenceKey(String ref) {
         if (ref.startsWith("#/$defs/")) {
@@ -253,7 +255,7 @@ private static String extractReferenceKey(String ref) {
         if (ref.startsWith("#/definitions/")) {
             return ref.substring("#/definitions/".length());
         }
-        return ref;
+        return null;
     }
 
     private static String[] toStringArray(ArrayNode jsonArray) {


Test patch:
diff --git a/langchain4j-mcp/src/test/java/dev/langchain4j/mcp/client/ToolSpecificationHelperTest.java b/langchain4j-mcp/src/test/java/dev/langchain4j/mcp/client/ToolSpecificationHelperTest.java
index 56c34f1372a..0014c6b07c2 100644
--- a/langchain4j-mcp/src/test/java/dev/langchain4j/mcp/client/ToolSpecificationHelperTest.java
+++ b/langchain4j-mcp/src/test/java/dev/langchain4j/mcp/client/ToolSpecificationHelperTest.java
@@ -14,6 +14,7 @@
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.fasterxml.jackson.databind.node.ArrayNode;
 import dev.langchain4j.agent.tool.ToolSpecification;
+import dev.langchain4j.internal.JsonSchemaElementUtils;
 import dev.langchain4j.model.chat.request.json.JsonAnyOfSchema;
 import dev.langchain4j.model.chat.request.json.JsonArraySchema;
 import dev.langchain4j.model.chat.request.json.JsonBooleanSchema;
@@ -852,4 +853,102 @@ void toolWithRefInAnyOf() throws JsonProcessingException {
                 (JsonObjectSchema) parameters.definitions().get("TextInput");
         assertThat(textInputDef.properties()).containsOnlyKeys("text", "language");
     }
+
+    @Test
+    void toolWithNonDefsRefIsNotTreatedAsReference() throws JsonProcessingException {
+        String text =
+                // language=json
+                """
+                [{
+                    "name": "send-chat-message",
+                    "inputSchema": {
+                      "type": "object",
+                      "properties": {
+                        "chatId": { "type": "string" },
+                        "body": {
+                          "type": "object",
+                          "properties": {
+                            "from": {
+                              "type": "object",
+                              "properties": {
+                                "application": { "$ref": "#/properties/body/properties/from/properties/application" }
+                              }
+                            }
+                          }
+                        }
+                      },
+                      "required": ["chatId"]
+                    }
+                }]
+                """;
+        ArrayNode json = OBJECT_MAPPER.readValue(text, ArrayNode.class);
+        List<ToolSpecification> toolSpecifications = ToolSpecificationHelper.toolSpecificationListFromMcpResponse(json);
+        JsonObjectSchema parameters = toolSpecifications.get(0).parameters();
+
+        JsonObjectSchema body = (JsonObjectSchema) parameters.properties().get("body");
+        JsonObjectSchema from = (JsonObjectSchema) body.properties().get("from");
+        JsonSchemaElement application = from.properties().get("application");
+
+        assertThat(application).isNotInstanceOf(JsonReferenceSchema.class);
+        assertThat(application).isInstanceOf(JsonObjectSchema.class);
+        assertThat(JsonSchemaElementUtils.toMap(parameters, false).toString()).doesNotContain("#/$defs/#");
+    }
+
+    @Test
+    void toolWithDefsRefAndBodyPointerRefAreHandledDifferently() throws JsonProcessingException {
+        String text =
+                // language=json
+                """
+                [{
+                    "name": "mixed_refs",
+                    "inputSchema": {
+                      "type": "object",
+                      "properties": {
+                        "contact": { "$ref": "#/$defs/Contact" },
+                        "self": { "$ref": "#/properties/contact" }
+                      },
+                      "$defs": {
+                        "Contact": { "type": "object", "properties": { "name": { "type": "string" } } }
+                      }
+                    }
+                }]
+                """;
+        ArrayNode json = OBJECT_MAPPER.readValue(text, ArrayNode.class);
+        List<ToolSpecification> toolSpecifications = ToolSpecificationHelper.toolSpecificationListFromMcpResponse(json);
+        JsonObjectSchema parameters = toolSpecifications.get(0).parameters();
+
+        JsonSchemaElement contact = parameters.properties().get("contact");
+        assertThat(contact).isInstanceOf(JsonReferenceSchema.class);
+        assertThat(((JsonReferenceSchema) contact).reference()).isEqualTo("Contact");
+
+        assertThat(parameters.properties().get("self")).isNotInstanceOf(JsonReferenceSchema.class);
+    }
+
+    @Test
+    void toolWithNonDefsRefKeepsSiblingKeywords() throws JsonProcessingException {
+        String text =
+                // language=json
+                """
+                [{
+                    "name": "mixed_ref_keywords",
+                    "inputSchema": {
+                      "type": "object",
+                      "properties": {
+                        "note": {
+                          "$ref": "#/properties/other",
+                          "type": "string",
+                          "description": "a note"
+                        }
+                      }
+                    }
+                }]
+                """;
+        ArrayNode json = OBJECT_MAPPER.readValue(text, ArrayNode.class);
+        List<ToolSpecification> toolSpecifications = ToolSpecificationHelper.toolSpecificationListFromMcpResponse(json);
+        JsonObjectSchema parameters = toolSpecifications.get(0).parameters();
+
+        JsonSchemaElement note = parameters.properties().get("note");
+        assertThat(note).isInstanceOf(JsonStringSchema.class);
+        assertThat(((JsonStringSchema) note).description()).isEqualTo("a note");
+    }
 }