From 33fb1d65d2b60f029da2ca66acf67484d3fa880d Mon Sep 17 00:00:00 2001 From: ngirchev Date: Tue, 5 May 2026 12:08:15 +0000 Subject: [PATCH 01/10] Add external MCP tool support --- Dockerfile | 6 + TODO.md | 2 +- docker-compose.yml | 4 + mcp-filesystem/.gitkeep | 0 opendaimon-app/pom.xml | 6 + .../src/main/resources/application.yml | 36 +++++ .../common/config/FeatureToggle.java | 2 + opendaimon-mcp/MCP_MODULE.md | 124 +++++++++++++++ opendaimon-mcp/pom.xml | 134 ++++++++++++++++ .../opendaimon/mcp/config/McpAutoConfig.java | 24 +++ ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../opendaimon/mcp/FilesystemMcpSmokeIT.java | 67 ++++++++ opendaimon-spring-ai/SPRING_AI_MODULE.md | 32 ++++ .../agent/SpringAgentLoopActions.java | 17 +++ .../ai/springai/config/AgentAutoConfig.java | 30 ++-- .../config/McpToolAccessProperties.java | 59 +++++++ .../springai/config/SpringAIAutoConfig.java | 18 ++- .../springai/service/SpringAIChatService.java | 9 +- .../service/SpringAIPromptFactory.java | 125 ++++++++++++++- .../springai/tool/ExternalToolCallbacks.java | 99 ++++++++++++ ...ringAgentLoopActionsFetchUrlGuardTest.java | 44 +++++- .../service/SpringAIChatServiceTest.java | 14 +- .../service/SpringAIPromptFactoryTest.java | 144 ++++++++++++++++++ .../tool/ExternalToolCallbacksTest.java | 92 +++++++++++ opendaimon-spring-boot-starter/pom.xml | 6 + .../opendaimon/opendaimon-defaults.yml | 26 ++++ pom.xml | 1 + 27 files changed, 1093 insertions(+), 29 deletions(-) create mode 100644 mcp-filesystem/.gitkeep create mode 100644 opendaimon-mcp/MCP_MODULE.md create mode 100644 opendaimon-mcp/pom.xml create mode 100644 opendaimon-mcp/src/main/java/io/github/ngirchev/opendaimon/mcp/config/McpAutoConfig.java create mode 100644 opendaimon-mcp/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 opendaimon-mcp/src/test/java/io/github/ngirchev/opendaimon/mcp/FilesystemMcpSmokeIT.java create mode 100644 opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/config/McpToolAccessProperties.java create mode 100644 opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/tool/ExternalToolCallbacks.java create mode 100644 opendaimon-spring-ai/src/test/java/io/github/ngirchev/opendaimon/ai/springai/tool/ExternalToolCallbacksTest.java diff --git a/Dockerfile b/Dockerfile index 176b98aa..3d0f6161 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,6 +8,7 @@ ARG APP_VERSION=1.1.0-SNAPSHOT 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,15 @@ RUN mvn -Drevision=${APP_VERSION} clean package -DskipTests -B FROM eclipse-temurin:21-jre-alpine WORKDIR /app +RUN apk add --no-cache nodejs npm + ARG APP_VERSION=1.1.0-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/TODO.md b/TODO.md index 51b72d7c..c75e8405 100644 --- a/TODO.md +++ b/TODO.md @@ -29,7 +29,7 @@ - [ ] Telegram RAG Module - [ ] Ability to read telegram chat history if needed - [ ] OpenCode Module (Claude) -- [ ] MCP Module +- [x] MCP Module — `opendaimon-mcp` adds Spring AI MCP client runtime support; external `ToolCallbackProvider` tools are merged into agent and normal Spring AI tool-calling flows with role-based `open-daimon.mcp.tool-access` filtering and built-in-first deduplication. Docker runtime includes opt-in filesystem MCP sandbox at `/app/mcp-filesystem`, restricted to ADMIN by default. - [ ] Voice recognition - [ ] Agent publication news module - [ ] UI Dashboard with full administration functionality diff --git a/docker-compose.yml b/docker-compose.yml index 6cd898df..56f56fa5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -42,12 +42,16 @@ 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). Disabled by default. + - MCP_CLIENT_ENABLED=${MCP_CLIENT_ENABLED:-false} + - 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/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 e06dadb5..f5973713 100644 --- a/opendaimon-app/pom.xml +++ b/opendaimon-app/pom.xml @@ -57,6 +57,12 @@ ${project.version} runtime + + io.github.ngirchev + opendaimon-mcp + ${project.version} + runtime + io.github.ngirchev opendaimon-gateway-mock diff --git a/opendaimon-app/src/main/resources/application.yml b/opendaimon-app/src/main/resources/application.yml index b8fc93c2..562b3d54 100644 --- a/opendaimon-app/src/main/resources/application.yml +++ b/opendaimon-app/src/main/resources/application.yml @@ -149,6 +149,15 @@ open-daimon: emails: ${REST_ACCESS_REGULAR_EMAILS:} # e.g. "user@example.com" ui: enabled: true + mcp: + enabled: true + 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: "^(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 @@ -361,6 +370,33 @@ 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: npx + args: + - -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/config/FeatureToggle.java b/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/config/FeatureToggle.java index bfe0ef1d..88416f79 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"; } @@ -107,6 +108,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), diff --git a/opendaimon-mcp/MCP_MODULE.md b/opendaimon-mcp/MCP_MODULE.md new file mode 100644 index 00000000..fab1546f --- /dev/null +++ b/opendaimon-mcp/MCP_MODULE.md @@ -0,0 +1,124 @@ +# 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 when available. +- External MCP tools are exposed according to `open-daimon.mcp.tool-access` rules. + 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. + +## 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: "^(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. The bundled +runtime and starter defaults keep client creation disabled until an application +opts in: + +```yaml +spring: + ai: + mcp: + client: + enabled: true + 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 Docker setup includes an opt-in filesystem MCP connection: + +```yaml +spring: + ai: + mcp: + client: + enabled: true + stdio: + connections: + filesystem: + command: npx + args: + - -y + - "@modelcontextprotocol/server-filesystem" + - ${MCP_FILESYSTEM_ROOT:/app/mcp-filesystem} +``` + +Enable it in Docker with `MCP_CLIENT_ENABLED=true`. The runtime image includes +Node.js/npm so `npx` can start the server. 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. This is enforced in both agent and normal Spring +AI prompt flows. + +The smoke test `FilesystemMcpSmokeIT` 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. 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..198b774d --- /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.autoconfigure.mcp.client.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/FilesystemMcpSmokeIT.java b/opendaimon-mcp/src/test/java/io/github/ngirchev/opendaimon/mcp/FilesystemMcpSmokeIT.java new file mode 100644 index 00000000..6c6f78b9 --- /dev/null +++ b/opendaimon-mcp/src/test/java/io/github/ngirchev/opendaimon/mcp/FilesystemMcpSmokeIT.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 FilesystemMcpSmokeIT { + + @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-spring-ai/SPRING_AI_MODULE.md b/opendaimon-spring-ai/SPRING_AI_MODULE.md index 45d88c75..626da4fd 100644 --- a/opendaimon-spring-ai/SPRING_AI_MODULE.md +++ b/opendaimon-spring-ai/SPRING_AI_MODULE.md @@ -416,6 +416,38 @@ 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. External MCP tools are +then filtered by `open-daimon.mcp.tool-access` rules before the model sees them. + +`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` +false until an application explicitly configures MCP connections. + +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. + ### Tool failure detection Spring AI's `@Tool` contract is **string-typed**: tool methods return a plain `String` 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..29f53ce6 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; @@ -94,6 +96,7 @@ public class SpringAgentLoopActions implements AgentLoopActions { 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 +133,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,6 +152,7 @@ public SpringAgentLoopActions(ChatModel chatModel, this.streamTimeout = Objects.requireNonNull(streamTimeout, "streamTimeout must not be null"); this.urlLivenessChecker = urlLivenessChecker; this.priorityRequestExecutor = priorityRequestExecutor; + this.mcpToolAccessProperties = mcpToolAccessProperties != null ? mcpToolAccessProperties : new McpToolAccessProperties(); this.rawToolCallParser = new RawToolCallParser(this.toolCallbacks); this.summaryModelInvoker = new SummaryModelInvoker(chatModel, priorityRequestExecutor); } @@ -583,6 +599,7 @@ List resolveEffectiveTools(AgentContext ctx) { } } return resolved.stream() + .filter(callback -> ExternalToolCallbacks.isAllowedFor(callback, ctx.getMetadata(), mcpToolAccessProperties)) .map(callback -> guardFetchUrlCallback(ctx, callback)) .toList(); } 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..819223f9 --- /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("^(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..d6b777de 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 @@ -60,6 +60,7 @@ import io.github.ngirchev.opendaimon.ai.springai.tool.WebTools; 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; @@ -83,7 +84,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 +159,22 @@ 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 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..ddb79a80 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,14 @@ 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); + Map metadata = command != null && command.metadata() != null ? command.metadata() : Map.of(); var promptBuilder = promptFactory.preparePrompt( modelConfig, modelForStream, chatOptions != null ? chatOptions.body() : null, conversationId, webEnabled, + metadata, messages, chatOptions ); @@ -131,8 +133,9 @@ public AIResponse callChat( ) { Object conversationId = command != null ? command.metadata().get(AICommand.THREAD_KEY_FIELD) : null; boolean webEnabled = webToolsEnabled(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, metadata, messages, chatOptions); } private AIResponse callChatOnce( @@ -140,6 +143,7 @@ private AIResponse callChatOnce( Map body, Object conversationId, boolean webEnabled, + Map metadata, List messages, OpenDaimonChatOptions chatOptions ) { @@ -150,6 +154,7 @@ private AIResponse callChatOnce( body, conversationId, webEnabled, + metadata != null ? metadata : Map.of(), messages, chatOptions ); @@ -224,7 +229,7 @@ public AIResponse callChatFromBody( boolean webEnabled, List messages ) { - return callChatOnce(modelConfig, requestBody, conversationId, webEnabled, messages, null); + return callChatOnce(modelConfig, requestBody, conversationId, webEnabled, Map.of(), messages, null); } private Flux trackStreamIfPossible(String modelId, Flux flux) { 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..770235ac 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,20 @@ 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.bulkhead.model.UserPriority; +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 +54,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 +69,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 +115,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 +127,32 @@ 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( @@ -94,6 +163,35 @@ public ChatClient.ChatClientRequestSpec preparePrompt( boolean webEnabled, List messages, OpenDaimonChatOptions chatOptions + ) { + return preparePrompt(modelConfig, modelName, body, conversationId, webEnabled, Map.of(), messages, chatOptions); + } + + public ChatClient.ChatClientRequestSpec preparePrompt( + SpringAIModelConfig modelConfig, + String modelName, + Map body, + Object conversationId, + boolean webEnabled, + boolean externalToolsAllowed, + List messages, + OpenDaimonChatOptions chatOptions + ) { + Map metadata = externalToolsAllowed + ? Map.of(AICommand.USER_PRIORITY_FIELD, UserPriority.ADMIN.name()) + : Map.of(); + return preparePrompt(modelConfig, modelName, body, conversationId, webEnabled, metadata, messages, chatOptions); + } + + public ChatClient.ChatClientRequestSpec preparePrompt( + SpringAIModelConfig modelConfig, + String modelName, + Map body, + Object conversationId, + boolean webEnabled, + Map metadata, + List messages, + OpenDaimonChatOptions chatOptions ) { String resolvedModelName = modelConfig != null ? modelConfig.getName() : modelName; ChatClient chatClient = getChatClient(modelConfig, resolvedModelName); @@ -107,7 +205,7 @@ public ChatClient.ChatClientRequestSpec preparePrompt( .advisors(new MessageOrderingAdvisor()); } addSystemMessagesIfPresent(promptBuilder, messages); - addWebToolsIfEnabled(promptBuilder, webEnabled); + addToolsIfEnabled(promptBuilder, webEnabled, metadata); addUserOrAllMessages(promptBuilder, messages); return promptBuilder; @@ -127,11 +225,22 @@ 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, Map metadata) { + List builtInCallbacks = webEnabled + ? Arrays.asList(ToolCallbacks.from(webTools)) + : List.of(); + List callbacks = ExternalToolCallbacks.merge( + builtInCallbacks, + externalToolCallbackProviders, + externalToolsEnabled).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={}).", + callbacks.size(), webEnabled, externalToolsEnabled); + } 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..eba13efa --- /dev/null +++ b/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/tool/ExternalToolCallbacks.java @@ -0,0 +1,99 @@ +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 -> addCallback(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); + } + } +} 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..49198d7d 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,31 @@ 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"); + } + + 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 +115,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/service/SpringAIChatServiceTest.java b/opendaimon-spring-ai/src/test/java/io/github/ngirchev/opendaimon/ai/springai/service/SpringAIChatServiceTest.java index 15eaf8fd..ce8a6faa 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 @@ -66,6 +66,7 @@ void callChat_returnsSpringAIResponse() { any(), anyBoolean(), any(), + any(), any())).thenReturn(requestSpec); OpenDaimonChatOptions options = new OpenDaimonChatOptions(0.7, 1000, "System", "User", false, Map.of()); @@ -96,6 +97,7 @@ void callChatFromBody_returnsSpringAIResponse() { any(), anyBoolean(), any(), + any(), isNull())).thenReturn(requestSpec); List messages = List.of(); @@ -125,6 +127,7 @@ void streamChat_returnsSpringAIStreamResponse() { any(), anyBoolean(), any(), + any(), any())).thenReturn(requestSpec); OpenDaimonChatOptions options = new OpenDaimonChatOptions(0.7, 1000, null, "Hi", true, Map.of()); @@ -162,13 +165,14 @@ void callChatFromBody_modelFromOptionsMap_usesOptionsModel() { any(), anyBoolean(), any(), + any(), isNull())).thenReturn(requestSpec); Map options = Map.of("model", "options-model-name"); Map body = Map.of("options", options, "messages", List.>of()); 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(), any(), any(), isNull()); } @Test @@ -186,6 +190,7 @@ void callChat_webEnabledTrueWhenWebOnlyInOptionalCapabilities() { any(), anyBoolean(), any(), + any(), any())).thenReturn(requestSpec); OpenDaimonChatOptions options = new OpenDaimonChatOptions(0.7, 1000, "System", "User", false, Map.of()); @@ -203,6 +208,7 @@ void callChat_webEnabledTrueWhenWebOnlyInOptionalCapabilities() { any(), eq(true), any(), + any(), eq(options)); } @@ -222,6 +228,7 @@ void streamChat_webEnabledTrueWhenWebOnlyInOptionalCapabilities() { any(), anyBoolean(), any(), + any(), any())).thenReturn(requestSpec); OpenDaimonChatOptions options = new OpenDaimonChatOptions(0.7, 1000, null, "Hi", true, Map.of()); @@ -239,6 +246,7 @@ void streamChat_webEnabledTrueWhenWebOnlyInOptionalCapabilities() { any(), eq(true), any(), + any(), eq(options)); } @@ -257,6 +265,7 @@ void callChat_webEnabledFalseWhenWebNotInRequiredOrOptional() { any(), anyBoolean(), any(), + any(), any())).thenReturn(requestSpec); OpenDaimonChatOptions options = new OpenDaimonChatOptions(0.7, 1000, "System", "User", false, Map.of()); @@ -274,6 +283,7 @@ void callChat_webEnabledFalseWhenWebNotInRequiredOrOptional() { any(), eq(false), any(), + any(), eq(options)); } @@ -281,7 +291,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(), 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..1197bb52 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,123 @@ 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, + 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_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, + 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 +424,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..3edc41d2 --- /dev/null +++ b/opendaimon-spring-ai/src/test/java/io/github/ngirchev/opendaimon/ai/springai/tool/ExternalToolCallbacksTest.java @@ -0,0 +1,92 @@ +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()); + } + + 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-boot-starter/pom.xml b/opendaimon-spring-boot-starter/pom.xml index 603cf95f..6832c328 100644 --- a/opendaimon-spring-boot-starter/pom.xml +++ b/opendaimon-spring-boot-starter/pom.xml @@ -41,6 +41,11 @@ opendaimon-spring-ai ${project.version} + + io.github.ngirchev + opendaimon-mcp + ${project.version} + @@ -76,6 +81,7 @@ source analysis cannot see resource-only aggregation. --> io.github.ngirchev:opendaimon-common io.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..02ebe3d2 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,20 @@ spring: ai: + mcp: + client: + enabled: ${MCP_CLIENT_ENABLED:false} + name: open-daimon + version: ${OPEN_DAIMON_VERSION:dev} + type: SYNC + request-timeout: 30s + stdio: + connections: + filesystem: + command: npx + args: + - -y + - "@modelcontextprotocol/server-filesystem" + - ${MCP_FILESYSTEM_ROOT:/app/mcp-filesystem} ollama: base-url: ${OLLAMA_BASE_URL:http://localhost:11434} request-timeout: 600s @@ -147,6 +162,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: "^(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/pom.xml b/pom.xml index be84d9e1..680bfdc7 100644 --- a/pom.xml +++ b/pom.xml @@ -20,6 +20,7 @@ opendaimon-spring-ai opendaimon-common + opendaimon-mcp opendaimon-spring-boot-starter opendaimon-ui opendaimon-rest From 6f45565269c1fd5796de8abae61bd159185ca841 Mon Sep 17 00:00:00 2001 From: ngirchev Date: Tue, 5 May 2026 17:35:09 +0000 Subject: [PATCH 02/10] Restrict external MCP tools --- opendaimon-mcp/MCP_MODULE.md | 8 ++- .../opendaimon/mcp/config/McpAutoConfig.java | 2 +- opendaimon-spring-ai/SPRING_AI_MODULE.md | 12 +++-- .../springai/service/SpringAIChatService.java | 17 +++++- .../service/SpringAIPromptFactory.java | 33 ++++++++---- .../springai/tool/ExternalToolCallbacks.java | 14 ++++- .../service/SpringAIChatServiceTest.java | 53 ++++++++++++++++++- .../service/SpringAIPromptFactoryTest.java | 39 ++++++++++++++ .../tool/ExternalToolCallbacksTest.java | 11 ++++ 9 files changed, 168 insertions(+), 21 deletions(-) diff --git a/opendaimon-mcp/MCP_MODULE.md b/opendaimon-mcp/MCP_MODULE.md index fab1546f..ff22343e 100644 --- a/opendaimon-mcp/MCP_MODULE.md +++ b/opendaimon-mcp/MCP_MODULE.md @@ -12,8 +12,11 @@ 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 when available. +- 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 @@ -121,4 +124,5 @@ Filesystem MCP list_directory result: [{"text":"[FILE] alpha.txt\n[DIR] nested"} 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. +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/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 index 198b774d..a9274e27 100644 --- 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 @@ -9,7 +9,7 @@ @Slf4j @AutoConfiguration -@AutoConfigureAfter(name = "org.springframework.ai.autoconfigure.mcp.client.McpClientAutoConfiguration") +@AutoConfigureAfter(name = "org.springframework.ai.mcp.client.common.autoconfigure.McpClientAutoConfiguration") @ConditionalOnProperty(name = FeatureToggle.Module.MCP_ENABLED, havingValue = "true", matchIfMissing = true) public class McpAutoConfig { diff --git a/opendaimon-spring-ai/SPRING_AI_MODULE.md b/opendaimon-spring-ai/SPRING_AI_MODULE.md index 626da4fd..dc4c0107 100644 --- a/opendaimon-spring-ai/SPRING_AI_MODULE.md +++ b/opendaimon-spring-ai/SPRING_AI_MODULE.md @@ -428,8 +428,12 @@ OpenDaimon tool discovery has two sources: `AgentAutoConfig#agentToolCallbacks` merges built-in callbacks with external provider callbacks. The normal `SpringAIPromptFactory` path performs the same -merge before adding callbacks to `ChatClient` prompts. External MCP tools are -then filtered by `open-daimon.mcp.tool-access` rules before the model sees them. +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 @@ -446,7 +450,9 @@ 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. +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 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 ddb79a80..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,6 +43,7 @@ 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, @@ -50,6 +51,7 @@ public AIResponse streamChat( chatOptions != null ? chatOptions.body() : null, conversationId, webEnabled, + externalToolsAllowed, metadata, messages, chatOptions @@ -133,9 +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, metadata, messages, chatOptions); + return callChatOnce(modelConfig, body, conversationId, webEnabled, externalToolsAllowed, metadata, messages, chatOptions); } private AIResponse callChatOnce( @@ -143,6 +146,7 @@ private AIResponse callChatOnce( Map body, Object conversationId, boolean webEnabled, + boolean externalToolsAllowed, Map metadata, List messages, OpenDaimonChatOptions chatOptions @@ -154,6 +158,7 @@ private AIResponse callChatOnce( body, conversationId, webEnabled, + externalToolsAllowed, metadata != null ? metadata : Map.of(), messages, chatOptions @@ -229,7 +234,7 @@ public AIResponse callChatFromBody( boolean webEnabled, List messages ) { - return callChatOnce(modelConfig, requestBody, conversationId, webEnabled, Map.of(), messages, null); + return callChatOnce(modelConfig, requestBody, conversationId, webEnabled, false, Map.of(), messages, null); } private Flux trackStreamIfPossible(String modelId, Flux flux) { @@ -252,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 770235ac..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 @@ -23,7 +23,6 @@ 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.bulkhead.model.UserPriority; 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; @@ -177,10 +176,7 @@ public ChatClient.ChatClientRequestSpec preparePrompt( List messages, OpenDaimonChatOptions chatOptions ) { - Map metadata = externalToolsAllowed - ? Map.of(AICommand.USER_PRIORITY_FIELD, UserPriority.ADMIN.name()) - : Map.of(); - return preparePrompt(modelConfig, modelName, body, conversationId, webEnabled, metadata, messages, chatOptions); + return preparePrompt(modelConfig, modelName, body, conversationId, webEnabled, externalToolsAllowed, Map.of(), messages, chatOptions); } public ChatClient.ChatClientRequestSpec preparePrompt( @@ -192,6 +188,20 @@ public ChatClient.ChatClientRequestSpec preparePrompt( 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 ) { String resolvedModelName = modelConfig != null ? modelConfig.getName() : modelName; ChatClient chatClient = getChatClient(modelConfig, resolvedModelName); @@ -205,7 +215,7 @@ public ChatClient.ChatClientRequestSpec preparePrompt( .advisors(new MessageOrderingAdvisor()); } addSystemMessagesIfPresent(promptBuilder, messages); - addToolsIfEnabled(promptBuilder, webEnabled, metadata); + addToolsIfEnabled(promptBuilder, webEnabled, externalToolsAllowed, metadata); addUserOrAllMessages(promptBuilder, messages); return promptBuilder; @@ -225,21 +235,24 @@ private void addSystemMessagesIfPresent(ChatClient.ChatClientRequestSpec promptB } } - private void addToolsIfEnabled(ChatClient.ChatClientRequestSpec promptBuilder, boolean webEnabled, Map metadata) { + 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).stream() + 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={}).", - callbacks.size(), webEnabled, externalToolsEnabled); + 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 index eba13efa..9bea8d5f 100644 --- 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 @@ -37,7 +37,7 @@ public static List merge( .map(ToolCallbackProvider::getToolCallbacks) .filter(callbacks -> callbacks != null && callbacks.length > 0) .flatMap(Arrays::stream) - .forEach(callback -> addCallback(callbacksByName, callback)); + .forEach(callback -> addExternalCallback(callbacksByName, callback)); } return List.copyOf(callbacksByName.values()); } @@ -96,4 +96,16 @@ private static void addCallback(Map callbacksByName, ToolC 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/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 ce8a6faa..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,7 @@ void callChat_returnsSpringAIResponse() { any(), any(), anyBoolean(), + anyBoolean(), any(), any(), any())).thenReturn(requestSpec); @@ -96,6 +97,7 @@ void callChatFromBody_returnsSpringAIResponse() { any(), any(), anyBoolean(), + anyBoolean(), any(), any(), isNull())).thenReturn(requestSpec); @@ -126,6 +128,7 @@ void streamChat_returnsSpringAIStreamResponse() { any(), any(), anyBoolean(), + anyBoolean(), any(), any(), any())).thenReturn(requestSpec); @@ -164,6 +167,7 @@ void callChatFromBody_modelFromOptionsMap_usesOptionsModel() { any(), any(), anyBoolean(), + anyBoolean(), any(), any(), isNull())).thenReturn(requestSpec); @@ -172,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(), any(), isNull()); + verify(promptFactory).preparePrompt(eq(configWithNullName), eq("options-model-name"), eq(body), any(), anyBoolean(), anyBoolean(), any(), any(), isNull()); } @Test @@ -189,6 +193,7 @@ void callChat_webEnabledTrueWhenWebOnlyInOptionalCapabilities() { any(), any(), anyBoolean(), + anyBoolean(), any(), any(), any())).thenReturn(requestSpec); @@ -207,6 +212,7 @@ void callChat_webEnabledTrueWhenWebOnlyInOptionalCapabilities() { any(), any(), eq(true), + eq(false), any(), any(), eq(options)); @@ -227,6 +233,7 @@ void streamChat_webEnabledTrueWhenWebOnlyInOptionalCapabilities() { any(), any(), anyBoolean(), + anyBoolean(), any(), any(), any())).thenReturn(requestSpec); @@ -245,6 +252,7 @@ void streamChat_webEnabledTrueWhenWebOnlyInOptionalCapabilities() { any(), any(), eq(true), + eq(false), any(), any(), eq(options)); @@ -264,6 +272,7 @@ void callChat_webEnabledFalseWhenWebNotInRequiredOrOptional() { any(), any(), anyBoolean(), + anyBoolean(), any(), any(), any())).thenReturn(requestSpec); @@ -282,6 +291,46 @@ 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)); @@ -291,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(), 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 1197bb52..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 @@ -272,6 +272,7 @@ void preparePrompt_withExternalToolsEnabled_addsExternalToolCallbacks() { 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()) ); @@ -287,6 +288,43 @@ void preparePrompt_withExternalToolsEnabled_addsExternalToolCallbacks() { .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"); @@ -348,6 +386,7 @@ void preparePrompt_regularUserGetsSharedMcpToolsButNotFilesystemTools() { 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()) 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 index 3edc41d2..0b966c89 100644 --- 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 @@ -52,6 +52,17 @@ void merge_keepsFirstCallbackWhenNamesDuplicate() { 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()) From df9c58e320b9d052d4eb99986bf0b792362a9afc Mon Sep 17 00:00:00 2001 From: ngirchev Date: Tue, 5 May 2026 20:02:23 +0000 Subject: [PATCH 03/10] Enable filesystem MCP by default --- docker-compose.yml | 4 +-- .../src/main/resources/application.yml | 2 +- opendaimon-mcp/MCP_MODULE.md | 12 ++++--- opendaimon-spring-ai/SPRING_AI_MODULE.md | 5 ++- .../opendaimon/opendaimon-defaults.yml | 10 +----- opendaimon-telegram/TELEGRAM_MODULE.md | 2 +- .../fsm/TelegramMessageHandlerActions.java | 7 ++-- ...elegramMessageHandlerActionsAgentTest.java | 33 +++++++++++++++++++ 8 files changed, 54 insertions(+), 21 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 56f56fa5..32b63bb4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -42,8 +42,8 @@ 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). Disabled by default. - - MCP_CLIENT_ENABLED=${MCP_CLIENT_ENABLED:-false} + # MCP filesystem server (admin-only in OpenDaimon tool exposure). Enabled by default. + - MCP_CLIENT_ENABLED=${MCP_CLIENT_ENABLED:-true} - 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. diff --git a/opendaimon-app/src/main/resources/application.yml b/opendaimon-app/src/main/resources/application.yml index 562b3d54..022c7920 100644 --- a/opendaimon-app/src/main/resources/application.yml +++ b/opendaimon-app/src/main/resources/application.yml @@ -372,7 +372,7 @@ spring: ai: mcp: client: - enabled: ${MCP_CLIENT_ENABLED:false} + enabled: ${MCP_CLIENT_ENABLED:true} name: open-daimon version: ${OPEN_DAIMON_VERSION:dev} type: SYNC diff --git a/opendaimon-mcp/MCP_MODULE.md b/opendaimon-mcp/MCP_MODULE.md index ff22343e..c9487dc0 100644 --- a/opendaimon-mcp/MCP_MODULE.md +++ b/opendaimon-mcp/MCP_MODULE.md @@ -42,9 +42,9 @@ Rules are evaluated in order by Java regular expression against 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. The bundled -runtime and starter defaults keep client creation disabled until an application -opts in: +Spring AI MCP client creation is controlled by Spring AI properties. OpenDaimon +defaults keep client creation enabled; applications can disable it with +`MCP_CLIENT_ENABLED=false` or `spring.ai.mcp.client.enabled=false`: ```yaml spring: @@ -82,7 +82,9 @@ spring: endpoint: /mcp ``` -The bundled Docker setup includes an opt-in filesystem MCP connection: +The bundled `opendaimon-app` setup includes the default filesystem MCP stdio +connection. The published starter defaults enable MCP client support but do not +define a concrete filesystem stdio connection for downstream applications: ```yaml spring: @@ -100,7 +102,7 @@ spring: - ${MCP_FILESYSTEM_ROOT:/app/mcp-filesystem} ``` -Enable it in Docker with `MCP_CLIENT_ENABLED=true`. The runtime image includes +Disable it in Docker with `MCP_CLIENT_ENABLED=false`. The runtime image includes Node.js/npm so `npx` can start the server. 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 diff --git a/opendaimon-spring-ai/SPRING_AI_MODULE.md b/opendaimon-spring-ai/SPRING_AI_MODULE.md index dc4c0107..82b79e63 100644 --- a/opendaimon-spring-ai/SPRING_AI_MODULE.md +++ b/opendaimon-spring-ai/SPRING_AI_MODULE.md @@ -438,7 +438,10 @@ 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` -false until an application explicitly configures MCP connections. +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. External provider callbacks are role-filtered. `SpringAIChatService` passes command metadata to `SpringAIPromptFactory`, and `SpringAgentLoopActions#resolveEffectiveTools` 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 02ebe3d2..5022fa45 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 @@ -2,19 +2,11 @@ spring: ai: mcp: client: - enabled: ${MCP_CLIENT_ENABLED:false} + enabled: ${MCP_CLIENT_ENABLED:true} name: open-daimon version: ${OPEN_DAIMON_VERSION:dev} type: SYNC request-timeout: 30s - stdio: - connections: - filesystem: - command: npx - args: - - -y - - "@modelcontextprotocol/server-filesystem" - - ${MCP_FILESYSTEM_ROOT:/app/mcp-filesystem} ollama: base-url: ${OLLAMA_BASE_URL:http://localhost:11434} request-timeout: 600s diff --git a/opendaimon-telegram/TELEGRAM_MODULE.md b/opendaimon-telegram/TELEGRAM_MODULE.md index 4309462c..3e25d70b 100644 --- a/opendaimon-telegram/TELEGRAM_MODULE.md +++ b/opendaimon-telegram/TELEGRAM_MODULE.md @@ -194,7 +194,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. --- diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/fsm/TelegramMessageHandlerActions.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/fsm/TelegramMessageHandlerActions.java index 5094b113..7e8ad1c3 100644 --- a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/fsm/TelegramMessageHandlerActions.java +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/fsm/TelegramMessageHandlerActions.java @@ -309,6 +309,9 @@ private void generateAgentResponse(MessageHandlerContext ctx) { TelegramCommand command = ctx.getCommand(); Map metadata = ctx.getMetadata(); AICommand aiCommand = ctx.getAiCommand(); + Map agentMetadata = aiCommand != null && aiCommand.metadata() != null + ? aiCommand.metadata() + : metadata; Long chatId = command.telegramId(); try { @@ -351,8 +354,8 @@ private void generateAgentResponse(MessageHandlerContext ctx) { } AgentRequest request = new AgentRequest( agentTask, - metadata.get(THREAD_KEY_FIELD), - metadata, + agentMetadata.get(THREAD_KEY_FIELD), + agentMetadata, agentMaxIterations, Set.of(), strategy, diff --git a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/fsm/TelegramMessageHandlerActionsAgentTest.java b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/fsm/TelegramMessageHandlerActionsAgentTest.java index fc32743c..c4125e4e 100644 --- a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/fsm/TelegramMessageHandlerActionsAgentTest.java +++ b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/fsm/TelegramMessageHandlerActionsAgentTest.java @@ -10,6 +10,7 @@ import io.github.ngirchev.opendaimon.common.ai.command.ChatAICommand; import io.github.ngirchev.opendaimon.common.ai.command.FixedModelChatAICommand; import io.github.ngirchev.opendaimon.common.ai.pipeline.AIRequestPipeline; +import io.github.ngirchev.opendaimon.bulkhead.model.UserPriority; import io.github.ngirchev.opendaimon.common.service.AIGateway; import io.github.ngirchev.opendaimon.common.service.AIGatewayRegistry; import io.github.ngirchev.opendaimon.common.service.OpenDaimonMessageService; @@ -151,6 +152,38 @@ void generateResponse_agentEnabled_buildsCorrectRequest() { assertThat(request.metadata()).containsKey(AICommand.THREAD_KEY_FIELD); } + @Test + @DisplayName("generateResponse forwards pipeline metadata to agent request") + void generateResponse_agentEnabled_usesPipelineMetadata() { + TelegramCommand command = mock(TelegramCommand.class); + when(command.telegramId()).thenReturn(42L); + Map metadata = new HashMap<>(); + metadata.put(AICommand.THREAD_KEY_FIELD, "test-thread-key"); + metadata.put(AICommand.USER_ID_FIELD, "42"); + MessageHandlerContext ctx = new MessageHandlerContext(command, null, s -> {}); + ctx.setMetadata(metadata); + ctx.setModelCapabilities(Set.of(ModelCapabilities.AUTO)); + Map pipelineMetadata = new HashMap<>(metadata); + pipelineMetadata.put(AICommand.USER_PRIORITY_FIELD, UserPriority.ADMIN.name()); + ctx.setAiCommand(new ChatAICommand( + Set.of(ModelCapabilities.AUTO), + 0.35, + 4000, + "system", + "List filesystem files", + pipelineMetadata)); + + Flux stream = Flux.just(AgentStreamEvent.finalAnswer("Files", 1)); + ArgumentCaptor captor = ArgumentCaptor.forClass(AgentRequest.class); + when(agentExecutor.executeStream(captor.capture())).thenReturn(stream); + + actions.generateResponse(ctx); + + AgentRequest request = captor.getValue(); + assertThat(request.metadata()) + .containsEntry(AICommand.USER_PRIORITY_FIELD, UserPriority.ADMIN.name()); + } + @Test @DisplayName("generateResponse sets error when agent stream emits ERROR event") void generateResponse_agentFailed_setsError() { From 47c7a0c383b0234f61bbc39f27d3d27ff1c2a2b1 Mon Sep 17 00:00:00 2001 From: ngirchev Date: Tue, 5 May 2026 21:19:32 +0000 Subject: [PATCH 04/10] Expose MCP tools in Telegram --- .gitignore | 1 + .../ai/tool/ExternalToolAccessContext.java | 9 ++ .../ai/tool/ExternalToolCatalogService.java | 8 ++ .../ai/tool/ExternalToolDescriptor.java | 7 + .../common/config/FeatureToggle.java | 4 +- opendaimon-mcp/MCP_MODULE.md | 4 + .../ai/springai/agent/AgentPromptBuilder.java | 14 +- .../springai/config/SpringAIAutoConfig.java | 14 ++ .../SpringAIExternalToolCatalogService.java | 64 +++++++++ .../agent/AgentPromptBuilderTest.java | 45 +++++++ ...pringAIExternalToolCatalogServiceTest.java | 87 +++++++++++++ opendaimon-telegram/TELEGRAM_MODULE.md | 6 + .../telegram/command/TelegramCommand.java | 1 + .../impl/McpTelegramCommandHandler.java | 84 ++++++++++++ .../config/TelegramCommandHandlerConfig.java | 18 +++ .../service/TelegramAgentStreamModel.java | 2 +- .../telegram/service/ToolLabels.java | 15 ++- .../fsm/TelegramMessageHandlerActions.java | 2 +- .../resources/messages/telegram_en.properties | 4 + .../resources/messages/telegram_ru.properties | 4 + .../impl/McpTelegramCommandHandlerTest.java | 123 ++++++++++++++++++ .../service/TelegramAgentStreamModelTest.java | 24 ++++ ...elegramMessageHandlerActionsAgentTest.java | 2 +- 23 files changed, 530 insertions(+), 12 deletions(-) create mode 100644 opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/ai/tool/ExternalToolAccessContext.java create mode 100644 opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/ai/tool/ExternalToolCatalogService.java create mode 100644 opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/ai/tool/ExternalToolDescriptor.java create mode 100644 opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/tool/SpringAIExternalToolCatalogService.java create mode 100644 opendaimon-spring-ai/src/test/java/io/github/ngirchev/opendaimon/ai/springai/tool/SpringAIExternalToolCatalogServiceTest.java create mode 100644 opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/McpTelegramCommandHandler.java create mode 100644 opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/McpTelegramCommandHandlerTest.java diff --git a/.gitignore b/.gitignore index 9929c256..2eef7729 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,4 @@ /opendaimon-app/logs/opendaimon.log /application.yml /.remember/ +/mcp-filesystem/ 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..b28d5ac5 --- /dev/null +++ b/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/ai/tool/ExternalToolCatalogService.java @@ -0,0 +1,8 @@ +package io.github.ngirchev.opendaimon.common.ai.tool; + +import java.util.List; + +public interface ExternalToolCatalogService { + + List listAvailableTools(ExternalToolAccessContext context); +} 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..526eba1b --- /dev/null +++ b/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/ai/tool/ExternalToolDescriptor.java @@ -0,0 +1,7 @@ +package io.github.ngirchev.opendaimon.common.ai.tool; + +public record ExternalToolDescriptor( + String name, + String description +) { +} 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 88416f79..d9ce99a9 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 @@ -78,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 MCP = "mcp-enabled"; } // ── OpenRouter model rotation toggles (prefix-based) ──────── @@ -129,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_MCP(TelegramCommand.PREFIX + "." + TelegramCommand.MCP); private final String propertyKey; diff --git a/opendaimon-mcp/MCP_MODULE.md b/opendaimon-mcp/MCP_MODULE.md index c9487dc0..aa0b5714 100644 --- a/opendaimon-mcp/MCP_MODULE.md +++ b/opendaimon-mcp/MCP_MODULE.md @@ -22,6 +22,10 @@ OpenDaimon consumes those callbacks in `opendaimon-spring-ai`: - Built-in tools (`web_search`, `fetch_url`, `http_get`, `http_post`) remain available independently of MCP. +Telegram exposes `/mcp` to show the external MCP tools available to the current +user. The command uses 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: 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/config/SpringAIAutoConfig.java b/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/config/SpringAIAutoConfig.java index d6b777de..da315333 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 @@ -58,6 +58,8 @@ 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; @@ -177,6 +179,18 @@ public SpringAIPromptFactory springAIPromptFactory( mcpToolAccessProperties); } + @Bean + @ConditionalOnMissingBean + public ExternalToolCatalogService externalToolCatalogService( + ObjectProvider externalToolCallbackProviders, + @Value("${" + FeatureToggle.Module.MCP_ENABLED + ":true}") boolean externalToolsEnabled, + McpToolAccessProperties mcpToolAccessProperties) { + return new SpringAIExternalToolCatalogService( + externalToolCallbackProviders, + externalToolsEnabled, + mcpToolAccessProperties); + } + @Bean public SpringAIChatService springAIChatService( SpringAIPromptFactory promptFactory, 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..dc83eef2 --- /dev/null +++ b/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/tool/SpringAIExternalToolCatalogService.java @@ -0,0 +1,64 @@ +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 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.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public class SpringAIExternalToolCatalogService implements ExternalToolCatalogService { + + private final ObjectProvider externalToolCallbackProviders; + private final boolean externalToolsEnabled; + private final McpToolAccessProperties mcpToolAccessProperties; + + public SpringAIExternalToolCatalogService( + ObjectProvider externalToolCallbackProviders, + boolean externalToolsEnabled, + McpToolAccessProperties mcpToolAccessProperties) { + this.externalToolCallbackProviders = externalToolCallbackProviders; + this.externalToolsEnabled = externalToolsEnabled; + this.mcpToolAccessProperties = mcpToolAccessProperties != null + ? mcpToolAccessProperties : new McpToolAccessProperties(); + } + + @Override + public List listAvailableTools(ExternalToolAccessContext context) { + if (!externalToolsEnabled || externalToolCallbackProviders == null || context == null) { + return List.of(); + } + Map toolsByName = new LinkedHashMap<>(); + 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 -> addDescriptor(toolsByName, callback)); + return List.copyOf(toolsByName.values()); + } + + private static void addDescriptor(Map toolsByName, ToolCallback callback) { + if (callback == null || callback.getToolDefinition() == null) { + return; + } + ToolDefinition definition = callback.getToolDefinition(); + if (definition.name() == null || definition.name().isBlank()) { + return; + } + toolsByName.putIfAbsent(definition.name(), + new ExternalToolDescriptor(definition.name(), definition.description())); + } +} 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/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..ca8a3063 --- /dev/null +++ b/opendaimon-spring-ai/src/test/java/io/github/ngirchev/opendaimon/ai/springai/tool/SpringAIExternalToolCatalogServiceTest.java @@ -0,0 +1,87 @@ +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 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.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class SpringAIExternalToolCatalogServiceTest { + + @Test + void shouldListAllowedExternalToolsOnly() { + ObjectProvider providers = providers( + toolCallback("web_search"), + toolCallback("read_file"), + toolCallback("weather_lookup")); + SpringAIExternalToolCatalogService service = new SpringAIExternalToolCatalogService( + providers, 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("read_file")); + SpringAIExternalToolCatalogService service = new SpringAIExternalToolCatalogService( + providers, true, new McpToolAccessProperties()); + + var tools = service.listAvailableTools(new ExternalToolAccessContext(1L, UserPriority.ADMIN)); + + assertThat(tools) + .extracting("name") + .containsExactly("read_file"); + } + + @Test + void shouldReturnEmptyWhenExternalToolsDisabled() { + ObjectProvider providers = providers(toolCallback("weather_lookup")); + SpringAIExternalToolCatalogService service = new SpringAIExternalToolCatalogService( + providers, false, new McpToolAccessProperties()); + + var tools = service.listAvailableTools(new ExternalToolAccessContext(1L, UserPriority.ADMIN)); + + assertThat(tools).isEmpty(); + } + + 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 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-telegram/TELEGRAM_MODULE.md b/opendaimon-telegram/TELEGRAM_MODULE.md index 3e25d70b..bc99f4f1 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 | +| `McpTelegramCommandHandler` | `/mcp` | 0 | | `BugreportTelegramCommandHandler` | `/bugreport` | 0 | | `HistoryTelegramCommandHandler` | `/history` | 0 | | `ThreadsTelegramCommandHandler` | `/threads` | 0 | @@ -147,6 +148,11 @@ Handlers sorted by `priority()` (lower = first). First handler where `canHandle( Each handler is conditional on `open-daimon.telegram.commands.-enabled` (default: true). +`/mcp` lists external MCP tools visible to the current invoker. It delegates to the +common `ExternalToolCatalogService` SPI, so Telegram never applies MCP access policy +itself. The Spring AI implementation filters tools through the same +`open-daimon.mcp.tool-access` rules used by real tool execution. + --- ## User Priority Resolution (TelegramUserPriorityService) 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..e059a654 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 MCP = "/mcp"; 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/McpTelegramCommandHandler.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/McpTelegramCommandHandler.java new file mode 100644 index 00000000..a932b10a --- /dev/null +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/McpTelegramCommandHandler.java @@ -0,0 +1,84 @@ +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.ExternalToolDescriptor; +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; + +public class McpTelegramCommandHandler extends AbstractTelegramCommandHandlerWithResponseSend { + + private final ObjectProvider externalToolCatalogServiceProvider; + private final IUserPriorityService userPriorityService; + + public McpTelegramCommandHandler( + 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.MCP.equals(commandType.command()); + } + + @Override + public String handleInner(TelegramCommand command) { + ExternalToolCatalogService catalog = externalToolCatalogServiceProvider.getIfAvailable(); + if (catalog == null) { + return messageLocalizationService.getMessage("telegram.mcp.unavailable", command.languageCode()); + } + + UserPriority priority = userPriorityService.getUserPriority(command.userId()); + List tools = catalog.listAvailableTools( + new ExternalToolAccessContext(command.userId(), priority)); + if (tools.isEmpty()) { + return messageLocalizationService.getMessage("telegram.mcp.empty", command.languageCode(), priority); + } + + StringBuilder response = new StringBuilder(messageLocalizationService.getMessage( + "telegram.mcp.header", command.languageCode(), priority)); + tools.stream() + .sorted(Comparator.comparing(ExternalToolDescriptor::name)) + .forEach(tool -> response.append('\n').append(renderTool(tool))); + return response.toString(); + } + + private static String renderTool(ExternalToolDescriptor tool) { + String name = TelegramHtmlEscaper.escape(tool.name()); + if (tool.description() == null || tool.description().isBlank()) { + return "• " + name + ""; + } + return "• " + name + " — " + TelegramHtmlEscaper.escape(tool.description()); + } + + @Override + public String getSupportedCommandText(String languageCode) { + return messageLocalizationService.getMessage("telegram.command.mcp.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..0b7f7914 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.MCP, havingValue = "true", matchIfMissing = true) + public McpTelegramCommandHandler mcpTelegramCommandHandler( + ObjectProvider telegramBotProvider, + TypingIndicatorService typingIndicatorService, + MessageLocalizationService messageLocalizationService, + ObjectProvider externalToolCatalogServiceProvider, + IUserPriorityService userPriorityService) { + return new McpTelegramCommandHandler( + 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/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:

