Skip to content

feat(spring-ai): bridge Spring AI ToolCallback to ADK BaseTool#1300

Open
mekkiamiri wants to merge 2 commits into
google:mainfrom
mekkiamiri:feat-spring-ai-bridges
Open

feat(spring-ai): bridge Spring AI ToolCallback to ADK BaseTool#1300
mekkiamiri wants to merge 2 commits into
google:mainfrom
mekkiamiri:feat-spring-ai-bridges

Conversation

@mekkiamiri

Copy link
Copy Markdown

Please ensure you have read the contribution guide before creating a pull request.

Link to Issue or Description of Change

1. Link to an existing issue (if applicable):

2. Or, if no issue exists, describe the change:

If applicable, please follow the issue templates to provide as much detail as
possible.

Problem:
Spring AI provides a rich ecosystem of ToolCallback implementations: SyncMcpToolCallback / AsyncMcpToolCallback produced by spring-ai-starter-mcp-client from spring.ai.mcp.client.* properties, @Tool-annotated method callbacks, programmatically declared FunctionToolCallbacks, and so on. Today, none of these can be used by an ADK LlmAgent without manual conversion. The existing ToolConverter in
contrib/spring-ai only converts ADK BaseTool → Spring AI ToolCallback (one direction). Spring Boot users who want their ADK agent to use MCP tools have to either reimplement MCP wiring via ADK's native McpToolset.createMcpToolset(...) Java API, or write their own ToolCallback → BaseTool adapter.

Solution:
Add SpringAiToolCallbackBackedAdkTool — an adapter that wraps any Spring AI ToolCallback as an ADK BaseTool. It is the reverse direction of the existing ToolConverter. Together they make ADK and Spring AI tool ecosystems fully interoperable.

How it works:

  • ToolCallback.getToolDefinition() provides the tool name, description, and JSON Schema.
  • The JSON Schema is converted to ADK's Schema via Schema.fromJson(...). If parsing fails (e.g. malformed schema), the bridge falls back to parametersJsonSchema(Object) with a logged warning — no hard failure.
  • At invocation time, ADK's Map<String, Object> arguments are serialized to JSON, dispatched to ToolCallback.call(String), and the JSON response is parsed back to Map<String, Object>.
  • Non-object responses (primitives, arrays, raw strings) are wrapped under a "result" key for structural consistency.
  • A static wrapAll(List<? extends ToolCallback>) helper fans a list of callbacks (e.g. from McpToolCallbackProvider.getToolCallbacks()) into a List<BaseTool> in one call.

Usage:

@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();
  }
}

The List<ToolCallback> is auto-injected by spring-ai-starter-mcp-client's McpToolCallbackAutoConfiguration. The bridge converts every callback into a BaseTool consumable by the agent. Full documentation is in contrib/spring-ai/README.md under "Tool / MCP Bridge — Spring AI ToolCallback as ADK BaseTool".

This PR is fully independent of #653 (the Spring Boot starter PR). It only touches contrib/spring-ai/. Either can be merged first; no ordering dependency.

ADK's native MCP client (com.google.adk.tools.mcp.*) is not removed and not changed — it remains the right choice for CLI / non-Spring-Boot usage. The bridge is additive.

### Testing Plan

_Please describe the tests that you ran to verify your changes. This is required
for all PRs that are not small documentation or typo fixes._

**Unit Tests:**

- [X] I have added or updated unit tests for my change.
- [X] All unit tests pass locally.

_Please include a summary of passed java test results._
7 unit tests in SpringAiToolCallbackBackedAdkToolTest cover:

1. declaration_isBuiltFromToolDefinitionverifies name / description / schema are extracted from ToolDefinition
2. runAsync_serializesArgs_andParsesJsonObjectResponsehappy-path JSON object response
3. runAsync_wrapsScalarResponse_underResultKeynon-object responses are wrapped
4. runAsync_emptyResponse_yieldsEmptyMapempty / blank responses produce an empty map
5. runAsync_nullArgs_sendsEmptyObjectnull args serialize to {} instead of null
6. wrapAll_convertsEveryCallbackstatic helper fans out correctly
7. invalidSchema_fallsBackToRawJsonSchema_withoutThrowinggraceful fallback on malformed JSON Schema

