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

Given the raw agent tool name (e.g. {@code web_search}), returns a user-facing - * English label (e.g. {@code Searching the web}) that is rendered into the - * {@code 🔧 Tool:

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 5094b113..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 @@ -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, @@ -594,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..4cc5d78f 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.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} @@ -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.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 09884cd0..921cd699 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.tools.desc=/tools - список доступных инструментов 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.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/ToolsTelegramCommandHandlerTest.java b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/ToolsTelegramCommandHandlerTest.java new file mode 100644 index 00000000..f3c3ecad --- /dev/null +++ b/opendaimon-telegram/src/test/java/io/github/ngirchev/opendaimon/telegram/command/handler/impl/ToolsTelegramCommandHandlerTest.java @@ -0,0 +1,176 @@ +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.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; +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 ToolsTelegramCommandHandlerTest { + + 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 ToolsTelegramCommandHandler handler; + + @BeforeEach + void setUp() { + 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, + catalogProvider, + userPriorityService); + } + + @Test + 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.TOOLS)); + + assertThat(response).isEqualTo("Tools are not configured."); + } + + @Test + void shouldReturnEmptyWhenUserHasNoAllowedTools() { + when(catalogProvider.getIfAvailable()).thenReturn(catalogService); + when(userPriorityService.getUserPriority(USER_ID)).thenReturn(UserPriority.REGULAR); + when(catalogService.listAvailableToolSources(new ExternalToolAccessContext(USER_ID, UserPriority.REGULAR))) + .thenReturn(List.of()); + + String response = handler.handleInner(command(TelegramCommand.TOOLS)); + + assertThat(response).contains("REGULAR"); + } + + @Test + 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("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 tools for ADMIN:") + .contains("• webtools - web_search") + .contains("• mcp: @modelcontextprotocol/server-filesystem@0.2.0 - read_file") + .doesNotContain("Search <web>") + .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", ExternalToolSourceType.MCP, + List.of(new ExternalToolDescriptor( + "custom_tool", + "First second third fourth fifth sixth seventh eighth ninth", + "custom-server", + ExternalToolSourceType.MCP))))); + + String response = handler.handleInner(command(TelegramCommand.TOOLS)); + + assertThat(response) + .contains("• mcp: 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", + ExternalToolSourceType.MCP)) + .toList(); + when(catalogService.listAvailableToolSources(new ExternalToolAccessContext(USER_ID, UserPriority.ADMIN))) + .thenReturn(List.of(new ExternalToolSourceDescriptor("server", ExternalToolSourceType.MCP, tools))); + + String response = handler.handleInner(command(TelegramCommand.TOOLS)); + + assertThat(response) + .hasSizeLessThan(4096) + .contains("..."); + } + + @Test + void shouldProvideStartMenuDescription() { + assertThat(handler.getSupportedCommandText("en")) + .isEqualTo("/tools - list available 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/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/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..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 @@ -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() { @@ -1133,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:"); diff --git a/pom.xml b/pom.xml index 23a01a91..b8486487 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 @@ -115,6 +116,7 @@ 2.3.232 1.1.2 + 0.17.0 UTF-8 UTF-8