feat: cache tool methods on pod startup
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 1m57s
All checks were successful
Deploy to OCIWP / deploy (push) Successful in 1m57s
This commit is contained in:
@@ -2,8 +2,6 @@ package io.shinhanlife.dap.lib.mcp;
|
|||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.networknt.schema.Error;
|
import com.networknt.schema.Error;
|
||||||
import io.shinhanlife.dap.lib.annotation.ToolHint;
|
|
||||||
import io.shinhanlife.dap.lib.config.McpProperties;
|
|
||||||
import io.shinhanlife.dap.lib.util.ToolSchemaResolver;
|
import io.shinhanlife.dap.lib.util.ToolSchemaResolver;
|
||||||
import io.shinhanlife.dap.lib.validation.ToolArgumentSchemaValidator;
|
import io.shinhanlife.dap.lib.validation.ToolArgumentSchemaValidator;
|
||||||
import java.lang.reflect.Method;
|
import java.lang.reflect.Method;
|
||||||
@@ -12,21 +10,16 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springaicommunity.mcp.annotation.McpTool;
|
|
||||||
import org.springframework.aop.support.AopUtils;
|
|
||||||
import org.springframework.context.ApplicationContext;
|
|
||||||
import org.springframework.core.annotation.AnnotationUtils;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
/** Executes a discovered Tool independently from its HTTP or MCP transport. */
|
/** Executes a cached Tool independently from its HTTP or MCP transport. */
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class McpToolExecutionService {
|
public class McpToolExecutionService {
|
||||||
|
|
||||||
private final ApplicationContext applicationContext;
|
private final McpToolMethodRegistry toolMethodRegistry;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final McpProperties mcpProperties;
|
|
||||||
private final ToolArgumentSchemaValidator toolArgumentSchemaValidator;
|
private final ToolArgumentSchemaValidator toolArgumentSchemaValidator;
|
||||||
private final ToolSchemaResolver toolSchemaResolver;
|
private final ToolSchemaResolver toolSchemaResolver;
|
||||||
|
|
||||||
@@ -37,7 +30,7 @@ public class McpToolExecutionService {
|
|||||||
String requestId = requestHeaders == null ? null : requestHeaders.requestId();
|
String requestId = requestHeaders == null ? null : requestHeaders.requestId();
|
||||||
log.info("[Tool] IN - trace-id: {}, request-id: {}, tool: {}", traceId, requestId, functionName);
|
log.info("[Tool] IN - trace-id: {}, request-id: {}, tool: {}", traceId, requestId, functionName);
|
||||||
|
|
||||||
ResolvedTool resolvedTool = findTool(functionName);
|
McpToolMethodRegistry.RegisteredTool resolvedTool = toolMethodRegistry.find(functionName);
|
||||||
if (resolvedTool == null) {
|
if (resolvedTool == null) {
|
||||||
return error(404, "TOOL_NOT_FOUND", "Tool not found: " + functionName, headerRequestId);
|
return error(404, "TOOL_NOT_FOUND", "Tool not found: " + functionName, headerRequestId);
|
||||||
}
|
}
|
||||||
@@ -62,35 +55,8 @@ public class McpToolExecutionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private ResolvedTool findTool(String functionName) {
|
private ToolExecutionResult validateInput(McpToolMethodRegistry.RegisteredTool tool,
|
||||||
for (Object bean : applicationContext.getBeansOfType(Object.class).values()) {
|
Map<String, Object> arguments, String requestId) {
|
||||||
Class<?> targetClass = AopUtils.getTargetClass(bean);
|
|
||||||
for (Method declaredMethod : targetClass.getDeclaredMethods()) {
|
|
||||||
McpTool annotation = AnnotationUtils.findAnnotation(declaredMethod, McpTool.class);
|
|
||||||
if (annotation != null && matches(functionName, annotation.name())) {
|
|
||||||
return new ResolvedTool(bean, findInvocableMethod(bean, declaredMethod), annotation,
|
|
||||||
AnnotationUtils.findAnnotation(declaredMethod, ToolHint.class));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean matches(String requestedName, String baseName) {
|
|
||||||
String namespace = mcpProperties.getNamespace();
|
|
||||||
String expectedName = namespace != null && !namespace.isEmpty() ? namespace + "_" + baseName : baseName;
|
|
||||||
return expectedName.equals(requestedName) || baseName.equals(requestedName);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Method findInvocableMethod(Object bean, Method declaredMethod) {
|
|
||||||
try {
|
|
||||||
return bean.getClass().getMethod(declaredMethod.getName(), declaredMethod.getParameterTypes());
|
|
||||||
} catch (NoSuchMethodException ignored) {
|
|
||||||
return declaredMethod;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private ToolExecutionResult validateInput(ResolvedTool tool, Map<String, Object> arguments, String requestId) {
|
|
||||||
if (tool.method().getParameterCount() == 0 || Map.class.isAssignableFrom(tool.method().getParameterTypes()[0])) return null;
|
if (tool.method().getParameterCount() == 0 || Map.class.isAssignableFrom(tool.method().getParameterTypes()[0])) return null;
|
||||||
try {
|
try {
|
||||||
Map<String, Object> schema = toolSchemaResolver.resolve(tool.annotation(), tool.hint(), tool.method().getParameterTypes()[0]);
|
Map<String, Object> schema = toolSchemaResolver.resolve(tool.annotation(), tool.hint(), tool.method().getParameterTypes()[0]);
|
||||||
@@ -107,11 +73,12 @@ public class McpToolExecutionService {
|
|||||||
return objectMapper.convertValue(arguments, method.getParameterTypes()[0]);
|
return objectMapper.convertValue(arguments, method.getParameterTypes()[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Object invoke(ResolvedTool tool, Object argument) throws Exception {
|
private Object invoke(McpToolMethodRegistry.RegisteredTool tool, Object argument) throws Exception {
|
||||||
return tool.method().getParameterCount() == 0 ? tool.method().invoke(tool.bean()) : tool.method().invoke(tool.bean(), argument);
|
return tool.method().getParameterCount() == 0 ? tool.method().invoke(tool.bean()) : tool.method().invoke(tool.bean(), argument);
|
||||||
}
|
}
|
||||||
|
|
||||||
private ToolExecutionResult validateOutput(ResolvedTool tool, Object methodResult, String requestId) {
|
private ToolExecutionResult validateOutput(McpToolMethodRegistry.RegisteredTool tool,
|
||||||
|
Object methodResult, String requestId) {
|
||||||
try {
|
try {
|
||||||
Map<String, Object> outputSchema = toolSchemaResolver.resolveOutput(tool.annotation(), tool.method().getReturnType(), tool.hint());
|
Map<String, Object> outputSchema = toolSchemaResolver.resolveOutput(tool.annotation(), tool.method().getReturnType(), tool.hint());
|
||||||
if (!outputSchema.isEmpty() && !toolArgumentSchemaValidator.validateValue(outputSchema, methodResult).isEmpty()) {
|
if (!outputSchema.isEmpty() && !toolArgumentSchemaValidator.validateValue(outputSchema, methodResult).isEmpty()) {
|
||||||
@@ -131,6 +98,4 @@ public class McpToolExecutionService {
|
|||||||
if (requestId != null) body.put("request_id", requestId);
|
if (requestId != null) body.put("request_id", requestId);
|
||||||
return new ToolExecutionResult(statusCode, body, Map.of());
|
return new ToolExecutionResult(statusCode, body, Map.of());
|
||||||
}
|
}
|
||||||
|
|
||||||
private record ResolvedTool(Object bean, Method method, McpTool annotation, ToolHint hint) { }
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package io.shinhanlife.dap.lib.mcp;
|
||||||
|
|
||||||
|
import io.shinhanlife.dap.lib.annotation.ToolHint;
|
||||||
|
import io.shinhanlife.dap.lib.config.McpProperties;
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springaicommunity.mcp.annotation.McpTool;
|
||||||
|
import org.springframework.aop.support.AopUtils;
|
||||||
|
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||||
|
import org.springframework.context.ApplicationContext;
|
||||||
|
import org.springframework.context.event.EventListener;
|
||||||
|
import org.springframework.core.annotation.AnnotationUtils;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Caches executable {@link McpTool} methods once when a Tool Pod starts.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class McpToolMethodRegistry {
|
||||||
|
|
||||||
|
private final ApplicationContext applicationContext;
|
||||||
|
private final McpProperties mcpProperties;
|
||||||
|
|
||||||
|
private volatile Map<String, RegisteredTool> tools = Map.of();
|
||||||
|
|
||||||
|
@EventListener(ApplicationReadyEvent.class)
|
||||||
|
public void initialize() {
|
||||||
|
Map<String, RegisteredTool> discovered = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
for (Object bean : applicationContext.getBeansOfType(Object.class).values()) {
|
||||||
|
Class<?> targetClass = AopUtils.getTargetClass(bean);
|
||||||
|
for (Method declaredMethod : targetClass.getDeclaredMethods()) {
|
||||||
|
McpTool annotation = AnnotationUtils.findAnnotation(declaredMethod, McpTool.class);
|
||||||
|
if (annotation == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
RegisteredTool tool = new RegisteredTool(
|
||||||
|
bean,
|
||||||
|
findInvocableMethod(bean, declaredMethod),
|
||||||
|
annotation,
|
||||||
|
AnnotationUtils.findAnnotation(declaredMethod, ToolHint.class));
|
||||||
|
register(discovered, annotation.name(), tool);
|
||||||
|
registerNamespaceAlias(discovered, annotation.name(), tool);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tools = Map.copyOf(discovered);
|
||||||
|
log.info("[Tool Registry] {} executable tool names cached", tools.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
public RegisteredTool find(String toolName) {
|
||||||
|
return tools.get(toolName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void registerNamespaceAlias(Map<String, RegisteredTool> discovered, String toolName,
|
||||||
|
RegisteredTool tool) {
|
||||||
|
String namespace = mcpProperties.getNamespace();
|
||||||
|
if (StringUtils.hasText(namespace)) {
|
||||||
|
register(discovered, namespace + "_" + toolName, tool);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void register(Map<String, RegisteredTool> discovered, String toolName, RegisteredTool tool) {
|
||||||
|
RegisteredTool existing = discovered.putIfAbsent(toolName, tool);
|
||||||
|
if (existing != null && existing != tool) {
|
||||||
|
throw new IllegalStateException("Duplicate @McpTool name: " + toolName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Method findInvocableMethod(Object bean, Method declaredMethod) {
|
||||||
|
try {
|
||||||
|
return bean.getClass().getMethod(declaredMethod.getName(), declaredMethod.getParameterTypes());
|
||||||
|
} catch (NoSuchMethodException ignored) {
|
||||||
|
return declaredMethod;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record RegisteredTool(Object bean, Method method, McpTool annotation, ToolHint hint) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package io.shinhanlife.dap.lib.mcp;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
|
||||||
|
import io.shinhanlife.dap.lib.config.McpProperties;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springaicommunity.mcp.annotation.McpTool;
|
||||||
|
import org.springframework.context.support.StaticApplicationContext;
|
||||||
|
|
||||||
|
class McpToolMethodRegistryTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void indexesAnnotatedToolDuringInitialization() {
|
||||||
|
StaticApplicationContext context = contextWith(new EchoTool());
|
||||||
|
McpToolMethodRegistry registry = new McpToolMethodRegistry(context, new McpProperties());
|
||||||
|
|
||||||
|
registry.initialize();
|
||||||
|
|
||||||
|
McpToolMethodRegistry.RegisteredTool tool = registry.find("oth.cmm.echo.search");
|
||||||
|
assertNotNull(tool);
|
||||||
|
assertEquals("execute", tool.method().getName());
|
||||||
|
assertEquals("oth.cmm.echo.search", tool.annotation().name());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsDuplicateToolNamesDuringInitialization() {
|
||||||
|
StaticApplicationContext context = contextWith(new EchoTool(), new DuplicateEchoTool());
|
||||||
|
McpToolMethodRegistry registry = new McpToolMethodRegistry(context, new McpProperties());
|
||||||
|
|
||||||
|
assertThrows(IllegalStateException.class, registry::initialize);
|
||||||
|
}
|
||||||
|
|
||||||
|
private StaticApplicationContext contextWith(Object... tools) {
|
||||||
|
StaticApplicationContext context = new StaticApplicationContext();
|
||||||
|
for (int index = 0; index < tools.length; index++) {
|
||||||
|
context.getBeanFactory().registerSingleton("tool" + index, tools[index]);
|
||||||
|
}
|
||||||
|
context.refresh();
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
static class EchoTool {
|
||||||
|
@McpTool(name = "oth.cmm.echo.search")
|
||||||
|
public String execute(String request) {
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static class DuplicateEchoTool {
|
||||||
|
@McpTool(name = "oth.cmm.echo.search")
|
||||||
|
public String execute(String request) {
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,7 +39,9 @@ class McpToolExecutionServiceTest {
|
|||||||
StaticApplicationContext context = new StaticApplicationContext();
|
StaticApplicationContext context = new StaticApplicationContext();
|
||||||
context.getBeanFactory().registerSingleton("toolBean", toolBean);
|
context.getBeanFactory().registerSingleton("toolBean", toolBean);
|
||||||
context.refresh();
|
context.refresh();
|
||||||
return new McpToolExecutionService(context, objectMapper, new McpProperties(),
|
McpToolMethodRegistry registry = new McpToolMethodRegistry(context, new McpProperties());
|
||||||
|
registry.initialize();
|
||||||
|
return new McpToolExecutionService(registry, objectMapper,
|
||||||
new ToolArgumentSchemaValidator(objectMapper), new ToolSchemaResolver(objectMapper));
|
new ToolArgumentSchemaValidator(objectMapper), new ToolSchemaResolver(objectMapper));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user