Test results:

[INFO] --- surefire:3.5.2:test (default-test) @ google-adk-spring-ai ---
[INFO] Tests run: 7, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

**Manual End-to-End (E2E) Tests:**

Tested end-to-end with the existing Spring AI SpringAIAutoConfiguration providing the SpringAI (BaseLlm) bean and a mocked ToolCallback representing an MCP tool. The agent invokes the bridge, which dispatches to the callback and returns parsed JSON to the agent flow. Real-world MCP integration follows the same path with spring-ai-starter-mcp-client providing the ToolCallback beans from spring.ai.mcp.client.* properties.

Setup to reproduce locally:

spring:
  ai:
    openai:
      api-key: ${OPENAI_API_KEY}
      chat.options.model: gpt-4o-mini
    mcp:
      client:
        sse:
          connections:
            filesystem:
              url: http://localhost:3000

Then wire the agent as in the Usage snippet above and start the Spring Boot appLlmAgent.tools(...) will receive every MCP tool exposed by the configured MCP server, transparently usable by the LLM.


### Checklist

- [X] I have read the [CONTRIBUTING.md](./CONTRIBUTING.md) document.
- [X] My pull request contains a single commit.
- [X] I have performed a self-review of my own code.
- [X] I have commented my code, particularly in hard-to-understand areas.
- [X] I have added tests that prove my fix is effective or that my feature works.
- [X] New and existing unit tests pass locally with my changes.
- [X] I have manually tested my changes end-to-end.
- [X] Any dependent changes have been merged and published in downstream modules.

### Additional context

- The bridge lives in contrib/spring-ai/src/main/java/com/google/adk/models/springai/bridge/ to keep it visually grouped with future bridges if Spring AI's VectorStore / ChatMemoryRepository integrations are ever requested.
- This PR deliberately scopes to tools only. The VectorStoreBaseMemoryService and ChatMemoryRepositoryBaseSessionService bridges are out of scopefor sessions/memory, ADK's native Firestore (contrib/firestore-session-service) is a better fit because it preserves the full Event semantics (function calls with structured args/results, state deltas, branch IDs for sub-agent transfers) that Spring AI's Message model
would lose in translation.
- No changes to ADK core. No changes to the starter (which is the subject of #653, independent).

Introduces SpringAiToolCallbackBackedAdkTool — an adapter that wraps any
Spring AI ToolCallback (FunctionToolCallback, MCP SyncMcpToolCallback /
AsyncMcpToolCallback, @Tool-annotated method callbacks, etc.) as an ADK
BaseTool so they can be attached to an LlmAgent.

The bridge is the reverse direction of the existing ToolConverter (ADK ->
Spring AI). Together they make ADK and Spring AI tools fully interoperable.

Schema is extracted from ToolCallback.getToolDefinition().inputSchema()
(JSON Schema string) via Schema.fromJson(). If the JSON Schema cannot be
parsed into ADK's structured Schema type, the bridge falls back to the
parametersJsonSchema(Object) escape hatch — no hard failure on malformed
schemas, just a logged warning.

Invocation path:
- ADK runtime calls runAsync(Map<String, Object>, ToolContext)
- Args are serialized to JSON via ObjectMapper.writeValueAsString
- Dispatched to ToolCallback.call(String) — Spring AI's sync invocation
- Response JSON is parsed back to Map<String, Object>
- Non-object responses (primitive / array / raw string) are wrapped
  under a "result" key for structural consistency

Also adds a static wrapAll(List<? extends ToolCallback>) helper so consumers
of McpToolCallbackProvider.getToolCallbacks() can fan out to BaseTool[] in
one line.

Tests (7): name/description/schema extraction, JSON object result,
scalar-wrapping, empty response, null args, wrapAll fan-out, and graceful
fallback on malformed schema.
Adds a new section to contrib/spring-ai/README.md describing
SpringAiToolCallbackBackedAdkTool — the reverse-direction tool bridge that
exposes any Spring AI ToolCallback (MCP, @Tool-annotated, FunctionToolCallback)
as an ADK BaseTool for use with LlmAgent. Includes usage examples for the
MCP-via-Spring-AI case and the single-tool case, plus a note on coexistence
with ADK's native McpToolset.
@mekkiamiri mekkiamiri force-pushed the feat-spring-ai-bridges branch from 9e8b683 to 763ce23 Compare June 24, 2026 09:15
@ddobrin ddobrin self-requested a review June 24, 2026 16:54
@ddobrin

ddobrin commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Hi @mekkiamiri
a few notes on this PR:

(1) Schema Degradation:

buildDeclaration(...) prefers Schema.fromJson(inputSchema) and assigns it via builder.parameters(...):

builder.parameters(Schema.fromJson(inputSchema)); // populates FunctionDeclaration.parameters()

When these tools run on the SpringAI model, ToolConverter.convertToSpringAiTools must turn the FunctionDeclaration back into a Spring AI schema, and it branches on which field is set ToolConverter.java:

if (declaration.parameters().isPresent()) {
// LOSSY: convertSchemaToSpringAi() copies only type/description/properties/required
// → drops items, enum, format, anyOf/oneOf, additionalProperties, $defs/$ref, default
} else if (declaration.parametersJsonSchema().isPresent()) {
// FAITHFUL: writeValueAsString(parametersJsonSchema.get()) — serializes the schema verbatim
}

Because the bridge sets parameters(), every bridged tool takes the lossy branch. The PR's test schema ({city: string}) is too flat to reveal it; a real MCP tool with an array/items, enum, or $defs reaches the model with those stripped.

The parametersJsonSchema fallback has the inverse problem: it passes the raw String (builder.parametersJsonSchema(inputSchema)), so the faithful branch does writeValueAsString("{...}") → a double-encoded, quoted string.

Option
Set the schema via parametersJsonSchema(parsedNode) and do not set parameters(...). This routes through the faithful branch, preserves the schema verbatim, and removes the double-encoding fallback in one move.

buildDeclaration is currently static and has no ObjectMapper; pass the instance mapper in (it's already a field).

Current:
String inputSchema = def.inputSchema();
if (inputSchema != null && !inputSchema.isBlank()) {
try {
builder.parameters(Schema.fromJson(inputSchema)); // → lossy branch
} catch (Exception parseFailed) {
builder.parametersJsonSchema(inputSchema); // → raw String → double-encoded
}
}

Suggestion (entirely within SpringAiToolCallbackBackedAdkTool):
String inputSchema = def.inputSchema();
if (inputSchema != null && !inputSchema.isBlank()) {
try {
// Pass the JSON Schema through verbatim, parsed to a structure (not a raw String).
// Mirrors AbstractMcpTool.declaration(); ToolConverter then serializes it faithfully,
// preserving items/enum/format/$defs/$ref/additionalProperties.
builder.parametersJsonSchema(objectMapper.readValue(inputSchema, MAP_TYPE));
} catch (Exception schemaUnparseable) {
// Genuinely malformed JSON: there is no usable schema to send. Warn and leave it unset
// rather than emit a degraded or quoted-string schema that misleads the model.
logger.warn(
"Spring AI tool '{}' has an unparseable input schema; declaring it with no parameter"
+ " schema. Cause: {}",
def.name(), schemaUnparseable.getMessage());
}
}

(2) Additional tests, not only mocks
All tests mock a generic ToolCallback + DefaultToolDefinition; the described E2E uses "a mocked ToolCallback."

Nothing exercises a real SyncMcpToolCallback/AsyncMcpToolCallback or an MCP-shaped result (content arrays, isError, the JSON the MCP callback actually emits)

(3) Exception handling
In runAsync, wrap the call(...) dispatch so a thrown exception (and a recognizable error payload) yields ImmutableMap.of("error", ...), matching AbstractMcpTool.wrapCallResult.

This needs to be documented in the README as well

(4) Autoconfigure can be updated to add automatic discovery of ToolCallback beans

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants