This is an automated email from the ASF dual-hosted git repository. asf-gitbox-commits pushed a commit to branch CAY-2936 in repository https://gitbox.apache.org/repos/asf/cayenne.git
commit 5b47664ea2d2417b89e8440a5d2d329daaac656d Author: Andrus Adamchik <[email protected]> AuthorDate: Sat May 9 15:05:04 2026 -0400 CAY-2936 Add a placeholder MCP server to the Modeler distribution --- assembly/pom.xml | 6 + .../assemblies/generic/assembly-generic.xml | 7 ++ .../main/resources/assemblies/mac/assembly-mac.xml | 8 ++ .../assemblies/windows/assembly-windows.xml | 7 ++ cayenne-mcp-server/pom.xml | 131 +++++++++++++++++++++ .../org/apache/cayenne/mcp/CayenneMcpMain.java | 54 +++++++++ .../org/apache/cayenne/mcp/CayenneMcpOptions.java | 60 ++++++++++ .../org/apache/cayenne/mcp/CayenneMcpServer.java | 65 ++++++++++ .../java/org/apache/cayenne/mcp/StderrLogging.java | 58 +++++++++ .../org/apache/cayenne/mcp/tools/HelloTool.java | 51 ++++++++ .../apache/cayenne/mcp/CayenneMcpOptionsTest.java | 60 ++++++++++ .../org/apache/cayenne/mcp/CayenneMcpServerIT.java | 72 +++++++++++ .../java/org/apache/cayenne/mcp/HelloMcpIT.java | 128 ++++++++++++++++++++ .../java/org/apache/cayenne/mcp/HelloToolTest.java | 49 ++++++++ .../org/apache/cayenne/mcp/ServerJarLocator.java | 27 +++++ modeler/cayenne-modeler-mac/pom.xml | 1 + pom.xml | 13 ++ 17 files changed, 797 insertions(+) diff --git a/assembly/pom.xml b/assembly/pom.xml index 632b226e9..4f5a29e73 100644 --- a/assembly/pom.xml +++ b/assembly/pom.xml @@ -124,6 +124,12 @@ <version>${project.version}</version> </dependency> + <dependency> + <groupId>org.apache.cayenne</groupId> + <artifactId>cayenne-mcp-server</artifactId> + <version>${project.version}</version> + </dependency> + </dependencies> <build> diff --git a/assembly/src/main/resources/assemblies/generic/assembly-generic.xml b/assembly/src/main/resources/assemblies/generic/assembly-generic.xml index 123820413..96cc74674 100644 --- a/assembly/src/main/resources/assemblies/generic/assembly-generic.xml +++ b/assembly/src/main/resources/assemblies/generic/assembly-generic.xml @@ -114,5 +114,12 @@ </excludes> </unpackOptions> </dependencySet> + <dependencySet> + <outputDirectory>bin</outputDirectory> + <includes> + <include>org.apache.cayenne:cayenne-mcp-server</include> + </includes> + <unpack>false</unpack> + </dependencySet> </dependencySets> </assembly> diff --git a/assembly/src/main/resources/assemblies/mac/assembly-mac.xml b/assembly/src/main/resources/assemblies/mac/assembly-mac.xml index a2f2b4dc0..fa8efd583 100644 --- a/assembly/src/main/resources/assemblies/mac/assembly-mac.xml +++ b/assembly/src/main/resources/assemblies/mac/assembly-mac.xml @@ -112,5 +112,13 @@ </excludes> </unpackOptions> </dependencySet> + <dependencySet> + <outputDirectory>CayenneModeler.app/Contents/Resources/mcp/</outputDirectory> + <includes> + <include>org.apache.cayenne:cayenne-mcp-server</include> + </includes> + <unpack>false</unpack> + <outputFileNameMapping>${artifact.artifactId}${dashClassifier?}.${artifact.extension}</outputFileNameMapping> + </dependencySet> </dependencySets> </assembly> diff --git a/assembly/src/main/resources/assemblies/windows/assembly-windows.xml b/assembly/src/main/resources/assemblies/windows/assembly-windows.xml index 7db437786..432832d4d 100644 --- a/assembly/src/main/resources/assemblies/windows/assembly-windows.xml +++ b/assembly/src/main/resources/assemblies/windows/assembly-windows.xml @@ -112,5 +112,12 @@ </excludes> </unpackOptions> </dependencySet> + <dependencySet> + <outputDirectory>bin</outputDirectory> + <includes> + <include>org.apache.cayenne:cayenne-mcp-server</include> + </includes> + <unpack>false</unpack> + </dependencySet> </dependencySets> </assembly> diff --git a/cayenne-mcp-server/pom.xml b/cayenne-mcp-server/pom.xml new file mode 100644 index 000000000..46b5a85f3 --- /dev/null +++ b/cayenne-mcp-server/pom.xml @@ -0,0 +1,131 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. +--> +<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd"> + + <modelVersion>4.0.0</modelVersion> + + <parent> + <groupId>org.apache.cayenne</groupId> + <artifactId>cayenne-parent</artifactId> + <version>5.0-SNAPSHOT</version> + </parent> + + <artifactId>cayenne-mcp-server</artifactId> + <name>cayenne-mcp-server: CayenneModeler MCP Server</name> + <packaging>jar</packaging> + + <description> + Self-contained MCP server that exposes CayenneModeler operations + to AI coding agents via the Model Context Protocol over stdio. + </description> + + <dependencies> + + <dependency> + <groupId>io.modelcontextprotocol.sdk</groupId> + <artifactId>mcp</artifactId> + </dependency> + + <dependency> + <groupId>org.slf4j</groupId> + <artifactId>slf4j-api</artifactId> + </dependency> + + <dependency> + <groupId>ch.qos.logback</groupId> + <artifactId>logback-classic</artifactId> + </dependency> + + <!-- Test dependencies --> + <dependency> + <groupId>org.junit.jupiter</groupId> + <artifactId>junit-jupiter</artifactId> + <scope>test</scope> + </dependency> + + <dependency> + <groupId>org.mockito</groupId> + <artifactId>mockito-core</artifactId> + <scope>test</scope> + </dependency> + + </dependencies> + + <build> + <plugins> + + <plugin> + <artifactId>maven-shade-plugin</artifactId> + <version>3.6.0</version> + <executions> + <execution> + <phase>package</phase> + <goals> + <goal>shade</goal> + </goals> + <configuration> + <shadedArtifactAttached>false</shadedArtifactAttached> + <createDependencyReducedPom>false</createDependencyReducedPom> + <filters> + <filter> + <artifact>*:*</artifact> + <excludes> + <exclude>META-INF/*.SF</exclude> + <exclude>META-INF/*.DSA</exclude> + <exclude>META-INF/*.RSA</exclude> + </excludes> + </filter> + </filters> + <transformers> + <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer"> + <mainClass>org.apache.cayenne.mcp.CayenneMcpMain</mainClass> + <manifestEntries> + <Implementation-Version>${project.version}</Implementation-Version> + </manifestEntries> + </transformer> + <transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/> + <transformer implementation="org.apache.maven.plugins.shade.resource.ApacheLicenseResourceTransformer"/> + <transformer implementation="org.apache.maven.plugins.shade.resource.ApacheNoticeResourceTransformer"> + <addHeader>false</addHeader> + </transformer> + </transformers> + </configuration> + </execution> + </executions> + </plugin> + + <plugin> + <artifactId>maven-failsafe-plugin</artifactId> + <version>${maven-surefire-plugin.version}</version> + <executions> + <execution> + <goals> + <goal>integration-test</goal> + <goal>verify</goal> + </goals> + </execution> + </executions> + </plugin> + + </plugins> + </build> + +</project> diff --git a/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/CayenneMcpMain.java b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/CayenneMcpMain.java new file mode 100644 index 000000000..4270e30be --- /dev/null +++ b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/CayenneMcpMain.java @@ -0,0 +1,54 @@ +/***************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + ****************************************************************/ +package org.apache.cayenne.mcp; + +/** + * Entry point for the Cayenne MCP server. + */ +public class CayenneMcpMain { + + public static void main(String[] args) { + StderrLogging.install(); + + CayenneMcpOptions opts = CayenneMcpOptions.parse(args); + + String version = version(); + + if (opts.isHelp()) { + System.err.println("Usage: java -jar cayenne-mcp-server.jar [options]"); + System.err.println(); + System.err.println("Options:"); + System.err.println(" -h, --help Print this help and exit."); + System.err.println(" -V, --version Print the server version and exit."); + System.exit(0); + } + + if (opts.isVersion()) { + System.err.println("cayenne-mcp-server " + version); + System.exit(0); + } + + new CayenneMcpServer().run(version); + } + + static String version() { + String v = CayenneMcpMain.class.getPackage().getImplementationVersion(); + return v != null ? v : "dev"; + } +} diff --git a/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/CayenneMcpOptions.java b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/CayenneMcpOptions.java new file mode 100644 index 000000000..36426c1de --- /dev/null +++ b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/CayenneMcpOptions.java @@ -0,0 +1,60 @@ +/***************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + ****************************************************************/ +package org.apache.cayenne.mcp; + +/** + * Parsed command-line options for the MCP server. + */ +public class CayenneMcpOptions { + + private final boolean help; + private final boolean version; + + private CayenneMcpOptions(boolean help, boolean version) { + this.help = help; + this.version = version; + } + + public static CayenneMcpOptions parse(String[] args) { + boolean help = false; + boolean version = false; + + for (String arg : args) { + switch (arg) { + case "-h", "--help" -> help = true; + case "-V", "--version" -> version = true; + default -> { + System.err.println("Unknown option: " + arg); + System.err.println("Run with -h for usage."); + System.exit(2); + } + } + } + + return new CayenneMcpOptions(help, version); + } + + public boolean isHelp() { + return help; + } + + public boolean isVersion() { + return version; + } +} diff --git a/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/CayenneMcpServer.java b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/CayenneMcpServer.java new file mode 100644 index 000000000..459d26d24 --- /dev/null +++ b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/CayenneMcpServer.java @@ -0,0 +1,65 @@ +/***************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + ****************************************************************/ +package org.apache.cayenne.mcp; + +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.json.McpJsonMapperSupplier; +import io.modelcontextprotocol.server.McpServer; +import io.modelcontextprotocol.server.McpSyncServer; +import io.modelcontextprotocol.server.transport.StdioServerTransportProvider; +import io.modelcontextprotocol.spec.McpSchema; +import org.apache.cayenne.mcp.tools.HelloTool; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ServiceLoader; + +/** + * Assembles and runs the MCP server on stdio. + */ +public class CayenneMcpServer { + + private static final Logger LOGGER = LoggerFactory.getLogger(CayenneMcpServer.class); + + public void run(String version) { + LOGGER.info("Starting Cayenne MCP server {}", version); + + McpJsonMapper jsonMapper = ServiceLoader.load(McpJsonMapperSupplier.class) + .findFirst() + .orElseThrow(() -> new IllegalStateException("No McpJsonMapperSupplier found on classpath")) + .get(); + + StdioServerTransportProvider transport = new StdioServerTransportProvider(jsonMapper); + + McpSyncServer server = McpServer.sync(transport) + .serverInfo("cayenne-mcp-server", version) + .capabilities(McpSchema.ServerCapabilities.builder().tools(true).build()) + .tools(HelloTool.spec()) + .build(); + + // Close server cleanly on SIGTERM / ctrl-C + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + LOGGER.info("Cayenne MCP server stopping"); + server.closeGracefully(); + })); + + LOGGER.info("Cayenne MCP server started, listening on stdio"); + // The transport's non-daemon threads keep the JVM alive until stdin closes. + } +} diff --git a/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/StderrLogging.java b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/StderrLogging.java new file mode 100644 index 000000000..f2a06b4e1 --- /dev/null +++ b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/StderrLogging.java @@ -0,0 +1,58 @@ +/***************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + ****************************************************************/ +package org.apache.cayenne.mcp; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.encoder.PatternLayoutEncoder; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.ConsoleAppender; +import org.slf4j.LoggerFactory; + +/** + * Forces all logging to stderr so that stdout remains clean for the MCP stdio transport. + * Must be called before any other code that may log. + */ +class StderrLogging { + + private static final String PATTERN = "%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"; + + public static void install() { + LoggerContext context = (LoggerContext) LoggerFactory.getILoggerFactory(); + context.reset(); + + PatternLayoutEncoder encoder = new PatternLayoutEncoder(); + encoder.setContext(context); + encoder.setPattern(PATTERN); + encoder.start(); + + ConsoleAppender<ILoggingEvent> appender = new ConsoleAppender<>(); + appender.setContext(context); + appender.setName("STDERR"); + // "System.err" is the target name Logback recognises for stderr + appender.setTarget("System.err"); + appender.setEncoder(encoder); + appender.start(); + + Logger root = context.getLogger(Logger.ROOT_LOGGER_NAME); + root.setLevel(Level.INFO); + root.addAppender(appender); + } +} diff --git a/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/HelloTool.java b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/HelloTool.java new file mode 100644 index 000000000..fedc309b9 --- /dev/null +++ b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/HelloTool.java @@ -0,0 +1,51 @@ +/***************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + ****************************************************************/ +package org.apache.cayenne.mcp.tools; + +import io.modelcontextprotocol.server.McpServerFeatures; +import io.modelcontextprotocol.spec.McpSchema; + +import java.util.List; + +/** + * Placeholder tool that returns "hello world". Verifies the MCP wiring is functional. + */ +public class HelloTool { + + public static final String NAME = "hello"; + + public static McpServerFeatures.SyncToolSpecification spec() { + McpSchema.Tool tool = new McpSchema.Tool( + NAME, + null, + "Returns a greeting. Use this to verify the Cayenne MCP server is running.", + new McpSchema.JsonSchema("object", null, null, null, null, null), + null, + null, + null + ); + + return new McpServerFeatures.SyncToolSpecification(tool, (exchange, request) -> + McpSchema.CallToolResult.builder() + .content(List.of(new McpSchema.TextContent("hello world"))) + .isError(false) + .build() + ); + } +} diff --git a/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/CayenneMcpOptionsTest.java b/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/CayenneMcpOptionsTest.java new file mode 100644 index 000000000..0b55b1f54 --- /dev/null +++ b/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/CayenneMcpOptionsTest.java @@ -0,0 +1,60 @@ +/***************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + ****************************************************************/ +package org.apache.cayenne.mcp; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class CayenneMcpOptionsTest { + + @Test + public void emptyArgs() { + CayenneMcpOptions opts = CayenneMcpOptions.parse(new String[0]); + assertFalse(opts.isHelp()); + assertFalse(opts.isVersion()); + } + + @Test + public void shortHelp() { + CayenneMcpOptions opts = CayenneMcpOptions.parse(new String[]{"-h"}); + assertTrue(opts.isHelp()); + assertFalse(opts.isVersion()); + } + + @Test + public void longHelp() { + CayenneMcpOptions opts = CayenneMcpOptions.parse(new String[]{"--help"}); + assertTrue(opts.isHelp()); + } + + @Test + public void shortVersion() { + CayenneMcpOptions opts = CayenneMcpOptions.parse(new String[]{"-V"}); + assertTrue(opts.isVersion()); + assertFalse(opts.isHelp()); + } + + @Test + public void longVersion() { + CayenneMcpOptions opts = CayenneMcpOptions.parse(new String[]{"--version"}); + assertTrue(opts.isVersion()); + } +} diff --git a/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/CayenneMcpServerIT.java b/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/CayenneMcpServerIT.java new file mode 100644 index 000000000..cb4b84d63 --- /dev/null +++ b/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/CayenneMcpServerIT.java @@ -0,0 +1,72 @@ +/***************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + ****************************************************************/ +package org.apache.cayenne.mcp; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Integration tests for server startup flags. + * Requires the shaded jar to be built before this test runs (failsafe executes after package). + */ +public class CayenneMcpServerIT { + + private static final Path MCP_JAR = ServerJarLocator.findShadedJar(); + + @Test + public void unknownArgExitsWithCode2() throws Exception { + Process process = startProcess("--unknown-flag"); + assertTrue(process.waitFor(10, TimeUnit.SECONDS), "Process did not exit"); + assertEquals(2, process.exitValue(), "Expected exit code 2 for unknown arg"); + } + + @Test + public void helpFlagExitsWithCode0() throws Exception { + Process process = startProcess("--help"); + assertTrue(process.waitFor(10, TimeUnit.SECONDS), "Process did not exit"); + assertEquals(0, process.exitValue(), "Expected exit code 0 for --help"); + } + + @Test + public void versionFlagExitsWithCode0() throws Exception { + Process process = startProcess("--version"); + assertTrue(process.waitFor(10, TimeUnit.SECONDS), "Process did not exit"); + assertEquals(0, process.exitValue(), "Expected exit code 0 for --version"); + + String err = new String(process.getErrorStream().readAllBytes()); + assertTrue(err.contains("cayenne-mcp-server"), "Version output missing server name: " + err); + } + + private static Process startProcess(String... flags) throws IOException { + List<String> cmd = new ArrayList<>(); + cmd.add(ProcessHandle.current().info().command().orElse("java")); + cmd.add("-jar"); + cmd.add(MCP_JAR.toString()); + cmd.addAll(List.of(flags)); + return new ProcessBuilder(cmd).start(); + } +} diff --git a/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/HelloMcpIT.java b/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/HelloMcpIT.java new file mode 100644 index 000000000..9c454f159 --- /dev/null +++ b/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/HelloMcpIT.java @@ -0,0 +1,128 @@ +/***************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + ****************************************************************/ +package org.apache.cayenne.mcp; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.nio.file.Path; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Integration tests for the hello tool over the MCP stdio protocol. + * Requires the shaded jar to be built before this test runs (failsafe executes after package). + */ +public class HelloMcpIT { + + private static final Path MCP_JAR = ServerJarLocator.findShadedJar(); + + private Process process; + private BufferedWriter writer; + private BufferedReader reader; + + @BeforeEach + void startServer() throws Exception { + process = new ProcessBuilder( + ProcessHandle.current().info().command().orElse("java"), + "-jar", + MCP_JAR.toString() + ).start(); + + Thread stderrDrain = new Thread(() -> { + try (InputStream in = process.getErrorStream()) { + in.transferTo(OutputStream.nullOutputStream()); + } catch (IOException ignored) { + } + }); + stderrDrain.setDaemon(true); + stderrDrain.start(); + + writer = new BufferedWriter(new OutputStreamWriter(process.getOutputStream())); + reader = new BufferedReader(new InputStreamReader(process.getInputStream())); + + sendMessage(""" + {"jsonrpc":"2.0","id":1,"method":"initialize","params":{\ + "protocolVersion":"2024-11-05",\ + "capabilities":{},\ + "clientInfo":{"name":"test","version":"1.0"}}}"""); + String initResponse = readLine(); + assertTrue(initResponse.contains("\"id\":1"), "initialize response missing: " + initResponse); + + sendMessage(""" + {"jsonrpc":"2.0","method":"notifications/initialized","params":{}}"""); + } + + @AfterEach + void stopServer() throws Exception { + writer.close(); + assertTrue(process.waitFor(10, TimeUnit.SECONDS), "Server process did not exit after stdin was closed"); + } + + @Test + public void toolsListIncludesHello() throws Exception { + sendMessage(""" + {"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}"""); + + String listResponse = readLine(); + assertTrue(listResponse.contains("\"hello\""), "tools/list missing 'hello' tool: " + listResponse); + } + + @Test + public void helloToolReturnsGreeting() throws Exception { + sendMessage(""" + {"jsonrpc":"2.0","id":3,"method":"tools/call",\ + "params":{"name":"hello","arguments":{}}}"""); + + String callResponse = readLine(); + assertTrue(callResponse.contains("hello world"), "tools/call missing 'hello world': " + callResponse); + assertTrue(callResponse.contains("\"id\":3"), "tools/call response id mismatch: " + callResponse); + } + + private void sendMessage(String json) throws IOException { + writer.write(json); + writer.newLine(); + writer.flush(); + } + + private String readLine() throws Exception { + long deadline = System.currentTimeMillis() + 5000L; + while (System.currentTimeMillis() < deadline) { + if (reader.ready()) { + String line = reader.readLine(); + if (line != null && !line.isBlank()) { + return line; + } + } + Thread.sleep(20); + } + throw new AssertionError("No response within " + 5000L + "ms"); + } + + +} diff --git a/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/HelloToolTest.java b/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/HelloToolTest.java new file mode 100644 index 000000000..0b57eceb3 --- /dev/null +++ b/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/HelloToolTest.java @@ -0,0 +1,49 @@ +/***************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + ****************************************************************/ +package org.apache.cayenne.mcp; + +import io.modelcontextprotocol.server.McpServerFeatures; +import io.modelcontextprotocol.spec.McpSchema; +import org.apache.cayenne.mcp.tools.HelloTool; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; + +public class HelloToolTest { + + @Test + public void toolNameIsHello() { + McpServerFeatures.SyncToolSpecification spec = HelloTool.spec(); + assertEquals("hello", spec.tool().name()); + } + + @Test + public void callReturnsHelloWorld() { + McpServerFeatures.SyncToolSpecification spec = HelloTool.spec(); + McpSchema.CallToolResult result = spec.callHandler().apply(null, null); + + assertFalse(result.isError()); + assertEquals(1, result.content().size()); + + McpSchema.TextContent first = assertInstanceOf(McpSchema.TextContent.class, result.content().get(0)); + assertEquals("hello world", first.text()); + } +} diff --git a/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/ServerJarLocator.java b/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/ServerJarLocator.java new file mode 100644 index 000000000..32177efb6 --- /dev/null +++ b/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/ServerJarLocator.java @@ -0,0 +1,27 @@ +package org.apache.cayenne.mcp; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.stream.Stream; + +class ServerJarLocator { + + public static Path findShadedJar() { + Path targetDir = Path.of(System.getProperty("user.dir"), "target"); + + if (!Files.isDirectory(targetDir)) { + throw new IllegalStateException("Shaded jar not found — run mvn package first"); + } + + try (Stream<Path> stream = Files.list(targetDir)) { + return stream + .filter(p -> p.getFileName().toString().matches("cayenne-mcp-server-.*\\.jar")) + .filter(p -> !p.getFileName().toString().contains("original")) + .findFirst() + .orElseThrow(() -> new IllegalStateException("Shaded jar not found — run mvn package first")); + } catch (IOException e) { + throw new RuntimeException(e); + } + } +} diff --git a/modeler/cayenne-modeler-mac/pom.xml b/modeler/cayenne-modeler-mac/pom.xml index 78c6521cc..a1bc63b2f 100644 --- a/modeler/cayenne-modeler-mac/pom.xml +++ b/modeler/cayenne-modeler-mac/pom.xml @@ -64,6 +64,7 @@ <artifactId>cayenne-wocompat</artifactId> <version>${project.version}</version> </dependency> + </dependencies> <profiles> diff --git a/pom.xml b/pom.xml index c050869b8..505099e54 100644 --- a/pom.xml +++ b/pom.xml @@ -47,6 +47,8 @@ <!-- Dependency versions --> <ant.version>1.10.15</ant.version> <junit.version>5.14.4</junit.version> + <logback.version>1.5.32</logback.version> + <mcp-sdk.version>1.1.2</mcp-sdk.version> <mockito.version>5.23.0</mockito.version> <mysql.connector.version>9.5.0</mysql.connector.version> <slf4j.version>2.0.17</slf4j.version> @@ -101,6 +103,7 @@ <module>cayenne-gradle-plugin</module> <module>cayenne-jcache</module> <module>cayenne-lifecycle</module> + <module>cayenne-mcp-server</module> <module>cayenne-project</module> <module>cayenne-project-compatibility</module> <module>cayenne-velocity</module> @@ -471,6 +474,16 @@ <version>${slf4j.version}</version> <scope>test</scope> </dependency> + <dependency> + <groupId>ch.qos.logback</groupId> + <artifactId>logback-classic</artifactId> + <version>${logback.version}</version> + </dependency> + <dependency> + <groupId>io.modelcontextprotocol.sdk</groupId> + <artifactId>mcp</artifactId> + <version>${mcp-sdk.version}</version> + </dependency> <!-- This dependency update the one provided by the testcontainers, should be removed once testcontainers uses a version without active CVEs --> <dependency>
