Skip to content

Commit 79a2704

Browse files
authored
Merge pull request #44 from schemacrawler/spring-ai
Take the first cut at the SchemaCrawler MCP server
2 parents 1efc7bb + 6e6f1a0 commit 79a2704

File tree

11 files changed

+386
-14
lines changed

11 files changed

+386
-14
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
lib/
22
tree.txt
33
**/gpl*.*
4+
.idea/
45

56
# Created by https://www.toptal.com/developers/gitignore/api/java,eclipse,intellij+all,visualstudiocode,maven,gradle
67
# Edit at https://www.toptal.com/developers/gitignore?templates=java,eclipse,intellij+all,visualstudiocode,maven,gradle

schemacrawler-ai-core/src/main/java/schemacrawler/tools/command/aichat/AiChatCommand.java

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,10 @@
2828

2929
package schemacrawler.tools.command.aichat;
3030

31-
import java.sql.Connection;
3231
import java.util.Scanner;
3332
import java.util.logging.Level;
3433
import java.util.logging.Logger;
3534
import static us.fatehi.utility.Utility.isBlank;
36-
import schemacrawler.schema.Catalog;
3735
import schemacrawler.schemacrawler.exceptions.SchemaCrawlerException;
3836
import schemacrawler.tools.command.aichat.options.AiChatCommandOptions;
3937
import schemacrawler.tools.executable.BaseSchemaCrawlerCommand;
@@ -66,7 +64,8 @@ public void execute() {
6664

6765
// Load ChatAssistant implementation using ChatAssistantRegistry
6866
final ChatAssistantRegistry registry = ChatAssistantRegistry.getChatAssistantRegistry();
69-
final ChatAssistant chatAssistant = registry.newChatAssistant(commandOptions, catalog, connection);
67+
final ChatAssistant chatAssistant =
68+
registry.newChatAssistant(commandOptions, catalog, connection);
7069

7170
try (final ChatAssistant assistant = chatAssistant;
7271
final Scanner scanner = new Scanner(System.in)) {

schemacrawler-ai-core/src/main/java/schemacrawler/tools/command/aichat/ChatAssistantRegistry.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,6 @@
4242
import schemacrawler.tools.command.aichat.options.AiChatCommandOptions;
4343
import schemacrawler.tools.registry.BasePluginRegistry;
4444
import us.fatehi.utility.property.PropertyName;
45-
import us.fatehi.utility.string.StringFormat;
4645

4746
/** Chat assistant registry for loading chat assistant implementations. */
4847
public final class ChatAssistantRegistry extends BasePluginRegistry {
@@ -74,7 +73,8 @@ public String getName() {
7473
public Collection<PropertyName> getRegisteredPlugins() {
7574
final List<PropertyName> assistants = new ArrayList<>();
7675
for (final Class<? extends ChatAssistant> chatAssistantClass : chatAssistantClasses) {
77-
assistants.add(new PropertyName(chatAssistantClass.getSimpleName(), chatAssistantClass.getName()));
76+
assistants.add(
77+
new PropertyName(chatAssistantClass.getSimpleName(), chatAssistantClass.getName()));
7878
}
7979
Collections.sort(assistants);
8080
return assistants;
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package schemacrawler.tools.command.aichat.mcp;
2+
3+
import org.springframework.ai.tool.annotation.Tool;
4+
import org.springframework.ai.tool.annotation.ToolParam;
5+
import org.springframework.stereotype.Service;
6+
import schemacrawler.Version;
7+
8+
@Service
9+
public class CommonService {
10+
11+
@Tool(name = "get-schemacrawler-version", description = "Gets the version of SchemaCrawler", returnDirect = true)
12+
public String getSchemaCrawlerVersion(
13+
@ToolParam(
14+
description =
15+
"""
16+
Current date, as an ISO 8601 local date.
17+
""",
18+
required = false) final String date) {
19+
System.out.printf("get-schemacrawler-version called with %s", date);
20+
return Version.about();
21+
}
22+
}

schemacrawler-ai-mcp/src/main/java/schemacrawler/tools/command/aichat/mcp/MCPApplication.java

Lines changed: 0 additions & 9 deletions
This file was deleted.
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package schemacrawler.tools.command.aichat.mcp;
2+
3+
import org.springframework.ai.tool.ToolCallback;
4+
import org.springframework.ai.tool.ToolCallbackProvider;
5+
import org.springframework.ai.tool.method.MethodToolCallbackProvider;
6+
import org.springframework.boot.SpringApplication;
7+
import org.springframework.boot.autoconfigure.SpringBootApplication;
8+
import org.springframework.context.annotation.Bean;
9+
10+
import java.util.List;
11+
12+
/**
13+
* Spring Boot application for the SchemaCrawler AI MCP server. This class enables the Spring AI MCP
14+
* server capabilities.
15+
*/
16+
@SpringBootApplication
17+
public class SchemaCrawlerMCPServer {
18+
19+
public static void main(final String[] args) {
20+
SpringApplication.run(SchemaCrawlerMCPServer.class, args);
21+
}
22+
23+
@Bean
24+
public ToolCallbackProvider schemaCrawlerTools() {
25+
final List<ToolCallback> tools = SpringAIUtility.toolCallbacks(SpringAIUtility.tools());
26+
final ToolCallbackProvider toolCallbackProvider = ToolCallbackProvider.from(tools);
27+
printTools(toolCallbackProvider);
28+
return toolCallbackProvider;
29+
}
30+
31+
@Bean
32+
public ToolCallbackProvider weatherTools(final CommonService weatherService) {
33+
final MethodToolCallbackProvider toolCallbackProvider =
34+
MethodToolCallbackProvider.builder().toolObjects(weatherService).build();
35+
printTools(toolCallbackProvider);
36+
return toolCallbackProvider;
37+
}
38+
39+
private void printTools(final ToolCallbackProvider toolCallbackProvider) {
40+
List.of(toolCallbackProvider.getToolCallbacks())
41+
.forEach(
42+
toolCallback -> {
43+
System.out.println(toolCallback.getToolDefinition().name());
44+
System.out.println(toolCallback.getToolDefinition().description());
45+
System.out.println(toolCallback.getToolDefinition().inputSchema());
46+
System.out.println("----------------------------------------");
47+
});
48+
}
49+
}
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
/*
2+
========================================================================
3+
SchemaCrawler
4+
http://www.schemacrawler.com
5+
Copyright (c) 2000-2025, Sualeh Fatehi <sualeh@hotmail.com>.
6+
All rights reserved.
7+
------------------------------------------------------------------------
8+
9+
SchemaCrawler is distributed in the hope that it will be useful, but
10+
WITHOUT ANY WARRANTY; without even the implied warranty of
11+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
12+
13+
SchemaCrawler and the accompanying materials are made available under
14+
the terms of the Eclipse Public License v1.0, GNU General Public License
15+
v3 or GNU Lesser General Public License v3.
16+
17+
You may elect to redistribute this code under any of these licenses.
18+
19+
The Eclipse Public License is available at:
20+
http://www.eclipse.org/legal/epl-v10.html
21+
22+
The GNU General Public License v3 and the GNU Lesser General Public
23+
License v3 are available at:
24+
http://www.gnu.org/licenses/
25+
26+
========================================================================
27+
*/
28+
29+
package schemacrawler.tools.command.aichat.mcp;
30+
31+
import com.fasterxml.jackson.databind.JsonNode;
32+
import com.fasterxml.jackson.databind.ObjectMapper;
33+
import com.fasterxml.jackson.databind.node.ArrayNode;
34+
import com.fasterxml.jackson.databind.node.ObjectNode;
35+
import com.fasterxml.jackson.module.jsonSchema.JsonSchema;
36+
import com.fasterxml.jackson.module.jsonSchema.JsonSchemaGenerator;
37+
import com.github.victools.jsonschema.generator.SchemaVersion;
38+
import org.springframework.ai.chat.model.ToolContext;
39+
import org.springframework.ai.tool.ToolCallback;
40+
import org.springframework.ai.tool.definition.ToolDefinition;
41+
import org.springframework.ai.util.json.JsonParser;
42+
import org.springframework.lang.Nullable;
43+
import schemacrawler.tools.command.aichat.FunctionDefinition;
44+
import schemacrawler.tools.command.aichat.FunctionDefinition.FunctionType;
45+
import schemacrawler.tools.command.aichat.functions.FunctionDefinitionRegistry;
46+
import us.fatehi.utility.UtilityMarker;
47+
48+
import java.util.*;
49+
import java.util.Map.Entry;
50+
import java.util.logging.Level;
51+
import java.util.logging.Logger;
52+
53+
@UtilityMarker
54+
public final class SpringAIUtility {
55+
56+
private static final Logger LOGGER = Logger.getLogger(SpringAIUtility.class.getCanonicalName());
57+
58+
private SpringAIUtility() {
59+
// Prevent instantiation
60+
}
61+
62+
public static List<ToolCallback> toolCallbacks(final List<ToolDefinition> tools) {
63+
Objects.requireNonNull(tools, "Tools must not be null");
64+
final List<ToolCallback> toolCallbacks = new ArrayList<>();
65+
for (final ToolDefinition toolDefinition : tools) {
66+
toolCallbacks.add(new SpringAIToolCallback(toolDefinition));
67+
}
68+
return toolCallbacks;
69+
}
70+
71+
public static List<ToolDefinition> tools() {
72+
73+
final List<ToolDefinition> toolDefinitions = new ArrayList<>();
74+
for (final FunctionDefinition<?> functionDefinition :
75+
FunctionDefinitionRegistry.getFunctionDefinitionRegistry().getFunctionDefinitions()) {
76+
if (functionDefinition.getFunctionType() != FunctionType.USER) {
77+
continue;
78+
}
79+
80+
try {
81+
final ToolDefinition toolDefinition =
82+
ToolDefinition.builder()
83+
.name(functionDefinition.getName())
84+
.description(functionDefinition.getDescription())
85+
.inputSchema(generateToolInput(functionDefinition.getParametersClass()))
86+
.build();
87+
toolDefinitions.add(toolDefinition);
88+
} catch (final Exception e) {
89+
LOGGER.log(
90+
Level.WARNING, String.format("Could not load <%s>", functionDefinition.getName()), e);
91+
}
92+
}
93+
94+
return toolDefinitions;
95+
}
96+
97+
/**
98+
* @see org.springframework.ai.util.json.schema.JsonSchemaGenerator
99+
*/
100+
private static String generateToolInput(final Class<?> parametersClass) throws Exception {
101+
Objects.requireNonNull(parametersClass, "Parameters must not be null");
102+
103+
final Map<String, JsonNode> parametersJsonSchema = jsonSchema(parametersClass);
104+
final ObjectNode schema = JsonParser.getObjectMapper().createObjectNode();
105+
schema.put("$schema", SchemaVersion.DRAFT_2020_12.getIdentifier());
106+
schema.put("type", "object");
107+
108+
final List<String> required = new ArrayList<>();
109+
final ObjectNode properties = schema.putObject("properties");
110+
for (final Entry<String, JsonNode> parameter : parametersJsonSchema.entrySet()) {
111+
final String parameterName = parameter.getKey();
112+
final JsonNode parameterSchema = parameter.getValue();
113+
if (parameterSchema.has("required") && parameterSchema.get("required").asBoolean()) {
114+
((ObjectNode) parameterSchema).remove("required");
115+
required.add(parameterName);
116+
}
117+
properties.set(parameterName, parameterSchema);
118+
}
119+
final ArrayNode requiredArray = schema.putArray("required");
120+
required.forEach(requiredArray::add);
121+
122+
schema.put("additionalProperties", false);
123+
124+
return schema.toPrettyString();
125+
}
126+
127+
private static Map<String, JsonNode> jsonSchema(final Class<?> parametersClass) throws Exception {
128+
final ObjectMapper mapper = new ObjectMapper();
129+
final JsonSchemaGenerator schemaGen = new JsonSchemaGenerator(mapper);
130+
final JsonSchema schema = schemaGen.generateSchema(parametersClass);
131+
final JsonNode schemaNode = mapper.valueToTree(schema);
132+
final JsonNode properties = schemaNode.get("properties");
133+
final Set<Entry<String, JsonNode>> namedProperties;
134+
if (properties == null) {
135+
namedProperties = new HashSet<>();
136+
} else {
137+
namedProperties = properties.properties();
138+
}
139+
final Map<String, JsonNode> propertiesMap = new HashMap<>();
140+
for (final Entry<String, JsonNode> entry : namedProperties) {
141+
propertiesMap.put(entry.getKey(), entry.getValue());
142+
}
143+
return propertiesMap;
144+
}
145+
146+
public record SpringAIToolCallback(
147+
ToolDefinition toolDefinition) implements ToolCallback {
148+
149+
public SpringAIToolCallback {
150+
Objects.requireNonNull(toolDefinition, "Tool definition must not be null");
151+
}
152+
153+
@Override
154+
public ToolDefinition getToolDefinition() {
155+
return toolDefinition;
156+
}
157+
158+
@Override
159+
public String call(final String toolInput) {
160+
final String callMessage =
161+
String.format(
162+
"Call to <%s>%n%s%nTool was successfully executed with no return value.",
163+
toolDefinition.name(), toolInput);
164+
System.out.println(callMessage);
165+
return callMessage;
166+
}
167+
168+
@Override
169+
public String call(final String toolInput, @Nullable final ToolContext tooContext) {
170+
return call(toolInput);
171+
}
172+
}
173+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package schemacrawler.tools.command.aichat.mcp.controller;
2+
3+
import java.time.LocalDateTime;
4+
import java.util.HashMap;
5+
import java.util.Map;
6+
import org.springframework.web.bind.annotation.GetMapping;
7+
import org.springframework.web.bind.annotation.RestController;
8+
9+
/** Simple controller to check if the server is running. */
10+
@RestController
11+
public class HealthController {
12+
13+
@GetMapping("/health")
14+
public Map<String, Object> healthCheck() {
15+
final Map<String, Object> response = new HashMap<>();
16+
response.put("status", "UP");
17+
response.put("service", "SchemaCrawler MCP Server");
18+
response.put("timestamp", LocalDateTime.now().toString());
19+
return response;
20+
}
21+
22+
@GetMapping("/")
23+
public Map<String, Object> root() {
24+
final Map<String, Object> response = new HashMap<>();
25+
response.put("message", "SchemaCrawler AI MCP Server is running");
26+
response.put("health_endpoint", "/health");
27+
response.put("timestamp", LocalDateTime.now().toString());
28+
return response;
29+
}
30+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package schemacrawler.tools.command.aichat.mcp;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
import java.util.Map;
5+
import org.junit.jupiter.api.DisplayName;
6+
import org.junit.jupiter.api.Test;
7+
import org.springframework.beans.factory.annotation.Autowired;
8+
import org.springframework.boot.test.context.SpringBootTest;
9+
import org.springframework.boot.test.web.client.TestRestTemplate;
10+
import org.springframework.boot.test.web.server.LocalServerPort;
11+
import org.springframework.http.HttpStatus;
12+
import org.springframework.http.ResponseEntity;
13+
14+
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
15+
public class SchemaCrawlerMCPServerTest {
16+
17+
@LocalServerPort private int port;
18+
19+
@Autowired private TestRestTemplate restTemplate;
20+
21+
@Test
22+
@DisplayName("Application context loads successfully")
23+
public void contextLoads() {
24+
// This test will fail if the application context cannot start
25+
}
26+
27+
@Test
28+
@DisplayName("Health endpoint returns status UP in integration test")
29+
public void healthEndpoint() {
30+
final ResponseEntity<Map> response =
31+
restTemplate.getForEntity("http://localhost:" + port + "/health", Map.class);
32+
33+
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
34+
assertThat(response.getBody()).containsKey("status");
35+
assertThat(response.getBody().get("status")).isEqualTo("UP");
36+
assertThat(response.getBody()).containsKey("service");
37+
assertThat(response.getBody().get("service")).isEqualTo("SchemaCrawler MCP Server");
38+
}
39+
40+
@Test
41+
@DisplayName("Root endpoint returns welcome message in integration test")
42+
public void rootEndpoint() {
43+
final ResponseEntity<Map> response =
44+
restTemplate.getForEntity("http://localhost:" + port + "/", Map.class);
45+
46+
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
47+
assertThat(response.getBody()).containsKey("message");
48+
assertThat(response.getBody().get("message").toString()).contains("running");
49+
assertThat(response.getBody()).containsKey("health_endpoint");
50+
assertThat(response.getBody().get("health_endpoint")).isEqualTo("/health");
51+
}
52+
}

0 commit comments

Comments
 (0)