diff --git a/.gitignore b/.gitignore
index 930ff041..6268430d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -51,3 +51,8 @@
/application.yml
/.remember/
/.m2repo/
+/mcp-filesystem/
+
+# Local Serena run memories
+/.serena/memories/run/application-start-command.md
+/.serena/memories/run/deploy_command_convention.md
diff --git a/Dockerfile b/Dockerfile
index 176b98aa..7dc103a3 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -2,12 +2,13 @@
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /app
-ARG APP_VERSION=1.1.0-SNAPSHOT
+ARG APP_VERSION=1.1.1-SNAPSHOT
# Copy pom.xml and all modules
COPY pom.xml .
COPY opendaimon-common/pom.xml ./opendaimon-common/
COPY opendaimon-spring-ai/pom.xml ./opendaimon-spring-ai/
+COPY opendaimon-mcp/pom.xml ./opendaimon-mcp/
COPY opendaimon-spring-boot-starter/pom.xml ./opendaimon-spring-boot-starter/
COPY opendaimon-ui/pom.xml ./opendaimon-ui/
COPY opendaimon-rest/pom.xml ./opendaimon-rest/
@@ -18,6 +19,7 @@ COPY opendaimon-app/pom.xml ./opendaimon-app/
# Copy source code
COPY opendaimon-common/src ./opendaimon-common/src
COPY opendaimon-spring-ai/src ./opendaimon-spring-ai/src
+COPY opendaimon-mcp/src ./opendaimon-mcp/src
COPY opendaimon-ui/src ./opendaimon-ui/src
COPY opendaimon-rest/src ./opendaimon-rest/src
COPY opendaimon-telegram/src ./opendaimon-telegram/src
@@ -31,11 +33,18 @@ RUN mvn -Drevision=${APP_VERSION} clean package -DskipTests -B
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
-ARG APP_VERSION=1.1.0-SNAPSHOT
+RUN apk add --no-cache nodejs npm \
+ && npm install -g @modelcontextprotocol/server-filesystem@0.2.0 \
+ && npm install -g zod-to-json-schema@3.23.5 \
+ && npm cache clean --force
+
+ARG APP_VERSION=1.1.1-SNAPSHOT
# Copy JAR from build stage
COPY --from=build /app/opendaimon-app/target/opendaimon-app-${APP_VERSION}.jar app.jar
+RUN mkdir -p /app/mcp-filesystem
+
# Expose port
EXPOSE 8080
diff --git a/docker-compose.yml b/docker-compose.yml
index 6cd898df..ee5202ae 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -14,7 +14,7 @@ services:
- .env
container_name: open-daimon-app
ports:
- - "8080:8080"
+ - "8081:8080"
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
@@ -42,12 +42,17 @@ services:
- MINIO_ENDPOINT=http://minio:9000
# Redis (use service name from docker-compose)
- REDIS_HOST=redis
+ # MCP filesystem server (admin-only in OpenDaimon tool exposure). Enabled by default.
+ - MCP_CLIENT_ENABLED=${MCP_CLIENT_ENABLED:-true}
+ - MCP_FILESYSTEM_COMMAND=mcp-server-filesystem
+ - MCP_FILESYSTEM_ROOT=/app/mcp-filesystem
# Config override: place application.yml next to docker-compose.yml.
# optional: prefix = if file is absent, uses bundled defaults.
- SPRING_CONFIG_ADDITIONAL_LOCATION=optional:file:/app/config/application.yml
# Mount project root so Spring Boot can find application.yml if it exists
volumes:
- ./:/app/config/:ro
+ - ./mcp-filesystem:/app/mcp-filesystem
depends_on:
postgres:
condition: service_healthy
diff --git a/docs/codex/config.example.toml b/docs/codex/config.example.toml
index ce5a8698..135e1483 100644
--- a/docs/codex/config.example.toml
+++ b/docs/codex/config.example.toml
@@ -13,7 +13,7 @@ service_tier = "fast"
trust_level = "trusted"
[features]
-codex_hooks = true
+hooks = true
memories = true
terminal_resize_reflow = true
diff --git a/docs/tariffs-and-models.md b/docs/tariffs-and-models.md
index 4052ddca..4bdb7412 100644
--- a/docs/tariffs-and-models.md
+++ b/docs/tariffs-and-models.md
@@ -109,6 +109,8 @@ Only free models from the API whose id is in this list (and passes other filters
| openrouter/free | CHAT, TOOL_CALLING, SUMMARIZATION, FREE (proxy) |
| qwen/qwen3-4b:free | CHAT, SUMMARIZATION, FREE |
| qwen/qwen3-coder:free | CHAT, SUMMARIZATION, FREE |
+| google/gemma-4-31b-it | CHAT, TOOL_CALLING, WEB, VISION, STRUCTURED_OUTPUT, THINKING (paid, ADMIN/VIP) |
+| deepseek/deepseek-v4-flash | CHAT, TOOL_CALLING, WEB, STRUCTURED_OUTPUT, THINKING (paid, ADMIN/VIP) |
| qwen/qwen3-vl-235b-a22b-thinking | CHAT, VISION, SUMMARIZATION (paid) |
| qwen/qwen3-vl-30b-a3b-thinking | CHAT, VISION, SUMMARIZATION (paid) |
| stepfun/step-3.5-flash:free | CHAT, TOOL_CALLING, SUMMARIZATION, FREE |
diff --git a/mcp-filesystem/.gitkeep b/mcp-filesystem/.gitkeep
new file mode 100644
index 00000000..e69de29b
diff --git a/opendaimon-app/pom.xml b/opendaimon-app/pom.xml
index 6e7f874e..b911bf18 100644
--- a/opendaimon-app/pom.xml
+++ b/opendaimon-app/pom.xml
@@ -58,6 +58,12 @@
${project.version}runtime
+
+ io.github.ngirchev
+ opendaimon-mcp
+ ${project.version}
+ runtime
+ io.github.ngirchevopendaimon-gateway-mock
diff --git a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/telegram/TelegramGroupEntityIT.java b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/telegram/TelegramGroupEntityIT.java
index 6fa2aa87..1f4c76fa 100644
--- a/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/telegram/TelegramGroupEntityIT.java
+++ b/opendaimon-app/src/it/java/io/github/ngirchev/opendaimon/it/telegram/TelegramGroupEntityIT.java
@@ -1,6 +1,5 @@
package io.github.ngirchev.opendaimon.it.telegram;
-import io.github.ngirchev.opendaimon.common.SupportedLanguages;
import io.github.ngirchev.opendaimon.common.config.CoreCommonProperties;
import io.github.ngirchev.opendaimon.common.config.CoreFlywayConfig;
import io.github.ngirchev.opendaimon.common.config.CoreJpaConfig;
@@ -171,8 +170,8 @@ void shouldLazilyCreateGroupOnFirstInteraction() {
assertEquals(chatId, groupOwner.getTelegramId());
assertEquals("Fresh team", groupOwner.getTitle());
assertEquals("supergroup", groupOwner.getType());
- assertEquals(SupportedLanguages.DEFAULT_LANGUAGE, groupOwner.getLanguageCode(),
- "language defaults to DEFAULT_LANGUAGE on creation; /language can override later");
+ assertNull(groupOwner.getLanguageCode(),
+ "language stays unset on creation so command mapping can fall back to the invoker until /language runs");
assertNull(groupOwner.getPreferredModelId(), "model is unset until /model runs");
Optional found = telegramGroupRepository.findByTelegramId(chatId);
diff --git a/opendaimon-app/src/main/resources/application-max.yml b/opendaimon-app/src/main/resources/application-max.yml
index 2a080032..cd1b0184 100644
--- a/opendaimon-app/src/main/resources/application-max.yml
+++ b/opendaimon-app/src/main/resources/application-max.yml
@@ -20,6 +20,8 @@ open-daimon:
- openai/gpt-5.4
- openai/gpt-5-nano
- z-ai/glm-5-turbo
+ - google/gemma-4-31b-it
+ - deepseek/deepseek-v4-flash
- google/gemma-3-4b
- qwen/qwen3-4b
- qwen/qwen3-vl-235b-a22b-thinking
@@ -107,6 +109,35 @@ open-daimon:
allowed-roles:
- ADMIN
- VIP
+ - name: "google/gemma-4-31b-it"
+ capabilities:
+ - CHAT
+ - TOOL_CALLING
+ - WEB
+ - VISION
+ - STRUCTURED_OUTPUT
+ - THINKING
+ provider-type: OPENAI
+ priority: 2
+ max-output-tokens: 16000
+ max-reasoning-tokens: 8000
+ allowed-roles:
+ - ADMIN
+ - VIP
+ - name: "deepseek/deepseek-v4-flash"
+ capabilities:
+ - CHAT
+ - TOOL_CALLING
+ - WEB
+ - STRUCTURED_OUTPUT
+ - THINKING
+ provider-type: OPENAI
+ priority: 2
+ max-output-tokens: 16000
+ max-reasoning-tokens: 8000
+ allowed-roles:
+ - ADMIN
+ - VIP
# qwen3.5 with thinking enabled (max profile only)
- name: "qwen3.5"
capabilities:
diff --git a/opendaimon-app/src/main/resources/application-mock.yml b/opendaimon-app/src/main/resources/application-mock.yml
index 0ecc22f1..30fb970e 100644
--- a/opendaimon-app/src/main/resources/application-mock.yml
+++ b/opendaimon-app/src/main/resources/application-mock.yml
@@ -31,6 +31,7 @@ open-daimon:
newthread-enabled: true
history-enabled: true
threads-enabled: true
+ tools-enabled: true
message-coalescing:
enabled: true
wait-window-ms: 1200
diff --git a/opendaimon-app/src/main/resources/application.yml b/opendaimon-app/src/main/resources/application.yml
index b8fc93c2..e7afca32 100644
--- a/opendaimon-app/src/main/resources/application.yml
+++ b/opendaimon-app/src/main/resources/application.yml
@@ -128,6 +128,7 @@ open-daimon:
language-enabled: true
model-enabled: true
mode-enabled: true
+ tools-enabled: true
cache:
redis-enabled: false # FEATURE FLAG - enable distributed Redis cache for session data
message-coalescing:
@@ -149,6 +150,17 @@ open-daimon:
emails: ${REST_ACCESS_REGULAR_EMAILS:} # e.g. "user@example.com"
ui:
enabled: true
+ mcp:
+ enabled: true
+ source-display-names:
+ filesystem: "@modelcontextprotocol/server-filesystem@0.2.0"
+ tool-access:
+ # External MCP tools not matched by a rule are available to all allowed tiers.
+ # Filesystem MCP tools are restricted to ADMIN by default.
+ default-roles: [ ADMIN, VIP, REGULAR ]
+ rules:
+ - name-pattern: "^(?:[A-Za-z0-9_]+_)?(read_file|read_text_file|read_media_file|read_multiple_files|write_file|edit_file|create_directory|list_directory|list_directory_with_sizes|directory_tree|move_file|search_files|get_file_info|list_allowed_directories)$"
+ roles: [ ADMIN ]
ai:
spring-ai:
enabled: true
@@ -168,6 +180,8 @@ open-daimon:
- roles: [ ADMIN, VIP ]
include-model-ids:
- google/gemini-2.5-flash
+ - google/gemma-4-31b-it
+ - deepseek/deepseek-v4-flash
- openai/gpt-5-nano
- z-ai/glm-5-turbo
- qwen/qwen3-vl-235b-a22b-thinking
@@ -246,6 +260,33 @@ open-daimon:
allowed-roles:
- ADMIN
- VIP
+ - name: "google/gemma-4-31b-it"
+ capabilities:
+ - CHAT
+ - TOOL_CALLING
+ - WEB
+ - VISION
+ - STRUCTURED_OUTPUT
+ - THINKING
+ provider-type: OPENAI
+ priority: 1
+ max-reasoning-tokens: 4000
+ allowed-roles:
+ - ADMIN
+ - VIP
+ - name: "deepseek/deepseek-v4-flash"
+ capabilities:
+ - CHAT
+ - TOOL_CALLING
+ - WEB
+ - STRUCTURED_OUTPUT
+ - THINKING
+ provider-type: OPENAI
+ priority: 1
+ max-reasoning-tokens: 4000
+ allowed-roles:
+ - ADMIN
+ - VIP
# Top 3 OpenRouter free models (explicit capabilities; others from include-model-ids get capabilities from API).
- name: "openrouter/free"
capabilities:
@@ -361,6 +402,32 @@ spring:
timeout: 2s
ai:
+ mcp:
+ client:
+ enabled: ${MCP_CLIENT_ENABLED:false}
+ name: open-daimon
+ version: ${OPEN_DAIMON_VERSION:dev}
+ type: SYNC
+ request-timeout: 30s
+ # Filesystem MCP runs inside the OpenDaimon container/process and sees only
+ # that filesystem plus explicitly mounted volumes. OpenDaimon exposes external
+ # MCP tools to ADMIN users only.
+ stdio:
+ connections:
+ filesystem:
+ command: sh
+ args:
+ - -c
+ - exec ${MCP_FILESYSTEM_COMMAND:npx -y @modelcontextprotocol/server-filesystem} ${MCP_FILESYSTEM_ROOT:/app/mcp-filesystem}
+ # sse:
+ # connections:
+ # remote:
+ # url: http://localhost:9000
+ # streamable-http:
+ # connections:
+ # remote-http:
+ # url: http://localhost:9001
+ # endpoint: /mcp
ollama:
base-url: ${OLLAMA_BASE_URL:http://localhost:11434}
request-timeout: 600s
diff --git a/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/ai/tool/ExternalToolAccessContext.java b/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/ai/tool/ExternalToolAccessContext.java
new file mode 100644
index 00000000..944bb063
--- /dev/null
+++ b/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/ai/tool/ExternalToolAccessContext.java
@@ -0,0 +1,9 @@
+package io.github.ngirchev.opendaimon.common.ai.tool;
+
+import io.github.ngirchev.opendaimon.bulkhead.model.UserPriority;
+
+public record ExternalToolAccessContext(
+ Long userId,
+ UserPriority userPriority
+) {
+}
diff --git a/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/ai/tool/ExternalToolCatalogService.java b/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/ai/tool/ExternalToolCatalogService.java
new file mode 100644
index 00000000..27211d03
--- /dev/null
+++ b/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/ai/tool/ExternalToolCatalogService.java
@@ -0,0 +1,34 @@
+package io.github.ngirchev.opendaimon.common.ai.tool;
+
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+public interface ExternalToolCatalogService {
+
+ List listAvailableTools(ExternalToolAccessContext context);
+
+ default List listAvailableToolSources(ExternalToolAccessContext context) {
+ Map> toolsBySource = listAvailableTools(context).stream()
+ .collect(Collectors.groupingBy(
+ ExternalToolCatalogService::sourceKey,
+ java.util.LinkedHashMap::new,
+ Collectors.toList()));
+ return toolsBySource.entrySet().stream()
+ .map(entry -> new ExternalToolSourceDescriptor(
+ sourceName(entry.getValue().getFirst()),
+ entry.getValue().getFirst().sourceType(),
+ entry.getValue()))
+ .toList();
+ }
+
+ private static String sourceKey(ExternalToolDescriptor tool) {
+ return tool.sourceType().name() + "\u0000" + sourceName(tool);
+ }
+
+ private static String sourceName(ExternalToolDescriptor tool) {
+ return tool.sourceName() != null && !tool.sourceName().isBlank()
+ ? tool.sourceName()
+ : "external";
+ }
+}
diff --git a/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/ai/tool/ExternalToolDescriptor.java b/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/ai/tool/ExternalToolDescriptor.java
new file mode 100644
index 00000000..4c676b01
--- /dev/null
+++ b/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/ai/tool/ExternalToolDescriptor.java
@@ -0,0 +1,21 @@
+package io.github.ngirchev.opendaimon.common.ai.tool;
+
+public record ExternalToolDescriptor(
+ String name,
+ String description,
+ String sourceName,
+ ExternalToolSourceType sourceType
+) {
+
+ public ExternalToolDescriptor(String name, String description) {
+ this(name, description, null, ExternalToolSourceType.EXTERNAL);
+ }
+
+ public ExternalToolDescriptor(String name, String description, String sourceName) {
+ this(name, description, sourceName, ExternalToolSourceType.EXTERNAL);
+ }
+
+ public ExternalToolDescriptor {
+ sourceType = sourceType != null ? sourceType : ExternalToolSourceType.EXTERNAL;
+ }
+}
diff --git a/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/ai/tool/ExternalToolSourceDescriptor.java b/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/ai/tool/ExternalToolSourceDescriptor.java
new file mode 100644
index 00000000..307a7548
--- /dev/null
+++ b/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/ai/tool/ExternalToolSourceDescriptor.java
@@ -0,0 +1,19 @@
+package io.github.ngirchev.opendaimon.common.ai.tool;
+
+import java.util.List;
+
+public record ExternalToolSourceDescriptor(
+ String name,
+ ExternalToolSourceType sourceType,
+ List tools
+) {
+
+ public ExternalToolSourceDescriptor(String name, List tools) {
+ this(name, ExternalToolSourceType.EXTERNAL, tools);
+ }
+
+ public ExternalToolSourceDescriptor {
+ sourceType = sourceType != null ? sourceType : ExternalToolSourceType.EXTERNAL;
+ tools = tools != null ? List.copyOf(tools) : List.of();
+ }
+}
diff --git a/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/ai/tool/ExternalToolSourceType.java b/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/ai/tool/ExternalToolSourceType.java
new file mode 100644
index 00000000..a16affad
--- /dev/null
+++ b/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/ai/tool/ExternalToolSourceType.java
@@ -0,0 +1,7 @@
+package io.github.ngirchev.opendaimon.common.ai.tool;
+
+public enum ExternalToolSourceType {
+ BUILT_IN,
+ MCP,
+ EXTERNAL
+}
diff --git a/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/config/FeatureToggle.java b/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/config/FeatureToggle.java
index bfe0ef1d..0a251960 100644
--- a/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/config/FeatureToggle.java
+++ b/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/config/FeatureToggle.java
@@ -31,6 +31,7 @@ private Module() {
public static final String REST_ENABLED = "open-daimon.rest.enabled";
public static final String UI_ENABLED = "open-daimon.ui.enabled";
public static final String AGENT_ENABLED = "open-daimon.agent.enabled";
+ public static final String MCP_ENABLED = "open-daimon.mcp.enabled";
public static final String GATEWAY_MOCK_ENABLED = "open-daimon.ai.gateway-mock.enabled";
}
@@ -77,6 +78,7 @@ private TelegramCommand() {
public static final String MODEL = "model-enabled";
public static final String MODE = "mode-enabled";
public static final String THINKING = "thinking-enabled";
+ public static final String TOOLS = "tools-enabled";
}
// ── OpenRouter model rotation toggles (prefix-based) ────────
@@ -107,6 +109,7 @@ public enum Toggle {
REST(Module.REST_ENABLED),
UI(Module.UI_ENABLED),
AGENT(Module.AGENT_ENABLED),
+ MCP(Module.MCP_ENABLED),
GATEWAY_MOCK(Module.GATEWAY_MOCK_ENABLED),
// Feature
RAG(Feature.RAG_ENABLED),
@@ -127,7 +130,8 @@ public enum Toggle {
CMD_MESSAGE(TelegramCommand.PREFIX + "." + TelegramCommand.MESSAGE),
CMD_MODEL(TelegramCommand.PREFIX + "." + TelegramCommand.MODEL),
CMD_MODE(TelegramCommand.PREFIX + "." + TelegramCommand.MODE),
- CMD_THINKING(TelegramCommand.PREFIX + "." + TelegramCommand.THINKING);
+ CMD_THINKING(TelegramCommand.PREFIX + "." + TelegramCommand.THINKING),
+ CMD_TOOLS(TelegramCommand.PREFIX + "." + TelegramCommand.TOOLS);
private final String propertyKey;
diff --git a/opendaimon-mcp/MCP_MODULE.md b/opendaimon-mcp/MCP_MODULE.md
new file mode 100644
index 00000000..99fb6eb5
--- /dev/null
+++ b/opendaimon-mcp/MCP_MODULE.md
@@ -0,0 +1,141 @@
+# OpenDaimon MCP Module
+
+`opendaimon-mcp` adds Spring AI MCP client runtime support to OpenDaimon.
+
+## Purpose
+
+The module is intentionally delivery-channel agnostic. It does not add Telegram,
+REST, or UI commands. Its job is to bring Spring AI MCP client autoconfiguration
+and transports onto the application classpath so external MCP server tools can be
+exposed as Spring AI `ToolCallbackProvider` beans.
+
+OpenDaimon consumes those callbacks in `opendaimon-spring-ai`:
+
+- Agent mode merges MCP callbacks into `agentToolCallbacks`.
+- The normal Spring AI prompt flow adds MCP callbacks to prompts only when the
+ command requests `TOOL_CALLING` in required or optional capabilities.
+- External MCP tools are exposed according to `open-daimon.mcp.tool-access` rules.
+ The rules use command `userPriority` metadata; missing or unknown priority
+ denies external MCP tools.
+ Filesystem MCP tools are ADMIN-only by default; other MCP tools can be exposed
+ to VIP/REGULAR users when the rules allow it.
+- Built-in tools (`web_search`, `fetch_url`, `http_get`, `http_post`) remain
+ available independently of MCP.
+
+Telegram exposes `/tools` to show the tools available to the current user.
+MCP tools are marked with an `mcp:` source prefix and use the same
+`open-daimon.mcp.tool-access` mapping as runtime tool execution, so ADMIN-only
+tools are not displayed to VIP or REGULAR users.
+
+## Configuration
+
+OpenDaimon-level consumption of external MCP tools is controlled by:
+
+```yaml
+open-daimon:
+ mcp:
+ enabled: true
+ tool-access:
+ default-roles: [ADMIN, VIP, REGULAR]
+ rules:
+ - name-pattern: "^(?:[A-Za-z0-9_]+_)?(read_file|read_text_file|read_media_file|read_multiple_files|write_file|edit_file|create_directory|list_directory|list_directory_with_sizes|directory_tree|move_file|search_files|get_file_info|list_allowed_directories)$"
+ roles: [ADMIN]
+```
+
+Rules are evaluated in order by Java regular expression against
+`ToolDefinition.name()`. The first matching rule wins. Tools without a matching
+rule use `default-roles`. Built-in OpenDaimon tools are not governed by these MCP
+rules.
+
+Spring AI MCP client creation is controlled by Spring AI properties. OpenDaimon
+defaults keep client creation disabled so local/mock startup does not require
+Node/npm or network access. Applications can enable it with `MCP_CLIENT_ENABLED=true`
+or `spring.ai.mcp.client.enabled=true`:
+
+```yaml
+spring:
+ ai:
+ mcp:
+ client:
+ enabled: false
+ type: SYNC
+ request-timeout: 30s
+```
+
+Example connection shapes:
+
+```yaml
+spring:
+ ai:
+ mcp:
+ client:
+ stdio:
+ connections:
+ filesystem:
+ command: npx
+ args:
+ - -y
+ - "@modelcontextprotocol/server-filesystem"
+ - /tmp
+ sse:
+ connections:
+ remote:
+ url: http://localhost:9000
+ streamable-http:
+ connections:
+ remote-http:
+ url: http://localhost:9001
+ endpoint: /mcp
+```
+
+The bundled `opendaimon-app` setup includes the default filesystem MCP stdio
+connection, but the client remains disabled unless `MCP_CLIENT_ENABLED=true` is
+set. The published starter defaults also disable MCP client support and do not
+define a concrete filesystem stdio connection for downstream applications:
+
+```yaml
+spring:
+ ai:
+ mcp:
+ client:
+ enabled: false
+ stdio:
+ connections:
+ filesystem:
+ command: sh
+ args:
+ - -c
+ - exec ${MCP_FILESYSTEM_COMMAND:npx -y @modelcontextprotocol/server-filesystem} ${MCP_FILESYSTEM_ROOT:/app/mcp-filesystem}
+```
+
+Docker Compose enables it explicitly with `MCP_CLIENT_ENABLED=true`. The runtime
+image includes Node.js/npm and preinstalls `@modelcontextprotocol/server-filesystem`;
+Docker sets `MCP_FILESYSTEM_COMMAND=mcp-server-filesystem` so startup does not
+depend on runtime `npx` package resolution. Without that environment variable,
+the bundled configuration falls back to `npx -y @modelcontextprotocol/server-filesystem`
+for opt-in local development. The server runs inside the OpenDaimon container and sees only
+the container filesystem plus mounted volumes. The compose file mounts
+`./mcp-filesystem` to `/app/mcp-filesystem`; keep that root narrow and do not
+point it at `/`, `/app/config`, or directories containing secrets.
+
+Even when configured, filesystem MCP tools are made available to ADMIN users by
+the default `tool-access` rule. Spring AI prefixes MCP tool names with the client
+name (for example `open_daimon_read_file`), so the default rule allows one optional
+prefix before the filesystem tool name. This is enforced in both agent and normal
+Spring AI prompt flows.
+
+The smoke test `FilesystemMcpSmokeTest` starts `@modelcontextprotocol/server-filesystem`
+with `npx`, creates a temporary sandbox containing `alpha.txt` and `nested/`,
+then calls the MCP `list_directory` tool. A successful run prints output like:
+
+```text
+Filesystem MCP tools: [read_file, read_text_file, read_media_file, read_multiple_files, write_file, edit_file, create_directory, list_directory, list_directory_with_sizes, directory_tree, move_file, search_files, get_file_info, list_allowed_directories]
+Filesystem MCP list_directory result: [{"text":"[FILE] alpha.txt\n[DIR] nested"}]
+```
+
+## Tool Name Collisions
+
+OpenDaimon deduplicates tool callbacks by `ToolDefinition.name()` while preserving
+registration order. Built-in tools are registered first, so an external MCP tool
+with the same name is ignored. External callbacks with reserved built-in names are
+also ignored when the corresponding built-in tool is not currently enabled.
diff --git a/opendaimon-mcp/pom.xml b/opendaimon-mcp/pom.xml
new file mode 100644
index 00000000..f4427dd8
--- /dev/null
+++ b/opendaimon-mcp/pom.xml
@@ -0,0 +1,134 @@
+
+
+ 4.0.0
+
+ io.github.ngirchev
+ opendaimon
+ ${revision}
+
+
+ opendaimon-mcp
+ OpenDaimon MCP
+
+
+ 21
+ 21
+ 21
+
+ UTF-8
+ UTF-8
+
+
+
+
+
+ io.github.ngirchev
+ opendaimon-common
+ ${project.version}
+
+
+
+ org.springframework.ai
+ spring-ai-starter-mcp-client
+
+
+
+
+ org.springframework.boot
+ spring-boot-autoconfigure
+
+
+ org.springframework
+ spring-context
+
+
+
+
+ org.slf4j
+ slf4j-api
+
+
+
+
+ org.projectlombok
+ lombok
+ provided
+ true
+
+
+
+
+ org.springframework.boot
+ spring-boot-test
+ test
+
+
+ org.springframework.boot
+ spring-boot
+ test
+
+
+ org.springframework.ai
+ spring-ai-autoconfigure-mcp-client-common
+ test
+
+
+ org.springframework.ai
+ spring-ai-model
+ test
+
+
+ org.junit.jupiter
+ junit-jupiter-api
+ test
+
+
+ org.junit.jupiter
+ junit-jupiter-engine
+ test
+
+
+ org.assertj
+ assertj-core
+ test
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-dependency-plugin
+
+
+
+ io.github.ngirchev:opendaimon-common
+
+ org.springframework.ai:spring-ai-starter-mcp-client
+
+ org.junit.jupiter:junit-jupiter-engine
+
+
+
+
+ org.apache.maven.plugins
+ maven-enforcer-plugin
+
+
+
+ true
+
+ org.springframework.boot:spring-boot-starter*
+
+
+
+
+
+
+
+
diff --git a/opendaimon-mcp/src/main/java/io/github/ngirchev/opendaimon/mcp/config/McpAutoConfig.java b/opendaimon-mcp/src/main/java/io/github/ngirchev/opendaimon/mcp/config/McpAutoConfig.java
new file mode 100644
index 00000000..a9274e27
--- /dev/null
+++ b/opendaimon-mcp/src/main/java/io/github/ngirchev/opendaimon/mcp/config/McpAutoConfig.java
@@ -0,0 +1,24 @@
+package io.github.ngirchev.opendaimon.mcp.config;
+
+import io.github.ngirchev.opendaimon.common.config.FeatureToggle;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.autoconfigure.AutoConfiguration;
+import org.springframework.boot.autoconfigure.AutoConfigureAfter;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.annotation.Bean;
+
+@Slf4j
+@AutoConfiguration
+@AutoConfigureAfter(name = "org.springframework.ai.mcp.client.common.autoconfigure.McpClientAutoConfiguration")
+@ConditionalOnProperty(name = FeatureToggle.Module.MCP_ENABLED, havingValue = "true", matchIfMissing = true)
+public class McpAutoConfig {
+
+ @Bean
+ public McpRuntimeMarker mcpRuntimeMarker() {
+ log.info("OpenDaimon MCP module enabled. External MCP tools will be consumed when Spring AI MCP clients are configured.");
+ return new McpRuntimeMarker();
+ }
+
+ public static final class McpRuntimeMarker {
+ }
+}
diff --git a/opendaimon-mcp/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/opendaimon-mcp/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
new file mode 100644
index 00000000..cf3b285f
--- /dev/null
+++ b/opendaimon-mcp/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
@@ -0,0 +1 @@
+io.github.ngirchev.opendaimon.mcp.config.McpAutoConfig
diff --git a/opendaimon-mcp/src/test/java/io/github/ngirchev/opendaimon/mcp/FilesystemMcpSmokeTest.java b/opendaimon-mcp/src/test/java/io/github/ngirchev/opendaimon/mcp/FilesystemMcpSmokeTest.java
new file mode 100644
index 00000000..37215753
--- /dev/null
+++ b/opendaimon-mcp/src/test/java/io/github/ngirchev/opendaimon/mcp/FilesystemMcpSmokeTest.java
@@ -0,0 +1,67 @@
+package io.github.ngirchev.opendaimon.mcp;
+
+import io.github.ngirchev.opendaimon.mcp.config.McpAutoConfig;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.springframework.ai.mcp.client.common.autoconfigure.McpClientAutoConfiguration;
+import org.springframework.ai.mcp.client.common.autoconfigure.McpToolCallbackAutoConfiguration;
+import org.springframework.ai.mcp.client.common.autoconfigure.StdioTransportAutoConfiguration;
+import org.springframework.ai.tool.ToolCallback;
+import org.springframework.ai.tool.ToolCallbackProvider;
+import org.springframework.boot.autoconfigure.AutoConfigurations;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Arrays;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class FilesystemMcpSmokeTest {
+
+ @TempDir
+ Path sandbox;
+
+ @Test
+ void filesystemMcpListsFilesFromSandbox() throws Exception {
+ Files.writeString(sandbox.resolve("alpha.txt"), "alpha");
+ Files.createDirectory(sandbox.resolve("nested"));
+ Files.writeString(sandbox.resolve("nested/beta.txt"), "beta");
+
+ new ApplicationContextRunner()
+ .withConfiguration(AutoConfigurations.of(
+ McpAutoConfig.class,
+ StdioTransportAutoConfiguration.class,
+ McpClientAutoConfiguration.class,
+ McpToolCallbackAutoConfiguration.class))
+ .withPropertyValues(
+ "open-daimon.mcp.enabled=true",
+ "spring.ai.mcp.client.enabled=true",
+ "spring.ai.mcp.client.type=SYNC",
+ "spring.ai.mcp.client.request-timeout=30s",
+ "spring.ai.mcp.client.stdio.connections.filesystem.command=npx",
+ "spring.ai.mcp.client.stdio.connections.filesystem.args[0]=-y",
+ "spring.ai.mcp.client.stdio.connections.filesystem.args[1]=@modelcontextprotocol/server-filesystem",
+ "spring.ai.mcp.client.stdio.connections.filesystem.args[2]=" + sandbox.toAbsolutePath())
+ .run(context -> {
+ assertThat(context).hasNotFailed();
+ ToolCallbackProvider provider = context.getBean(ToolCallbackProvider.class);
+ List callbacks = Arrays.asList(provider.getToolCallbacks());
+ List toolNames = callbacks.stream()
+ .map(callback -> callback.getToolDefinition().name())
+ .toList();
+ System.out.println("Filesystem MCP tools: " + toolNames);
+
+ ToolCallback listDirectory = callbacks.stream()
+ .filter(callback -> callback.getToolDefinition().name().endsWith("list_directory"))
+ .findFirst()
+ .orElseThrow(() -> new AssertionError("list_directory tool not found: " + toolNames));
+
+ String result = listDirectory.call("{\"path\":\"" + sandbox.toAbsolutePath() + "\"}");
+ System.out.println("Filesystem MCP list_directory result: " + result);
+
+ assertThat(result).contains("alpha.txt", "nested");
+ });
+ }
+}
diff --git a/opendaimon-rest/src/main/java/io/github/ngirchev/opendaimon/rest/dto/admin/UserSummaryDto.java b/opendaimon-rest/src/main/java/io/github/ngirchev/opendaimon/rest/dto/admin/UserSummaryDto.java
index 04a8c5db..cd9b94b4 100644
--- a/opendaimon-rest/src/main/java/io/github/ngirchev/opendaimon/rest/dto/admin/UserSummaryDto.java
+++ b/opendaimon-rest/src/main/java/io/github/ngirchev/opendaimon/rest/dto/admin/UserSummaryDto.java
@@ -2,7 +2,7 @@
/**
* Short user info for admin lists and filter dropdown.
- * userType mirrors the JPA discriminator (TELEGRAM, REST, USER).
+ * userType mirrors the JPA discriminator (TELEGRAM, TELEGRAM_GROUP, REST, USER).
*/
public record UserSummaryDto(
Long id,
diff --git a/opendaimon-rest/src/main/java/io/github/ngirchev/opendaimon/rest/repository/AdminConversationRepository.java b/opendaimon-rest/src/main/java/io/github/ngirchev/opendaimon/rest/repository/AdminConversationRepository.java
index 7868e739..e1b71646 100644
--- a/opendaimon-rest/src/main/java/io/github/ngirchev/opendaimon/rest/repository/AdminConversationRepository.java
+++ b/opendaimon-rest/src/main/java/io/github/ngirchev/opendaimon/rest/repository/AdminConversationRepository.java
@@ -20,19 +20,22 @@ public interface AdminConversationRepository extends JpaRepository findAllWithFilters(
- @Param("userId") Long userId,
- @Param("scopeKind") ThreadScopeKind scopeKind,
- @Param("isActive") Boolean isActive,
- Pageable pageable);
+ @Query(value = "SELECT t FROM ConversationThread t JOIN FETCH t.user u " +
+ "WHERE (:userId IS NULL OR u.id = :userId) " +
+ "AND (:scopeKind IS NULL OR t.scopeKind = :scopeKind) " +
+ "AND (:scopeId IS NULL OR t.scopeId = :scopeId) " +
+ "AND (:isActive IS NULL OR t.isActive = :isActive)",
+ countQuery = "SELECT COUNT(t) FROM ConversationThread t " +
+ "WHERE (:userId IS NULL OR t.user.id = :userId) " +
+ "AND (:scopeKind IS NULL OR t.scopeKind = :scopeKind) " +
+ "AND (:scopeId IS NULL OR t.scopeId = :scopeId) " +
+ "AND (:isActive IS NULL OR t.isActive = :isActive)")
+ Page findAllWithFilters(
+ @Param("userId") Long userId,
+ @Param("scopeKind") ThreadScopeKind scopeKind,
+ @Param("scopeId") Long scopeId,
+ @Param("isActive") Boolean isActive,
+ Pageable pageable);
/**
* Detail lookup that eagerly fetches the owner to avoid LazyInitializationException
diff --git a/opendaimon-rest/src/main/java/io/github/ngirchev/opendaimon/rest/service/AdminQueryService.java b/opendaimon-rest/src/main/java/io/github/ngirchev/opendaimon/rest/service/AdminQueryService.java
index 41dc1c6e..7c55f799 100644
--- a/opendaimon-rest/src/main/java/io/github/ngirchev/opendaimon/rest/service/AdminQueryService.java
+++ b/opendaimon-rest/src/main/java/io/github/ngirchev/opendaimon/rest/service/AdminQueryService.java
@@ -47,8 +47,17 @@ public class AdminQueryService {
@Transactional(readOnly = true)
public AdminPageResponse listConversations(
Long userId, ThreadScopeKind scopeKind, Boolean isActive, Pageable pageable) {
+ Long scopeId = null;
+ if (userId != null) {
+ User user = adminUserRepository.findById(userId).orElse(null);
+ if (user != null && TELEGRAM_GROUP_CLASS.equals(user.getClass().getSimpleName())) {
+ scopeKind = ThreadScopeKind.TELEGRAM_CHAT;
+ scopeId = parseLong(invokeTelegramId(user));
+ userId = null;
+ }
+ }
Page page = adminConversationRepository
- .findAllWithFilters(userId, scopeKind, isActive, pageable);
+ .findAllWithFilters(userId, scopeKind, scopeId, isActive, pageable);
return AdminPageResponse.from(page.map(this::toConversationSummary));
}
@@ -144,7 +153,7 @@ private AdminUserSummary toUserSummary(User user) {
return new AdminUserSummary(
user.getId(),
discriminator,
- user.getUsername(),
+ resolveUsername(user),
user.getFirstName(),
user.getLastName(),
identity,
@@ -154,7 +163,9 @@ private AdminUserSummary toUserSummary(User user) {
}
private static final String TELEGRAM_USER_CLASS = "TelegramUser";
+ private static final String TELEGRAM_GROUP_CLASS = "TelegramGroup";
private static final String TELEGRAM_ID_GETTER = "getTelegramId";
+ private static final String TELEGRAM_GROUP_TITLE_GETTER = "getTitle";
private String resolveUserType(User user) {
if (user instanceof RestUser) {
@@ -163,25 +174,49 @@ private String resolveUserType(User user) {
if (TELEGRAM_USER_CLASS.equals(user.getClass().getSimpleName())) {
return "TELEGRAM";
}
+ if (TELEGRAM_GROUP_CLASS.equals(user.getClass().getSimpleName())) {
+ return "TELEGRAM_GROUP";
+ }
return "USER";
}
+ private String resolveUsername(User user) {
+ if (TELEGRAM_GROUP_CLASS.equals(user.getClass().getSimpleName())) {
+ return invokeStringGetter(user, TELEGRAM_GROUP_TITLE_GETTER);
+ }
+ return user.getUsername();
+ }
+
private String resolveIdentity(User user) {
if (user instanceof RestUser ru) {
return ru.getEmail();
}
- if (TELEGRAM_USER_CLASS.equals(user.getClass().getSimpleName())) {
+ if (TELEGRAM_USER_CLASS.equals(user.getClass().getSimpleName())
+ || TELEGRAM_GROUP_CLASS.equals(user.getClass().getSimpleName())) {
return invokeTelegramId(user);
}
return null;
}
private String invokeTelegramId(User user) {
+ return invokeStringGetter(user, TELEGRAM_ID_GETTER);
+ }
+
+ private String invokeStringGetter(User user, String getter) {
try {
- Object v = user.getClass().getMethod(TELEGRAM_ID_GETTER).invoke(user);
+ Object v = user.getClass().getMethod(getter).invoke(user);
return v != null ? v.toString() : null;
} catch (ReflectiveOperationException e) {
- log.debug("Failed to reflect TelegramUser.getTelegramId on {}", user.getClass(), e);
+ log.debug("Failed to reflect {} on {}", getter, user.getClass(), e);
+ return null;
+ }
+ }
+
+ private Long parseLong(String value) {
+ try {
+ return value != null ? Long.parseLong(value) : null;
+ } catch (NumberFormatException e) {
+ log.debug("Failed to parse long value: {}", value);
return null;
}
}
diff --git a/opendaimon-rest/src/test/java/io/github/ngirchev/opendaimon/rest/service/AdminQueryServiceTest.java b/opendaimon-rest/src/test/java/io/github/ngirchev/opendaimon/rest/service/AdminQueryServiceTest.java
index 397179b4..6c55ef2e 100644
--- a/opendaimon-rest/src/test/java/io/github/ngirchev/opendaimon/rest/service/AdminQueryServiceTest.java
+++ b/opendaimon-rest/src/test/java/io/github/ngirchev/opendaimon/rest/service/AdminQueryServiceTest.java
@@ -6,6 +6,7 @@
import io.github.ngirchev.opendaimon.common.model.RequestType;
import io.github.ngirchev.opendaimon.common.model.ResponseStatus;
import io.github.ngirchev.opendaimon.common.model.ThreadScopeKind;
+import io.github.ngirchev.opendaimon.common.model.User;
import io.github.ngirchev.opendaimon.common.repository.OpenDaimonMessageRepository;
import io.github.ngirchev.opendaimon.rest.service.model.AdminConversationSummary;
import io.github.ngirchev.opendaimon.rest.service.model.AdminMessageDetail;
@@ -34,6 +35,7 @@
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@@ -62,7 +64,7 @@ void shouldMapConversationSummaryWithRestUser() {
ConversationThread t = thread(10L, user);
Pageable pageable = PageRequest.of(0, 25);
Page page = new PageImpl<>(List.of(t), pageable, 1);
- when(adminConversationRepository.findAllWithFilters(any(), any(), any(), eq(pageable))).thenReturn(page);
+ when(adminConversationRepository.findAllWithFilters(any(), any(), any(), any(), eq(pageable))).thenReturn(page);
AdminPageResponse result = service.listConversations(null, null, null, pageable);
@@ -77,6 +79,50 @@ void shouldMapConversationSummaryWithRestUser() {
assertThat(dto.user().isAdmin()).isTrue();
}
+ @Test
+ void shouldFilterTelegramGroupConversationsByChatScope() {
+ TelegramGroup group = new TelegramGroup();
+ group.setId(6L);
+ group.setTelegramId(-1001651885521L);
+ group.setTitle("Beer & Politics");
+ RestUser owner = new RestUser();
+ owner.setId(2L);
+ owner.setEmail("owner@test.com");
+ ConversationThread t = thread(11L, owner);
+ t.setScopeKind(ThreadScopeKind.TELEGRAM_CHAT);
+ t.setScopeId(-1001651885521L);
+ Pageable pageable = PageRequest.of(0, 25);
+ Page page = new PageImpl<>(List.of(t), pageable, 1);
+ when(adminUserRepository.findById(6L)).thenReturn(Optional.of(group));
+ when(adminConversationRepository.findAllWithFilters(
+ isNull(), eq(ThreadScopeKind.TELEGRAM_CHAT), eq(-1001651885521L), isNull(), eq(pageable)))
+ .thenReturn(page);
+
+ AdminPageResponse result = service.listConversations(6L, null, null, pageable);
+
+ assertThat(result.content()).hasSize(1);
+ assertThat(result.content().get(0).scopeKind()).isEqualTo(ThreadScopeKind.TELEGRAM_CHAT.name());
+ assertThat(result.content().get(0).scopeId()).isEqualTo(-1001651885521L);
+ }
+
+ @Test
+ void shouldMapTelegramGroupUserSummary() {
+ TelegramGroup group = new TelegramGroup();
+ group.setId(6L);
+ group.setTelegramId(-1001651885521L);
+ group.setTitle("Beer & Politics");
+ Pageable pageable = PageRequest.of(0, 25);
+ Page page = new PageImpl<>(List.of(group), pageable, 1);
+ when(adminUserRepository.searchAll(null, pageable)).thenReturn(page);
+
+ AdminPageResponse> result = service.listUsers(null, pageable);
+
+ assertThat(result.content()).hasSize(1);
+ Object dto = result.content().get(0);
+ assertThat(dto).extracting("userType", "username", "emailOrTelegramId")
+ .containsExactly("TELEGRAM_GROUP", "Beer & Politics", "-1001651885521");
+ }
+
@Test
void shouldThrowWhenConversationMissing() {
when(adminConversationRepository.findByIdWithUser(99L)).thenReturn(Optional.empty());
@@ -178,4 +224,26 @@ private OpenDaimonMessage message(Long id, int seq, MessageRole role, String con
m.setCreatedAt(OffsetDateTime.now());
return m;
}
+
+ public static class TelegramGroup extends User {
+
+ private Long telegramId;
+ private String title;
+
+ public Long getTelegramId() {
+ return telegramId;
+ }
+
+ public void setTelegramId(Long telegramId) {
+ this.telegramId = telegramId;
+ }
+
+ public String getTitle() {
+ return title;
+ }
+
+ public void setTitle(String title) {
+ this.title = title;
+ }
+ }
}
diff --git a/opendaimon-spring-ai/SPRING_AI_MODULE.md b/opendaimon-spring-ai/SPRING_AI_MODULE.md
index 45d88c75..ad3c7c98 100644
--- a/opendaimon-spring-ai/SPRING_AI_MODULE.md
+++ b/opendaimon-spring-ai/SPRING_AI_MODULE.md
@@ -416,6 +416,52 @@ specific step.
See `docs/usecases/agent-image-attachment.md` and the use-case fixture
`TelegramAgentImageFixtureIT`.
+### External MCP tools
+
+OpenDaimon tool discovery has two sources:
+
+1. Built-in Spring beans converted through `ToolCallbacks.from(...)`:
+ `WebTools` (`web_search`, `fetch_url`) and `HttpApiTool` (`http_get`,
+ `http_post`, when enabled).
+2. External `ToolCallbackProvider` beans, including Spring AI MCP client providers
+ added by `opendaimon-mcp` / `spring-ai-starter-mcp-client`.
+
+`AgentAutoConfig#agentToolCallbacks` merges built-in callbacks with external
+provider callbacks. The normal `SpringAIPromptFactory` path performs the same
+merge before adding callbacks to `ChatClient` prompts, but external MCP callbacks
+are only considered when the command requests `TOOL_CALLING` in required or
+optional capabilities. External MCP tools are then filtered by
+`open-daimon.mcp.tool-access` rules using the command `userPriority` metadata
+before the model sees them. Missing or unknown priority denies external MCP
+tools.
+
+`open-daimon.mcp.enabled=false` disables OpenDaimon's consumption of external
+provider callbacks. Spring AI MCP client creation itself is controlled by
+`spring.ai.mcp.client.*`; bundled defaults keep `spring.ai.mcp.client.enabled`
+true, while concrete MCP server connections are supplied by the application
+configuration. The bundled `opendaimon-app` configures the filesystem stdio
+server; the published starter defaults do not start a concrete stdio server for
+downstream applications.
+
+`SpringAIExternalToolCatalogService` lists the same built-in tools and the
+role-filtered MCP tools for user-facing `/tools` output. MCP entries keep their
+resolved source display name, so Telegram can mark them as `mcp: ` while
+showing built-in groups such as `webtools` and `http-api` without an MCP marker.
+
+External provider callbacks are role-filtered. `SpringAIChatService` passes
+command metadata to `SpringAIPromptFactory`, and `SpringAgentLoopActions#resolveEffectiveTools`
+uses the same metadata in agent mode. By default, tools without an explicit rule
+are available to ADMIN, VIP, and REGULAR; filesystem MCP tools are matched by a
+default ADMIN-only rule. This is especially important for filesystem MCP: the
+filesystem server runs inside the OpenDaimon process/container and must not be
+available to regular users.
+
+Tool callbacks are deduplicated by `ToolDefinition.name()` with first-wins
+semantics. Built-in tools are registered first, so an MCP tool named `fetch_url`
+or `web_search` will not replace the OpenDaimon built-in implementation. External
+callbacks with reserved built-in names are ignored even when the corresponding
+built-in tool is not currently enabled for the prompt.
+
### Tool failure detection
Spring AI's `@Tool` contract is **string-typed**: tool methods return a plain `String`
diff --git a/opendaimon-spring-ai/pom.xml b/opendaimon-spring-ai/pom.xml
index 31f3f354..cff7d2e8 100644
--- a/opendaimon-spring-ai/pom.xml
+++ b/opendaimon-spring-ai/pom.xml
@@ -99,6 +99,15 @@
org.springframework.aispring-ai-openai
+
+ org.springframework.ai
+ spring-ai-mcp
+
+
+ io.modelcontextprotocol.sdk
+ mcp-core
+ ${mcp-core.version}
+ org.springframework.ai
diff --git a/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/AgentPromptBuilder.java b/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/AgentPromptBuilder.java
index 2ece94f9..e078e401 100644
--- a/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/AgentPromptBuilder.java
+++ b/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/AgentPromptBuilder.java
@@ -84,7 +84,7 @@ private static String appendLanguageInstruction(String prompt, Map history = ctx.getStepHistory();
if (history.isEmpty()) {
- return ctx.getTask();
+ return appendLanguageInstructionToUserMessage(ctx.getTask(), ctx.getMetadata());
}
var sb = new StringBuilder();
@@ -112,6 +112,16 @@ public static String buildUserMessage(AgentContext ctx) {
sb.append("Based on the above steps and observations, continue solving the task. ");
sb.append("Either call another tool or provide your final answer.");
- return sb.toString();
+ return appendLanguageInstructionToUserMessage(sb.toString(), ctx.getMetadata());
+ }
+
+ private static String appendLanguageInstructionToUserMessage(String message, Map metadata) {
+ if (metadata == null) return message;
+ String code = metadata.get(AICommand.LANGUAGE_CODE_FIELD);
+ return LanguageInstructions.displayName(code)
+ .map(name -> message
+ + "\n\nAnswer language: " + name + " (" + code + ")."
+ + " Follow this language for the final answer even if earlier conversation turns used another language.")
+ .orElse(message);
}
}
diff --git a/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/SpringAgentLoopActions.java b/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/SpringAgentLoopActions.java
index e09ffdaa..76b0c9b9 100644
--- a/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/SpringAgentLoopActions.java
+++ b/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/agent/SpringAgentLoopActions.java
@@ -4,6 +4,8 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.ngirchev.opendaimon.ai.springai.agent.RawToolCallParser.RawToolCall;
import io.github.ngirchev.opendaimon.ai.springai.agent.ToolObservationClassifier.Classification;
+import io.github.ngirchev.opendaimon.ai.springai.config.McpToolAccessProperties;
+import io.github.ngirchev.opendaimon.ai.springai.tool.ExternalToolCallbacks;
import io.github.ngirchev.opendaimon.ai.springai.tool.UrlLivenessChecker;
import io.github.ngirchev.opendaimon.ai.springai.tool.WebTools;
import io.github.ngirchev.opendaimon.bulkhead.service.PriorityRequestExecutor;
@@ -91,9 +93,9 @@ public class SpringAgentLoopActions implements AgentLoopActions {
private final Duration streamTimeout;
/** Optional — when set, final-answer text is passed through to strip dead URLs. */
private final UrlLivenessChecker urlLivenessChecker;
- private final RawToolCallParser rawToolCallParser;
private final SummaryModelInvoker summaryModelInvoker;
private final PriorityRequestExecutor priorityRequestExecutor;
+ private final McpToolAccessProperties mcpToolAccessProperties;
private static final String KEY_CONVERSATION_HISTORY = "spring.conversationHistory";
private static final String KEY_LAST_PROMPT = "spring.lastPrompt";
@@ -130,6 +132,18 @@ public SpringAgentLoopActions(ChatModel chatModel,
Duration streamTimeout,
UrlLivenessChecker urlLivenessChecker,
PriorityRequestExecutor priorityRequestExecutor) {
+ this(chatModel, toolCallingManager, toolCallbacks, chatMemory, streamTimeout,
+ urlLivenessChecker, priorityRequestExecutor, new McpToolAccessProperties());
+ }
+
+ public SpringAgentLoopActions(ChatModel chatModel,
+ ToolCallingManager toolCallingManager,
+ List toolCallbacks,
+ ChatMemory chatMemory,
+ Duration streamTimeout,
+ UrlLivenessChecker urlLivenessChecker,
+ PriorityRequestExecutor priorityRequestExecutor,
+ McpToolAccessProperties mcpToolAccessProperties) {
this.chatModel = chatModel;
this.toolCallingManager = toolCallingManager;
this.toolCallbacks = toolCallbacks != null ? List.copyOf(toolCallbacks) : List.of();
@@ -137,7 +151,7 @@ public SpringAgentLoopActions(ChatModel chatModel,
this.streamTimeout = Objects.requireNonNull(streamTimeout, "streamTimeout must not be null");
this.urlLivenessChecker = urlLivenessChecker;
this.priorityRequestExecutor = priorityRequestExecutor;
- this.rawToolCallParser = new RawToolCallParser(this.toolCallbacks);
+ this.mcpToolAccessProperties = mcpToolAccessProperties != null ? mcpToolAccessProperties : new McpToolAccessProperties();
this.summaryModelInvoker = new SummaryModelInvoker(chatModel, priorityRequestExecutor);
}
@@ -235,7 +249,7 @@ public void think(AgentContext ctx) {
firstToolCall.name(), firstToolCall.arguments());
} else {
String rawText = AgentTextSanitizer.stripThinkTags(output.getText());
- RawToolCall rawToolCall = rawToolCallParser.tryParseRawToolCall(rawText);
+ RawToolCall rawToolCall = new RawToolCallParser(effectiveCallbacks).tryParseRawToolCall(rawText);
if (rawToolCall != null) {
ctx.setCurrentThought("Calling tool (fallback): " + rawToolCall.name());
ctx.setCurrentToolName(rawToolCall.name());
@@ -583,6 +597,7 @@ List resolveEffectiveTools(AgentContext ctx) {
}
}
return resolved.stream()
+ .filter(callback -> ExternalToolCallbacks.isAllowedFor(callback, ctx.getMetadata(), mcpToolAccessProperties))
.map(callback -> guardFetchUrlCallback(ctx, callback))
.toList();
}
@@ -754,7 +769,7 @@ private void executeFallbackToolCall(AgentContext ctx) {
String toolName = ctx.getCurrentToolName();
String toolArgs = ctx.getCurrentToolArguments();
- ToolCallback callback = toolCallbacks.stream()
+ ToolCallback callback = resolveEffectiveTools(ctx).stream()
.filter(cb -> cb.getToolDefinition().name().equals(toolName))
.findFirst()
.orElse(null);
@@ -766,7 +781,7 @@ private void executeFallbackToolCall(AgentContext ctx) {
log.info("Agent executeTool (fallback): tool={}, args={}", toolName, toolArgs);
- String result = guardFetchUrlCallback(ctx, callback).call(toolArgs);
+ String result = callback.call(toolArgs);
ctx.setToolResult(AgentToolResult.success(toolName, result));
List messages = getOrCreateHistory(ctx);
diff --git a/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/config/AgentAutoConfig.java b/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/config/AgentAutoConfig.java
index 73a4ae67..ab559641 100644
--- a/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/config/AgentAutoConfig.java
+++ b/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/config/AgentAutoConfig.java
@@ -12,6 +12,7 @@
import io.github.ngirchev.opendaimon.bulkhead.service.PriorityRequestExecutor;
import io.github.ngirchev.opendaimon.common.config.FeatureToggle;
import io.github.ngirchev.opendaimon.ai.springai.retry.SpringAIModelRegistry;
+import io.github.ngirchev.opendaimon.ai.springai.tool.ExternalToolCallbacks;
import io.github.ngirchev.opendaimon.ai.springai.tool.HttpApiTool;
import io.github.ngirchev.opendaimon.ai.springai.tool.UrlLivenessChecker;
import io.github.ngirchev.opendaimon.ai.springai.tool.WebTools;
@@ -29,9 +30,11 @@
import org.springframework.ai.ollama.OllamaChatModel;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.ai.tool.ToolCallback;
+import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.ai.support.ToolCallbacks;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.AutoConfigureAfter;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
@@ -63,7 +66,7 @@
@AutoConfiguration
@AutoConfigureAfter(SpringAIAutoConfig.class)
@ConditionalOnProperty(name = FeatureToggle.Module.AGENT_ENABLED, havingValue = "true")
-@EnableConfigurationProperties(AgentProperties.class)
+@EnableConfigurationProperties({AgentProperties.class, McpToolAccessProperties.class})
public class AgentAutoConfig {
/**
@@ -91,7 +94,8 @@ public SpringAgentLoopActions agentLoopActions(
ObjectProvider chatMemoryProvider,
ObjectProvider urlLivenessCheckerProvider,
PriorityRequestExecutor priorityRequestExecutor,
- AgentProperties agentProperties) {
+ AgentProperties agentProperties,
+ McpToolAccessProperties mcpToolAccessProperties) {
Duration streamTimeout = Duration.ofSeconds(agentProperties.getStreamTimeoutSeconds());
return new SpringAgentLoopActions(
agentChatModel,
@@ -100,7 +104,8 @@ public SpringAgentLoopActions agentLoopActions(
chatMemoryProvider.getIfAvailable(),
streamTimeout,
urlLivenessCheckerProvider.getIfAvailable(),
- priorityRequestExecutor);
+ priorityRequestExecutor,
+ mcpToolAccessProperties);
}
@Bean("agentLoopFsm")
@@ -167,14 +172,21 @@ public AgentOrchestrator agentOrchestrator(
@ConditionalOnMissingBean(name = "agentToolCallbacks")
public List agentToolCallbacks(
ObjectProvider webToolsProvider,
- ObjectProvider httpApiToolProvider) {
- List callbacks = new ArrayList<>();
+ ObjectProvider httpApiToolProvider,
+ ObjectProvider externalToolCallbackProviders,
+ @Value("${" + FeatureToggle.Module.MCP_ENABLED + ":true}") boolean externalToolsEnabled) {
+ List builtInCallbacks = new ArrayList<>();
webToolsProvider.ifAvailable(tools ->
- callbacks.addAll(Arrays.asList(ToolCallbacks.from(tools))));
+ builtInCallbacks.addAll(Arrays.asList(ToolCallbacks.from(tools))));
httpApiToolProvider.ifAvailable(tool ->
- callbacks.addAll(Arrays.asList(ToolCallbacks.from(tool))));
- log.info("Agent tool callbacks registered: {}", callbacks.size());
- return List.copyOf(callbacks);
+ builtInCallbacks.addAll(Arrays.asList(ToolCallbacks.from(tool))));
+ List callbacks = ExternalToolCallbacks.merge(
+ builtInCallbacks,
+ externalToolCallbackProviders,
+ externalToolsEnabled);
+ log.info("Agent tool callbacks registered: {} (built-in={}, external-enabled={})",
+ callbacks.size(), builtInCallbacks.size(), externalToolsEnabled);
+ return callbacks;
}
// --- Built-in agent tools ---
diff --git a/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/config/McpToolAccessProperties.java b/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/config/McpToolAccessProperties.java
new file mode 100644
index 00000000..6ae86acd
--- /dev/null
+++ b/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/config/McpToolAccessProperties.java
@@ -0,0 +1,59 @@
+package io.github.ngirchev.opendaimon.ai.springai.config;
+
+import io.github.ngirchev.opendaimon.bulkhead.model.UserPriority;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotEmpty;
+import jakarta.validation.constraints.NotNull;
+import lombok.Getter;
+import lombok.Setter;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.validation.annotation.Validated;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Pattern;
+
+@ConfigurationProperties(prefix = "open-daimon.mcp.tool-access")
+@Validated
+@Getter
+@Setter
+public class McpToolAccessProperties {
+
+ @NotEmpty(message = "tool-access.default-roles is required")
+ private List defaultRoles = new ArrayList<>(List.of(
+ UserPriority.ADMIN,
+ UserPriority.VIP,
+ UserPriority.REGULAR));
+
+ @Valid
+ private List rules = new ArrayList<>(List.of(filesystemAdminRule()));
+
+ public boolean isAllowed(String toolName, UserPriority userPriority) {
+ if (toolName == null || userPriority == null) {
+ return false;
+ }
+ return rules.stream()
+ .filter(rule -> Pattern.compile(rule.getNamePattern()).matcher(toolName).matches())
+ .findFirst()
+ .map(rule -> rule.getRoles().contains(userPriority))
+ .orElseGet(() -> defaultRoles.contains(userPriority));
+ }
+
+ private static Rule filesystemAdminRule() {
+ Rule rule = new Rule();
+ rule.setNamePattern("^(?:[A-Za-z0-9_]+_)?(read_file|read_text_file|read_media_file|read_multiple_files|write_file|edit_file|create_directory|list_directory|list_directory_with_sizes|directory_tree|move_file|search_files|get_file_info|list_allowed_directories)$");
+ rule.setRoles(new ArrayList<>(List.of(UserPriority.ADMIN)));
+ return rule;
+ }
+
+ @Getter
+ @Setter
+ public static class Rule {
+ @NotBlank(message = "tool-access.rules[].name-pattern is required")
+ private String namePattern;
+
+ @NotNull(message = "tool-access.rules[].roles is required")
+ private List roles = new ArrayList<>();
+ }
+}
diff --git a/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/config/SpringAIAutoConfig.java b/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/config/SpringAIAutoConfig.java
index 39160881..56f54e79 100644
--- a/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/config/SpringAIAutoConfig.java
+++ b/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/config/SpringAIAutoConfig.java
@@ -19,6 +19,9 @@
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.boot.context.properties.bind.Bindable;
+import org.springframework.boot.context.properties.bind.Binder;
+import org.springframework.core.env.Environment;
import org.springframework.util.StringUtils;
import org.springframework.boot.web.reactive.function.client.WebClientCustomizer;
import org.springframework.boot.web.client.RestClientCustomizer;
@@ -55,17 +58,23 @@
import io.github.ngirchev.opendaimon.ai.springai.service.SpringAIChatService;
import io.github.ngirchev.opendaimon.ai.springai.retry.OpenRouterModelRotationAspect;
import io.github.ngirchev.opendaimon.ai.springai.tool.UnknownToolFallbackResolver;
+import io.github.ngirchev.opendaimon.ai.springai.tool.HttpApiTool;
import io.github.ngirchev.opendaimon.ai.springai.tool.UrlLivenessChecker;
import io.github.ngirchev.opendaimon.ai.springai.tool.UrlLivenessCheckerImpl;
import io.github.ngirchev.opendaimon.ai.springai.tool.WebTools;
+import io.github.ngirchev.opendaimon.ai.springai.tool.SpringAIExternalToolCatalogService;
+import io.github.ngirchev.opendaimon.common.ai.tool.ExternalToolCatalogService;
import org.springframework.ai.model.tool.DefaultToolCallingManager;
import org.springframework.ai.model.tool.ToolCallingManager;
+import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.ai.tool.resolution.DelegatingToolCallbackResolver;
import org.springframework.ai.tool.resolution.SpringBeanToolCallbackResolver;
import org.springframework.ai.tool.resolution.StaticToolCallbackResolver;
import org.springframework.context.support.GenericApplicationContext;
import java.util.List;
+import java.util.LinkedHashMap;
+import java.util.Map;
import io.github.ngirchev.opendaimon.ai.springai.retry.OpenRouterFreeModelResolver;
import io.github.ngirchev.opendaimon.ai.springai.retry.OpenRouterModelsApiClient;
import io.github.ngirchev.opendaimon.ai.springai.retry.OpenRouterModelStatsRecorder;
@@ -83,7 +92,7 @@
"org.springframework.ai.model.chat.memory.autoconfigure.ChatMemoryAutoConfiguration"
})
@AutoConfigureBefore(name = "org.springframework.ai.model.tool.autoconfigure.ToolCallingAutoConfiguration")
-@EnableConfigurationProperties({SpringAIProperties.class, OpenRouterModelsProperties.class})
+@EnableConfigurationProperties({SpringAIProperties.class, OpenRouterModelsProperties.class, McpToolAccessProperties.class})
@Import(SpringAIFlywayConfig.class)
@ConditionalOnProperty(name = FeatureToggle.Module.SPRING_AI_ENABLED, havingValue = "true")
public class SpringAIAutoConfig {
@@ -158,11 +167,63 @@ public SpringAIPromptFactory springAIPromptFactory(
ObjectProvider openAiChatModelProvider,
WebTools webTools,
ChatMemory chatMemory,
- SpringAIModelType springAIModelType
+ SpringAIModelType springAIModelType,
+ ObjectProvider externalToolCallbackProviders,
+ @Value("${" + FeatureToggle.Module.MCP_ENABLED + ":true}") boolean externalToolsEnabled,
+ McpToolAccessProperties mcpToolAccessProperties
) {
// Providers are stored and resolved lazily on first request — ordering relative to
// OllamaChatAutoConfiguration / OpenAiChatAutoConfiguration does not matter.
- return new SpringAIPromptFactory(ollamaChatModelProvider, openAiChatModelProvider, webTools, chatMemory, springAIModelType);
+ return new SpringAIPromptFactory(
+ ollamaChatModelProvider,
+ openAiChatModelProvider,
+ webTools,
+ chatMemory,
+ springAIModelType,
+ externalToolCallbackProviders,
+ externalToolsEnabled,
+ mcpToolAccessProperties);
+ }
+
+ @Bean
+ @ConditionalOnMissingBean
+ public ExternalToolCatalogService externalToolCatalogService(
+ ObjectProvider webToolsProvider,
+ ObjectProvider httpApiToolProvider,
+ ObjectProvider externalToolCallbackProviders,
+ ObjectProvider mcpSyncClients,
+ Environment environment,
+ @Value("${" + FeatureToggle.Module.MCP_ENABLED + ":true}") boolean externalToolsEnabled,
+ McpToolAccessProperties mcpToolAccessProperties) {
+ return new SpringAIExternalToolCatalogService(
+ webToolsProvider,
+ httpApiToolProvider,
+ externalToolCallbackProviders,
+ mcpSyncClients,
+ configuredMcpSourceNames(environment),
+ externalToolsEnabled,
+ mcpToolAccessProperties);
+ }
+
+ private static List configuredMcpSourceNames(Environment environment) {
+ Binder binder = Binder.get(environment);
+ Map displayNames = binder.bind("open-daimon.mcp.source-display-names",
+ Bindable.mapOf(String.class, String.class))
+ .orElse(Map.of());
+ List names = new java.util.ArrayList<>();
+ names.addAll(sourceNames(binder, "spring.ai.mcp.client.stdio.connections", displayNames));
+ names.addAll(sourceNames(binder, "spring.ai.mcp.client.sse.connections", displayNames));
+ names.addAll(sourceNames(binder, "spring.ai.mcp.client.streamable-http.connections", displayNames));
+ return names.stream().distinct().toList();
+ }
+
+ private static List sourceNames(Binder binder, String prefix, Map displayNames) {
+ return binder.bind(prefix, Bindable.mapOf(String.class, Object.class))
+ .map(LinkedHashMap::new)
+ .map(map -> map.keySet().stream()
+ .map(name -> displayNames.getOrDefault(name, name))
+ .toList())
+ .orElse(List.of());
}
@Bean
diff --git a/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/service/SpringAIChatService.java b/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/service/SpringAIChatService.java
index 43fbc78f..362916ba 100644
--- a/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/service/SpringAIChatService.java
+++ b/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/service/SpringAIChatService.java
@@ -43,12 +43,16 @@ public AIResponse streamChat(
String modelForStream = resolveModelName(modelConfig, chatOptions != null ? chatOptions.body() : null);
Object conversationId = command != null ? command.metadata().get(AICommand.THREAD_KEY_FIELD) : null;
boolean webEnabled = webToolsEnabled(command);
+ boolean externalToolsAllowed = externalToolsAllowed(command);
+ Map metadata = command != null && command.metadata() != null ? command.metadata() : Map.of();
var promptBuilder = promptFactory.preparePrompt(
modelConfig,
modelForStream,
chatOptions != null ? chatOptions.body() : null,
conversationId,
webEnabled,
+ externalToolsAllowed,
+ metadata,
messages,
chatOptions
);
@@ -131,8 +135,10 @@ public AIResponse callChat(
) {
Object conversationId = command != null ? command.metadata().get(AICommand.THREAD_KEY_FIELD) : null;
boolean webEnabled = webToolsEnabled(command);
+ boolean externalToolsAllowed = externalToolsAllowed(command);
+ Map metadata = command != null && command.metadata() != null ? command.metadata() : Map.of();
Map body = chatOptions != null ? chatOptions.body() : null;
- return callChatOnce(modelConfig, body, conversationId, webEnabled, messages, chatOptions);
+ return callChatOnce(modelConfig, body, conversationId, webEnabled, externalToolsAllowed, metadata, messages, chatOptions);
}
private AIResponse callChatOnce(
@@ -140,6 +146,8 @@ private AIResponse callChatOnce(
Map body,
Object conversationId,
boolean webEnabled,
+ boolean externalToolsAllowed,
+ Map metadata,
List messages,
OpenDaimonChatOptions chatOptions
) {
@@ -150,6 +158,8 @@ private AIResponse callChatOnce(
body,
conversationId,
webEnabled,
+ externalToolsAllowed,
+ metadata != null ? metadata : Map.of(),
messages,
chatOptions
);
@@ -224,7 +234,7 @@ public AIResponse callChatFromBody(
boolean webEnabled,
List messages
) {
- return callChatOnce(modelConfig, requestBody, conversationId, webEnabled, messages, null);
+ return callChatOnce(modelConfig, requestBody, conversationId, webEnabled, false, Map.of(), messages, null);
}
private Flux trackStreamIfPossible(String modelId, Flux flux) {
@@ -247,6 +257,14 @@ private static boolean webToolsEnabled(AICommand command) {
|| command.optionalCapabilities().contains(ModelCapabilities.WEB);
}
+ private static boolean externalToolsAllowed(AICommand command) {
+ if (command == null) {
+ return false;
+ }
+ return command.modelCapabilities().contains(ModelCapabilities.TOOL_CALLING)
+ || command.optionalCapabilities().contains(ModelCapabilities.TOOL_CALLING);
+ }
+
private void logStreamError(Throwable error, String modelName, Map body) {
if (AIUtils.shouldLogWithoutStacktrace(error)) {
log.error("Spring AI stream error. model={}, cause={}", modelName, AIUtils.getRootCauseMessage(error));
diff --git a/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/service/SpringAIPromptFactory.java b/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/service/SpringAIPromptFactory.java
index 8cfffa48..78531aaf 100644
--- a/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/service/SpringAIPromptFactory.java
+++ b/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/service/SpringAIPromptFactory.java
@@ -1,6 +1,7 @@
package io.github.ngirchev.opendaimon.ai.springai.service;
import lombok.extern.slf4j.Slf4j;
+import io.github.ngirchev.opendaimon.ai.springai.config.McpToolAccessProperties;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.memory.ChatMemory;
@@ -15,13 +16,19 @@
import org.springframework.ai.ollama.api.ThinkOption;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.ai.openai.OpenAiChatOptions;
+import org.springframework.ai.support.ToolCallbacks;
+import org.springframework.ai.tool.ToolCallback;
+import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.beans.factory.ObjectProvider;
import io.github.ngirchev.opendaimon.ai.springai.config.SpringAIModelConfig;
+import io.github.ngirchev.opendaimon.ai.springai.tool.ExternalToolCallbacks;
import io.github.ngirchev.opendaimon.ai.springai.tool.WebTools;
+import io.github.ngirchev.opendaimon.common.ai.command.AICommand;
import io.github.ngirchev.opendaimon.common.ai.ModelCapabilities;
import io.github.ngirchev.opendaimon.common.ai.command.OpenDaimonChatOptions;
import java.util.Collections;
+import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -46,6 +53,9 @@ public class SpringAIPromptFactory {
private final WebTools webTools;
private final ChatMemory chatMemory;
private final SpringAIModelType springAIModelType;
+ private final ObjectProvider externalToolCallbackProviders;
+ private final boolean externalToolsEnabled;
+ private final McpToolAccessProperties mcpToolAccessProperties;
/**
* Production constructor. Clients are resolved lazily on first use — after the full
@@ -58,13 +68,42 @@ public SpringAIPromptFactory(
ObjectProvider openAiChatModelProvider,
WebTools webTools,
ChatMemory chatMemory,
- SpringAIModelType springAIModelType
+ SpringAIModelType springAIModelType,
+ ObjectProvider externalToolCallbackProviders,
+ boolean externalToolsEnabled,
+ McpToolAccessProperties mcpToolAccessProperties
) {
this.ollamaChatModelProvider = ollamaChatModelProvider;
this.openAiChatModelProvider = openAiChatModelProvider;
this.webTools = webTools;
this.chatMemory = chatMemory;
this.springAIModelType = springAIModelType;
+ this.externalToolCallbackProviders = externalToolCallbackProviders;
+ this.externalToolsEnabled = externalToolsEnabled;
+ this.mcpToolAccessProperties = mcpToolAccessProperties != null ? mcpToolAccessProperties : new McpToolAccessProperties();
+ }
+
+ public SpringAIPromptFactory(
+ ObjectProvider ollamaChatModelProvider,
+ ObjectProvider openAiChatModelProvider,
+ WebTools webTools,
+ ChatMemory chatMemory,
+ SpringAIModelType springAIModelType
+ ) {
+ this(ollamaChatModelProvider, openAiChatModelProvider, webTools, chatMemory, springAIModelType, null, false, new McpToolAccessProperties());
+ }
+
+ public SpringAIPromptFactory(
+ ObjectProvider ollamaChatModelProvider,
+ ObjectProvider openAiChatModelProvider,
+ WebTools webTools,
+ ChatMemory chatMemory,
+ SpringAIModelType springAIModelType,
+ ObjectProvider externalToolCallbackProviders,
+ boolean externalToolsEnabled
+ ) {
+ this(ollamaChatModelProvider, openAiChatModelProvider, webTools, chatMemory, springAIModelType,
+ externalToolCallbackProviders, externalToolsEnabled, new McpToolAccessProperties());
}
/**
@@ -75,7 +114,10 @@ public SpringAIPromptFactory(
ChatClient openAiChatClient,
WebTools webTools,
ChatMemory chatMemory,
- SpringAIModelType springAIModelType
+ SpringAIModelType springAIModelType,
+ ObjectProvider externalToolCallbackProviders,
+ boolean externalToolsEnabled,
+ McpToolAccessProperties mcpToolAccessProperties
) {
this.ollamaChatModelProvider = null;
this.openAiChatModelProvider = null;
@@ -84,6 +126,44 @@ public SpringAIPromptFactory(
this.webTools = webTools;
this.chatMemory = chatMemory;
this.springAIModelType = springAIModelType;
+ this.externalToolCallbackProviders = externalToolCallbackProviders;
+ this.externalToolsEnabled = externalToolsEnabled;
+ this.mcpToolAccessProperties = mcpToolAccessProperties != null ? mcpToolAccessProperties : new McpToolAccessProperties();
+ }
+
+ public SpringAIPromptFactory(
+ ChatClient ollamaChatClient,
+ ChatClient openAiChatClient,
+ WebTools webTools,
+ ChatMemory chatMemory,
+ SpringAIModelType springAIModelType
+ ) {
+ this(ollamaChatClient, openAiChatClient, webTools, chatMemory, springAIModelType, null, false, new McpToolAccessProperties());
+ }
+
+ public SpringAIPromptFactory(
+ ChatClient ollamaChatClient,
+ ChatClient openAiChatClient,
+ WebTools webTools,
+ ChatMemory chatMemory,
+ SpringAIModelType springAIModelType,
+ ObjectProvider externalToolCallbackProviders,
+ boolean externalToolsEnabled
+ ) {
+ this(ollamaChatClient, openAiChatClient, webTools, chatMemory, springAIModelType,
+ externalToolCallbackProviders, externalToolsEnabled, new McpToolAccessProperties());
+ }
+
+ public ChatClient.ChatClientRequestSpec preparePrompt(
+ SpringAIModelConfig modelConfig,
+ String modelName,
+ Map body,
+ Object conversationId,
+ boolean webEnabled,
+ List messages,
+ OpenDaimonChatOptions chatOptions
+ ) {
+ return preparePrompt(modelConfig, modelName, body, conversationId, webEnabled, Map.of(), messages, chatOptions);
}
public ChatClient.ChatClientRequestSpec preparePrompt(
@@ -92,6 +172,34 @@ public ChatClient.ChatClientRequestSpec preparePrompt(
Map body,
Object conversationId,
boolean webEnabled,
+ boolean externalToolsAllowed,
+ List messages,
+ OpenDaimonChatOptions chatOptions
+ ) {
+ return preparePrompt(modelConfig, modelName, body, conversationId, webEnabled, externalToolsAllowed, Map.of(), messages, chatOptions);
+ }
+
+ public ChatClient.ChatClientRequestSpec preparePrompt(
+ SpringAIModelConfig modelConfig,
+ String modelName,
+ Map body,
+ Object conversationId,
+ boolean webEnabled,
+ Map metadata,
+ List messages,
+ OpenDaimonChatOptions chatOptions
+ ) {
+ return preparePrompt(modelConfig, modelName, body, conversationId, webEnabled, false, metadata, messages, chatOptions);
+ }
+
+ public ChatClient.ChatClientRequestSpec preparePrompt(
+ SpringAIModelConfig modelConfig,
+ String modelName,
+ Map body,
+ Object conversationId,
+ boolean webEnabled,
+ boolean externalToolsAllowed,
+ Map metadata,
List messages,
OpenDaimonChatOptions chatOptions
) {
@@ -107,7 +215,7 @@ public ChatClient.ChatClientRequestSpec preparePrompt(
.advisors(new MessageOrderingAdvisor());
}
addSystemMessagesIfPresent(promptBuilder, messages);
- addWebToolsIfEnabled(promptBuilder, webEnabled);
+ addToolsIfEnabled(promptBuilder, webEnabled, externalToolsAllowed, metadata);
addUserOrAllMessages(promptBuilder, messages);
return promptBuilder;
@@ -127,11 +235,25 @@ private void addSystemMessagesIfPresent(ChatClient.ChatClientRequestSpec promptB
}
}
- private void addWebToolsIfEnabled(ChatClient.ChatClientRequestSpec promptBuilder, boolean webEnabled) {
- if (webEnabled) {
- promptBuilder.tools(webTools);
- log.debug("Web tools added to prompt (web_search, fetch_url). Model may invoke them.");
- } else {
+ private void addToolsIfEnabled(ChatClient.ChatClientRequestSpec promptBuilder,
+ boolean webEnabled,
+ boolean externalToolsAllowed,
+ Map metadata) {
+ List builtInCallbacks = webEnabled
+ ? Arrays.asList(ToolCallbacks.from(webTools))
+ : List.of();
+ List callbacks = ExternalToolCallbacks.merge(
+ builtInCallbacks,
+ externalToolCallbackProviders,
+ externalToolsEnabled && externalToolsAllowed).stream()
+ .filter(callback -> ExternalToolCallbacks.isAllowedFor(callback, metadata, mcpToolAccessProperties))
+ .toList();
+ if (!callbacks.isEmpty()) {
+ ToolCallback[] toolCallbacks = callbacks.toArray(ToolCallback[]::new);
+ promptBuilder.toolCallbacks(toolCallbacks);
+ log.debug("Tools added to prompt: {} (webEnabled={}, external-enabled={}, external-allowed={}).",
+ callbacks.size(), webEnabled, externalToolsEnabled, externalToolsAllowed);
+ } else if (!webEnabled) {
log.debug("Web tools NOT added to prompt (webEnabled=false). Serper/fetch_url are only registered when the AI command requests WEB in required or optional capabilities.");
}
}
diff --git a/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/tool/ExternalToolCallbacks.java b/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/tool/ExternalToolCallbacks.java
new file mode 100644
index 00000000..9bea8d5f
--- /dev/null
+++ b/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/tool/ExternalToolCallbacks.java
@@ -0,0 +1,111 @@
+package io.github.ngirchev.opendaimon.ai.springai.tool;
+
+import io.github.ngirchev.opendaimon.bulkhead.model.UserPriority;
+import io.github.ngirchev.opendaimon.ai.springai.config.McpToolAccessProperties;
+import io.github.ngirchev.opendaimon.common.ai.command.AICommand;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.ai.tool.ToolCallback;
+import org.springframework.ai.tool.ToolCallbackProvider;
+import org.springframework.beans.factory.ObjectProvider;
+
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+@Slf4j
+public final class ExternalToolCallbacks {
+
+ public static final Set BUILT_IN_TOOL_NAMES = Set.of(
+ "web_search",
+ "fetch_url",
+ "http_get",
+ "http_post");
+
+ private ExternalToolCallbacks() {
+ }
+
+ public static List merge(
+ List builtInCallbacks,
+ ObjectProvider providers,
+ boolean externalToolsEnabled) {
+ Map callbacksByName = new LinkedHashMap<>();
+ addCallbacks(callbacksByName, builtInCallbacks);
+ if (externalToolsEnabled && providers != null) {
+ providers.orderedStream()
+ .map(ToolCallbackProvider::getToolCallbacks)
+ .filter(callbacks -> callbacks != null && callbacks.length > 0)
+ .flatMap(Arrays::stream)
+ .forEach(callback -> addExternalCallback(callbacksByName, callback));
+ }
+ return List.copyOf(callbacksByName.values());
+ }
+
+ public static boolean isBuiltInTool(ToolCallback callback) {
+ return callback != null
+ && callback.getToolDefinition() != null
+ && BUILT_IN_TOOL_NAMES.contains(callback.getToolDefinition().name());
+ }
+
+ public static boolean isAdmin(Map metadata) {
+ return UserPriority.ADMIN == resolvePriority(metadata);
+ }
+
+ public static UserPriority resolvePriority(Map metadata) {
+ if (metadata == null) {
+ return null;
+ }
+ String raw = metadata.get(AICommand.USER_PRIORITY_FIELD);
+ if (raw == null) {
+ return null;
+ }
+ try {
+ return UserPriority.valueOf(raw);
+ } catch (IllegalArgumentException e) {
+ log.warn("Unknown userPriority in MCP tool access metadata: {}", raw);
+ return null;
+ }
+ }
+
+ public static boolean isAllowedFor(ToolCallback callback, Map metadata, McpToolAccessProperties accessProperties) {
+ if (isBuiltInTool(callback)) {
+ return true;
+ }
+ if (callback == null || callback.getToolDefinition() == null) {
+ return false;
+ }
+ McpToolAccessProperties properties = accessProperties != null ? accessProperties : new McpToolAccessProperties();
+ return properties.isAllowed(callback.getToolDefinition().name(), resolvePriority(metadata));
+ }
+
+ private static void addCallbacks(Map callbacksByName, List callbacks) {
+ if (callbacks == null || callbacks.isEmpty()) {
+ return;
+ }
+ callbacks.forEach(callback -> addCallback(callbacksByName, callback));
+ }
+
+ private static void addCallback(Map callbacksByName, ToolCallback callback) {
+ if (callback == null || callback.getToolDefinition() == null || callback.getToolDefinition().name() == null) {
+ return;
+ }
+ String name = callback.getToolDefinition().name();
+ ToolCallback previous = callbacksByName.putIfAbsent(name, callback);
+ if (previous != null && previous != callback) {
+ log.warn("Ignoring duplicate tool callback '{}'. Keeping the first registered callback.", name);
+ }
+ }
+
+ private static void addExternalCallback(Map callbacksByName, ToolCallback callback) {
+ if (callback == null || callback.getToolDefinition() == null || callback.getToolDefinition().name() == null) {
+ return;
+ }
+ String name = callback.getToolDefinition().name();
+ if (BUILT_IN_TOOL_NAMES.contains(name) && !callbacksByName.containsKey(name)) {
+ log.warn("Ignoring external tool callback '{}' because the name is reserved for OpenDaimon built-in tools.", name);
+ return;
+ }
+ addCallback(callbacksByName, callback);
+ }
+}
diff --git a/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/tool/SpringAIExternalToolCatalogService.java b/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/tool/SpringAIExternalToolCatalogService.java
new file mode 100644
index 00000000..d5582143
--- /dev/null
+++ b/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/tool/SpringAIExternalToolCatalogService.java
@@ -0,0 +1,194 @@
+package io.github.ngirchev.opendaimon.ai.springai.tool;
+
+import io.github.ngirchev.opendaimon.ai.springai.config.McpToolAccessProperties;
+import io.github.ngirchev.opendaimon.common.ai.command.AICommand;
+import io.github.ngirchev.opendaimon.common.ai.tool.ExternalToolAccessContext;
+import io.github.ngirchev.opendaimon.common.ai.tool.ExternalToolCatalogService;
+import io.github.ngirchev.opendaimon.common.ai.tool.ExternalToolDescriptor;
+import io.github.ngirchev.opendaimon.common.ai.tool.ExternalToolSourceType;
+import io.modelcontextprotocol.client.McpSyncClient;
+import org.springframework.ai.mcp.SyncMcpToolCallbackProvider;
+import org.springframework.ai.tool.ToolCallback;
+import org.springframework.ai.tool.ToolCallbackProvider;
+import org.springframework.ai.tool.definition.ToolDefinition;
+import org.springframework.ai.support.ToolCallbacks;
+import org.springframework.beans.factory.ObjectProvider;
+
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+public class SpringAIExternalToolCatalogService implements ExternalToolCatalogService {
+
+ private static final String SOURCE_WEBTOOLS = "webtools";
+ private static final String SOURCE_HTTP_API = "http-api";
+
+ private final ObjectProvider webToolsProvider;
+ private final ObjectProvider httpApiToolProvider;
+ private final ObjectProvider externalToolCallbackProviders;
+ private final ObjectProvider mcpSyncClients;
+ private final List configuredMcpConnectionNames;
+ private final boolean externalToolsEnabled;
+ private final McpToolAccessProperties mcpToolAccessProperties;
+
+ public SpringAIExternalToolCatalogService(
+ ObjectProvider webToolsProvider,
+ ObjectProvider httpApiToolProvider,
+ ObjectProvider externalToolCallbackProviders,
+ ObjectProvider mcpSyncClients,
+ List configuredMcpConnectionNames,
+ boolean externalToolsEnabled,
+ McpToolAccessProperties mcpToolAccessProperties) {
+ this.webToolsProvider = webToolsProvider;
+ this.httpApiToolProvider = httpApiToolProvider;
+ this.externalToolCallbackProviders = externalToolCallbackProviders;
+ this.mcpSyncClients = mcpSyncClients;
+ this.configuredMcpConnectionNames = configuredMcpConnectionNames != null
+ ? List.copyOf(configuredMcpConnectionNames) : List.of();
+ this.externalToolsEnabled = externalToolsEnabled;
+ this.mcpToolAccessProperties = mcpToolAccessProperties != null
+ ? mcpToolAccessProperties : new McpToolAccessProperties();
+ }
+
+ public SpringAIExternalToolCatalogService(
+ ObjectProvider externalToolCallbackProviders,
+ ObjectProvider mcpSyncClients,
+ List configuredMcpConnectionNames,
+ boolean externalToolsEnabled,
+ McpToolAccessProperties mcpToolAccessProperties) {
+ this(null, null, externalToolCallbackProviders, mcpSyncClients,
+ configuredMcpConnectionNames, externalToolsEnabled, mcpToolAccessProperties);
+ }
+
+ @Override
+ public List listAvailableTools(ExternalToolAccessContext context) {
+ if (context == null) {
+ return List.of();
+ }
+ Map toolsByName = new LinkedHashMap<>();
+ addBuiltInTools(toolsByName);
+ if (!externalToolsEnabled || externalToolCallbackProviders == null) {
+ return List.copyOf(toolsByName.values());
+ }
+ Map sourceByToolName = resolveSourceByToolName();
+ Map metadata = context.userPriority() != null
+ ? Map.of(AICommand.USER_PRIORITY_FIELD, context.userPriority().name())
+ : Map.of();
+ externalToolCallbackProviders.orderedStream()
+ .map(ToolCallbackProvider::getToolCallbacks)
+ .filter(callbacks -> callbacks != null && callbacks.length > 0)
+ .flatMap(Arrays::stream)
+ .filter(callback -> !ExternalToolCallbacks.isBuiltInTool(callback))
+ .filter(callback -> ExternalToolCallbacks.isAllowedFor(callback, metadata, mcpToolAccessProperties))
+ .forEach(callback -> addMcpDescriptor(toolsByName, callback, sourceByToolName));
+ return List.copyOf(toolsByName.values());
+ }
+
+ private void addBuiltInTools(Map toolsByName) {
+ if (webToolsProvider != null) {
+ webToolsProvider.ifAvailable(webTools -> Arrays.stream(ToolCallbacks.from(webTools))
+ .forEach(callback -> addBuiltInDescriptor(toolsByName, callback, SOURCE_WEBTOOLS)));
+ }
+ if (httpApiToolProvider != null) {
+ httpApiToolProvider.ifAvailable(httpApiTool -> Arrays.stream(ToolCallbacks.from(httpApiTool))
+ .forEach(callback -> addBuiltInDescriptor(toolsByName, callback, SOURCE_HTTP_API)));
+ }
+ }
+
+ private Map resolveSourceByToolName() {
+ if (mcpSyncClients == null) {
+ return Map.of();
+ }
+ Map sourceByToolName = new LinkedHashMap<>();
+ List clients = mcpSyncClients.orderedStream().toList();
+ for (int i = 0; i < clients.size(); i++) {
+ addClientTools(sourceByToolName, clients.get(i), configuredSourceName(i, clients.size()));
+ }
+ return sourceByToolName;
+ }
+
+ private String configuredSourceName(int clientIndex, int clientCount) {
+ if (configuredMcpConnectionNames.size() == clientCount) {
+ return configuredMcpConnectionNames.get(clientIndex);
+ }
+ return singleConfiguredSourceName();
+ }
+
+ private String singleConfiguredSourceName() {
+ return configuredMcpConnectionNames.size() == 1 ? configuredMcpConnectionNames.getFirst() : null;
+ }
+
+ private static void addClientTools(Map sourceByToolName, McpSyncClient client, String configuredSourceName) {
+ if (client == null) {
+ return;
+ }
+ String resolvedSourceName = configuredSourceName != null ? configuredSourceName : resolveSourceName(client);
+ if (resolvedSourceName == null || resolvedSourceName.isBlank()) {
+ return;
+ }
+ ToolCallback[] callbacks = new SyncMcpToolCallbackProvider(client).getToolCallbacks();
+ if (callbacks == null) {
+ return;
+ }
+ Arrays.stream(callbacks)
+ .map(ToolCallback::getToolDefinition)
+ .filter(definition -> definition != null && definition.name() != null && !definition.name().isBlank())
+ .forEach(definition -> sourceByToolName.putIfAbsent(definition.name(), resolvedSourceName));
+ }
+
+ static String resolveSourceName(McpSyncClient client) {
+ if (client.getServerInfo() != null && client.getServerInfo().name() != null
+ && !client.getServerInfo().name().isBlank()) {
+ return client.getServerInfo().name();
+ }
+ return client.getClientInfo() != null ? client.getClientInfo().name() : null;
+ }
+
+ private static void addBuiltInDescriptor(Map toolsByName, ToolCallback callback,
+ String sourceName) {
+ ToolDefinition definition = toolDefinition(callback);
+ if (definition == null) {
+ return;
+ }
+ toolsByName.putIfAbsent(definition.name(),
+ new ExternalToolDescriptor(
+ definition.name(),
+ definition.description(),
+ sourceName,
+ ExternalToolSourceType.BUILT_IN));
+ }
+
+ private void addMcpDescriptor(Map toolsByName, ToolCallback callback,
+ Map sourceByToolName) {
+ ToolDefinition definition = toolDefinition(callback);
+ if (definition == null) {
+ return;
+ }
+ toolsByName.putIfAbsent(definition.name(),
+ new ExternalToolDescriptor(
+ definition.name(),
+ definition.description(),
+ resolveSourceName(definition.name(), sourceByToolName),
+ ExternalToolSourceType.MCP));
+ }
+
+ private static ToolDefinition toolDefinition(ToolCallback callback) {
+ if (callback == null || callback.getToolDefinition() == null) {
+ return null;
+ }
+ ToolDefinition definition = callback.getToolDefinition();
+ if (definition.name() == null || definition.name().isBlank()) {
+ return null;
+ }
+ return definition;
+ }
+
+ private String resolveSourceName(String toolName, Map sourceByToolName) {
+ String sourceName = sourceByToolName.get(toolName);
+ if (sourceName != null && !sourceName.isBlank()) {
+ return sourceName;
+ }
+ return singleConfiguredSourceName();
+ }
+}
diff --git a/opendaimon-spring-ai/src/test/java/io/github/ngirchev/opendaimon/ai/springai/agent/AgentPromptBuilderTest.java b/opendaimon-spring-ai/src/test/java/io/github/ngirchev/opendaimon/ai/springai/agent/AgentPromptBuilderTest.java
index 32f45f9d..320717bb 100644
--- a/opendaimon-spring-ai/src/test/java/io/github/ngirchev/opendaimon/ai/springai/agent/AgentPromptBuilderTest.java
+++ b/opendaimon-spring-ai/src/test/java/io/github/ngirchev/opendaimon/ai/springai/agent/AgentPromptBuilderTest.java
@@ -1,9 +1,13 @@
package io.github.ngirchev.opendaimon.ai.springai.agent;
import io.github.ngirchev.opendaimon.common.ai.command.AICommand;
+import io.github.ngirchev.opendaimon.common.agent.AgentContext;
+import io.github.ngirchev.opendaimon.common.agent.AgentStepResult;
import org.junit.jupiter.api.Test;
+import java.time.Instant;
import java.util.Map;
+import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
@@ -36,4 +40,45 @@ void shouldAppendToolCallingInstructionAlways() {
assertThat(result).contains("you MUST provide all required parameters");
}
+
+ @Test
+ void shouldAppendLanguageInstructionToInitialUserMessage() {
+ AgentContext ctx = new AgentContext(
+ "сделай cat для файла",
+ "thread-1",
+ Map.of(AICommand.LANGUAGE_CODE_FIELD, "ru"),
+ 3,
+ Set.of());
+
+ String result = AgentPromptBuilder.buildUserMessage(ctx);
+
+ assertThat(result)
+ .contains("сделай cat для файла")
+ .contains("Answer language: Russian (ru)")
+ .contains("even if earlier conversation turns used another language");
+ }
+
+ @Test
+ void shouldAppendLanguageInstructionToFollowUpUserMessageWithStepHistory() {
+ AgentContext ctx = new AgentContext(
+ "сделай cat для файла",
+ "thread-1",
+ Map.of(AICommand.LANGUAGE_CODE_FIELD, "ru"),
+ 3,
+ Set.of());
+ ctx.recordStep(new AgentStepResult(
+ 0,
+ "Calling tool",
+ "list_directory",
+ "{\"path\":\"/app/mcp-filesystem\"}",
+ "[FILE] conversation_history.md",
+ Instant.now()));
+
+ String result = AgentPromptBuilder.buildUserMessage(ctx);
+
+ assertThat(result)
+ .contains("Original task: сделай cat для файла")
+ .contains("Previous steps:")
+ .contains("Answer language: Russian (ru)");
+ }
}
diff --git a/opendaimon-spring-ai/src/test/java/io/github/ngirchev/opendaimon/ai/springai/agent/SpringAgentLoopActionsFetchUrlGuardTest.java b/opendaimon-spring-ai/src/test/java/io/github/ngirchev/opendaimon/ai/springai/agent/SpringAgentLoopActionsFetchUrlGuardTest.java
index 856aca1d..6060c94f 100644
--- a/opendaimon-spring-ai/src/test/java/io/github/ngirchev/opendaimon/ai/springai/agent/SpringAgentLoopActionsFetchUrlGuardTest.java
+++ b/opendaimon-spring-ai/src/test/java/io/github/ngirchev/opendaimon/ai/springai/agent/SpringAgentLoopActionsFetchUrlGuardTest.java
@@ -1,5 +1,7 @@
package io.github.ngirchev.opendaimon.ai.springai.agent;
+import io.github.ngirchev.opendaimon.bulkhead.model.UserPriority;
+import io.github.ngirchev.opendaimon.common.ai.command.AICommand;
import io.github.ngirchev.opendaimon.common.agent.AgentContext;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.model.ChatModel;
@@ -80,11 +82,50 @@ void shouldNotPoisonUrlOrHostAfterSuccessfulFetch() {
assertThat(calls).hasValue(2);
}
- private static SpringAgentLoopActions actionsWith(ToolCallback callback) {
+ @Test
+ void shouldExposeExternalToolsOnlyForAdminContext() {
+ ToolCallback builtIn = fetchUrlCallback(arguments -> "ok");
+ ToolCallback external = toolCallback("read_file", arguments -> "secret");
+ ToolCallback sharedExternal = toolCallback("weather_lookup", arguments -> "sunny");
+ SpringAgentLoopActions actions = actionsWith(builtIn, external);
+
+ assertThat(actions.resolveEffectiveTools(context()))
+ .extracting(callback -> callback.getToolDefinition().name())
+ .containsExactly("fetch_url");
+ assertThat(actions.resolveEffectiveTools(adminContext()))
+ .extracting(callback -> callback.getToolDefinition().name())
+ .containsExactly("fetch_url", "read_file");
+
+ SpringAgentLoopActions sharedActions = actionsWith(builtIn, sharedExternal);
+ assertThat(sharedActions.resolveEffectiveTools(regularContext()))
+ .extracting(callback -> callback.getToolDefinition().name())
+ .containsExactly("fetch_url", "weather_lookup");
+ }
+
+ @Test
+ void shouldParseRawToolCallsOnlyFromEffectiveTools() {
+ ToolCallback restrictedFilesystem = toolCallback("read_file", arguments -> "secret");
+ SpringAgentLoopActions actions = actionsWith(restrictedFilesystem);
+ String rawCall = """
+
+ read_file
+ path/etc/passwd
+
+ """;
+
+ assertThat(new RawToolCallParser(actions.resolveEffectiveTools(regularContext()))
+ .tryParseRawToolCall(rawCall))
+ .isNull();
+ assertThat(new RawToolCallParser(actions.resolveEffectiveTools(adminContext()))
+ .tryParseRawToolCall(rawCall))
+ .isNotNull();
+ }
+
+ private static SpringAgentLoopActions actionsWith(ToolCallback... callbacks) {
return new SpringAgentLoopActions(
mock(ChatModel.class),
mock(ToolCallingManager.class),
- List.of(callback),
+ List.of(callbacks),
null,
Duration.ofSeconds(30));
}
@@ -93,10 +134,24 @@ private static AgentContext context() {
return new AgentContext("task", "conversation", Map.of(), 5, Set.of());
}
+ private static AgentContext adminContext() {
+ return new AgentContext("task", "conversation",
+ Map.of(AICommand.USER_PRIORITY_FIELD, UserPriority.ADMIN.name()), 5, Set.of());
+ }
+
+ private static AgentContext regularContext() {
+ return new AgentContext("task", "conversation",
+ Map.of(AICommand.USER_PRIORITY_FIELD, UserPriority.REGULAR.name()), 5, Set.of());
+ }
+
private static ToolCallback fetchUrlCallback(Function behavior) {
+ return toolCallback("fetch_url", behavior);
+ }
+
+ private static ToolCallback toolCallback(String name, Function behavior) {
ToolDefinition definition = ToolDefinition.builder()
- .name("fetch_url")
- .description("Fetch a URL")
+ .name(name)
+ .description(name + " description")
.inputSchema("{\"type\":\"object\"}")
.build();
return new ToolCallback() {
diff --git a/opendaimon-spring-ai/src/test/java/io/github/ngirchev/opendaimon/ai/springai/retry/SpringAIModelRegistryTest.java b/opendaimon-spring-ai/src/test/java/io/github/ngirchev/opendaimon/ai/springai/retry/SpringAIModelRegistryTest.java
index dc4e5ed6..a814d0e2 100644
--- a/opendaimon-spring-ai/src/test/java/io/github/ngirchev/opendaimon/ai/springai/retry/SpringAIModelRegistryTest.java
+++ b/opendaimon-spring-ai/src/test/java/io/github/ngirchev/opendaimon/ai/springai/retry/SpringAIModelRegistryTest.java
@@ -39,6 +39,18 @@ class SpringAIModelRegistryTest {
"architecture": {"modality": "text->text"},
"supported_parameters": ["tools"]
},
+ {
+ "id": "google/gemma-4-31b-it",
+ "pricing": {"prompt": "0.00000013", "completion": "0.00000038"},
+ "architecture": {"input_modalities": ["text", "image"]},
+ "supported_parameters": ["tools", "response_format"]
+ },
+ {
+ "id": "deepseek/deepseek-v4-flash",
+ "pricing": {"prompt": "0.00000014", "completion": "0.00000028"},
+ "architecture": {"modality": "text->text"},
+ "supported_parameters": ["tools", "response_format"]
+ },
{
"id": "google/gemma-3-27b-it:free",
"pricing": {"prompt": "0", "completion": "0"},
@@ -49,6 +61,10 @@ class SpringAIModelRegistryTest {
}
""";
+ private static final String EMBEDDING_MODELS_JSON = """
+ {"data": []}
+ """;
+
@Mock
private RestTemplate restTemplate;
@@ -63,6 +79,13 @@ void setUp() {
eq(String.class)))
.thenReturn(ResponseEntity.status(HttpStatus.OK).body(MODELS_JSON));
+ when(restTemplate.exchange(
+ contains("/v1/embeddings/models"),
+ eq(HttpMethod.GET),
+ any(HttpEntity.class),
+ eq(String.class)))
+ .thenReturn(ResponseEntity.status(HttpStatus.OK).body(EMBEDDING_MODELS_JSON));
+
OpenRouterModelsApiClient client = new OpenRouterModelsApiClient(restTemplate, new ObjectMapper());
OpenRouterModelsProperties props = new OpenRouterModelsProperties();
@@ -76,7 +99,8 @@ void setUp() {
// Whitelist entry 1: ADMIN + VIP see paid models by exact ID
OpenRouterModelsProperties.Whitelist adminVip = new OpenRouterModelsProperties.Whitelist();
adminVip.setRoles(List.of(UserPriority.ADMIN, UserPriority.VIP));
- adminVip.setIncludeModelIds(List.of("openai/gpt-5.4", "openai/gpt-5-nano"));
+ adminVip.setIncludeModelIds(List.of(
+ "openai/gpt-5.4", "openai/gpt-5-nano", "google/gemma-4-31b-it", "deepseek/deepseek-v4-flash"));
// Whitelist entry 2: ADMIN + REGULAR see free models by exact ID
OpenRouterModelsProperties.Whitelist adminRegular = new OpenRouterModelsProperties.Whitelist();
@@ -103,11 +127,11 @@ void paidModelIsVisibleToAdminAndVip() {
assertThat(registry.getAllModels(UserPriority.ADMIN))
.extracting(SpringAIModelConfig::getName)
- .contains("openai/gpt-5.4");
+ .contains("openai/gpt-5.4", "google/gemma-4-31b-it", "deepseek/deepseek-v4-flash");
assertThat(registry.getAllModels(UserPriority.VIP))
.extracting(SpringAIModelConfig::getName)
- .contains("openai/gpt-5.4");
+ .contains("openai/gpt-5.4", "google/gemma-4-31b-it", "deepseek/deepseek-v4-flash");
}
@Test
@@ -116,7 +140,7 @@ void paidModelIsNotVisibleToRegular() {
assertThat(registry.getAllModels(UserPriority.REGULAR))
.extracting(SpringAIModelConfig::getName)
- .doesNotContain("openai/gpt-5.4", "openai/gpt-5-nano");
+ .doesNotContain("openai/gpt-5.4", "openai/gpt-5-nano", "google/gemma-4-31b-it", "deepseek/deepseek-v4-flash");
}
@Test
diff --git a/opendaimon-spring-ai/src/test/java/io/github/ngirchev/opendaimon/ai/springai/service/SpringAIChatServiceTest.java b/opendaimon-spring-ai/src/test/java/io/github/ngirchev/opendaimon/ai/springai/service/SpringAIChatServiceTest.java
index 15eaf8fd..98f4adcf 100644
--- a/opendaimon-spring-ai/src/test/java/io/github/ngirchev/opendaimon/ai/springai/service/SpringAIChatServiceTest.java
+++ b/opendaimon-spring-ai/src/test/java/io/github/ngirchev/opendaimon/ai/springai/service/SpringAIChatServiceTest.java
@@ -65,6 +65,8 @@ void callChat_returnsSpringAIResponse() {
any(),
any(),
anyBoolean(),
+ anyBoolean(),
+ any(),
any(),
any())).thenReturn(requestSpec);
@@ -95,6 +97,8 @@ void callChatFromBody_returnsSpringAIResponse() {
any(),
any(),
anyBoolean(),
+ anyBoolean(),
+ any(),
any(),
isNull())).thenReturn(requestSpec);
@@ -124,6 +128,8 @@ void streamChat_returnsSpringAIStreamResponse() {
any(),
any(),
anyBoolean(),
+ anyBoolean(),
+ any(),
any(),
any())).thenReturn(requestSpec);
@@ -161,6 +167,8 @@ void callChatFromBody_modelFromOptionsMap_usesOptionsModel() {
any(),
any(),
anyBoolean(),
+ anyBoolean(),
+ any(),
any(),
isNull())).thenReturn(requestSpec);
Map options = Map.of("model", "options-model-name");
@@ -168,7 +176,7 @@ void callChatFromBody_modelFromOptionsMap_usesOptionsModel() {
AIResponse response = chatService.callChatFromBody(configWithNullName, body, null, false, List.of());
assertNotNull(response);
assertEquals("From options model", ((SpringAIResponse) response).chatResponse().getResult().getOutput().getText());
- verify(promptFactory).preparePrompt(eq(configWithNullName), eq("options-model-name"), eq(body), any(), anyBoolean(), any(), isNull());
+ verify(promptFactory).preparePrompt(eq(configWithNullName), eq("options-model-name"), eq(body), any(), anyBoolean(), anyBoolean(), any(), any(), isNull());
}
@Test
@@ -185,6 +193,8 @@ void callChat_webEnabledTrueWhenWebOnlyInOptionalCapabilities() {
any(),
any(),
anyBoolean(),
+ anyBoolean(),
+ any(),
any(),
any())).thenReturn(requestSpec);
@@ -202,6 +212,8 @@ void callChat_webEnabledTrueWhenWebOnlyInOptionalCapabilities() {
any(),
any(),
eq(true),
+ eq(false),
+ any(),
any(),
eq(options));
}
@@ -221,6 +233,8 @@ void streamChat_webEnabledTrueWhenWebOnlyInOptionalCapabilities() {
any(),
any(),
anyBoolean(),
+ anyBoolean(),
+ any(),
any(),
any())).thenReturn(requestSpec);
@@ -238,6 +252,8 @@ void streamChat_webEnabledTrueWhenWebOnlyInOptionalCapabilities() {
any(),
any(),
eq(true),
+ eq(false),
+ any(),
any(),
eq(options));
}
@@ -256,6 +272,8 @@ void callChat_webEnabledFalseWhenWebNotInRequiredOrOptional() {
any(),
any(),
anyBoolean(),
+ anyBoolean(),
+ any(),
any(),
any())).thenReturn(requestSpec);
@@ -273,6 +291,47 @@ void callChat_webEnabledFalseWhenWebNotInRequiredOrOptional() {
any(),
any(),
eq(false),
+ eq(false),
+ any(),
+ any(),
+ eq(options));
+ }
+
+ @Test
+ void callChat_externalToolsAllowedTrueWhenToolCallingInOptionalCapabilities() {
+ ChatResponse mockResponse = ChatResponse.builder()
+ .generations(List.of(new Generation(new AssistantMessage("ok"))))
+ .build();
+ org.springframework.ai.chat.client.ChatClient.ChatClientRequestSpec requestSpec =
+ mock(org.springframework.ai.chat.client.ChatClient.ChatClientRequestSpec.class, RETURNS_DEEP_STUBS);
+ when(requestSpec.call().chatResponse()).thenReturn(mockResponse);
+ when(promptFactory.preparePrompt(
+ eq(modelConfig),
+ any(),
+ any(),
+ any(),
+ anyBoolean(),
+ anyBoolean(),
+ any(),
+ any(),
+ any())).thenReturn(requestSpec);
+
+ OpenDaimonChatOptions options = new OpenDaimonChatOptions(0.7, 1000, "System", "User", false, Map.of());
+ ChatAICommand command = new ChatAICommand(
+ Set.of(ModelCapabilities.CHAT),
+ Set.of(ModelCapabilities.TOOL_CALLING),
+ 0.7, 1000, null, "System", "User", false, Map.of(), Map.of(), List.of());
+
+ chatService.callChat(modelConfig, command, options, List.of());
+
+ verify(promptFactory).preparePrompt(
+ eq(modelConfig),
+ any(),
+ any(),
+ any(),
+ eq(false),
+ eq(true),
+ any(),
any(),
eq(options));
}
@@ -281,7 +340,7 @@ void callChat_webEnabledFalseWhenWebNotInRequiredOrOptional() {
void callChat_webClientResponseException_thrown() {
org.springframework.ai.chat.client.ChatClient.ChatClientRequestSpec requestSpec =
mock(org.springframework.ai.chat.client.ChatClient.ChatClientRequestSpec.class, RETURNS_DEEP_STUBS);
- when(promptFactory.preparePrompt(eq(modelConfig), any(), any(), any(), anyBoolean(), any(), any())).thenReturn(requestSpec);
+ when(promptFactory.preparePrompt(eq(modelConfig), any(), any(), any(), anyBoolean(), anyBoolean(), any(), any(), any())).thenReturn(requestSpec);
WebClientResponseException error = WebClientResponseException.create(429, "Too Many Requests",
org.springframework.http.HttpHeaders.EMPTY, "rate limit".getBytes(java.nio.charset.StandardCharsets.UTF_8),
java.nio.charset.StandardCharsets.UTF_8);
diff --git a/opendaimon-spring-ai/src/test/java/io/github/ngirchev/opendaimon/ai/springai/service/SpringAIPromptFactoryTest.java b/opendaimon-spring-ai/src/test/java/io/github/ngirchev/opendaimon/ai/springai/service/SpringAIPromptFactoryTest.java
index e606298f..491e43d9 100644
--- a/opendaimon-spring-ai/src/test/java/io/github/ngirchev/opendaimon/ai/springai/service/SpringAIPromptFactoryTest.java
+++ b/opendaimon-spring-ai/src/test/java/io/github/ngirchev/opendaimon/ai/springai/service/SpringAIPromptFactoryTest.java
@@ -1,8 +1,10 @@
package io.github.ngirchev.opendaimon.ai.springai.service;
import io.github.ngirchev.opendaimon.ai.springai.config.SpringAIModelConfig;
+import io.github.ngirchev.opendaimon.bulkhead.model.UserPriority;
import io.github.ngirchev.opendaimon.ai.springai.tool.WebTools;
import io.github.ngirchev.opendaimon.common.ai.ModelCapabilities;
+import io.github.ngirchev.opendaimon.common.ai.command.AICommand;
import io.github.ngirchev.opendaimon.common.ai.command.OpenDaimonChatOptions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -19,13 +21,19 @@
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.prompt.ChatOptions;
import org.springframework.ai.chat.prompt.Prompt;
+import org.springframework.ai.model.tool.ToolCallingChatOptions;
import org.springframework.ai.ollama.OllamaChatModel;
import org.springframework.ai.ollama.api.OllamaChatOptions;
+import org.springframework.ai.tool.ToolCallback;
+import org.springframework.ai.tool.ToolCallbackProvider;
+import org.springframework.ai.tool.definition.ToolDefinition;
+import org.springframework.beans.factory.ObjectProvider;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
+import java.util.stream.Stream;
import static io.github.ngirchev.opendaimon.common.ai.LlmParamNames.*;
import static org.junit.jupiter.api.Assertions.*;
@@ -240,6 +248,162 @@ void preparePrompt_ollama_withoutChatOptions_usesFallbackMaxTokens() {
assertEquals(4000, ((OllamaChatOptions) options).getNumPredict());
}
+ @Test
+ void preparePrompt_withExternalToolsEnabled_addsExternalToolCallbacks() {
+ ToolCallback externalTool = toolCallback("mcp_search");
+ ToolCallbackProvider provider = mock(ToolCallbackProvider.class);
+ when(provider.getToolCallbacks()).thenReturn(new ToolCallback[]{externalTool});
+ @SuppressWarnings("unchecked")
+ ObjectProvider providers = mock(ObjectProvider.class);
+ when(providers.orderedStream()).thenReturn(Stream.of(provider));
+ SpringAIPromptFactory factory = new SpringAIPromptFactory(
+ chatClient,
+ chatClient,
+ webTools,
+ null,
+ springAIModelType,
+ providers,
+ true);
+
+ var spec = factory.preparePrompt(
+ ollamaModelConfig,
+ "ollama-model",
+ null,
+ null,
+ false,
+ true,
+ Map.of(AICommand.USER_PRIORITY_FIELD, UserPriority.ADMIN.name()),
+ List.of(new UserMessage("Use the external tool if needed")),
+ new OpenDaimonChatOptions(0.7, 1000, null, "Use the external tool if needed", false, Map.of())
+ );
+
+ spec.call().chatResponse();
+ ArgumentCaptor captor = ArgumentCaptor.forClass(Prompt.class);
+ verify(ollamaChatModel).call(captor.capture());
+
+ ChatOptions options = captor.getValue().getOptions();
+ assertInstanceOf(ToolCallingChatOptions.class, options);
+ ToolCallingChatOptions toolOptions = (ToolCallingChatOptions) options;
+ assertTrue(toolOptions.getToolCallbacks().stream()
+ .anyMatch(callback -> "mcp_search".equals(callback.getToolDefinition().name())));
+ }
+
+ @Test
+ void preparePrompt_withExternalToolsAllowedButNoPriority_doesNotAddExternalToolCallbacks() {
+ ToolCallback externalTool = toolCallback("mcp_search");
+ ToolCallbackProvider provider = mock(ToolCallbackProvider.class);
+ when(provider.getToolCallbacks()).thenReturn(new ToolCallback[]{externalTool});
+ @SuppressWarnings("unchecked")
+ ObjectProvider providers = mock(ObjectProvider.class);
+ when(providers.orderedStream()).thenReturn(Stream.of(provider));
+ SpringAIPromptFactory factory = new SpringAIPromptFactory(
+ chatClient,
+ chatClient,
+ webTools,
+ null,
+ springAIModelType,
+ providers,
+ true);
+
+ var spec = factory.preparePrompt(
+ ollamaModelConfig,
+ "ollama-model",
+ null,
+ null,
+ false,
+ true,
+ List.of(new UserMessage("Do not infer admin access")),
+ new OpenDaimonChatOptions(0.7, 1000, null, "Do not infer admin access", false, Map.of())
+ );
+
+ spec.call().chatResponse();
+ ArgumentCaptor captor = ArgumentCaptor.forClass(Prompt.class);
+ verify(ollamaChatModel).call(captor.capture());
+
+ if (captor.getValue().getOptions() instanceof ToolCallingChatOptions toolOptions) {
+ assertTrue(toolOptions.getToolCallbacks() == null || toolOptions.getToolCallbacks().isEmpty());
+ }
+ }
+
+ @Test
+ void preparePrompt_withExternalToolsNotAllowed_doesNotAddExternalToolCallbacks() {
+ ToolCallback externalTool = toolCallback("mcp_search");
+ ToolCallbackProvider provider = mock(ToolCallbackProvider.class);
+ when(provider.getToolCallbacks()).thenReturn(new ToolCallback[]{externalTool});
+ @SuppressWarnings("unchecked")
+ ObjectProvider providers = mock(ObjectProvider.class);
+ when(providers.orderedStream()).thenReturn(Stream.of(provider));
+ SpringAIPromptFactory factory = new SpringAIPromptFactory(
+ chatClient,
+ chatClient,
+ webTools,
+ null,
+ springAIModelType,
+ providers,
+ true);
+
+ var spec = factory.preparePrompt(
+ ollamaModelConfig,
+ "ollama-model",
+ null,
+ null,
+ false,
+ false,
+ List.of(new UserMessage("Do not expose the external tool")),
+ new OpenDaimonChatOptions(0.7, 1000, null, "Do not expose the external tool", false, Map.of())
+ );
+
+ spec.call().chatResponse();
+ ArgumentCaptor captor = ArgumentCaptor.forClass(Prompt.class);
+ verify(ollamaChatModel).call(captor.capture());
+
+ if (captor.getValue().getOptions() instanceof ToolCallingChatOptions toolOptions) {
+ assertTrue(toolOptions.getToolCallbacks() == null || toolOptions.getToolCallbacks().isEmpty());
+ }
+ }
+
+ @Test
+ void preparePrompt_regularUserGetsSharedMcpToolsButNotFilesystemTools() {
+ ToolCallback filesystemTool = toolCallback("read_file");
+ ToolCallback sharedTool = toolCallback("weather_lookup");
+ ToolCallbackProvider provider = mock(ToolCallbackProvider.class);
+ when(provider.getToolCallbacks()).thenReturn(new ToolCallback[]{filesystemTool, sharedTool});
+ @SuppressWarnings("unchecked")
+ ObjectProvider providers = mock(ObjectProvider.class);
+ when(providers.orderedStream()).thenReturn(Stream.of(provider));
+ SpringAIPromptFactory factory = new SpringAIPromptFactory(
+ chatClient,
+ chatClient,
+ webTools,
+ null,
+ springAIModelType,
+ providers,
+ true);
+
+ var spec = factory.preparePrompt(
+ ollamaModelConfig,
+ "ollama-model",
+ null,
+ null,
+ false,
+ true,
+ Map.of(AICommand.USER_PRIORITY_FIELD, UserPriority.REGULAR.name()),
+ List.of(new UserMessage("Use shared tools only")),
+ new OpenDaimonChatOptions(0.7, 1000, null, "Use shared tools only", false, Map.of())
+ );
+
+ spec.call().chatResponse();
+ ArgumentCaptor captor = ArgumentCaptor.forClass(Prompt.class);
+ verify(ollamaChatModel).call(captor.capture());
+
+ assertInstanceOf(ToolCallingChatOptions.class, captor.getValue().getOptions());
+ ToolCallingChatOptions toolOptions = (ToolCallingChatOptions) captor.getValue().getOptions();
+ assertTrue(toolOptions.getToolCallbacks().stream()
+ .anyMatch(callback -> "weather_lookup".equals(callback.getToolDefinition().name())));
+ assertFalse(toolOptions.getToolCallbacks().stream()
+ .anyMatch(callback -> "read_file".equals(callback.getToolDefinition().name())));
+ }
+
// --- null openAiChatClient (Ollama-only setup) ---
@Test
@@ -299,4 +463,23 @@ void preparePrompt_withNullOpenAiClient_throwsIllegalStateException_whenProvider
);
assertTrue(ex.getMessage().contains("spring.ai.openai.api-key"));
}
+
+ private static ToolCallback toolCallback(String name) {
+ ToolDefinition definition = ToolDefinition.builder()
+ .name(name)
+ .description(name + " description")
+ .inputSchema("{\"type\":\"object\"}")
+ .build();
+ return new ToolCallback() {
+ @Override
+ public ToolDefinition getToolDefinition() {
+ return definition;
+ }
+
+ @Override
+ public String call(String toolInput) {
+ return "ok";
+ }
+ };
+ }
}
diff --git a/opendaimon-spring-ai/src/test/java/io/github/ngirchev/opendaimon/ai/springai/tool/ExternalToolCallbacksTest.java b/opendaimon-spring-ai/src/test/java/io/github/ngirchev/opendaimon/ai/springai/tool/ExternalToolCallbacksTest.java
new file mode 100644
index 00000000..0b966c89
--- /dev/null
+++ b/opendaimon-spring-ai/src/test/java/io/github/ngirchev/opendaimon/ai/springai/tool/ExternalToolCallbacksTest.java
@@ -0,0 +1,103 @@
+package io.github.ngirchev.opendaimon.ai.springai.tool;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.ai.tool.ToolCallback;
+import org.springframework.ai.tool.ToolCallbackProvider;
+import org.springframework.ai.tool.definition.ToolDefinition;
+import org.springframework.beans.factory.ObjectProvider;
+
+import java.util.List;
+import java.util.stream.Stream;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class ExternalToolCallbacksTest {
+
+ @Test
+ void merge_addsExternalProviderCallbacksAfterBuiltIns() {
+ ToolCallback builtIn = toolCallback("fetch_url");
+ ToolCallback external = toolCallback("mcp_search");
+ List callbacks = ExternalToolCallbacks.merge(
+ List.of(builtIn),
+ providers(provider(external)),
+ true);
+
+ assertEquals(List.of("fetch_url", "mcp_search"), toolNames(callbacks));
+ }
+
+ @Test
+ void merge_ignoresExternalProviderCallbacksWhenDisabled() {
+ ToolCallback builtIn = toolCallback("fetch_url");
+ ToolCallback external = toolCallback("mcp_search");
+ List callbacks = ExternalToolCallbacks.merge(
+ List.of(builtIn),
+ providers(provider(external)),
+ false);
+
+ assertEquals(List.of("fetch_url"), toolNames(callbacks));
+ }
+
+ @Test
+ void merge_keepsFirstCallbackWhenNamesDuplicate() {
+ ToolCallback builtIn = toolCallback("fetch_url");
+ ToolCallback duplicate = toolCallback("fetch_url");
+ List callbacks = ExternalToolCallbacks.merge(
+ List.of(builtIn),
+ providers(provider(duplicate)),
+ true);
+
+ assertEquals(1, callbacks.size());
+ assertEquals(builtIn, callbacks.getFirst());
+ }
+
+ @Test
+ void merge_ignoresExternalCallbacksWithReservedBuiltInNames() {
+ ToolCallback reservedExternal = toolCallback("fetch_url");
+ List callbacks = ExternalToolCallbacks.merge(
+ List.of(),
+ providers(provider(reservedExternal)),
+ true);
+
+ assertEquals(List.of(), toolNames(callbacks));
+ }
+
+ private static List toolNames(List callbacks) {
+ return callbacks.stream()
+ .map(callback -> callback.getToolDefinition().name())
+ .toList();
+ }
+
+ private static ToolCallbackProvider provider(ToolCallback... callbacks) {
+ ToolCallbackProvider provider = mock(ToolCallbackProvider.class);
+ when(provider.getToolCallbacks()).thenReturn(callbacks);
+ return provider;
+ }
+
+ @SuppressWarnings("unchecked")
+ private static ObjectProvider providers(ToolCallbackProvider... providers) {
+ ObjectProvider objectProvider = mock(ObjectProvider.class);
+ when(objectProvider.orderedStream()).thenReturn(Stream.of(providers));
+ return objectProvider;
+ }
+
+ private static ToolCallback toolCallback(String name) {
+ ToolDefinition definition = ToolDefinition.builder()
+ .name(name)
+ .description(name + " description")
+ .inputSchema("{\"type\":\"object\"}")
+ .build();
+ return new ToolCallback() {
+ @Override
+ public ToolDefinition getToolDefinition() {
+ return definition;
+ }
+
+ @Override
+ public String call(String toolInput) {
+ return "ok";
+ }
+ };
+ }
+}
diff --git a/opendaimon-spring-ai/src/test/java/io/github/ngirchev/opendaimon/ai/springai/tool/SpringAIExternalToolCatalogServiceTest.java b/opendaimon-spring-ai/src/test/java/io/github/ngirchev/opendaimon/ai/springai/tool/SpringAIExternalToolCatalogServiceTest.java
new file mode 100644
index 00000000..35d0fbc6
--- /dev/null
+++ b/opendaimon-spring-ai/src/test/java/io/github/ngirchev/opendaimon/ai/springai/tool/SpringAIExternalToolCatalogServiceTest.java
@@ -0,0 +1,219 @@
+package io.github.ngirchev.opendaimon.ai.springai.tool;
+
+import io.github.ngirchev.opendaimon.ai.springai.config.McpToolAccessProperties;
+import io.github.ngirchev.opendaimon.bulkhead.model.UserPriority;
+import io.github.ngirchev.opendaimon.common.ai.tool.ExternalToolAccessContext;
+import io.github.ngirchev.opendaimon.common.ai.tool.ExternalToolSourceType;
+import io.modelcontextprotocol.client.McpSyncClient;
+import io.modelcontextprotocol.spec.McpSchema;
+import org.junit.jupiter.api.Test;
+import org.springframework.ai.tool.ToolCallback;
+import org.springframework.ai.tool.ToolCallbackProvider;
+import org.springframework.ai.tool.definition.ToolDefinition;
+import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.web.reactive.function.client.WebClient;
+
+import java.util.List;
+import java.util.function.Consumer;
+import java.util.stream.Stream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class SpringAIExternalToolCatalogServiceTest {
+
+ @Test
+ void shouldListAllowedExternalToolsOnly() {
+ ObjectProvider providers = providers(
+ toolCallback("web_search"),
+ toolCallback("open_daimon_read_file"),
+ toolCallback("weather_lookup"));
+ SpringAIExternalToolCatalogService service = new SpringAIExternalToolCatalogService(
+ providers, mcpClients(), List.of(), true, new McpToolAccessProperties());
+
+ var tools = service.listAvailableTools(new ExternalToolAccessContext(1L, UserPriority.REGULAR));
+
+ assertThat(tools)
+ .extracting("name")
+ .containsExactly("weather_lookup");
+ }
+
+ @Test
+ void shouldListAdminFilesystemTools() {
+ ObjectProvider providers = providers(toolCallback("open_daimon_read_file"));
+ SpringAIExternalToolCatalogService service = new SpringAIExternalToolCatalogService(
+ providers, mcpClients(), List.of(), true, new McpToolAccessProperties());
+
+ var tools = service.listAvailableTools(new ExternalToolAccessContext(1L, UserPriority.ADMIN));
+
+ assertThat(tools)
+ .extracting("name")
+ .containsExactly("open_daimon_read_file");
+ }
+
+ @Test
+ void shouldUseMcpServerIdentifierAsSourceName() {
+ McpSyncClient client = mock(McpSyncClient.class);
+ when(client.getServerInfo()).thenReturn(new McpSchema.Implementation("filesystem-server", "1.0.0"));
+ when(client.getClientInfo()).thenReturn(new McpSchema.Implementation("open-daimon", "1.0.0"));
+
+ String sourceName = SpringAIExternalToolCatalogService.resolveSourceName(client);
+
+ assertThat(sourceName).isEqualTo("filesystem-server");
+ }
+
+ @Test
+ void shouldFallbackToMcpClientIdentifierWhenServerIdentifierMissing() {
+ McpSyncClient client = mock(McpSyncClient.class);
+ when(client.getClientInfo()).thenReturn(new McpSchema.Implementation("filesystem", "1.0.0"));
+
+ String sourceName = SpringAIExternalToolCatalogService.resolveSourceName(client);
+
+ assertThat(sourceName).isEqualTo("filesystem");
+ }
+
+ @Test
+ void shouldUseSingleConfiguredConnectionNameAsSourceFallback() {
+ ObjectProvider providers = providers(toolCallback("read_file"));
+ SpringAIExternalToolCatalogService service = new SpringAIExternalToolCatalogService(
+ providers, mcpClients(), List.of("filesystem"), true, new McpToolAccessProperties());
+
+ var tools = service.listAvailableTools(new ExternalToolAccessContext(1L, UserPriority.ADMIN));
+
+ assertThat(tools)
+ .extracting("sourceName")
+ .containsExactly("filesystem");
+ }
+
+ @Test
+ void shouldUseConfiguredConnectionNamesByMcpClientOrder() {
+ ObjectProvider providers = providers(
+ toolCallback("read_file"),
+ toolCallback("weather_lookup"));
+ SpringAIExternalToolCatalogService service = new SpringAIExternalToolCatalogService(
+ providers,
+ mcpClients(
+ mcpClient("read_file"),
+ mcpClient("weather_lookup")),
+ List.of("filesystem", "weather"),
+ true,
+ new McpToolAccessProperties());
+
+ var tools = service.listAvailableTools(new ExternalToolAccessContext(1L, UserPriority.ADMIN));
+
+ assertThat(tools)
+ .extracting("name", "sourceName")
+ .containsExactly(
+ org.assertj.core.groups.Tuple.tuple("read_file", "filesystem"),
+ org.assertj.core.groups.Tuple.tuple("weather_lookup", "weather"));
+ }
+
+ @Test
+ void shouldReturnEmptyWhenExternalToolsDisabled() {
+ ObjectProvider providers = providers(toolCallback("weather_lookup"));
+ SpringAIExternalToolCatalogService service = new SpringAIExternalToolCatalogService(
+ providers, mcpClients(), List.of(), false, new McpToolAccessProperties());
+
+ var tools = service.listAvailableTools(new ExternalToolAccessContext(1L, UserPriority.ADMIN));
+
+ assertThat(tools).isEmpty();
+ }
+
+ @Test
+ void shouldListBuiltInAndMcpToolsWithSourceTypes() {
+ ObjectProvider providers = providers(toolCallback("read_file"));
+ SpringAIExternalToolCatalogService service = new SpringAIExternalToolCatalogService(
+ webToolsProvider(),
+ httpApiToolProvider(),
+ providers,
+ mcpClients(mcpClient("read_file")),
+ List.of("@modelcontextprotocol/server-filesystem@0.2.0"),
+ true,
+ new McpToolAccessProperties());
+
+ var tools = service.listAvailableTools(new ExternalToolAccessContext(1L, UserPriority.ADMIN));
+
+ assertThat(tools)
+ .extracting("name", "sourceName", "sourceType")
+ .contains(
+ org.assertj.core.groups.Tuple.tuple("web_search", "webtools", ExternalToolSourceType.BUILT_IN),
+ org.assertj.core.groups.Tuple.tuple("fetch_url", "webtools", ExternalToolSourceType.BUILT_IN),
+ org.assertj.core.groups.Tuple.tuple("http_get", "http-api", ExternalToolSourceType.BUILT_IN),
+ org.assertj.core.groups.Tuple.tuple("http_post", "http-api", ExternalToolSourceType.BUILT_IN),
+ org.assertj.core.groups.Tuple.tuple(
+ "read_file",
+ "@modelcontextprotocol/server-filesystem@0.2.0",
+ ExternalToolSourceType.MCP));
+ }
+
+ private static ObjectProvider providers(ToolCallback... callbacks) {
+ ToolCallbackProvider provider = mock(ToolCallbackProvider.class);
+ when(provider.getToolCallbacks()).thenReturn(callbacks);
+ ObjectProvider providers = mock(ObjectProvider.class);
+ when(providers.orderedStream()).thenReturn(Stream.of(provider));
+ return providers;
+ }
+
+ private static ObjectProvider mcpClients() {
+ return mcpClients(new McpSyncClient[0]);
+ }
+
+ private static ObjectProvider mcpClients(McpSyncClient... mcpClients) {
+ ObjectProvider clients = mock(ObjectProvider.class);
+ when(clients.orderedStream()).thenReturn(Stream.of(mcpClients));
+ return clients;
+ }
+
+ private static ObjectProvider webToolsProvider() {
+ ObjectProvider provider = mock(ObjectProvider.class);
+ doAnswer(invocation -> {
+ Consumer consumer = invocation.getArgument(0);
+ consumer.accept(new WebTools(mock(WebClient.class), "test-key", "https://serper.dev/search"));
+ return null;
+ }).when(provider).ifAvailable(any());
+ return provider;
+ }
+
+ private static ObjectProvider httpApiToolProvider() {
+ ObjectProvider provider = mock(ObjectProvider.class);
+ doAnswer(invocation -> {
+ Consumer consumer = invocation.getArgument(0);
+ consumer.accept(new HttpApiTool(mock(WebClient.class)));
+ return null;
+ }).when(provider).ifAvailable(any());
+ return provider;
+ }
+
+ private static McpSyncClient mcpClient(String toolName) {
+ McpSyncClient client = mock(McpSyncClient.class);
+ McpSchema.Tool tool = McpSchema.Tool.builder()
+ .name(toolName)
+ .description(toolName + " description")
+ .inputSchema(new McpSchema.JsonSchema("object", java.util.Map.of(), List.of(), false, null, null))
+ .build();
+ when(client.listTools()).thenReturn(new McpSchema.ListToolsResult(List.of(tool), null));
+ return client;
+ }
+
+ private static ToolCallback toolCallback(String name) {
+ ToolDefinition definition = ToolDefinition.builder()
+ .name(name)
+ .description(name + " description")
+ .inputSchema("{\"type\":\"object\"}")
+ .build();
+ return new ToolCallback() {
+ @Override
+ public ToolDefinition getToolDefinition() {
+ return definition;
+ }
+
+ @Override
+ public String call(String toolInput) {
+ return "ok";
+ }
+ };
+ }
+}
diff --git a/opendaimon-spring-boot-starter/pom.xml b/opendaimon-spring-boot-starter/pom.xml
index 6f7b247c..541e97cd 100644
--- a/opendaimon-spring-boot-starter/pom.xml
+++ b/opendaimon-spring-boot-starter/pom.xml
@@ -42,6 +42,11 @@
opendaimon-spring-ai${project.version}
+
+ io.github.ngirchev
+ opendaimon-mcp
+ ${project.version}
+
@@ -77,6 +82,7 @@
source analysis cannot see resource-only aggregation. -->
io.github.ngirchev:opendaimon-commonio.github.ngirchev:opendaimon-spring-ai
+ io.github.ngirchev:opendaimon-mcp
diff --git a/opendaimon-spring-boot-starter/src/main/resources/META-INF/opendaimon/opendaimon-defaults.yml b/opendaimon-spring-boot-starter/src/main/resources/META-INF/opendaimon/opendaimon-defaults.yml
index be6bcc09..10025f3e 100644
--- a/opendaimon-spring-boot-starter/src/main/resources/META-INF/opendaimon/opendaimon-defaults.yml
+++ b/opendaimon-spring-boot-starter/src/main/resources/META-INF/opendaimon/opendaimon-defaults.yml
@@ -1,5 +1,12 @@
spring:
ai:
+ mcp:
+ client:
+ enabled: ${MCP_CLIENT_ENABLED:false}
+ name: open-daimon
+ version: ${OPEN_DAIMON_VERSION:dev}
+ type: SYNC
+ request-timeout: 30s
ollama:
base-url: ${OLLAMA_BASE_URL:http://localhost:11434}
request-timeout: 600s
@@ -147,6 +154,17 @@ open-daimon:
User question:
%s
vision-extraction-prompt: "Extract ALL text content from this image exactly as written. Include all headings, paragraphs, tables, lists, captions, and any visible text. Preserve the original structure and formatting as much as possible. Output only the extracted text, no commentary."
+ mcp:
+ enabled: true
+ tool-access:
+ default-roles:
+ - ADMIN
+ - VIP
+ - REGULAR
+ rules:
+ - name-pattern: "^(?:[A-Za-z0-9_]+_)?(read_file|read_text_file|read_media_file|read_multiple_files|write_file|edit_file|create_directory|list_directory|list_directory_with_sizes|directory_tree|move_file|search_files|get_file_info|list_allowed_directories)$"
+ roles:
+ - ADMIN
agent:
enabled: true
max-iterations: 10
diff --git a/opendaimon-telegram/TELEGRAM_MODULE.md b/opendaimon-telegram/TELEGRAM_MODULE.md
index 4309462c..7d455acb 100644
--- a/opendaimon-telegram/TELEGRAM_MODULE.md
+++ b/opendaimon-telegram/TELEGRAM_MODULE.md
@@ -139,6 +139,7 @@ Handlers sorted by `priority()` (lower = first). First handler where `canHandle(
| `RoleTelegramCommandHandler` | `/role` | 0 |
| `LanguageTelegramCommandHandler` | `/language` | 0 |
| `ModelTelegramCommandHandler` | `/model` | 0 |
+| `ToolsTelegramCommandHandler` | `/tools` | 0 |
| `BugreportTelegramCommandHandler` | `/bugreport` | 0 |
| `HistoryTelegramCommandHandler` | `/history` | 0 |
| `ThreadsTelegramCommandHandler` | `/threads` | 0 |
@@ -147,6 +148,16 @@ Handlers sorted by `priority()` (lower = first). First handler where `canHandle(
Each handler is conditional on `open-daimon.telegram.commands.-enabled` (default: true).
+`/tools` lists all tools visible to the current invoker. It delegates to the
+common `ExternalToolCatalogService` SPI, so Telegram never applies tool access
+policy itself. Built-in tools are grouped by their built-in source names, while
+MCP tools are filtered through the same `open-daimon.mcp.tool-access` rules used
+by real tool execution and rendered with an `mcp:` source prefix. MCP tools are
+grouped by configured source display name when present (for example,
+`mcp: @modelcontextprotocol/server-filesystem@0.2.0 - read_file, list_directory`),
+otherwise by MCP client connection name, so the output identifies which MCP
+server provides each tool.
+
---
## User Priority Resolution (TelegramUserPriorityService)
@@ -194,7 +205,7 @@ See the canonical specification in **[## Agent Mode — REACT Loop Telegram UX](
2. A separate **answer message** that is created only when the final user answer is confirmed (`FINAL_ANSWER` or `MAX_ITERATIONS` fallback).
3. Streaming `PARTIAL_ANSWER` chunks are kept in a Java-side model buffer and rendered as status overlay while the iteration is still open.
-Implementation: `TelegramMessageHandlerActions` feeds provider-neutral stream events into `TelegramAgentStreamModel` and flushes snapshots through `TelegramAgentStreamView`. Flush cadence is configured via `open-daimon.telegram.agent-stream-view.*` and enforced per chat by `TelegramChatPacer`. Assistant response is persisted in DB; keyboard status is sent afterwards.
+Implementation: `TelegramMessageHandlerActions` feeds provider-neutral stream events into `TelegramAgentStreamModel` and flushes snapshots through `TelegramAgentStreamView`. The `AgentRequest` receives pipeline-enriched `AICommand.metadata()` so downstream agent tools see fields added by `AIRequestPipeline`, including `userPriority` used by MCP tool-access rules. Flush cadence is configured via `open-daimon.telegram.agent-stream-view.*` and enforced per chat by `TelegramChatPacer`. Assistant response is persisted in DB; keyboard status is sent afterwards.
---
@@ -381,14 +392,15 @@ Implementation: `TelegramMessageHandlerActions` feeds provider-neutral stream ev
**Trigger:** `/language`
**Handler:** `LanguageTelegramCommandHandler` — sends one inline-menu message with current language, ru/en choices, and a localized cancel/close button.
- This UI-only flow does not start the typing indicator.
+- New group chats start with no stored group language; command mapping falls back to the invoker's language until `/language` stores a chat-scoped value on the `TelegramGroup` row.
- `LANG_CANCEL` acknowledges the callback and deletes the menu message without changing language.
---
### UC-19: `/language` — select via callback
**Trigger:** `LANG_ru` or `LANG_en` callback
-**Handler:** `TelegramUserService.updateLanguageCode()` → `TelegramBotMenuService.setupBotMenuForUser()` — reloads bot command menu in new language for this user's chat.
-- Confirmation is callback-only (`telegram.language.updated`); the inline menu is deleted and no separate chat message is sent.
+**Handler:** `ChatSettingsService.updateLanguageCode()` → user or group owner (`TelegramUser` in private chats, `TelegramGroup` in groups) → `TelegramBotMenuService.setupBotMenuForUser()` — reloads bot command menu in the new chat language.
+- Confirmation is sent as both callback ack and a chat message (`telegram.language.updated`); the inline menu is deleted.
---
diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/TelegramCommand.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/TelegramCommand.java
index 75628fe0..c1114674 100644
--- a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/TelegramCommand.java
+++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/TelegramCommand.java
@@ -28,6 +28,7 @@ public class TelegramCommand implements IChatCommand {
public static final String MODEL = "/model";
public static final String MODE = "/mode";
public static final String THINKING = "/thinking";
+ public static final String TOOLS = "/tools";
public static final String MODEL_KEYBOARD_PREFIX = "🤖";
public static final String CONTEXT_KEYBOARD_PREFIX = "💬";
diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/ToolsTelegramCommandHandler.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/ToolsTelegramCommandHandler.java
new file mode 100644
index 00000000..c6ccc93a
--- /dev/null
+++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/ToolsTelegramCommandHandler.java
@@ -0,0 +1,118 @@
+package io.github.ngirchev.opendaimon.telegram.command.handler.impl;
+
+import io.github.ngirchev.opendaimon.bulkhead.model.UserPriority;
+import io.github.ngirchev.opendaimon.bulkhead.service.IUserPriorityService;
+import io.github.ngirchev.opendaimon.common.ai.tool.ExternalToolAccessContext;
+import io.github.ngirchev.opendaimon.common.ai.tool.ExternalToolCatalogService;
+import io.github.ngirchev.opendaimon.common.ai.tool.ExternalToolSourceDescriptor;
+import io.github.ngirchev.opendaimon.common.ai.tool.ExternalToolSourceType;
+import io.github.ngirchev.opendaimon.common.command.ICommand;
+import io.github.ngirchev.opendaimon.common.service.MessageLocalizationService;
+import io.github.ngirchev.opendaimon.telegram.TelegramBot;
+import io.github.ngirchev.opendaimon.telegram.command.TelegramCommand;
+import io.github.ngirchev.opendaimon.telegram.command.TelegramCommandType;
+import io.github.ngirchev.opendaimon.telegram.command.handler.AbstractTelegramCommandHandlerWithResponseSend;
+import io.github.ngirchev.opendaimon.telegram.service.TelegramHtmlEscaper;
+import io.github.ngirchev.opendaimon.telegram.service.TypingIndicatorService;
+import org.springframework.beans.factory.ObjectProvider;
+
+import java.util.Comparator;
+import java.util.List;
+import java.util.stream.Collectors;
+
+public class ToolsTelegramCommandHandler extends AbstractTelegramCommandHandlerWithResponseSend {
+
+ private static final int MAX_RESPONSE_CHARS = 3900;
+ private static final int MAX_TOOL_LINE_CHARS = 1000;
+
+ private final ObjectProvider externalToolCatalogServiceProvider;
+ private final IUserPriorityService userPriorityService;
+
+ public ToolsTelegramCommandHandler(
+ ObjectProvider telegramBotProvider,
+ TypingIndicatorService typingIndicatorService,
+ MessageLocalizationService messageLocalizationService,
+ ObjectProvider externalToolCatalogServiceProvider,
+ IUserPriorityService userPriorityService) {
+ super(telegramBotProvider, typingIndicatorService, messageLocalizationService);
+ this.externalToolCatalogServiceProvider = externalToolCatalogServiceProvider;
+ this.userPriorityService = userPriorityService;
+ }
+
+ @Override
+ protected boolean shouldShowTypingIndicator(TelegramCommand command) {
+ return false;
+ }
+
+ @Override
+ public boolean canHandle(ICommand command) {
+ var commandType = command.commandType();
+ return command instanceof TelegramCommand
+ && commandType != null
+ && TelegramCommand.TOOLS.equals(commandType.command());
+ }
+
+ @Override
+ public String handleInner(TelegramCommand command) {
+ ExternalToolCatalogService catalog = externalToolCatalogServiceProvider.getIfAvailable();
+ if (catalog == null) {
+ return messageLocalizationService.getMessage("telegram.tools.unavailable", command.languageCode());
+ }
+
+ UserPriority priority = userPriorityService.getUserPriority(command.userId());
+ List sources = catalog.listAvailableToolSources(
+ new ExternalToolAccessContext(command.userId(), priority));
+ if (sources.isEmpty()) {
+ return messageLocalizationService.getMessage("telegram.tools.empty", command.languageCode(), priority);
+ }
+
+ StringBuilder response = new StringBuilder(messageLocalizationService.getMessage(
+ "telegram.tools.header", command.languageCode(), priority));
+ String line = "\n" + renderToolLine(sources);
+ if (response.length() + line.length() > MAX_RESPONSE_CHARS) {
+ response.append("\n...");
+ } else {
+ response.append(line);
+ }
+ return response.toString();
+ }
+
+ private static String renderToolLine(List sources) {
+ String commands = sources.stream()
+ .sorted(Comparator.comparing(ToolsTelegramCommandHandler::sourceSortKey))
+ .map(ToolsTelegramCommandHandler::renderSourceTools)
+ .collect(Collectors.joining("\n"));
+ return TelegramHtmlEscaper.escape(truncateToolLine(commands));
+ }
+
+ private static String sourceSortKey(ExternalToolSourceDescriptor source) {
+ return source.sourceType().name() + ":" + source.name();
+ }
+
+ private static String renderSourceTools(ExternalToolSourceDescriptor source) {
+ String commands = source.tools().stream()
+ .map(tool -> tool.name())
+ .sorted()
+ .collect(Collectors.joining(", "));
+ return "• " + sourceLabel(source) + " - " + commands;
+ }
+
+ private static String sourceLabel(ExternalToolSourceDescriptor source) {
+ if (ExternalToolSourceType.MCP.equals(source.sourceType())) {
+ return "mcp: " + source.name();
+ }
+ return source.name();
+ }
+
+ private static String truncateToolLine(String commands) {
+ if (commands.length() <= MAX_TOOL_LINE_CHARS) {
+ return commands;
+ }
+ return commands.substring(0, MAX_TOOL_LINE_CHARS - 3) + "...";
+ }
+
+ @Override
+ public String getSupportedCommandText(String languageCode) {
+ return messageLocalizationService.getMessage("telegram.command.tools.desc", languageCode);
+ }
+}
diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/config/TelegramCommandHandlerConfig.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/config/TelegramCommandHandlerConfig.java
index 88a39310..74f8fe1e 100644
--- a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/config/TelegramCommandHandlerConfig.java
+++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/config/TelegramCommandHandlerConfig.java
@@ -13,6 +13,7 @@
import io.github.ngirchev.opendaimon.bulkhead.service.IUserPriorityService;
import io.github.ngirchev.opendaimon.common.agent.AgentExecutor;
import io.github.ngirchev.opendaimon.common.ai.pipeline.AIRequestPipeline;
+import io.github.ngirchev.opendaimon.common.ai.tool.ExternalToolCatalogService;
import io.github.ngirchev.opendaimon.common.config.CoreCommonProperties;
import io.github.ngirchev.opendaimon.common.repository.OpenDaimonMessageRepository;
import io.github.ngirchev.opendaimon.common.service.*;
@@ -142,6 +143,23 @@ public ThinkingTelegramCommandHandler thinkingTelegramCommandHandler(
chatSettingsService);
}
+ @Bean
+ @ConditionalOnMissingBean
+ @ConditionalOnProperty(prefix = FeatureToggle.TelegramCommand.PREFIX, name = FeatureToggle.TelegramCommand.TOOLS, havingValue = "true", matchIfMissing = true)
+ public ToolsTelegramCommandHandler toolsTelegramCommandHandler(
+ ObjectProvider telegramBotProvider,
+ TypingIndicatorService typingIndicatorService,
+ MessageLocalizationService messageLocalizationService,
+ ObjectProvider externalToolCatalogServiceProvider,
+ IUserPriorityService userPriorityService) {
+ return new ToolsTelegramCommandHandler(
+ telegramBotProvider,
+ typingIndicatorService,
+ messageLocalizationService,
+ externalToolCatalogServiceProvider,
+ userPriorityService);
+ }
+
@Bean
@ConditionalOnMissingBean
@ConditionalOnProperty(prefix = FeatureToggle.TelegramCommand.PREFIX, name = FeatureToggle.TelegramCommand.NEW_THREAD, havingValue = "true", matchIfMissing = true)
diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramAgentStreamModel.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramAgentStreamModel.java
index 52eaadfa..273d1e68 100644
--- a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramAgentStreamModel.java
+++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramAgentStreamModel.java
@@ -286,7 +286,7 @@ private String candidateTailOverlay() {
}
private String renderToolCallBlock(String toolName, String args) {
- String label = ToolLabels.label(toolName);
+ String label = TelegramHtmlEscaper.escape(ToolLabels.label(toolName));
String escapedArgs = args == null || args.isBlank()
? ""
: TelegramHtmlEscaper.escape(ToolLabels.truncateArg(args));
diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramGroupService.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramGroupService.java
index 28ffee16..93b55fe9 100644
--- a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramGroupService.java
+++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/TelegramGroupService.java
@@ -4,7 +4,6 @@
import lombok.extern.slf4j.Slf4j;
import org.springframework.transaction.annotation.Transactional;
import org.telegram.telegrambots.meta.api.objects.Chat;
-import io.github.ngirchev.opendaimon.common.SupportedLanguages;
import io.github.ngirchev.opendaimon.common.model.AssistantRole;
import io.github.ngirchev.opendaimon.common.model.ThinkingMode;
import io.github.ngirchev.opendaimon.common.service.AssistantRoleService;
@@ -137,7 +136,7 @@ private TelegramGroup createGroupInner(Chat chat) {
group.setIsBlocked(false);
group.setIsAdmin(false);
group.setIsPremium(false);
- group.setLanguageCode(SupportedLanguages.DEFAULT_LANGUAGE);
+ group.setLanguageCode(null);
group.setAgentModeEnabled(defaultAgentModeEnabled);
TelegramGroup saved = telegramGroupRepository.save(group);
log.info("Telegram group created: id={}, chatId={}, title='{}', type={}",
diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/ToolLabels.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/ToolLabels.java
index 0aa22b0f..e86a6692 100644
--- a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/ToolLabels.java
+++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/ToolLabels.java
@@ -3,12 +3,11 @@
import java.util.Map;
/**
- * Per-tool friendly label mapping for the status transcript.
+ * Per-tool label mapping for the status transcript.
*
- *
Given the raw agent tool name (e.g. {@code web_search}), returns a user-facing
- * English label (e.g. {@code Searching the web}) that is rendered into the
- * {@code 🔧 Tool: