Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,39 @@ The reusable Spring Boot binding code lives in
`FastMcpSafeProperties` and `FastMcpSafeConfigurationFactory`, but it is not a
standalone Agent framework starter and does not create MCP clients by itself.

## Production integration checklist

Production validation does not require FastMCP Java to implement the MCP
protocol. It validates that the safety wrapper remains the only model-facing
tool path when Spring AI, AgentScope, and the underlying MCP SDKs run against a
real MCP service.

Before using a configured server in production:

- Verify the model-facing tool list contains only virtual names such as
`get_my_orders`, never raw MCP names such as `getOrdersByUserId` or
`mcp__orders__getOrdersByUserId`.
- Verify virtual input schemas contain only model-fillable business arguments,
and do not expose protected arguments such as `userId`, `tenantId`, `role`, or
`includeDeleted`.
- 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.
- 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.
- Configure a `SafeAuditSink` and verify audits contain virtual/raw tool names,
caller/tenant identifiers, and injected argument names, but not injected
argument values.
- If the application streams tool events to AG-UI, CopilotKit, logs, or a
frontend, verify those events do not leak raw tool names, injected values, raw
arguments, or backend-only metadata.
- Run real MCP smoke tests from private deployment configuration only. Do not
commit real company domains, real MCP endpoints, credentials, or business tool
names to this repository.

## Build

```bash
Expand Down
26 changes: 26 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,32 @@ starter 或应用自身提供 `Toolkit`;本库不负责创建 AgentScope agent
`FastMcpSafeProperties` 和 `FastMcpSafeConfigurationFactory`,但它不是独立的
Agent 框架 starter,也不会自行创建 MCP client。

## 生产接入检查清单

生产验证并不要求 FastMCP Java 自己实现 MCP 协议。它要验证的是:当 Spring AI、
AgentScope 和底层 MCP SDK 连接真实 MCP 服务时,安全包装层仍然是唯一面向模型的
tool 路径。

在生产使用某个配置 server 前,应至少核对:

- 模型可见 tool 列表只包含 `get_my_orders` 这类 virtual names,不包含
`getOrdersByUserId` 或 `mcp__orders__getOrdersByUserId` 这类 raw MCP names。
- virtual input schema 只包含模型可填写的业务参数,不暴露 `userId`、`tenantId`、
`role`、`includeDeleted` 等 protected arguments。
- protected values 必须通过 resolver bean 从服务端运行时上下文解析,不写进
`fastmcp.safe.*` 配置。
- Spring AI 生产部署建议设置
`fastmcp.safe.diagnostics.external-raw-provider=fail`,除非应用明确使用文档里的
external-provider 兼容路径。
- 模型侧只接收 safe provider,例如 `fastMcpSafeToolCallbackProvider`;不要把所有
`ToolCallbackProvider` bean 作为集合直接交给模型,除非已经过滤掉 raw providers。
- 配置 `SafeAuditSink`,并确认 audit 只包含 virtual/raw tool names、caller/tenant
标识和 injected argument names,不包含 injected argument values。
- 如果应用会把 tool events 流式发送到 AG-UI、CopilotKit、日志或前端,需要确认这些
event 不泄露 raw tool names、注入值、raw arguments 或后端内部 metadata。
- 真实 MCP smoke test 只应使用私有部署配置执行。不要把真实公司域名、真实 MCP
endpoint、凭据或业务 tool 名称提交到本仓库。

## 构建

```bash
Expand Down
7 changes: 7 additions & 0 deletions fastmcp-examples/spring-ai-boot-starter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ It covers:
- a localhost fake MCP server exposing raw `searchCatalogByTenant`, while the model
only sees the safe virtual `search_catalog(keyword)` callback

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.

Run it from the repository root with JDK 17 or newer:

```bash
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,20 @@ public CompletionStage<SafeToolResult> callAsync(Map<String, ?> input, SafeToolC
recordAudit(safeContext, false, exception.code(), policyDecision);
return failed(exception);
}
CompletionStage<RawToolResult> rawResult = rawToolInvoker.callAsync(spec.rawServerName(), spec.rawToolName(),
Collections.unmodifiableMap(new LinkedHashMap<>(rawArguments)), safeContext);
CompletionStage<RawToolResult> rawResult;
try {
rawResult = rawToolInvoker.callAsync(spec.rawServerName(), spec.rawToolName(),
Collections.unmodifiableMap(new LinkedHashMap<>(rawArguments)), safeContext);
if (rawResult == null) {
SafeMcpException exception = new SafeMcpException("RAW_TOOL_FAILED",
"Raw tool invoker returned null");
recordAudit(safeContext, false, exception.code(), "allow");
return failed(exception);
}
} catch (RuntimeException exception) {
recordAudit(safeContext, false, "RAW_TOOL_FAILED", "allow");
return failed(exception);
}
return rawResult.handle((result, exception) -> {
if (exception != null) {
Throwable cause = unwrap(exception);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,32 @@ void mapsVirtualArgumentsAndInjectsProtectedArguments() {
assertEquals(SetSupport.of("userId", "tenantId"), auditEvents.get(0).injectedArgumentNames());
}

@Test
void synchronousRawInvokerFailureRecordsRawToolFailedAudit() {
List<SafeAuditEvent> auditEvents = new ArrayList<>();
RuntimeException rawFailure = new IllegalStateException("raw callback failed");
SafeMcpTool tool = new SafeMcpTool("test", orderToolSpec(),
(serverName, rawToolName, rawArguments, context) -> {
throw rawFailure;
},
SafeMcpPolicies.allow(),
auditEvents::add);

CompletionException exception = assertThrows(CompletionException.class,
() -> tool.callAsync(
Map.of("status", "paid"),
SafeToolCallContext.builder().userId("user-1").tenantId("tenant-1").build())
.toCompletableFuture()
.join());

assertEquals(rawFailure, exception.getCause());
assertEquals(1, auditEvents.size());
SafeAuditEvent event = auditEvents.get(0);
assertFalse(event.success());
assertEquals("RAW_TOOL_FAILED", event.errorCode());
assertEquals("allow", event.policyDecision());
}

@Test
void rejectsModelSuppliedProtectedRawArgument() {
SafeMcpTool tool = new SafeMcpTool("test", orderToolSpec(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
import io.github.sandking.fastmcp.safe.SafeMcpToolSpec;
import io.github.sandking.fastmcp.safe.SafeToolCallContext;
import io.github.sandking.fastmcp.safe.SafeToolResult;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
Expand Down Expand Up @@ -75,9 +74,10 @@ public static ToolCallbackProvider wrap(String defaultRawServerName, List<ToolCa
Map<String, ToolCallback> rawTools = new LinkedHashMap<>();
for (ToolCallback rawCallback : rawCallbacks) {
Objects.requireNonNull(rawCallback, "rawCallback must not be null");
String rawServerName = originalServerName(rawCallback).orElse(defaultRawServerName);
putRawTool(rawTools, rawServerName, rawCallback.getToolDefinition().name(), rawCallback);
originalToolName(rawCallback).ifPresent(name -> putRawTool(rawTools, rawServerName, name, rawCallback));
SpringAiRawToolIdentity identity = SpringAiRawToolIdentity.from(rawCallback, defaultRawServerName);
putRawTool(rawTools, identity.serverName(), identity.definitionToolName(), rawCallback);
identity.originalToolName().ifPresent(name ->
putRawTool(rawTools, identity.serverName(), name, rawCallback));
}

List<ToolCallback> safeCallbacks = new ArrayList<>();
Expand All @@ -93,27 +93,6 @@ public static ToolCallbackProvider wrap(String defaultRawServerName, List<ToolCa
return ToolCallbackProvider.from(safeCallbacks);
}

private static java.util.Optional<String> originalToolName(ToolCallback rawCallback) {
return reflectedString(rawCallback, "getOriginalToolName");
}

private static java.util.Optional<String> originalServerName(ToolCallback rawCallback) {
return reflectedString(rawCallback, "getOriginalServerName");
}

private static java.util.Optional<String> reflectedString(ToolCallback rawCallback, String methodName) {
try {
Method method = rawCallback.getClass().getMethod(methodName);
Object value = method.invoke(rawCallback);
if (value instanceof String && !((String) value).trim().isEmpty()) {
return java.util.Optional.of((String) value);
}
return java.util.Optional.empty();
} catch (ReflectiveOperationException exception) {
return java.util.Optional.empty();
}
}

private static String rawKey(String rawServerName, String rawToolName) {
return SpringAiMcpToolMapping.requireText(rawServerName, "rawServerName") + "\n"
+ SpringAiMcpToolMapping.requireText(rawToolName, "rawToolName");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package io.github.sandking.fastmcp.springai;

import java.lang.reflect.Method;
import java.util.Objects;
import java.util.Optional;
import org.springframework.ai.tool.ToolCallback;

final class SpringAiRawToolIdentity {
private final String serverName;
private final String definitionToolName;
private final Optional<String> originalToolName;

private SpringAiRawToolIdentity(String serverName, String definitionToolName, Optional<String> originalToolName) {
this.serverName = SpringAiMcpToolMapping.requireText(serverName, "serverName");
this.definitionToolName = SpringAiMcpToolMapping.requireText(definitionToolName, "definitionToolName");
this.originalToolName = Objects.requireNonNull(originalToolName, "originalToolName must not be null");
}

static SpringAiRawToolIdentity from(ToolCallback rawCallback, String defaultRawServerName) {
Objects.requireNonNull(rawCallback, "rawCallback must not be null");
String serverName = reflectedString(rawCallback, "getOriginalServerName")
.orElse(SpringAiMcpToolMapping.requireText(defaultRawServerName, "defaultRawServerName"));
return new SpringAiRawToolIdentity(serverName, rawCallback.getToolDefinition().name(),
reflectedString(rawCallback, "getOriginalToolName"));
}

String serverName() {
return serverName;
}

String definitionToolName() {
return definitionToolName;
}

Optional<String> originalToolName() {
return originalToolName;
}

private static Optional<String> reflectedString(ToolCallback rawCallback, String methodName) {
try {
Method method = rawCallback.getClass().getMethod(methodName);
Object value = method.invoke(rawCallback);
if (value instanceof String && !((String) value).trim().isEmpty()) {
return Optional.of((String) value);
}
return Optional.empty();
} catch (ReflectiveOperationException exception) {
return Optional.empty();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package io.github.sandking.fastmcp.springai;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.definition.DefaultToolDefinition;
import org.springframework.ai.tool.definition.ToolDefinition;

class SpringAiRawToolIdentityTest {
@Test
void usesOriginalServerAndToolNamesWhenCallbacksExposeThem() {
SpringAiRawToolIdentity identity = SpringAiRawToolIdentity.from(
new OriginalNamesCallback("mcp__orders__getOrdersByUserId", "orders", "getOrdersByUserId"),
"spring-ai");

assertEquals("orders", identity.serverName());
assertEquals("mcp__orders__getOrdersByUserId", identity.definitionToolName());
assertTrue(identity.originalToolName().isPresent());
assertEquals("getOrdersByUserId", identity.originalToolName().get());
}

@Test
void fallsBackToDefaultServerAndDefinitionNameWhenOriginalNamesAreUnavailable() {
SpringAiRawToolIdentity identity = SpringAiRawToolIdentity.from(
new BasicCallback("getOrdersByUserId"),
"spring-ai");

assertEquals("spring-ai", identity.serverName());
assertEquals("getOrdersByUserId", identity.definitionToolName());
assertFalse(identity.originalToolName().isPresent());
}

@Test
void ignoresBlankOriginalNames() {
SpringAiRawToolIdentity identity = SpringAiRawToolIdentity.from(
new OriginalNamesCallback("getOrdersByUserId", " ", ""),
"spring-ai");

assertEquals("spring-ai", identity.serverName());
assertEquals("getOrdersByUserId", identity.definitionToolName());
assertFalse(identity.originalToolName().isPresent());
}

@Test
void ignoresOriginalNameAccessorsThatThrow() {
SpringAiRawToolIdentity identity = SpringAiRawToolIdentity.from(
new ThrowingOriginalNamesCallback("getOrdersByUserId"),
"spring-ai");

assertEquals("spring-ai", identity.serverName());
assertEquals("getOrdersByUserId", identity.definitionToolName());
assertFalse(identity.originalToolName().isPresent());
}

private static class BasicCallback implements ToolCallback {
private final ToolDefinition toolDefinition;

BasicCallback(String name) {
this.toolDefinition = new DefaultToolDefinition(name, "Raw tool", "{}");
}

@Override
public ToolDefinition getToolDefinition() {
return toolDefinition;
}

@Override
public String call(String toolInput) {
return call(toolInput, new ToolContext(java.util.Map.of()));
}

@Override
public String call(String toolInput, ToolContext toolContext) {
return "ok";
}
}

private static final class OriginalNamesCallback extends BasicCallback {
private final String originalServerName;
private final String originalToolName;

private OriginalNamesCallback(String name, String originalServerName, String originalToolName) {
super(name);
this.originalServerName = originalServerName;
this.originalToolName = originalToolName;
}

public String getOriginalServerName() {
return originalServerName;
}

public String getOriginalToolName() {
return originalToolName;
}
}

private static final class ThrowingOriginalNamesCallback extends BasicCallback {
private ThrowingOriginalNamesCallback(String name) {
super(name);
}

public String getOriginalServerName() {
throw new IllegalStateException("server name unavailable");
}

public String getOriginalToolName() {
throw new IllegalStateException("tool name unavailable");
}
}
}
Loading
Loading