diff --git a/README.md b/README.md index 25a1474..703d609 100644 --- a/README.md +++ b/README.md @@ -195,8 +195,9 @@ to the model. External provider diagnostics make that compatibility risk visible. This is a conservative diagnostic: external Spring AI `ToolCallbackProvider` beans are the main raw-provider exposure risk, although they may include non-raw business -providers. The default is `warn`; production deployments should use `fail` or at -least keep the default warning enabled: +providers. The default is `fail`; set `warn` or `off` only when the application +intentionally uses the external-provider compatibility path and still guarantees +that models receive `fastMcpSafeToolCallbackProvider` instead of raw providers: ```yaml fastmcp: @@ -279,8 +280,9 @@ Before using a configured server in production: - Resolve protected values from server-side runtime context through resolver beans; do not put sensitive values into `fastmcp.safe.*` configuration. - For Spring AI production deployments, set - `fastmcp.safe.diagnostics.external-raw-provider=fail` unless the application - intentionally uses the documented external-provider compatibility path. + `fastmcp.safe.diagnostics.external-raw-provider=fail` or keep the default + fail-closed behavior unless the application intentionally uses the documented + external-provider compatibility path. - Pass the safe provider, for example `fastMcpSafeToolCallbackProvider`, to the model. Do not pass every `ToolCallbackProvider` bean as a collection unless raw providers have been filtered out. diff --git a/README.zh-CN.md b/README.zh-CN.md index dec5ed3..16dcc06 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -182,8 +182,9 @@ Spring AI `ToolCallbackProvider` bean 仍然兼容,但应用侧应该把 safe 外部 provider diagnostics 可以显式暴露这条兼容路径的风险。这是保守诊断:外部 Spring AI `ToolCallbackProvider` bean 是 raw provider 暴露的主要风险,也可能包含 -非 raw 的业务 provider。默认值是 `warn`;生产环境建议使用 `fail`,至少保留默认 -warning: +非 raw 的业务 provider。默认值是 `fail`;只有当应用明确使用外部 provider +兼容路径,并且仍能保证模型侧接收的是 `fastMcpSafeToolCallbackProvider` 而不是 raw +provider 时,才应显式设置 `warn` 或 `off`: ```yaml fastmcp: @@ -259,9 +260,8 @@ Agent 框架 starter,也不会自行创建 MCP client。 `role`、`includeDeleted` 等 protected arguments。 - protected values 必须通过 resolver bean 从服务端运行时上下文解析,不写进 `fastmcp.safe.*` 配置。 -- Spring AI 生产部署建议设置 - `fastmcp.safe.diagnostics.external-raw-provider=fail`,除非应用明确使用文档里的 - external-provider 兼容路径。 +- Spring AI 生产部署应设置 `fastmcp.safe.diagnostics.external-raw-provider=fail`,或保留 + 默认 fail-closed 行为;除非应用明确使用文档里的 external-provider 兼容路径。 - 模型侧只接收 safe provider,例如 `fastMcpSafeToolCallbackProvider`;不要把所有 `ToolCallbackProvider` bean 作为集合直接交给模型,除非已经过滤掉 raw providers。 - 配置 `SafeAuditSink`,并确认 audit 只包含 virtual/raw tool names、caller/tenant diff --git a/fastmcp-examples/spring-ai-boot-starter/README.md b/fastmcp-examples/spring-ai-boot-starter/README.md index 9963f2e..36c9020 100644 --- a/fastmcp-examples/spring-ai-boot-starter/README.md +++ b/fastmcp-examples/spring-ai-boot-starter/README.md @@ -13,6 +13,8 @@ existing raw ToolCallbackProvider bean It covers: - `fastmcp.safe.*` binding into `SafeMcpConfiguration` +- explicit `fastmcp.safe.diagnostics.external-raw-provider=warn` for the + documented external raw provider compatibility path - resolver bean names such as `currentUserId` and `currentTenantId` - a primary safe `ToolCallbackProvider` published by the starter - raw provider remaining present but not being the provider selected by type @@ -24,9 +26,10 @@ It covers: When adapting this example to an application, inject the safe provider named `fastMcpSafeToolCallbackProvider` into the model wiring. Do not pass every `ToolCallbackProvider` bean to the model unless raw providers have been filtered -out. For production Spring AI deployments, prefer -`fastmcp.safe.diagnostics.external-raw-provider=fail` so accidental external raw -providers fail startup instead of relying on logs. +out. The starter defaults to +`fastmcp.safe.diagnostics.external-raw-provider=fail`; this example opts into +`warn` only because it intentionally demonstrates wrapping an existing external +raw provider. Run it from the repository root with JDK 17 or newer: diff --git a/fastmcp-examples/spring-ai-boot-starter/src/main/java/io/github/sandking/fastmcp/examples/springai/boot/FastMcpSpringAiBootExample.java b/fastmcp-examples/spring-ai-boot-starter/src/main/java/io/github/sandking/fastmcp/examples/springai/boot/FastMcpSpringAiBootExample.java index 5573732..cf9ca01 100644 --- a/fastmcp-examples/spring-ai-boot-starter/src/main/java/io/github/sandking/fastmcp/examples/springai/boot/FastMcpSpringAiBootExample.java +++ b/fastmcp-examples/spring-ai-boot-starter/src/main/java/io/github/sandking/fastmcp/examples/springai/boot/FastMcpSpringAiBootExample.java @@ -28,6 +28,7 @@ public static ToolContext currentUser(String tenantId, String userId) { public static String[] safeOrderProperties() { return new String[] { + "fastmcp.safe.diagnostics.external-raw-provider=warn", "fastmcp.safe.servers.orders.enabled=false", "fastmcp.safe.servers.orders.transport=stdio", "fastmcp.safe.servers.orders.command=node", diff --git a/fastmcp-safe-spring-boot-autoconfigure-support/src/main/java/io/github/sandking/fastmcp/safe/boot/FastMcpSafeProperties.java b/fastmcp-safe-spring-boot-autoconfigure-support/src/main/java/io/github/sandking/fastmcp/safe/boot/FastMcpSafeProperties.java index 3739d52..d16c740 100644 --- a/fastmcp-safe-spring-boot-autoconfigure-support/src/main/java/io/github/sandking/fastmcp/safe/boot/FastMcpSafeProperties.java +++ b/fastmcp-safe-spring-boot-autoconfigure-support/src/main/java/io/github/sandking/fastmcp/safe/boot/FastMcpSafeProperties.java @@ -162,14 +162,14 @@ private static boolean hasText(String value) { } public static class Diagnostics { - private String externalRawProvider = "warn"; + private String externalRawProvider = "fail"; public String getExternalRawProvider() { return externalRawProvider; } public void setExternalRawProvider(String externalRawProvider) { - this.externalRawProvider = externalRawProvider == null ? "warn" : externalRawProvider; + this.externalRawProvider = externalRawProvider == null ? "fail" : externalRawProvider; } } diff --git a/fastmcp-spring-ai-boot-starter/src/main/java/io/github/sandking/fastmcp/springai/boot/SpringAiExternalRawProviderDiagnostics.java b/fastmcp-spring-ai-boot-starter/src/main/java/io/github/sandking/fastmcp/springai/boot/SpringAiExternalRawProviderDiagnostics.java index 77d0778..cd8f934 100644 --- a/fastmcp-spring-ai-boot-starter/src/main/java/io/github/sandking/fastmcp/springai/boot/SpringAiExternalRawProviderDiagnostics.java +++ b/fastmcp-spring-ai-boot-starter/src/main/java/io/github/sandking/fastmcp/springai/boot/SpringAiExternalRawProviderDiagnostics.java @@ -45,7 +45,7 @@ void diagnose(List externalRawProviderNames, SafeAuditSink auditSink) { } private static String normalize(String mode) { - String normalizedMode = mode == null ? "warn" : mode.trim().toLowerCase(Locale.ROOT); + String normalizedMode = mode == null ? "fail" : mode.trim().toLowerCase(Locale.ROOT); if (!"warn".equals(normalizedMode) && !"fail".equals(normalizedMode) && !"off".equals(normalizedMode)) { throw new IllegalArgumentException("Unsupported fastmcp.safe.diagnostics.external-raw-provider: " + mode); } diff --git a/fastmcp-spring-ai-boot-starter/src/test/java/io/github/sandking/fastmcp/springai/boot/FastMcpSafeAutoConfigurationTest.java b/fastmcp-spring-ai-boot-starter/src/test/java/io/github/sandking/fastmcp/springai/boot/FastMcpSafeAutoConfigurationTest.java index 02d54b8..e44cf24 100644 --- a/fastmcp-spring-ai-boot-starter/src/test/java/io/github/sandking/fastmcp/springai/boot/FastMcpSafeAutoConfigurationTest.java +++ b/fastmcp-spring-ai-boot-starter/src/test/java/io/github/sandking/fastmcp/springai/boot/FastMcpSafeAutoConfigurationTest.java @@ -72,27 +72,30 @@ void bindsFastMcpSafePropertiesIntoSafeConfiguration() { @Test void createsPrimarySafeToolCallbackProviderFromConfiguredMappings() { - contextRunner.withUserConfiguration(RawToolConfiguration.class).run(context -> { - assertThat(context).hasBean("fastMcpSafeToolCallbackProvider"); - assertThat(context.getBean(ToolCallbackProvider.class)) - .isSameAs(context.getBean("fastMcpSafeToolCallbackProvider")); - - ToolCallbackProvider safeProvider = context.getBean("fastMcpSafeToolCallbackProvider", - ToolCallbackProvider.class); - ToolCallback safeTool = safeProvider.getToolCallbacks()[0]; - - assertThat(safeTool.getToolDefinition().name()).isEqualTo("get_my_orders"); - assertThat(safeTool.getToolDefinition().description()) - .isEqualTo("Get orders for the authenticated user."); - assertThat(safeTool.getToolDefinition().inputSchema()).doesNotContain("userId"); - - String result = safeTool.call("{\"status\":\"PAID\"}", new ToolContext(Map.of("userId", "user-123"))); - - CapturingToolCallback rawTool = context.getBean(CapturingToolCallback.class); - assertThat(result).isEqualTo("orders for user-123 with status PAID"); - assertThat(rawTool.lastInput()).containsEntry("orderStatus", "PAID") - .containsEntry("userId", "user-123"); - }); + contextRunner.withUserConfiguration(RawToolConfiguration.class) + .withPropertyValues("fastmcp.safe.diagnostics.external-raw-provider=off") + .run(context -> { + assertThat(context).hasBean("fastMcpSafeToolCallbackProvider"); + assertThat(context.getBean(ToolCallbackProvider.class)) + .isSameAs(context.getBean("fastMcpSafeToolCallbackProvider")); + + ToolCallbackProvider safeProvider = context.getBean("fastMcpSafeToolCallbackProvider", + ToolCallbackProvider.class); + ToolCallback safeTool = safeProvider.getToolCallbacks()[0]; + + assertThat(safeTool.getToolDefinition().name()).isEqualTo("get_my_orders"); + assertThat(safeTool.getToolDefinition().description()) + .isEqualTo("Get orders for the authenticated user."); + assertThat(safeTool.getToolDefinition().inputSchema()).doesNotContain("userId"); + + String result = safeTool.call("{\"status\":\"PAID\"}", + new ToolContext(Map.of("userId", "user-123"))); + + CapturingToolCallback rawTool = context.getBean(CapturingToolCallback.class); + assertThat(result).isEqualTo("orders for user-123 with status PAID"); + assertThat(rawTool.lastInput()).containsEntry("orderStatus", "PAID") + .containsEntry("userId", "user-123"); + }); } @Test @@ -126,6 +129,7 @@ void recordsExternalRawProviderDiagnosticsWithConfiguredAuditSink() { AUDIT_EVENTS.clear(); contextRunner.withUserConfiguration(RawToolConfiguration.class, AuditSinkConfiguration.class) + .withPropertyValues("fastmcp.safe.diagnostics.external-raw-provider=warn") .run(context -> { assertThat(context).hasNotFailed(); assertThat(context).hasBean("fastMcpSafeToolCallbackProvider"); @@ -145,7 +149,9 @@ void recordsExternalRawProviderDiagnosticsWithConfiguredAuditSink() { @Test void wrapsExternalRawProviderWhenManagedClientIsDisabled() { contextRunner.withUserConfiguration(RawToolConfiguration.class) - .withPropertyValues("fastmcp.safe.servers.orders.enabled=false") + .withPropertyValues( + "fastmcp.safe.diagnostics.external-raw-provider=off", + "fastmcp.safe.servers.orders.enabled=false") .run(context -> { ToolCallbackProvider safeProvider = context.getBean("fastMcpSafeToolCallbackProvider", ToolCallbackProvider.class); @@ -253,11 +259,13 @@ void failsClearlyWhenExternalRawProviderDiagnosticsModeIsInvalid() { } @Test - void defaultWarnDiagnosticsAllowsExternalRawProvider(CapturedOutput output) { - contextRunner.withUserConfiguration(RawToolConfiguration.class).run(context -> { - assertThat(context).hasNotFailed(); - assertThat(context).hasBean("fastMcpSafeToolCallbackProvider"); - }); + void warnDiagnosticsAllowsExternalRawProvider(CapturedOutput output) { + contextRunner.withUserConfiguration(RawToolConfiguration.class) + .withPropertyValues("fastmcp.safe.diagnostics.external-raw-provider=warn") + .run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasBean("fastMcpSafeToolCallbackProvider"); + }); assertThat(output).contains("External raw Spring AI ToolCallbackProvider beans are present") .contains("fastMcpSafeToolCallbackProvider"); diff --git a/fastmcp-spring-ai-boot-starter/src/test/java/io/github/sandking/fastmcp/springai/boot/SpringAiExternalRawProviderDiagnosticsTest.java b/fastmcp-spring-ai-boot-starter/src/test/java/io/github/sandking/fastmcp/springai/boot/SpringAiExternalRawProviderDiagnosticsTest.java index ddef820..e139f6c 100644 --- a/fastmcp-spring-ai-boot-starter/src/test/java/io/github/sandking/fastmcp/springai/boot/SpringAiExternalRawProviderDiagnosticsTest.java +++ b/fastmcp-spring-ai-boot-starter/src/test/java/io/github/sandking/fastmcp/springai/boot/SpringAiExternalRawProviderDiagnosticsTest.java @@ -10,6 +10,21 @@ import org.junit.jupiter.api.Test; class SpringAiExternalRawProviderDiagnosticsTest { + @Test + void defaultModeRecordsDiagnosticAuditAndThrows() { + SpringAiExternalRawProviderDiagnostics diagnostics = SpringAiExternalRawProviderDiagnostics.from( + new FastMcpSafeProperties()); + List events = new ArrayList<>(); + + assertThatThrownBy(() -> diagnostics.diagnose(List.of("rawOrderToolProvider"), events::add)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("External raw Spring AI ToolCallbackProvider beans are present") + .hasMessageContaining("rawOrderToolProvider"); + + assertThat(events).hasSize(1); + assertThat(events.get(0).details()).containsEntry("mode", "fail"); + } + @Test void failModeRecordsDiagnosticAuditAndThrows() { SpringAiExternalRawProviderDiagnostics diagnostics = SpringAiExternalRawProviderDiagnostics.from(