Given the raw agent tool name (e.g. {@code web_search}), returns a status label + * that keeps the raw name visible. Unknown tools render as their raw name so MCP tools + * can be identified without adding a hardcoded label for each one. */ public final class ToolLabels { @@ -30,7 +29,11 @@ public static String label(String toolName) { if (toolName == null || toolName.isBlank()) { return DEFAULT_LABEL; } - return LABELS.getOrDefault(toolName, DEFAULT_LABEL); + String friendlyLabel = LABELS.get(toolName); + if (friendlyLabel == null) { + return toolName; + } + return friendlyLabel + " (" + toolName + ")"; } public static String truncateArg(String arg) { diff --git a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/fsm/TelegramMessageHandlerActions.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/fsm/TelegramMessageHandlerActions.java index 7e8ad1c3..422c529c 100644 --- a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/fsm/TelegramMessageHandlerActions.java +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/service/fsm/TelegramMessageHandlerActions.java @@ -597,7 +597,7 @@ private void replaceTrailingThinkingLineWithEscaped(MessageHandlerContext ctx, } private Mono appendToolCallBlock(MessageHandlerContext ctx, 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/resources/messages/telegram_en.properties b/opendaimon-telegram/src/main/resources/messages/telegram_en.properties index b1467f95..f3f84a91 100644 --- a/opendaimon-telegram/src/main/resources/messages/telegram_en.properties +++ b/opendaimon-telegram/src/main/resources/messages/telegram_en.properties @@ -89,6 +89,7 @@ telegram.mode.updated=Mode switched: {0} telegram.mode.close=\u274C Cancel / Close telegram.mode.unknown=Unknown mode telegram.command.thinking.desc=/thinking - configure reasoning visibility +telegram.command.mcp.desc=/mcp - list available MCP tools telegram.thinking.current=Current setting: {0} telegram.thinking.select=Choose reasoning visibility: telegram.thinking.updated=Reasoning visibility updated: {0} @@ -100,3 +101,6 @@ telegram.thinking.current.tools_only=Tools only telegram.thinking.current.silent=Silent mode telegram.thinking.close=\u274C Cancel / Close telegram.thinking.unknown=Unknown option +telegram.mcp.unavailable=MCP tools are not configured. +telegram.mcp.empty=No MCP tools are available for your access level ({0}). +telegram.mcp.header=Available MCP tools for {0}: diff --git a/opendaimon-telegram/src/main/resources/messages/telegram_ru.properties b/opendaimon-telegram/src/main/resources/messages/telegram_ru.properties index 09884cd0..04bcfafc 100644 --- a/opendaimon-telegram/src/main/resources/messages/telegram_ru.properties +++ b/opendaimon-telegram/src/main/resources/messages/telegram_ru.properties @@ -89,6 +89,7 @@ telegram.mode.updated=Режим изменён: {0} telegram.mode.close=\u274C Отмена / закрыть telegram.mode.unknown=Неизвестный режим telegram.command.thinking.desc=/thinking - настройка отображения рассуждений +telegram.command.mcp.desc=/mcp - список доступных MCP-инструментов telegram.thinking.current=Текущая настройка: {0} telegram.thinking.select=Выберите режим отображения рассуждений: telegram.thinking.updated=Режим отображения рассуждений изменён: {0} @@ -100,3 +101,6 @@ telegram.thinking.current.tools_only=Только инструменты telegram.thinking.current.silent=Тихий режим telegram.thinking.close=\u274C Отмена / закрыть telegram.thinking.unknown=Неизвестная опция +telegram.mcp.unavailable=MCP-инструменты не настроены. +telegram.mcp.empty=Для вашего уровня доступа ({0}) нет доступных MCP-инструментов. +telegram.mcp.header=Доступные MCP-инструменты для {0}: diff --git a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/McpTelegramCommandHandlerTest.java b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/McpTelegramCommandHandlerTest.java new file mode 100644 index 00000000..7a3954d2 --- /dev/null +++ b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/McpTelegramCommandHandlerTest.java @@ -0,0 +1,123 @@ +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.ExternalToolDescriptor; +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.service.TypingIndicatorService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.quality.Strictness; +import org.springframework.beans.factory.ObjectProvider; +import org.telegram.telegrambots.meta.api.objects.Update; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class McpTelegramCommandHandlerTest { + + private static final long USER_ID = 2L; + private static final long CHAT_ID = 42L; + + @Mock private ObjectProvider telegramBotProvider; + @Mock private TypingIndicatorService typingIndicatorService; + @Mock private MessageLocalizationService messageLocalizationService; + @Mock private ObjectProvider catalogProvider; + @Mock private ExternalToolCatalogService catalogService; + @Mock private IUserPriorityService userPriorityService; + + private McpTelegramCommandHandler handler; + + @BeforeEach + void setUp() { + when(messageLocalizationService.getMessage(eq("telegram.command.mcp.desc"), anyString())) + .thenReturn("/mcp - list available MCP tools"); + when(messageLocalizationService.getMessage(eq("telegram.mcp.unavailable"), anyString())) + .thenReturn("MCP tools are not configured."); + when(messageLocalizationService.getMessage(eq("telegram.mcp.empty"), anyString(), any())) + .thenAnswer(inv -> "No MCP tools are available for " + inv.getArgument(2)); + when(messageLocalizationService.getMessage(eq("telegram.mcp.header"), anyString(), any())) + .thenAnswer(inv -> "Available MCP tools for " + inv.getArgument(2) + ":"); + handler = new McpTelegramCommandHandler( + telegramBotProvider, + typingIndicatorService, + messageLocalizationService, + catalogProvider, + userPriorityService); + } + + @Test + void canHandleMcpCommand() { + assertThat(handler.canHandle(command(TelegramCommand.MCP))).isTrue(); + assertThat(handler.canHandle(command(TelegramCommand.MODEL))).isFalse(); + } + + @Test + void shouldReturnUnavailableWhenCatalogMissing() { + when(catalogProvider.getIfAvailable()).thenReturn(null); + + String response = handler.handleInner(command(TelegramCommand.MCP)); + + assertThat(response).isEqualTo("MCP tools are not configured."); + } + + @Test + void shouldReturnEmptyWhenUserHasNoAllowedTools() { + when(catalogProvider.getIfAvailable()).thenReturn(catalogService); + when(userPriorityService.getUserPriority(USER_ID)).thenReturn(UserPriority.REGULAR); + when(catalogService.listAvailableTools(new ExternalToolAccessContext(USER_ID, UserPriority.REGULAR))) + .thenReturn(List.of()); + + String response = handler.handleInner(command(TelegramCommand.MCP)); + + assertThat(response).contains("REGULAR"); + } + + @Test + void shouldRenderAvailableToolsEscaped() { + when(catalogProvider.getIfAvailable()).thenReturn(catalogService); + when(userPriorityService.getUserPriority(USER_ID)).thenReturn(UserPriority.ADMIN); + when(catalogService.listAvailableTools(new ExternalToolAccessContext(USER_ID, UserPriority.ADMIN))) + .thenReturn(List.of(new ExternalToolDescriptor("read_file", "Read "))); + + String response = handler.handleInner(command(TelegramCommand.MCP)); + + assertThat(response) + .contains("Available MCP tools for ADMIN:") + .contains("read_file") + .contains("Read <files>"); + } + + @Test + void shouldProvideStartMenuDescription() { + assertThat(handler.getSupportedCommandText("en")) + .isEqualTo("/mcp - list available MCP tools"); + } + + private static TelegramCommand command(String commandText) { + TelegramCommand command = new TelegramCommand( + USER_ID, + CHAT_ID, + new TelegramCommandType(commandText), + mock(Update.class)); + command.languageCode("en"); + return command; + } +} diff --git a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/TelegramAgentStreamModelTest.java b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/TelegramAgentStreamModelTest.java index 019518a4..94cf527d 100644 --- a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/TelegramAgentStreamModelTest.java +++ b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/TelegramAgentStreamModelTest.java @@ -253,6 +253,30 @@ void shouldPreserveTrailingReasoningOverlayOnFinalAnswerWhenShowAllIsEnabled() { assertThat(model.answerHtml()).contains("Final answer."); } + @Test + @DisplayName("should render raw tool name in the status label") + void shouldRenderRawToolNameInStatusLabel() { + TelegramAgentStreamModel model = new TelegramAgentStreamModel(false, false); + + model.apply(AgentStreamEvent.toolCall("web_search", "{\"query\":\"telegram limits\"}", 0)); + + assertThat(model.statusHtml()) + .contains("🔧 Tool: Searching the web (web_search)") + .contains("telegram limits"); + } + + @Test + @DisplayName("should render unknown MCP tool by raw name") + void shouldRenderUnknownMcpToolByRawName() { + TelegramAgentStreamModel model = new TelegramAgentStreamModel(false, false); + + model.apply(AgentStreamEvent.toolCall("filesystem_read", "{\"path\":\"/tmp/file.txt\"}", 0)); + + assertThat(model.statusHtml()) + .contains("🔧 Tool: filesystem_read") + .contains("/tmp/file.txt"); + } + @Test @DisplayName("should render empty tool arguments as missing query") void shouldRenderEmptyToolArgumentsAsMissingQuery() { diff --git a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/fsm/TelegramMessageHandlerActionsAgentTest.java b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/fsm/TelegramMessageHandlerActionsAgentTest.java index c4125e4e..88f95cc2 100644 --- a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/fsm/TelegramMessageHandlerActionsAgentTest.java +++ b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/fsm/TelegramMessageHandlerActionsAgentTest.java @@ -1166,7 +1166,7 @@ void shouldRenderToolCallWithBoldLabels() { verify(messageSender, atLeastOnce()) .editHtml(eq(CHAT_ID), eq(STATUS_MSG_ID), editCaptor.capture(), eq(true)); String finalHtml = editCaptor.getValue(); - assertThat(finalHtml).contains("🔧 Tool: Searching the web"); + assertThat(finalHtml).contains("🔧 Tool: Searching the web (web_search)"); assertThat(finalHtml).contains("Query:"); // The label is HTML bold — no unformatted "Tool:" or "Query:" leaking through. assertThat(finalHtml).doesNotContain("🔧 Tool:"); From a988af1ee817f58084ada90ef2b64f84a1a5c87c Mon Sep 17 00:00:00 2001 From: ngirchev Date: Sun, 10 May 2026 16:34:59 +0000 Subject: [PATCH 05/10] Expose MCP tool sources --- Dockerfile | 5 +- docker-compose.yml | 3 +- docs/codex/config.example.toml | 2 +- docs/tariffs-and-models.md | 2 + .../it/telegram/TelegramGroupEntityIT.java | 5 +- .../src/main/resources/application-max.yml | 31 ++++++++ .../src/main/resources/application.yml | 38 ++++++++-- .../ai/tool/ExternalToolCatalogService.java | 19 +++++ .../ai/tool/ExternalToolDescriptor.java | 7 +- .../ai/tool/ExternalToolSourceDescriptor.java | 13 ++++ opendaimon-mcp/MCP_MODULE.md | 22 +++--- ...okeIT.java => FilesystemMcpSmokeTest.java} | 2 +- .../rest/dto/admin/UserSummaryDto.java | 2 +- .../AdminConversationRepository.java | 29 ++++---- .../rest/service/AdminQueryService.java | 45 ++++++++++-- .../rest/service/AdminQueryServiceTest.java | 70 ++++++++++++++++++- opendaimon-spring-ai/pom.xml | 9 +++ .../springai/config/SpringAIAutoConfig.java | 30 ++++++++ .../SpringAIExternalToolCatalogService.java | 66 ++++++++++++++++- .../retry/SpringAIModelRegistryTest.java | 32 +++++++-- ...pringAIExternalToolCatalogServiceTest.java | 41 ++++++++++- opendaimon-telegram/TELEGRAM_MODULE.md | 11 ++- .../impl/McpTelegramCommandHandler.java | 45 ++++++++---- .../service/TelegramGroupService.java | 3 +- .../impl/McpTelegramCommandHandlerTest.java | 49 +++++++++++-- .../service/TelegramGroupServiceTest.java | 2 +- pom.xml | 1 + 27 files changed, 509 insertions(+), 75 deletions(-) create mode 100644 opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/ai/tool/ExternalToolSourceDescriptor.java rename opendaimon-mcp/src/test/java/io/github/ngirchev/opendaimon/mcp/{FilesystemMcpSmokeIT.java => FilesystemMcpSmokeTest.java} (99%) diff --git a/Dockerfile b/Dockerfile index 3d0f6161..bdfe0535 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,7 +33,10 @@ RUN mvn -Drevision=${APP_VERSION} clean package -DskipTests -B FROM eclipse-temurin:21-jre-alpine WORKDIR /app -RUN apk add --no-cache nodejs npm +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.0-SNAPSHOT diff --git a/docker-compose.yml b/docker-compose.yml index 32b63bb4..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: @@ -44,6 +44,7 @@ services: - 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. 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/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.yml b/opendaimon-app/src/main/resources/application.yml index 022c7920..e8104e83 100644 --- a/opendaimon-app/src/main/resources/application.yml +++ b/opendaimon-app/src/main/resources/application.yml @@ -151,6 +151,8 @@ open-daimon: 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. @@ -177,6 +179,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 @@ -255,6 +259,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: @@ -383,11 +414,10 @@ spring: stdio: connections: filesystem: - command: npx + command: sh args: - - -y - - "@modelcontextprotocol/server-filesystem" - - ${MCP_FILESYSTEM_ROOT:/app/mcp-filesystem} + - -c + - exec ${MCP_FILESYSTEM_COMMAND:npx -y @modelcontextprotocol/server-filesystem} ${MCP_FILESYSTEM_ROOT:/app/mcp-filesystem} # sse: # connections: # remote: 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 index b28d5ac5..fd91cd32 100644 --- 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 @@ -1,8 +1,27 @@ 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::sourceName, + java.util.LinkedHashMap::new, + Collectors.toList())); + return toolsBySource.entrySet().stream() + .map(entry -> new ExternalToolSourceDescriptor(entry.getKey(), entry.getValue())) + .toList(); + } + + 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 index 526eba1b..f5610b29 100644 --- 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 @@ -2,6 +2,11 @@ public record ExternalToolDescriptor( String name, - String description + String description, + String sourceName ) { + + public ExternalToolDescriptor(String name, String description) { + this(name, description, null); + } } 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..7df4e267 --- /dev/null +++ b/opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/ai/tool/ExternalToolSourceDescriptor.java @@ -0,0 +1,13 @@ +package io.github.ngirchev.opendaimon.common.ai.tool; + +import java.util.List; + +public record ExternalToolSourceDescriptor( + String name, + List tools +) { + + public ExternalToolSourceDescriptor { + tools = tools != null ? List.copyOf(tools) : List.of(); + } +} diff --git a/opendaimon-mcp/MCP_MODULE.md b/opendaimon-mcp/MCP_MODULE.md index aa0b5714..93e43334 100644 --- a/opendaimon-mcp/MCP_MODULE.md +++ b/opendaimon-mcp/MCP_MODULE.md @@ -99,25 +99,27 @@ spring: stdio: connections: filesystem: - command: npx + command: sh args: - - -y - - "@modelcontextprotocol/server-filesystem" - - ${MCP_FILESYSTEM_ROOT:/app/mcp-filesystem} + - -c + - exec ${MCP_FILESYSTEM_COMMAND:npx -y @modelcontextprotocol/server-filesystem} ${MCP_FILESYSTEM_ROOT:/app/mcp-filesystem} ``` Disable it in Docker with `MCP_CLIENT_ENABLED=false`. The runtime image includes -Node.js/npm so `npx` can start the server. 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. +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 +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. This is enforced in both agent and normal Spring AI prompt flows. -The smoke test `FilesystemMcpSmokeIT` starts `@modelcontextprotocol/server-filesystem` +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: diff --git a/opendaimon-mcp/src/test/java/io/github/ngirchev/opendaimon/mcp/FilesystemMcpSmokeIT.java b/opendaimon-mcp/src/test/java/io/github/ngirchev/opendaimon/mcp/FilesystemMcpSmokeTest.java similarity index 99% rename from opendaimon-mcp/src/test/java/io/github/ngirchev/opendaimon/mcp/FilesystemMcpSmokeIT.java rename to opendaimon-mcp/src/test/java/io/github/ngirchev/opendaimon/mcp/FilesystemMcpSmokeTest.java index 6c6f78b9..37215753 100644 --- a/opendaimon-mcp/src/test/java/io/github/ngirchev/opendaimon/mcp/FilesystemMcpSmokeIT.java +++ b/opendaimon-mcp/src/test/java/io/github/ngirchev/opendaimon/mcp/FilesystemMcpSmokeTest.java @@ -18,7 +18,7 @@ import static org.assertj.core.api.Assertions.assertThat; -class FilesystemMcpSmokeIT { +class FilesystemMcpSmokeTest { @TempDir Path sandbox; 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/pom.xml b/opendaimon-spring-ai/pom.xml index 6257a729..47950673 100644 --- a/opendaimon-spring-ai/pom.xml +++ b/opendaimon-spring-ai/pom.xml @@ -98,6 +98,15 @@ org.springframework.ai spring-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/config/SpringAIAutoConfig.java b/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/config/SpringAIAutoConfig.java index da315333..86d65126 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; @@ -69,6 +72,8 @@ 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; @@ -183,14 +188,39 @@ public SpringAIPromptFactory springAIPromptFactory( @ConditionalOnMissingBean public ExternalToolCatalogService externalToolCatalogService( ObjectProvider externalToolCallbackProviders, + ObjectProvider mcpSyncClients, + Environment environment, @Value("${" + FeatureToggle.Module.MCP_ENABLED + ":true}") boolean externalToolsEnabled, McpToolAccessProperties mcpToolAccessProperties) { return new SpringAIExternalToolCatalogService( 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 public SpringAIChatService springAIChatService( SpringAIPromptFactory promptFactory, 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 index dc83eef2..96ac40bb 100644 --- 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 @@ -5,6 +5,8 @@ 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.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; @@ -18,14 +20,21 @@ public class SpringAIExternalToolCatalogService implements ExternalToolCatalogService { private final ObjectProvider externalToolCallbackProviders; + private final ObjectProvider mcpSyncClients; + private final List configuredMcpConnectionNames; private final boolean externalToolsEnabled; private final McpToolAccessProperties mcpToolAccessProperties; public SpringAIExternalToolCatalogService( ObjectProvider externalToolCallbackProviders, + ObjectProvider mcpSyncClients, + List configuredMcpConnectionNames, boolean externalToolsEnabled, McpToolAccessProperties mcpToolAccessProperties) { 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(); @@ -37,6 +46,7 @@ public List listAvailableTools(ExternalToolAccessContext return List.of(); } Map toolsByName = new LinkedHashMap<>(); + Map sourceByToolName = resolveSourceByToolName(); Map metadata = context.userPriority() != null ? Map.of(AICommand.USER_PRIORITY_FIELD, context.userPriority().name()) : Map.of(); @@ -46,11 +56,53 @@ public List listAvailableTools(ExternalToolAccessContext .flatMap(Arrays::stream) .filter(callback -> !ExternalToolCallbacks.isBuiltInTool(callback)) .filter(callback -> ExternalToolCallbacks.isAllowedFor(callback, metadata, mcpToolAccessProperties)) - .forEach(callback -> addDescriptor(toolsByName, callback)); + .forEach(callback -> addDescriptor(toolsByName, callback, sourceByToolName)); return List.copyOf(toolsByName.values()); } - private static void addDescriptor(Map toolsByName, ToolCallback callback) { + private Map resolveSourceByToolName() { + if (mcpSyncClients == null) { + return Map.of(); + } + Map sourceByToolName = new LinkedHashMap<>(); + String singleConfiguredSourceName = singleConfiguredSourceName(); + mcpSyncClients.orderedStream() + .forEach(client -> addClientTools(sourceByToolName, client, singleConfiguredSourceName)); + return sourceByToolName; + } + + 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.getClientInfo() != null && client.getClientInfo().name() != null + && !client.getClientInfo().name().isBlank()) { + return client.getClientInfo().name(); + } + return client.getServerInfo() != null ? client.getServerInfo().name() : null; + } + + private void addDescriptor(Map toolsByName, ToolCallback callback, + Map sourceByToolName) { if (callback == null || callback.getToolDefinition() == null) { return; } @@ -59,6 +111,14 @@ private static void addDescriptor(Map toolsByNam return; } toolsByName.putIfAbsent(definition.name(), - new ExternalToolDescriptor(definition.name(), definition.description())); + new ExternalToolDescriptor(definition.name(), definition.description(), resolveSourceName(definition.name(), sourceByToolName))); + } + + 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/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/tool/SpringAIExternalToolCatalogServiceTest.java b/opendaimon-spring-ai/src/test/java/io/github/ngirchev/opendaimon/ai/springai/tool/SpringAIExternalToolCatalogServiceTest.java index ca8a3063..32533e00 100644 --- 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 @@ -3,6 +3,8 @@ 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.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; @@ -25,7 +27,7 @@ void shouldListAllowedExternalToolsOnly() { toolCallback("read_file"), toolCallback("weather_lookup")); SpringAIExternalToolCatalogService service = new SpringAIExternalToolCatalogService( - providers, true, new McpToolAccessProperties()); + providers, mcpClients(), List.of(), true, new McpToolAccessProperties()); var tools = service.listAvailableTools(new ExternalToolAccessContext(1L, UserPriority.REGULAR)); @@ -38,7 +40,7 @@ void shouldListAllowedExternalToolsOnly() { void shouldListAdminFilesystemTools() { ObjectProvider providers = providers(toolCallback("read_file")); SpringAIExternalToolCatalogService service = new SpringAIExternalToolCatalogService( - providers, true, new McpToolAccessProperties()); + providers, mcpClients(), List.of(), true, new McpToolAccessProperties()); var tools = service.listAvailableTools(new ExternalToolAccessContext(1L, UserPriority.ADMIN)); @@ -47,11 +49,34 @@ void shouldListAdminFilesystemTools() { .containsExactly("read_file"); } + @Test + void shouldUseMcpClientIdentifierAsSourceName() { + 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 shouldReturnEmptyWhenExternalToolsDisabled() { ObjectProvider providers = providers(toolCallback("weather_lookup")); SpringAIExternalToolCatalogService service = new SpringAIExternalToolCatalogService( - providers, false, new McpToolAccessProperties()); + providers, mcpClients(), List.of(), false, new McpToolAccessProperties()); var tools = service.listAvailableTools(new ExternalToolAccessContext(1L, UserPriority.ADMIN)); @@ -66,6 +91,16 @@ private static ObjectProvider providers(ToolCallback... ca 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 ToolCallback toolCallback(String name) { ToolDefinition definition = ToolDefinition.builder() .name(name) diff --git a/opendaimon-telegram/TELEGRAM_MODULE.md b/opendaimon-telegram/TELEGRAM_MODULE.md index bc99f4f1..6d810346 100644 --- a/opendaimon-telegram/TELEGRAM_MODULE.md +++ b/opendaimon-telegram/TELEGRAM_MODULE.md @@ -151,7 +151,11 @@ Each handler is conditional on `open-daimon.telegram.commands.-enabled` `/mcp` lists external MCP tools visible to the current invoker. It delegates to the common `ExternalToolCatalogService` SPI, so Telegram never applies MCP access policy itself. The Spring AI implementation filters tools through the same -`open-daimon.mcp.tool-access` rules used by real tool execution. +`open-daimon.mcp.tool-access` rules used by real tool execution. Tools are grouped +by configured MCP source display name when present (for example, +`@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. --- @@ -387,14 +391,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/handler/impl/McpTelegramCommandHandler.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/McpTelegramCommandHandler.java index a932b10a..202e650b 100644 --- a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/McpTelegramCommandHandler.java +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/McpTelegramCommandHandler.java @@ -4,7 +4,7 @@ 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.ExternalToolDescriptor; +import io.github.ngirchev.opendaimon.common.ai.tool.ExternalToolSourceDescriptor; import io.github.ngirchev.opendaimon.common.command.ICommand; import io.github.ngirchev.opendaimon.common.service.MessageLocalizationService; import io.github.ngirchev.opendaimon.telegram.TelegramBot; @@ -15,11 +15,14 @@ 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 McpTelegramCommandHandler 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; @@ -55,26 +58,44 @@ public String handleInner(TelegramCommand command) { } UserPriority priority = userPriorityService.getUserPriority(command.userId()); - List tools = catalog.listAvailableTools( + List sources = catalog.listAvailableToolSources( new ExternalToolAccessContext(command.userId(), priority)); - if (tools.isEmpty()) { + if (sources.isEmpty()) { return messageLocalizationService.getMessage("telegram.mcp.empty", command.languageCode(), priority); } StringBuilder response = new StringBuilder(messageLocalizationService.getMessage( "telegram.mcp.header", command.languageCode(), priority)); - tools.stream() - .sorted(Comparator.comparing(ExternalToolDescriptor::name)) - .forEach(tool -> response.append('\n').append(renderTool(tool))); + 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 renderTool(ExternalToolDescriptor tool) { - String name = TelegramHtmlEscaper.escape(tool.name()); - if (tool.description() == null || tool.description().isBlank()) { - return "• " + name + ""; + private static String renderToolLine(List sources) { + String commands = sources.stream() + .sorted(java.util.Comparator.comparing(ExternalToolSourceDescriptor::name)) + .map(McpTelegramCommandHandler::renderSourceTools) + .collect(Collectors.joining("\n")); + return TelegramHtmlEscaper.escape(truncateToolLine(commands)); + } + + private static String renderSourceTools(ExternalToolSourceDescriptor source) { + String commands = source.tools().stream() + .map(tool -> tool.name()) + .sorted() + .collect(Collectors.joining(", ")); + return "• " + source.name() + " - " + commands; + } + + private static String truncateToolLine(String commands) { + if (commands.length() <= MAX_TOOL_LINE_CHARS) { + return commands; } - return "• " + name + " — " + TelegramHtmlEscaper.escape(tool.description()); + return commands.substring(0, MAX_TOOL_LINE_CHARS - 3) + "..."; } @Override 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/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/McpTelegramCommandHandlerTest.java b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/McpTelegramCommandHandlerTest.java index 7a3954d2..bdb71d7a 100644 --- a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/McpTelegramCommandHandlerTest.java +++ b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/McpTelegramCommandHandlerTest.java @@ -5,6 +5,7 @@ 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.ExternalToolSourceDescriptor; import io.github.ngirchev.opendaimon.common.service.MessageLocalizationService; import io.github.ngirchev.opendaimon.telegram.TelegramBot; import io.github.ngirchev.opendaimon.telegram.command.TelegramCommand; @@ -82,7 +83,7 @@ void shouldReturnUnavailableWhenCatalogMissing() { void shouldReturnEmptyWhenUserHasNoAllowedTools() { when(catalogProvider.getIfAvailable()).thenReturn(catalogService); when(userPriorityService.getUserPriority(USER_ID)).thenReturn(UserPriority.REGULAR); - when(catalogService.listAvailableTools(new ExternalToolAccessContext(USER_ID, UserPriority.REGULAR))) + when(catalogService.listAvailableToolSources(new ExternalToolAccessContext(USER_ID, UserPriority.REGULAR))) .thenReturn(List.of()); String response = handler.handleInner(command(TelegramCommand.MCP)); @@ -94,15 +95,53 @@ void shouldReturnEmptyWhenUserHasNoAllowedTools() { void shouldRenderAvailableToolsEscaped() { when(catalogProvider.getIfAvailable()).thenReturn(catalogService); when(userPriorityService.getUserPriority(USER_ID)).thenReturn(UserPriority.ADMIN); - when(catalogService.listAvailableTools(new ExternalToolAccessContext(USER_ID, UserPriority.ADMIN))) - .thenReturn(List.of(new ExternalToolDescriptor("read_file", "Read "))); + when(catalogService.listAvailableToolSources(new ExternalToolAccessContext(USER_ID, UserPriority.ADMIN))) + .thenReturn(List.of(new ExternalToolSourceDescriptor("filesystem", + List.of(new ExternalToolDescriptor("read_file", "Read "))))); String response = handler.handleInner(command(TelegramCommand.MCP)); assertThat(response) .contains("Available MCP tools for ADMIN:") - .contains("read_file") - .contains("Read <files>"); + .contains("• filesystem - read_file") + .doesNotContain("Read <files>"); + } + + @Test + void shouldRenderToolNamesOnly() { + when(catalogProvider.getIfAvailable()).thenReturn(catalogService); + when(userPriorityService.getUserPriority(USER_ID)).thenReturn(UserPriority.ADMIN); + when(catalogService.listAvailableToolSources(new ExternalToolAccessContext(USER_ID, UserPriority.ADMIN))) + .thenReturn(List.of(new ExternalToolSourceDescriptor("custom-server", + List.of(new ExternalToolDescriptor( + "custom_tool", + "First second third fourth fifth sixth seventh eighth ninth"))))); + + String response = handler.handleInner(command(TelegramCommand.MCP)); + + assertThat(response) + .contains("• custom-server - custom_tool") + .doesNotContain("First second"); + } + + @Test + void shouldKeepResponseBelowTelegramLimit() { + when(catalogProvider.getIfAvailable()).thenReturn(catalogService); + when(userPriorityService.getUserPriority(USER_ID)).thenReturn(UserPriority.ADMIN); + List tools = java.util.stream.IntStream.range(0, 500) + .mapToObj(i -> new ExternalToolDescriptor( + "very_long_external_tool_name_" + i, + "Very long description ".repeat(100), + "server")) + .toList(); + when(catalogService.listAvailableToolSources(new ExternalToolAccessContext(USER_ID, UserPriority.ADMIN))) + .thenReturn(List.of(new ExternalToolSourceDescriptor("server", tools))); + + String response = handler.handleInner(command(TelegramCommand.MCP)); + + assertThat(response) + .hasSizeLessThan(4096) + .contains("..."); } @Test diff --git a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/TelegramGroupServiceTest.java b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/TelegramGroupServiceTest.java index 5b3f12b7..a8cbb25f 100644 --- a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/TelegramGroupServiceTest.java +++ b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/service/TelegramGroupServiceTest.java @@ -55,7 +55,7 @@ void shouldCreateNewGroupWhenGetOrCreateCalledForUnknownChat() { assertThat(result.getIsAdmin()).isFalse(); assertThat(result.getAgentModeEnabled()).isEqualTo(DEFAULT_AGENT_MODE_ENABLED); assertThat(result.getCreatedAt()).isNotNull(); - assertThat(result.getLanguageCode()).isEqualTo("en"); // default language on creation + assertThat(result.getLanguageCode()).isNull(); } @Test diff --git a/pom.xml b/pom.xml index acf93318..67303ca1 100644 --- a/pom.xml +++ b/pom.xml @@ -116,6 +116,7 @@ 2.3.232 1.1.2 + 0.17.0 UTF-8 UTF-8 From c1c89dce95b65cccca5913f67f98d41e7fb1916b Mon Sep 17 00:00:00 2001 From: ngirchev Date: Sun, 10 May 2026 16:59:08 +0000 Subject: [PATCH 06/10] Fix MCP tool access safeguards --- .../agent/SpringAgentLoopActions.java | 8 ++-- .../SpringAIExternalToolCatalogService.java | 22 ++++++--- ...ringAgentLoopActionsFetchUrlGuardTest.java | 19 ++++++++ ...pringAIExternalToolCatalogServiceTest.java | 47 ++++++++++++++++++- 4 files changed, 83 insertions(+), 13 deletions(-) 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 29f53ce6..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 @@ -93,7 +93,6 @@ 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; @@ -153,7 +152,6 @@ public SpringAgentLoopActions(ChatModel chatModel, this.urlLivenessChecker = urlLivenessChecker; this.priorityRequestExecutor = priorityRequestExecutor; this.mcpToolAccessProperties = mcpToolAccessProperties != null ? mcpToolAccessProperties : new McpToolAccessProperties(); - this.rawToolCallParser = new RawToolCallParser(this.toolCallbacks); this.summaryModelInvoker = new SummaryModelInvoker(chatModel, priorityRequestExecutor); } @@ -251,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()); @@ -771,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); @@ -783,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/tool/SpringAIExternalToolCatalogService.java b/opendaimon-spring-ai/src/main/java/io/github/ngirchev/opendaimon/ai/springai/tool/SpringAIExternalToolCatalogService.java index 96ac40bb..ec7562a7 100644 --- 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 @@ -65,12 +65,20 @@ private Map resolveSourceByToolName() { return Map.of(); } Map sourceByToolName = new LinkedHashMap<>(); - String singleConfiguredSourceName = singleConfiguredSourceName(); - mcpSyncClients.orderedStream() - .forEach(client -> addClientTools(sourceByToolName, client, singleConfiguredSourceName)); + 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; } @@ -94,11 +102,11 @@ private static void addClientTools(Map sourceByToolName, McpSync } static String resolveSourceName(McpSyncClient client) { - if (client.getClientInfo() != null && client.getClientInfo().name() != null - && !client.getClientInfo().name().isBlank()) { - return client.getClientInfo().name(); + if (client.getServerInfo() != null && client.getServerInfo().name() != null + && !client.getServerInfo().name().isBlank()) { + return client.getServerInfo().name(); } - return client.getServerInfo() != null ? client.getServerInfo().name() : null; + return client.getClientInfo() != null ? client.getClientInfo().name() : null; } private void addDescriptor(Map toolsByName, ToolCallback callback, 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 49198d7d..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 @@ -102,6 +102,25 @@ void shouldExposeExternalToolsOnlyForAdminContext() { .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), 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 index 32533e00..51e4b25e 100644 --- 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 @@ -50,7 +50,18 @@ void shouldListAdminFilesystemTools() { } @Test - void shouldUseMcpClientIdentifierAsSourceName() { + 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")); @@ -72,6 +83,29 @@ void shouldUseSingleConfiguredConnectionNameAsSourceFallback() { .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")); @@ -101,6 +135,17 @@ private static ObjectProvider mcpClients(McpSyncClient... mcpClie return clients; } + 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) From e7518fe31fb825e16820217ba8244b836cf9c68e Mon Sep 17 00:00:00 2001 From: ngirchev Date: Sun, 10 May 2026 20:23:39 +0000 Subject: [PATCH 07/10] Replace MCP command with tools catalog --- Dockerfile | 4 +- .../src/main/resources/application-mock.yml | 1 + .../src/main/resources/application.yml | 1 + .../ai/tool/ExternalToolCatalogService.java | 11 ++- .../ai/tool/ExternalToolDescriptor.java | 13 +++- .../ai/tool/ExternalToolSourceDescriptor.java | 6 ++ .../ai/tool/ExternalToolSourceType.java | 7 ++ .../common/config/FeatureToggle.java | 4 +- opendaimon-mcp/MCP_MODULE.md | 7 +- opendaimon-spring-ai/SPRING_AI_MODULE.md | 5 ++ .../springai/config/SpringAIAutoConfig.java | 5 ++ .../SpringAIExternalToolCatalogService.java | 78 +++++++++++++++++-- ...pringAIExternalToolCatalogServiceTest.java | 52 +++++++++++++ opendaimon-telegram/TELEGRAM_MODULE.md | 19 ++--- .../telegram/command/TelegramCommand.java | 2 +- ....java => ToolsTelegramCommandHandler.java} | 33 +++++--- .../config/TelegramCommandHandlerConfig.java | 6 +- .../resources/messages/telegram_en.properties | 8 +- .../resources/messages/telegram_ru.properties | 8 +- .../telegram/command/TelegramCommandTest.java | 1 + ...a => ToolsTelegramCommandHandlerTest.java} | 78 +++++++++++-------- 21 files changed, 267 insertions(+), 82 deletions(-) create mode 100644 opendaimon-common/src/main/java/io/github/ngirchev/opendaimon/common/ai/tool/ExternalToolSourceType.java rename opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/{McpTelegramCommandHandler.java => ToolsTelegramCommandHandler.java} (78%) rename opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/{McpTelegramCommandHandlerTest.java => ToolsTelegramCommandHandlerTest.java} (69%) diff --git a/Dockerfile b/Dockerfile index bdfe0535..7dc103a3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ 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 . @@ -38,7 +38,7 @@ RUN apk add --no-cache nodejs npm \ && npm install -g zod-to-json-schema@3.23.5 \ && npm cache clean --force -ARG APP_VERSION=1.1.0-SNAPSHOT +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 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 e8104e83..9d245923 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: 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 index fd91cd32..27211d03 100644 --- 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 @@ -11,14 +11,21 @@ public interface ExternalToolCatalogService { default List listAvailableToolSources(ExternalToolAccessContext context) { Map> toolsBySource = listAvailableTools(context).stream() .collect(Collectors.groupingBy( - ExternalToolCatalogService::sourceName, + ExternalToolCatalogService::sourceKey, java.util.LinkedHashMap::new, Collectors.toList())); return toolsBySource.entrySet().stream() - .map(entry -> new ExternalToolSourceDescriptor(entry.getKey(), entry.getValue())) + .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() 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 index f5610b29..4c676b01 100644 --- 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 @@ -3,10 +3,19 @@ public record ExternalToolDescriptor( String name, String description, - String sourceName + String sourceName, + ExternalToolSourceType sourceType ) { public ExternalToolDescriptor(String name, String description) { - this(name, description, null); + 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 index 7df4e267..307a7548 100644 --- 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 @@ -4,10 +4,16 @@ 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 d9ce99a9..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 @@ -78,7 +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 MCP = "mcp-enabled"; + public static final String TOOLS = "tools-enabled"; } // ── OpenRouter model rotation toggles (prefix-based) ──────── @@ -131,7 +131,7 @@ public enum Toggle { CMD_MODEL(TelegramCommand.PREFIX + "." + TelegramCommand.MODEL), CMD_MODE(TelegramCommand.PREFIX + "." + TelegramCommand.MODE), CMD_THINKING(TelegramCommand.PREFIX + "." + TelegramCommand.THINKING), - CMD_MCP(TelegramCommand.PREFIX + "." + TelegramCommand.MCP); + CMD_TOOLS(TelegramCommand.PREFIX + "." + TelegramCommand.TOOLS); private final String propertyKey; diff --git a/opendaimon-mcp/MCP_MODULE.md b/opendaimon-mcp/MCP_MODULE.md index 93e43334..b5cdcd31 100644 --- a/opendaimon-mcp/MCP_MODULE.md +++ b/opendaimon-mcp/MCP_MODULE.md @@ -22,9 +22,10 @@ OpenDaimon consumes those callbacks in `opendaimon-spring-ai`: - Built-in tools (`web_search`, `fetch_url`, `http_get`, `http_post`) remain available independently of MCP. -Telegram exposes `/mcp` to show the external MCP tools available to the current -user. The command uses the same `open-daimon.mcp.tool-access` mapping as runtime -tool execution, so ADMIN-only tools are not displayed to VIP or REGULAR users. +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 diff --git a/opendaimon-spring-ai/SPRING_AI_MODULE.md b/opendaimon-spring-ai/SPRING_AI_MODULE.md index 82b79e63..ad3c7c98 100644 --- a/opendaimon-spring-ai/SPRING_AI_MODULE.md +++ b/opendaimon-spring-ai/SPRING_AI_MODULE.md @@ -443,6 +443,11 @@ 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 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 86d65126..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 @@ -58,6 +58,7 @@ 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; @@ -187,12 +188,16 @@ public SpringAIPromptFactory springAIPromptFactory( @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), 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 index ec7562a7..d5582143 100644 --- 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 @@ -5,11 +5,13 @@ 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; @@ -19,6 +21,11 @@ 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; @@ -26,11 +33,15 @@ public class SpringAIExternalToolCatalogService implements ExternalToolCatalogSe 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 @@ -40,12 +51,26 @@ public SpringAIExternalToolCatalogService( ? 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 (!externalToolsEnabled || externalToolCallbackProviders == null || context == null) { + 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()) @@ -56,10 +81,21 @@ public List listAvailableTools(ExternalToolAccessContext .flatMap(Arrays::stream) .filter(callback -> !ExternalToolCallbacks.isBuiltInTool(callback)) .filter(callback -> ExternalToolCallbacks.isAllowedFor(callback, metadata, mcpToolAccessProperties)) - .forEach(callback -> addDescriptor(toolsByName, callback, sourceByToolName)); + .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(); @@ -109,17 +145,43 @@ static String resolveSourceName(McpSyncClient client) { return client.getClientInfo() != null ? client.getClientInfo().name() : null; } - private void addDescriptor(Map toolsByName, ToolCallback callback, - Map sourceByToolName) { - if (callback == null || callback.getToolDefinition() == null) { + private static void addBuiltInDescriptor(Map toolsByName, ToolCallback callback, + String sourceName) { + ToolDefinition definition = toolDefinition(callback); + if (definition == null) { return; } - ToolDefinition definition = callback.getToolDefinition(); - if (definition.name() == null || definition.name().isBlank()) { + 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))); + 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) { 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 index 51e4b25e..d620f002 100644 --- 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 @@ -3,6 +3,7 @@ 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; @@ -10,11 +11,15 @@ 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; @@ -117,6 +122,33 @@ void shouldReturnEmptyWhenExternalToolsDisabled() { 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); @@ -135,6 +167,26 @@ private static ObjectProvider mcpClients(McpSyncClient... mcpClie 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() diff --git a/opendaimon-telegram/TELEGRAM_MODULE.md b/opendaimon-telegram/TELEGRAM_MODULE.md index 6d810346..7d455acb 100644 --- a/opendaimon-telegram/TELEGRAM_MODULE.md +++ b/opendaimon-telegram/TELEGRAM_MODULE.md @@ -139,7 +139,7 @@ Handlers sorted by `priority()` (lower = first). First handler where `canHandle( | `RoleTelegramCommandHandler` | `/role` | 0 | | `LanguageTelegramCommandHandler` | `/language` | 0 | | `ModelTelegramCommandHandler` | `/model` | 0 | -| `McpTelegramCommandHandler` | `/mcp` | 0 | +| `ToolsTelegramCommandHandler` | `/tools` | 0 | | `BugreportTelegramCommandHandler` | `/bugreport` | 0 | | `HistoryTelegramCommandHandler` | `/history` | 0 | | `ThreadsTelegramCommandHandler` | `/threads` | 0 | @@ -148,14 +148,15 @@ Handlers sorted by `priority()` (lower = first). First handler where `canHandle( Each handler is conditional on `open-daimon.telegram.commands.-enabled` (default: true). -`/mcp` lists external MCP tools visible to the current invoker. It delegates to the -common `ExternalToolCatalogService` SPI, so Telegram never applies MCP access policy -itself. The Spring AI implementation filters tools through the same -`open-daimon.mcp.tool-access` rules used by real tool execution. Tools are grouped -by configured MCP source display name when present (for example, -`@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. +`/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. --- 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 e059a654..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,7 +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 MCP = "/mcp"; + 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/McpTelegramCommandHandler.java b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/ToolsTelegramCommandHandler.java similarity index 78% rename from opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/McpTelegramCommandHandler.java rename to opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/ToolsTelegramCommandHandler.java index 202e650b..c6ccc93a 100644 --- a/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/McpTelegramCommandHandler.java +++ b/opendaimon-telegram/src/main/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/ToolsTelegramCommandHandler.java @@ -5,6 +5,7 @@ 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; @@ -15,10 +16,11 @@ 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 McpTelegramCommandHandler extends AbstractTelegramCommandHandlerWithResponseSend { +public class ToolsTelegramCommandHandler extends AbstractTelegramCommandHandlerWithResponseSend { private static final int MAX_RESPONSE_CHARS = 3900; private static final int MAX_TOOL_LINE_CHARS = 1000; @@ -26,7 +28,7 @@ public class McpTelegramCommandHandler extends AbstractTelegramCommandHandlerWit private final ObjectProvider externalToolCatalogServiceProvider; private final IUserPriorityService userPriorityService; - public McpTelegramCommandHandler( + public ToolsTelegramCommandHandler( ObjectProvider telegramBotProvider, TypingIndicatorService typingIndicatorService, MessageLocalizationService messageLocalizationService, @@ -47,25 +49,25 @@ public boolean canHandle(ICommand command) { var commandType = command.commandType(); return command instanceof TelegramCommand && commandType != null - && TelegramCommand.MCP.equals(commandType.command()); + && TelegramCommand.TOOLS.equals(commandType.command()); } @Override public String handleInner(TelegramCommand command) { ExternalToolCatalogService catalog = externalToolCatalogServiceProvider.getIfAvailable(); if (catalog == null) { - return messageLocalizationService.getMessage("telegram.mcp.unavailable", command.languageCode()); + 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.mcp.empty", command.languageCode(), priority); + return messageLocalizationService.getMessage("telegram.tools.empty", command.languageCode(), priority); } StringBuilder response = new StringBuilder(messageLocalizationService.getMessage( - "telegram.mcp.header", command.languageCode(), priority)); + "telegram.tools.header", command.languageCode(), priority)); String line = "\n" + renderToolLine(sources); if (response.length() + line.length() > MAX_RESPONSE_CHARS) { response.append("\n..."); @@ -77,18 +79,29 @@ public String handleInner(TelegramCommand command) { private static String renderToolLine(List sources) { String commands = sources.stream() - .sorted(java.util.Comparator.comparing(ExternalToolSourceDescriptor::name)) - .map(McpTelegramCommandHandler::renderSourceTools) + .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 "• " + source.name() + " - " + commands; + 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) { @@ -100,6 +113,6 @@ private static String truncateToolLine(String commands) { @Override public String getSupportedCommandText(String languageCode) { - return messageLocalizationService.getMessage("telegram.command.mcp.desc", 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 0b7f7914..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 @@ -145,14 +145,14 @@ public ThinkingTelegramCommandHandler thinkingTelegramCommandHandler( @Bean @ConditionalOnMissingBean - @ConditionalOnProperty(prefix = FeatureToggle.TelegramCommand.PREFIX, name = FeatureToggle.TelegramCommand.MCP, havingValue = "true", matchIfMissing = true) - public McpTelegramCommandHandler mcpTelegramCommandHandler( + @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 McpTelegramCommandHandler( + return new ToolsTelegramCommandHandler( telegramBotProvider, typingIndicatorService, messageLocalizationService, diff --git a/opendaimon-telegram/src/main/resources/messages/telegram_en.properties b/opendaimon-telegram/src/main/resources/messages/telegram_en.properties index f3f84a91..4cc5d78f 100644 --- a/opendaimon-telegram/src/main/resources/messages/telegram_en.properties +++ b/opendaimon-telegram/src/main/resources/messages/telegram_en.properties @@ -89,7 +89,7 @@ telegram.mode.updated=Mode switched: {0} telegram.mode.close=\u274C Cancel / Close telegram.mode.unknown=Unknown mode telegram.command.thinking.desc=/thinking - configure reasoning visibility -telegram.command.mcp.desc=/mcp - list available MCP tools +telegram.command.tools.desc=/tools - list available tools telegram.thinking.current=Current setting: {0} telegram.thinking.select=Choose reasoning visibility: telegram.thinking.updated=Reasoning visibility updated: {0} @@ -101,6 +101,6 @@ telegram.thinking.current.tools_only=Tools only telegram.thinking.current.silent=Silent mode telegram.thinking.close=\u274C Cancel / Close telegram.thinking.unknown=Unknown option -telegram.mcp.unavailable=MCP tools are not configured. -telegram.mcp.empty=No MCP tools are available for your access level ({0}). -telegram.mcp.header=Available MCP tools for {0}: +telegram.tools.unavailable=Tools are not configured. +telegram.tools.empty=No tools are available for your access level ({0}). +telegram.tools.header=Available tools for {0}: diff --git a/opendaimon-telegram/src/main/resources/messages/telegram_ru.properties b/opendaimon-telegram/src/main/resources/messages/telegram_ru.properties index 04bcfafc..921cd699 100644 --- a/opendaimon-telegram/src/main/resources/messages/telegram_ru.properties +++ b/opendaimon-telegram/src/main/resources/messages/telegram_ru.properties @@ -89,7 +89,7 @@ telegram.mode.updated=Режим изменён: {0} telegram.mode.close=\u274C Отмена / закрыть telegram.mode.unknown=Неизвестный режим telegram.command.thinking.desc=/thinking - настройка отображения рассуждений -telegram.command.mcp.desc=/mcp - список доступных MCP-инструментов +telegram.command.tools.desc=/tools - список доступных инструментов telegram.thinking.current=Текущая настройка: {0} telegram.thinking.select=Выберите режим отображения рассуждений: telegram.thinking.updated=Режим отображения рассуждений изменён: {0} @@ -101,6 +101,6 @@ telegram.thinking.current.tools_only=Только инструменты telegram.thinking.current.silent=Тихий режим telegram.thinking.close=\u274C Отмена / закрыть telegram.thinking.unknown=Неизвестная опция -telegram.mcp.unavailable=MCP-инструменты не настроены. -telegram.mcp.empty=Для вашего уровня доступа ({0}) нет доступных MCP-инструментов. -telegram.mcp.header=Доступные MCP-инструменты для {0}: +telegram.tools.unavailable=Инструменты не настроены. +telegram.tools.empty=Для вашего уровня доступа ({0}) нет доступных инструментов. +telegram.tools.header=Доступные инструменты для {0}: diff --git a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/TelegramCommandTest.java b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/TelegramCommandTest.java index 87eac370..ec9061ef 100644 --- a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/TelegramCommandTest.java +++ b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/TelegramCommandTest.java @@ -92,5 +92,6 @@ void commandConstants_areDefined() { assertEquals("/history", TelegramCommand.HISTORY); assertEquals("/threads", TelegramCommand.THREADS); assertEquals("/language", TelegramCommand.LANGUAGE); + assertEquals("/tools", TelegramCommand.TOOLS); } } diff --git a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/McpTelegramCommandHandlerTest.java b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/ToolsTelegramCommandHandlerTest.java similarity index 69% rename from opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/McpTelegramCommandHandlerTest.java rename to opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/ToolsTelegramCommandHandlerTest.java index bdb71d7a..f3c3ecad 100644 --- a/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/McpTelegramCommandHandlerTest.java +++ b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/ToolsTelegramCommandHandlerTest.java @@ -6,6 +6,7 @@ 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.ExternalToolSourceDescriptor; +import io.github.ngirchev.opendaimon.common.ai.tool.ExternalToolSourceType; import io.github.ngirchev.opendaimon.common.service.MessageLocalizationService; import io.github.ngirchev.opendaimon.telegram.TelegramBot; import io.github.ngirchev.opendaimon.telegram.command.TelegramCommand; @@ -32,7 +33,7 @@ @ExtendWith(MockitoExtension.class) @MockitoSettings(strictness = Strictness.LENIENT) -class McpTelegramCommandHandlerTest { +class ToolsTelegramCommandHandlerTest { private static final long USER_ID = 2L; private static final long CHAT_ID = 42L; @@ -44,19 +45,19 @@ class McpTelegramCommandHandlerTest { @Mock private ExternalToolCatalogService catalogService; @Mock private IUserPriorityService userPriorityService; - private McpTelegramCommandHandler handler; + private ToolsTelegramCommandHandler handler; @BeforeEach void setUp() { - when(messageLocalizationService.getMessage(eq("telegram.command.mcp.desc"), anyString())) - .thenReturn("/mcp - list available MCP tools"); - when(messageLocalizationService.getMessage(eq("telegram.mcp.unavailable"), anyString())) - .thenReturn("MCP tools are not configured."); - when(messageLocalizationService.getMessage(eq("telegram.mcp.empty"), anyString(), any())) - .thenAnswer(inv -> "No MCP tools are available for " + inv.getArgument(2)); - when(messageLocalizationService.getMessage(eq("telegram.mcp.header"), anyString(), any())) - .thenAnswer(inv -> "Available MCP tools for " + inv.getArgument(2) + ":"); - handler = new McpTelegramCommandHandler( + when(messageLocalizationService.getMessage(eq("telegram.command.tools.desc"), anyString())) + .thenReturn("/tools - list available tools"); + when(messageLocalizationService.getMessage(eq("telegram.tools.unavailable"), anyString())) + .thenReturn("Tools are not configured."); + when(messageLocalizationService.getMessage(eq("telegram.tools.empty"), anyString(), any())) + .thenAnswer(inv -> "No tools are available for " + inv.getArgument(2)); + when(messageLocalizationService.getMessage(eq("telegram.tools.header"), anyString(), any())) + .thenAnswer(inv -> "Available tools for " + inv.getArgument(2) + ":"); + handler = new ToolsTelegramCommandHandler( telegramBotProvider, typingIndicatorService, messageLocalizationService, @@ -65,18 +66,18 @@ void setUp() { } @Test - void canHandleMcpCommand() { - assertThat(handler.canHandle(command(TelegramCommand.MCP))).isTrue(); - assertThat(handler.canHandle(command(TelegramCommand.MODEL))).isFalse(); + void canHandleToolsCommandOnly() { + assertThat(handler.canHandle(command(TelegramCommand.TOOLS))).isTrue(); + assertThat(handler.canHandle(command("/mcp"))).isFalse(); } @Test void shouldReturnUnavailableWhenCatalogMissing() { when(catalogProvider.getIfAvailable()).thenReturn(null); - String response = handler.handleInner(command(TelegramCommand.MCP)); + String response = handler.handleInner(command(TelegramCommand.TOOLS)); - assertThat(response).isEqualTo("MCP tools are not configured."); + assertThat(response).isEqualTo("Tools are not configured."); } @Test @@ -86,24 +87,34 @@ void shouldReturnEmptyWhenUserHasNoAllowedTools() { when(catalogService.listAvailableToolSources(new ExternalToolAccessContext(USER_ID, UserPriority.REGULAR))) .thenReturn(List.of()); - String response = handler.handleInner(command(TelegramCommand.MCP)); + String response = handler.handleInner(command(TelegramCommand.TOOLS)); assertThat(response).contains("REGULAR"); } @Test - void shouldRenderAvailableToolsEscaped() { + void shouldRenderBuiltInAndMcpToolsEscaped() { when(catalogProvider.getIfAvailable()).thenReturn(catalogService); when(userPriorityService.getUserPriority(USER_ID)).thenReturn(UserPriority.ADMIN); when(catalogService.listAvailableToolSources(new ExternalToolAccessContext(USER_ID, UserPriority.ADMIN))) - .thenReturn(List.of(new ExternalToolSourceDescriptor("filesystem", - List.of(new ExternalToolDescriptor("read_file", "Read "))))); - - String response = handler.handleInner(command(TelegramCommand.MCP)); + .thenReturn(List.of( + new ExternalToolSourceDescriptor("webtools", ExternalToolSourceType.BUILT_IN, + List.of(new ExternalToolDescriptor( + "web_search", "Search ", "webtools", ExternalToolSourceType.BUILT_IN))), + new ExternalToolSourceDescriptor("@modelcontextprotocol/server-filesystem@0.2.0", + ExternalToolSourceType.MCP, + List.of(new ExternalToolDescriptor( + "read_file", "Read ", + "@modelcontextprotocol/server-filesystem@0.2.0", + ExternalToolSourceType.MCP))))); + + String response = handler.handleInner(command(TelegramCommand.TOOLS)); assertThat(response) - .contains("Available MCP tools for ADMIN:") - .contains("• filesystem - read_file") + .contains("Available tools for ADMIN:") + .contains("• webtools - web_search") + .contains("• mcp: @modelcontextprotocol/server-filesystem@0.2.0 - read_file") + .doesNotContain("Search <web>") .doesNotContain("Read <files>"); } @@ -112,15 +123,17 @@ void shouldRenderToolNamesOnly() { when(catalogProvider.getIfAvailable()).thenReturn(catalogService); when(userPriorityService.getUserPriority(USER_ID)).thenReturn(UserPriority.ADMIN); when(catalogService.listAvailableToolSources(new ExternalToolAccessContext(USER_ID, UserPriority.ADMIN))) - .thenReturn(List.of(new ExternalToolSourceDescriptor("custom-server", + .thenReturn(List.of(new ExternalToolSourceDescriptor("custom-server", ExternalToolSourceType.MCP, List.of(new ExternalToolDescriptor( "custom_tool", - "First second third fourth fifth sixth seventh eighth ninth"))))); + "First second third fourth fifth sixth seventh eighth ninth", + "custom-server", + ExternalToolSourceType.MCP))))); - String response = handler.handleInner(command(TelegramCommand.MCP)); + String response = handler.handleInner(command(TelegramCommand.TOOLS)); assertThat(response) - .contains("• custom-server - custom_tool") + .contains("• mcp: custom-server - custom_tool") .doesNotContain("First second"); } @@ -132,12 +145,13 @@ void shouldKeepResponseBelowTelegramLimit() { .mapToObj(i -> new ExternalToolDescriptor( "very_long_external_tool_name_" + i, "Very long description ".repeat(100), - "server")) + "server", + ExternalToolSourceType.MCP)) .toList(); when(catalogService.listAvailableToolSources(new ExternalToolAccessContext(USER_ID, UserPriority.ADMIN))) - .thenReturn(List.of(new ExternalToolSourceDescriptor("server", tools))); + .thenReturn(List.of(new ExternalToolSourceDescriptor("server", ExternalToolSourceType.MCP, tools))); - String response = handler.handleInner(command(TelegramCommand.MCP)); + String response = handler.handleInner(command(TelegramCommand.TOOLS)); assertThat(response) .hasSizeLessThan(4096) @@ -147,7 +161,7 @@ void shouldKeepResponseBelowTelegramLimit() { @Test void shouldProvideStartMenuDescription() { assertThat(handler.getSupportedCommandText("en")) - .isEqualTo("/mcp - list available MCP tools"); + .isEqualTo("/tools - list available tools"); } private static TelegramCommand command(String commandText) { From a414d7387e7508516d222690eb224fd09427e5d9 Mon Sep 17 00:00:00 2001 From: ngirchev Date: Sun, 10 May 2026 22:30:06 +0000 Subject: [PATCH 08/10] Fix MCP filesystem access defaults --- .../src/main/resources/application.yml | 4 +-- opendaimon-mcp/MCP_MODULE.md | 32 +++++++++++-------- .../config/McpToolAccessProperties.java | 2 +- ...pringAIExternalToolCatalogServiceTest.java | 6 ++-- .../opendaimon/opendaimon-defaults.yml | 4 +-- 5 files changed, 26 insertions(+), 22 deletions(-) diff --git a/opendaimon-app/src/main/resources/application.yml b/opendaimon-app/src/main/resources/application.yml index 9d245923..e7afca32 100644 --- a/opendaimon-app/src/main/resources/application.yml +++ b/opendaimon-app/src/main/resources/application.yml @@ -159,7 +159,7 @@ open-daimon: # Filesystem MCP tools are restricted to ADMIN by default. default-roles: [ ADMIN, VIP, REGULAR ] rules: - - name-pattern: "^(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)$" + - 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: @@ -404,7 +404,7 @@ spring: ai: mcp: client: - enabled: ${MCP_CLIENT_ENABLED:true} + enabled: ${MCP_CLIENT_ENABLED:false} name: open-daimon version: ${OPEN_DAIMON_VERSION:dev} type: SYNC diff --git a/opendaimon-mcp/MCP_MODULE.md b/opendaimon-mcp/MCP_MODULE.md index b5cdcd31..99fb6eb5 100644 --- a/opendaimon-mcp/MCP_MODULE.md +++ b/opendaimon-mcp/MCP_MODULE.md @@ -38,7 +38,7 @@ open-daimon: tool-access: default-roles: [ADMIN, VIP, REGULAR] rules: - - name-pattern: "^(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)$" + - 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] ``` @@ -48,15 +48,16 @@ rule use `default-roles`. Built-in OpenDaimon tools are not governed by these MC rules. Spring AI MCP client creation is controlled by Spring AI properties. OpenDaimon -defaults keep client creation enabled; applications can disable it with -`MCP_CLIENT_ENABLED=false` or `spring.ai.mcp.client.enabled=false`: +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: true + enabled: false type: SYNC request-timeout: 30s ``` @@ -88,7 +89,8 @@ spring: ``` The bundled `opendaimon-app` setup includes the default filesystem MCP stdio -connection. The published starter defaults enable MCP client support but do not +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 @@ -96,7 +98,7 @@ spring: ai: mcp: client: - enabled: true + enabled: false stdio: connections: filesystem: @@ -106,19 +108,21 @@ spring: - exec ${MCP_FILESYSTEM_COMMAND:npx -y @modelcontextprotocol/server-filesystem} ${MCP_FILESYSTEM_ROOT:/app/mcp-filesystem} ``` -Disable it in Docker with `MCP_CLIENT_ENABLED=false`. 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 -local development. The server runs inside the OpenDaimon container and sees only +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. This is enforced in both agent and normal Spring -AI prompt flows. +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/`, 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 index 819223f9..6ae86acd 100644 --- 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 @@ -42,7 +42,7 @@ public boolean isAllowed(String toolName, UserPriority userPriority) { private static Rule filesystemAdminRule() { Rule rule = new Rule(); - rule.setNamePattern("^(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.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; } 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 index d620f002..35d0fbc6 100644 --- 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 @@ -29,7 +29,7 @@ class SpringAIExternalToolCatalogServiceTest { void shouldListAllowedExternalToolsOnly() { ObjectProvider providers = providers( toolCallback("web_search"), - toolCallback("read_file"), + toolCallback("open_daimon_read_file"), toolCallback("weather_lookup")); SpringAIExternalToolCatalogService service = new SpringAIExternalToolCatalogService( providers, mcpClients(), List.of(), true, new McpToolAccessProperties()); @@ -43,7 +43,7 @@ void shouldListAllowedExternalToolsOnly() { @Test void shouldListAdminFilesystemTools() { - ObjectProvider providers = providers(toolCallback("read_file")); + ObjectProvider providers = providers(toolCallback("open_daimon_read_file")); SpringAIExternalToolCatalogService service = new SpringAIExternalToolCatalogService( providers, mcpClients(), List.of(), true, new McpToolAccessProperties()); @@ -51,7 +51,7 @@ void shouldListAdminFilesystemTools() { assertThat(tools) .extracting("name") - .containsExactly("read_file"); + .containsExactly("open_daimon_read_file"); } @Test 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 5022fa45..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 @@ -2,7 +2,7 @@ spring: ai: mcp: client: - enabled: ${MCP_CLIENT_ENABLED:true} + enabled: ${MCP_CLIENT_ENABLED:false} name: open-daimon version: ${OPEN_DAIMON_VERSION:dev} type: SYNC @@ -162,7 +162,7 @@ open-daimon: - VIP - REGULAR rules: - - name-pattern: "^(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)$" + - 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: From eef52968dd9d58f9eea4cad88511b587e713d45c Mon Sep 17 00:00:00 2001 From: ngirchev Date: Thu, 21 May 2026 20:01:44 +0000 Subject: [PATCH 09/10] v2 --- .serena/memories/run/application-start-command.md | 1 + .serena/memories/run/deploy_command_convention.md | 1 + 2 files changed, 2 insertions(+) create mode 100644 .serena/memories/run/application-start-command.md create mode 100644 .serena/memories/run/deploy_command_convention.md diff --git a/.serena/memories/run/application-start-command.md b/.serena/memories/run/application-start-command.md new file mode 100644 index 00000000..7c0e72ba --- /dev/null +++ b/.serena/memories/run/application-start-command.md @@ -0,0 +1 @@ +When the user asks to start/run the application (`запусти приложение`, `запусти` in the context of the app), run it in Docker with a build: `docker compose up --build -d`. Use the repository root as the working directory. After starting, report container status/log hints. \ No newline at end of file diff --git a/.serena/memories/run/deploy_command_convention.md b/.serena/memories/run/deploy_command_convention.md new file mode 100644 index 00000000..de727290 --- /dev/null +++ b/.serena/memories/run/deploy_command_convention.md @@ -0,0 +1 @@ +When the user says "задеплой", "deploy", "запусти", or "run" for this project, interpret it as Docker Compose deployment/restart by default: use docker compose with build, i.e. `docker compose up -d --build` (or the repository-equivalent command if it explicitly wraps that). Do not ask for clarification unless the user mentions a different target/environment. \ No newline at end of file From 529b873ebaf5ae3e7ac9bc1bfd0a7fc98d535dc4 Mon Sep 17 00:00:00 2001 From: ngirchev Date: Thu, 21 May 2026 20:23:22 +0000 Subject: [PATCH 10/10] ignore local files --- .gitignore | 4 ++++ .serena/memories/run/application-start-command.md | 1 - .serena/memories/run/deploy_command_convention.md | 1 - 3 files changed, 4 insertions(+), 2 deletions(-) delete mode 100644 .serena/memories/run/application-start-command.md delete mode 100644 .serena/memories/run/deploy_command_convention.md diff --git a/.gitignore b/.gitignore index e2700597..6268430d 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,7 @@ /.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/.serena/memories/run/application-start-command.md b/.serena/memories/run/application-start-command.md deleted file mode 100644 index 7c0e72ba..00000000 --- a/.serena/memories/run/application-start-command.md +++ /dev/null @@ -1 +0,0 @@ -When the user asks to start/run the application (`запусти приложение`, `запусти` in the context of the app), run it in Docker with a build: `docker compose up --build -d`. Use the repository root as the working directory. After starting, report container status/log hints. \ No newline at end of file diff --git a/.serena/memories/run/deploy_command_convention.md b/.serena/memories/run/deploy_command_convention.md deleted file mode 100644 index de727290..00000000 --- a/.serena/memories/run/deploy_command_convention.md +++ /dev/null @@ -1 +0,0 @@ -When the user says "задеплой", "deploy", "запусти", or "run" for this project, interpret it as Docker Compose deployment/restart by default: use docker compose with build, i.e. `docker compose up -d --build` (or the repository-equivalent command if it explicitly wraps that). Do not ask for clarification unless the user mentions a different target/environment. \ No newline at end of file