Repository navigation
feat(spring-ai): bridge Spring AI ToolCallback to ADK BaseTool #1300
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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. | ||
|
|
||
| ### 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
|
|
||
| ### 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. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
|
|
||
| ## Architecture | ||
|
|
||
| ### Core Components | ||
|
|
||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| } | ||
| } | ||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| 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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. HIGH - The raw |
||
| * 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 { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. LOW - ADK Kotlin's Spring AI 2.0 bridge is |
||
|
|
||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 ( |
||
|
|
||
| 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( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. MEDIUM - |
||
| () -> { | ||
| String requestJson = objectMapper.writeValueAsString(args == null ? Map.of() : args); | ||
| if (logger.isDebugEnabled()) { | ||
| logger.debug("Invoking Spring AI tool '{}' with args: {}", name(), requestJson); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, |
||
| } | ||
| try { | ||
| String responseJson = toolCallback.call(requestJson); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. HIGH - Spring AI 2.0 delivers context only through |
||
| return parseResponse(responseJson); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. LOW - In Spring AI 2.0 the tool loop ( |
||
| } 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. MEDIUM - In 2.0, ADK runs these tools, so Spring AI's |
||
| } | ||
| }); | ||
| } | ||
|
|
||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. HIGH - Decode what Spring AI 2.0 actually returns. Its 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. MEDIUM - Spring AI 2.0's |
||
| } | ||
| } | ||
| 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 |
There was a problem hiding this comment.
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,@Toolobjects become callbacks throughToolCallbacks.from(...)or aMethodToolCallbackProvider, and MCP tools arrive through aToolCallbackProvider. Also drop what no longer matches the code:Schema.fromJson, the lostitems/enum(fixed in #1617), and thewrapCallResultcomparison.