Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
97 changes: 97 additions & 0 deletions contrib/spring-ai/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,103 @@ adk:
metrics-enabled: true
```

## Tool / MCP Bridge — Spring AI `ToolCallback` as ADK `BaseTool`

In addition to wrapping Spring AI `ChatModel`s as ADK `BaseLlm`s, this library can wrap **any Spring AI `ToolCallback`** as an ADK `BaseTool` via `SpringAiToolCallbackBackedAdkTool`. This unlocks the full Spring AI tool ecosystem for ADK agents:

- **MCP tools** — `SyncMcpToolCallback` / `AsyncMcpToolCallback` produced by `spring-ai-starter-mcp-client` from `spring.ai.mcp.client.*` properties
- **`@Tool`-annotated methods** — Spring AI's annotation-driven function calling
- **`FunctionToolCallback`** — programmatically declared tools
- Any other implementation of `org.springframework.ai.tool.ToolCallback`

The bridge is the **reverse direction** of the existing `ToolConverter` (which goes ADK → Spring AI). Together they make ADK and Spring AI tool ecosystems fully interoperable.

### How it works

`SpringAiToolCallbackBackedAdkTool` reads `ToolCallback.getToolDefinition()` to extract the tool name, description, and JSON Schema. The schema is converted to ADK's `Schema` type via `Schema.fromJson(...)`; if parsing fails the bridge falls back to the `parametersJsonSchema(Object)` escape hatch (no hard failure). At invocation time the bridge serializes the `Map<String, Object>` arguments to JSON, dispatches to `ToolCallback.call(String)`, and parses the JSON response back to `Map<String, Object>`. Non-object responses (primitives / arrays / arbitrary strings) are wrapped under a `"result"` key for structural consistency.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

MEDIUM - Please rewrite this section for Spring AI 2.0. 2.0 removed the tool loop from every ChatModel (upgrade notes), so with .model(springAI) ADK runs the bridged tools. In 2.0, @Tool objects become callbacks through ToolCallbacks.from(...) or a MethodToolCallbackProvider, and MCP tools arrive through a ToolCallbackProvider. Also drop what no longer matches the code: Schema.fromJson, the lost items/enum (fixed in #1617), and the wrapCallResult comparison.


### Usage — MCP tools via Spring AI

`application.yaml`:

```yaml
spring:
ai:
mcp:
client:
sse:
connections:
filesystem:
url: http://localhost:3000
```

Java:

```java
import com.google.adk.agents.LlmAgent;
import com.google.adk.models.springai.SpringAI;
import com.google.adk.models.springai.bridge.SpringAiToolCallbackBackedAdkTool;
import org.springframework.ai.tool.ToolCallback;

@Configuration
class AgentConfig {

@Bean
public LlmAgent rootAgent(SpringAI springAI, List<ToolCallback> mcpToolCallbacks) {
return LlmAgent.builder()
.name("root_agent")
.model(springAI)
.tools(SpringAiToolCallbackBackedAdkTool.wrapAll(mcpToolCallbacks))
.instruction("Use the available tools to answer the user.")
.build();
}
}
```

That's it. The `List<ToolCallback>` is auto-injected by `spring-ai-starter-mcp-client`'s `McpToolCallbackAutoConfiguration`. The bridge converts every callback into a `BaseTool`. The agent uses them transparently.
Comment on lines +374 to +385

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

HIGH - Under Spring AI 2.0 this injects no MCP tools. The 2.0 MCP starter registers SyncMcpToolCallbackProvider/AsyncMcpToolCallbackProvider beans (source), not ToolCallbacks; verified with an ApplicationContextRunner (0 tools, no error). The provider lists tools lazily and drops its cache on McpToolsChangedEvent (source), so query it per request through a BaseToolset, like ADK Kotlin's SpringAiToolset.


### Usage — single tool

When you only need to wrap one callback:

```java
ToolCallback callback = /* obtained from any Spring AI source */;
BaseTool adkTool = new SpringAiToolCallbackBackedAdkTool(callback);

LlmAgent agent = LlmAgent.builder()
.name("my_agent")
.model(springAI)
.tools(List.of(adkTool))
.build();
```

### Automatic discovery via `SpringAiToolBridgeAutoConfiguration`

The library also ships `SpringAiToolBridgeAutoConfiguration` which auto-discovers **every** `ToolCallback` bean in the Spring context and exposes them as a single `@Bean("springAiTools") List<BaseTool>`. Wiring becomes:

```java
@Bean
public LlmAgent rootAgent(
SpringAI llm,
@Qualifier("springAiTools") List<BaseTool> springAiTools) {
return LlmAgent.builder().name("root").model(llm).tools(springAiTools).build();
}
```

Active only when at least one `ToolCallback` bean exists (from `spring-ai-starter-mcp-client`, `@Tool` methods, `FunctionToolCallback` beans, etc.).

### Error handling

If the underlying `ToolCallback.call(...)` throws, the bridge catches the exception and returns a structured `Map.of("error", "<message>")` result — matching ADK's native `AbstractMcpTool.wrapCallResult(...)` shape. The agent sees a tool result with an `error` key rather than aborting the invocation. Falls back to the exception's simple class name when the message is null.

### Schema fidelity

The bridge sets `FunctionDeclaration.parametersJsonSchema(Map)` from the parsed JSON schema, **not** `parameters(Schema.fromJson(...))`. This routes through the faithful branch of Spring AI's `ToolConverter` and preserves `items`, `enum`, `format`, `anyOf`/`oneOf`, `additionalProperties`, `$defs` and `$ref`. Unparseable schemas leave the field unset (with a logged warning) rather than emit a degraded or double-encoded schema.

### Coexistence with ADK's native MCP

ADK ships its own MCP client in `com.google.adk.tools.mcp.*` (CLI / non-Spring-Boot scenarios). The two paths can be mixed at the `.tools(...)` boundary — both produce `BaseTool` instances — but it is strongly recommended to **pick one** in any given application. The Spring AI MCP route is the natural choice for Spring Boot apps because everything is property-driven; ADK's native `McpToolset` remains the right choice for non-Spring usage.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LOW - Worth stating the trade-off here. Through Spring AI 2.0, MCP tools lose outputSchema and structuredContent: McpToolUtils.createToolDefinition copies only name, description and input schema (source), and SyncMcpToolCallback returns only content (source). ADK's McpToolset keeps the output schema as responseJsonSchema (source).


## Architecture

### Core Components
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/*
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*/
package com.google.adk.models.springai.bridge;

import com.google.adk.tools.BaseTool;
import java.util.List;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;

/**
* Auto-configures a {@code List<BaseTool>} exposing every Spring AI {@link ToolCallback} bean in
* the context as an ADK {@link BaseTool}, wrapped via {@link
* SpringAiToolCallbackBackedAdkTool#wrapAll(List)}.
*
* <p>This spares users the boilerplate of manually calling {@code wrapAll(...)}: with this
* autoconfig on the classpath, an application can inject {@code List<BaseTool>} named {@code
* springAiTools} directly into an agent factory.
*
* <pre>{@code
* @Bean
* public LlmAgent rootAgent(
* SpringAI llm,
* @Qualifier("springAiTools") List<BaseTool> springAiTools) {
* return LlmAgent.builder().name("root").model(llm).tools(springAiTools).build();
* }
* }</pre>
*
* <p>Active only when at least one {@link ToolCallback} bean exists (e.g. from {@code
* spring-ai-starter-mcp-client}) — otherwise it stays out of the way.
*/
@AutoConfiguration
@ConditionalOnClass({ToolCallback.class, SpringAiToolCallbackBackedAdkTool.class})
public class SpringAiToolBridgeAutoConfiguration {

@Bean(name = "springAiTools")
@ConditionalOnBean(ToolCallback.class)
@ConditionalOnMissingBean(name = "springAiTools")
public List<BaseTool> springAiTools(List<ToolCallback> toolCallbacks) {
return SpringAiToolCallbackBackedAdkTool.wrapAll(toolCallbacks);
Comment on lines +42 to +50

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

HIGH - This doesn't match how Spring AI 2.0 exposes tools. 2.0 dropped bean-name resolution (upgrade notes), so tools arrive as ToolCallback or ToolCallbackProvider beans (@Tool objects, MCP). Its own ToolCallingAutoConfiguration reads both and leaves MCP providers out of its eager list (source). This class reads ToolCallback only, so MCP and @Tool tools never show up (verified). Either mirror that with a toolset, ordering (@AutoConfigureOrder(LOWEST_PRECEDENCE), as SpringAIAutoConfiguration does since #1502), the adk.spring-ai.auto-configuration.enabled guard and tests, or drop it.

}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/*
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*/
Comment on lines +1 to +9

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LOW - The license header is truncated: the "Unless required by applicable law..." paragraph is missing here and in the other two new files (see java.header).

package com.google.adk.models.springai.bridge;

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.adk.JsonBaseModel;
import com.google.adk.tools.BaseTool;
import com.google.adk.tools.ToolContext;
import com.google.common.collect.ImmutableMap;
import com.google.genai.types.FunctionDeclaration;
import io.reactivex.rxjava3.core.Single;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.tool.ToolCallback;

/**
* ADK {@link BaseTool} backed by a Spring AI {@link ToolCallback}.
*
* <p>Adapts <strong>any</strong> Spring AI {@code ToolCallback} (including {@code
* SyncMcpToolCallback} / {@code AsyncMcpToolCallback} from {@code spring-ai-starter-mcp-client},
* {@code FunctionToolCallback}, {@code @Tool}-annotated method callbacks, etc.) into the ADK tool
* model so they can be attached to an {@link com.google.adk.agents.LlmAgent}.
*
* <p><strong>Schema preservation:</strong> the JSON Schema returned by {@code
* ToolCallback.getToolDefinition().inputSchema()} is parsed into a structured Map and set via
* {@code FunctionDeclaration.parametersJsonSchema(...)} rather than {@code
* parameters(Schema.fromJson(...))}. This routes through the faithful branch of the downstream
* Spring AI {@code ToolConverter}, preserving {@code items}, {@code enum}, {@code format}, {@code
* anyOf}/{@code oneOf}, {@code additionalProperties}, {@code $defs} and {@code $ref} — none of
* which survive the {@code parameters(Schema)} path today.
*
* <p><strong>Invocation & errors:</strong> {@link #runAsync(Map, ToolContext)} serializes args to

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

HIGH - The raw & breaks ./mvnw -Prelease clean package, the CI validation command: error: bad HTML entity. Use &amp;. CI never ran on this PR, so this was not caught.

* JSON, dispatches to {@link ToolCallback#call(String)}, and parses the JSON response back. Any
* exception thrown by the callback is caught and translated to {@code Map.of("error", "<message>")}
* — matching the shape used by ADK's native {@code AbstractMcpTool.wrapCallResult(...)} — so the
* agent flow sees a structured error result instead of a hard failure on the {@link Single}.
*/
public class SpringAiToolCallbackBackedAdkTool extends BaseTool {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LOW - ADK Kotlin's Spring AI 2.0 bridge is SpringAiTool, with SpringAiTool.from(...) and SpringAiToolset, in ...models.springai (source). Its KDoc says the context key "matches ADK Java's Spring AI bridge". Please use the same names and package here (no bridge package), make the class final, drop toolCallback(), and return an ImmutableList.


private static final Logger logger =
LoggerFactory.getLogger(SpringAiToolCallbackBackedAdkTool.class);

private static final TypeReference<Map<String, Object>> MAP_TYPE = new TypeReference<>() {};

private final ToolCallback toolCallback;
private final ObjectMapper objectMapper;
private final Optional<FunctionDeclaration> declaration;

public SpringAiToolCallbackBackedAdkTool(ToolCallback toolCallback) {
this(toolCallback, JsonBaseModel.getMapper());
}

public SpringAiToolCallbackBackedAdkTool(ToolCallback toolCallback, ObjectMapper objectMapper) {
super(
Objects.requireNonNull(toolCallback, "toolCallback").getToolDefinition().name(),
toolCallback.getToolDefinition().description());
this.toolCallback = toolCallback;
this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper");
this.declaration = buildDeclaration(toolCallback, objectMapper);
}
Comment on lines +66 to +73

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LOW - Spring AI 2.0 and Spring Boot 4 are on Jackson 3 (tools.jackson), so this public Jackson 2 ObjectMapper constructor can't take the application's mapper.


public static List<BaseTool> wrapAll(List<? extends ToolCallback> toolCallbacks) {
return toolCallbacks.stream()
.map(SpringAiToolCallbackBackedAdkTool::new)
.collect(Collectors.toList());
}

@Override
public Optional<FunctionDeclaration> declaration() {
return declaration;
}

@Override
public Single<Map<String, Object>> runAsync(Map<String, Object> args, ToolContext toolContext) {
return Single.fromCallable(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

MEDIUM - call() blocks. Spring AI 2.0's AsyncMcpToolCallback calls Reactor's block() (source), which throws on Reactor non-blocking threads. Verified: on Schedulers.parallel() the bridged async MCP tool returned {error=block()/blockFirst()/blockLast() are blocking, which is not supported in thread parallel-5}, and worked with .subscribeOn(Schedulers.io()). Kotlin runs the call on Dispatchers.IO (L71);

() -> {
String requestJson = objectMapper.writeValueAsString(args == null ? Map.of() : args);
if (logger.isDebugEnabled()) {
logger.debug("Invoking Spring AI tool '{}' with args: {}", name(), requestJson);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

HIGH - This logs the full tool arguments at DEBUG, and L101-L102 log the exception message at WARN. In 2.0, SyncMcpToolCallback puts the MCP isError content into that message (source), so tool output reaches the log. ADK keeps tool arguments, results and user content out of logs. Log only the tool name and exception class.

}
try {
String responseJson = toolCallback.call(requestJson);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

HIGH - Spring AI 2.0 delivers context only through call(String, ToolContext). MethodToolCallback rejects ToolContext-taking @Tool methods without it (verified: {error=ToolContext is required by the method as an argument}, source). Pass the ADK context under ToolConverter.ADK_TOOL_CONTEXT_KEY, as Kotlin does (L67-L71). MCP calls keep working with 2.0's default ToolContextToMcpMetaConverter; it adds _meta: {"adk_tool_context": {}} (verified live).

return parseResponse(responseJson);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LOW - In Spring AI 2.0 the tool loop (ToolCallingAdvisor or ToolCallingManager) applies ToolMetadata.returnDirect(). ADK replaces that loop, so the flag is silently ignored. Map it to skipSummarization after a successful call, as Kotlin does (L74-L78).

} catch (Exception callFailed) {
// Match AbstractMcpTool.wrapCallResult(...) — return a structured error map rather
// than propagate the raw exception through the ADK Single, so the agent flow can
// observe the failure as a tool result instead of aborting the invocation.
logger.warn(
"Spring AI tool '{}' invocation failed: {}", name(), callFailed.getMessage());
return ImmutableMap.of(
"error",
callFailed.getMessage() == null
? callFailed.getClass().getSimpleName()
: callFailed.getMessage());
Comment on lines +97 to +107

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

MEDIUM - In 2.0, ADK runs these tools, so Spring AI's DefaultToolExecutionExceptionProcessor never sees their failures. Its contract: a ToolExecutionException with a runtime cause becomes a message for the model, and everything else is rethrown (source). McpError is a hard failure in 2.0 (source). The catch-all sends everything to the model and skips ADK's onToolErrorCallback: live, it ran 0 times here and once with propagation. Apply that contract here

}
});
}

private Map<String, Object> parseResponse(String responseJson) {
if (responseJson == null || responseJson.isBlank()) {
return ImmutableMap.of();
}
try {
return objectMapper.readValue(responseJson, MAP_TYPE);
} catch (Exception notAnObject) {
Object decoded = tryDecodeAsJsonValue(responseJson);
return ImmutableMap.of("result", decoded);
}
}

private Object tryDecodeAsJsonValue(String responseJson) {
try {
return objectMapper.readTree(responseJson);
} catch (Exception notJson) {
return responseJson;
}
}
Comment on lines +112 to +130

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

HIGH - Decode what Spring AI 2.0 actually returns. Its DefaultToolCallResultConverter writes JSON: null for a null result and quoted strings (source). MCP callbacks return the content array. Today a null aborts the run with an NPE, readTree turns "404 Not Found" into 404, and arrays stay JsonNodes, which FirestoreSessionService persists as {nodeType=ARRAY, ...} (all verified). Decode strictly into plain values, with null as an empty map, like Kotlin (L72-L73, L117-L123). Spring AI's JsonHelper won't help: its mapper turns off FAIL_ON_TRAILING_TOKENS (source).

objectMapper.readerFor(Object.class).with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS).readValue(json)


public ToolCallback toolCallback() {
return toolCallback;
}

private static Optional<FunctionDeclaration> buildDeclaration(
ToolCallback toolCallback, ObjectMapper objectMapper) {
var def = toolCallback.getToolDefinition();
FunctionDeclaration.Builder builder = FunctionDeclaration.builder().name(def.name());
if (def.description() != null && !def.description().isBlank()) {
builder.description(def.description());
}
String inputSchema = def.inputSchema();
if (inputSchema != null && !inputSchema.isBlank()) {
try {
// Pass the JSON Schema through verbatim, parsed to a Map (not a raw String).
// ToolConverter serializes parametersJsonSchema faithfully — preserving items, enum,
// format, anyOf/oneOf, additionalProperties, $defs, $ref. Using parameters(Schema.fromJson)
// instead would route through a lossy branch that drops most of those.
builder.parametersJsonSchema(objectMapper.readValue(inputSchema, MAP_TYPE));
} catch (Exception schemaUnparseable) {
// Genuinely malformed JSON: leave parameters unset rather than emit a degraded schema.
logger.warn(
"Spring AI tool '{}' has an unparseable input schema; declaring it with no parameter"
+ " schema. Cause: {}",
def.name(),
schemaUnparseable.getMessage());
Comment on lines +151 to +157

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

MEDIUM - Spring AI 2.0's DefaultToolDefinition always has an input schema (source), but a custom ToolDefinition may not. Then this leaves the declaration with no parameters, and on the SpringAI path ToolConverter throws IllegalArgumentException: inputType cannot be null (reproduced). Fall back to an empty object schema, as Kotlin does (L103-L115).

}
}
return Optional.of(builder.build());
}
}
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
com.google.adk.models.springai.autoconfigure.SpringAIAutoConfiguration
com.google.adk.models.springai.autoconfigure.SpringAIAutoConfiguration
com.google.adk.models.springai.bridge.SpringAiToolBridgeAutoConfiguration
Loading