diff --git a/.github/workflows/ci.yml b/.github/workflows/ci-chat-server.yml similarity index 72% rename from .github/workflows/ci.yml rename to .github/workflows/ci-chat-server.yml index 94f8bdb..b9ec39e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci-chat-server.yml @@ -1,10 +1,16 @@ -name: CI +name: chat-server on: push: branches: [ main ] + paths: + - .github/workflows/ci-chat-server.yml + - chat-server/** pull_request: branches: [ main ] + paths: + - .github/workflows/ci-chat-server.yml + - chat-server/** jobs: build: @@ -28,12 +34,10 @@ jobs: with: java-version: 21 distribution: temurin + cache: gradle + cache-dependency-path: chat-server/build.gradle.kts - - name: Build mcp-server - working-directory: mcp-server - run: ./gradlew build --no-daemon - - - name: Build chat-server + - name: Build working-directory: chat-server run: ./gradlew build --no-daemon diff --git a/.github/workflows/ci-mcp-server.yml b/.github/workflows/ci-mcp-server.yml new file mode 100644 index 0000000..46fc508 --- /dev/null +++ b/.github/workflows/ci-mcp-server.yml @@ -0,0 +1,34 @@ +name: mcp-server + +on: + push: + branches: [ main ] + paths: + - .github/workflows/ci-mcp-server.yml + - mcp-server/** + pull_request: + branches: [ main ] + paths: + - .github/workflows/ci-mcp-server.yml + - mcp-server/** + +jobs: + build: + name: build + runs-on: ubuntu-latest + steps: + + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Java + uses: actions/setup-java@v4 + with: + java-version: 21 + distribution: temurin + cache: gradle + cache-dependency-path: mcp-server/build.gradle.kts + + - name: Build + working-directory: mcp-server + run: ./gradlew build --no-daemon diff --git a/mcp-server/build.gradle.kts b/mcp-server/build.gradle.kts index da2a40e..1e7ee4c 100644 --- a/mcp-server/build.gradle.kts +++ b/mcp-server/build.gradle.kts @@ -32,6 +32,10 @@ dependencies { testImplementation("org.junit.jupiter:junit-jupiter") testImplementation("org.junit.jupiter:junit-jupiter-params") testRuntimeOnly("org.junit.platform:junit-platform-launcher") + + testImplementation("org.mockito:mockito-core:5.17.0") + testImplementation("org.mockito:mockito-junit-jupiter:5.17.0") + testImplementation("com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0") } dependencyManagement { diff --git a/mcp-server/src/test/kotlin/com/rogervinas/McpServerApplicationTest.kt b/mcp-server/src/test/kotlin/com/rogervinas/McpServerApplicationTest.kt new file mode 100644 index 0000000..ffbc5b6 --- /dev/null +++ b/mcp-server/src/test/kotlin/com/rogervinas/McpServerApplicationTest.kt @@ -0,0 +1,93 @@ +package com.rogervinas + +import com.nhaarman.mockitokotlin2.argumentCaptor +import com.nhaarman.mockitokotlin2.whenever +import com.rogervinas.tools.BookingService +import io.modelcontextprotocol.client.McpClient +import io.modelcontextprotocol.client.McpSyncClient +import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport +import io.modelcontextprotocol.spec.McpSchema.CallToolRequest +import io.modelcontextprotocol.spec.McpSchema.TextContent +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.MethodOrderer +import org.junit.jupiter.api.Order +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS +import org.junit.jupiter.api.TestMethodOrder +import org.mockito.Mockito.doReturn +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT +import org.springframework.boot.test.web.server.LocalServerPort +import org.springframework.test.context.bean.override.mockito.MockitoBean +import java.time.LocalDate +import java.util.function.Consumer + + +@SpringBootTest(webEnvironment = RANDOM_PORT) +@TestInstance(PER_CLASS) +@TestMethodOrder(MethodOrderer.OrderAnnotation::class) +class McpServerApplicationTest { + + @LocalServerPort + val port: Int = 0 + + val client: McpSyncClient by lazy { + McpClient.sync(HttpClientSseClientTransport("http://localhost:$port")).build().apply { + initialize() + ping() + } + } + + @AfterAll + fun closeClient() { + client.closeGracefully() + } + + @MockitoBean + lateinit var bookingService: BookingService + + @Test + @Order(0) + fun `should list tools`() { + assertThat(client.listTools().tools).singleElement().satisfies(Consumer { + assertThat(it.name).isEqualTo("book") + assertThat(it.description).isEqualTo("make a reservation for accommodation for a given city and date") + }) + } + + @Test + fun `should book`() { + val bookResult = "Booking is done!" + val cityCaptor = argumentCaptor() + val checkinDateCaptor = argumentCaptor() + val checkoutDateCaptor = argumentCaptor() + doReturn(bookResult) + .whenever(bookingService) + .book(cityCaptor.capture(), checkinDateCaptor.capture(), checkoutDateCaptor.capture()) + + val city = "Barcelona" + val checkinDate = "2025-04-15" + val checkoutDate = "2025-04-18" + val result = client.callTool( + CallToolRequest( + "book", + mapOf( + "city" to city, + "checkinDate" to checkinDate, + "checkoutDate" to checkoutDate + ) + ) + ) + + assertThat(result.isError).isFalse() + assertThat(result.content).singleElement().isInstanceOfSatisfying(TextContent::class.java) { + // TODO why is text double quoted? + assertThat(it.text).isEqualTo("\"$bookResult\"") + } + assertThat(cityCaptor.allValues).singleElement().isEqualTo(city) + assertThat(checkinDateCaptor.allValues).singleElement().isEqualTo(LocalDate.parse(checkinDate)) + assertThat(checkoutDateCaptor.allValues).singleElement().isEqualTo(LocalDate.parse(checkoutDate)) + } +}