diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2b769d109..f97721bc6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -180,6 +180,7 @@ jobs: sample: - kotlin-mcp-client - kotlin-mcp-server + - simple-streamable-server - weather-stdio-server name: "Build Sample: ${{ matrix.sample }}" diff --git a/.github/workflows/samples.yml b/.github/workflows/samples.yml index cdd98bb08..373f00a56 100644 --- a/.github/workflows/samples.yml +++ b/.github/workflows/samples.yml @@ -29,6 +29,7 @@ jobs: sample: - kotlin-mcp-client - kotlin-mcp-server + - simple-streamable-server - weather-stdio-server name: Build Sample (${{ matrix.sample }}) diff --git a/README.md b/README.md index 30d1b40d3..acb5c054c 100644 --- a/README.md +++ b/README.md @@ -21,34 +21,34 @@ standardized protocol interface. * [Overview](#overview) * [Installation](#installation) - * [Artifacts](#artifacts) - * [Gradle setup (JVM)](#gradle-setup-jvm) - * [Multiplatform](#multiplatform) - * [Ktor dependencies](#ktor-dependencies) + * [Artifacts](#artifacts) + * [Gradle setup (JVM)](#gradle-setup-jvm) + * [Multiplatform](#multiplatform) + * [Ktor dependencies](#ktor-dependencies) * [Quickstart](#quickstart) - * [Creating a Client](#creating-a-client) - * [Creating a Server](#creating-a-server) + * [Creating a Client](#creating-a-client) + * [Creating a Server](#creating-a-server) * [Core Concepts](#core-concepts) - * [MCP Primitives](#mcp-primitives) - * [Capabilities](#capabilities) - * [Server Capabilities](#server-capabilities) - * [Client Capabilities](#client-capabilities) - * [Server Features](#server-features) - * [Prompts](#prompts) - * [Resources](#resources) - * [Tools](#tools) - * [Completion](#completion) - * [Logging](#logging) - * [Pagination](#pagination) - * [Client Features](#client-features) - * [Roots](#roots) - * [Sampling](#sampling) + * [MCP Primitives](#mcp-primitives) + * [Capabilities](#capabilities) + * [Server Capabilities](#server-capabilities) + * [Client Capabilities](#client-capabilities) + * [Server Features](#server-features) + * [Prompts](#prompts) + * [Resources](#resources) + * [Tools](#tools) + * [Completion](#completion) + * [Logging](#logging) + * [Pagination](#pagination) + * [Client Features](#client-features) + * [Roots](#roots) + * [Sampling](#sampling) * [Transports](#transports) - * [STDIO Transport](#stdio-transport) - * [Streamable HTTP Transport](#streamable-http-transport) - * [SSE Transport](#sse-transport) - * [WebSocket Transport](#websocket-transport) - * [ChannelTransport (testing)](#channeltransport-testing) + * [STDIO Transport](#stdio-transport) + * [Streamable HTTP Transport](#streamable-http-transport) + * [SSE Transport](#sse-transport) + * [WebSocket Transport](#websocket-transport) + * [ChannelTransport (testing)](#channeltransport-testing) * [Connecting your server](#connecting-your-server) * [Examples](#examples) * [Documentation](#documentation) @@ -183,17 +183,24 @@ fun main(args: Array) = runBlocking { ### Creating a Server -Create an MCP server that exposes a simple tool and runs on an embedded Ktor server with SSE transport: +Create an MCP server that exposes a simple tool and runs on an embedded Ktor server with Streamable HTTP transport. +For a full working project with all required dependencies, see +the [simple-streamable-server](samples/simple-streamable-server) sample. + ```kotlin +import io.ktor.serialization.kotlinx.json.json +import io.ktor.server.application.install import io.ktor.server.cio.CIO import io.ktor.server.engine.embeddedServer +import io.ktor.server.plugins.contentnegotiation.ContentNegotiation import io.modelcontextprotocol.kotlin.sdk.server.Server import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions -import io.modelcontextprotocol.kotlin.sdk.server.mcp +import io.modelcontextprotocol.kotlin.sdk.server.mcpStreamableHttp import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult import io.modelcontextprotocol.kotlin.sdk.types.Implementation +import io.modelcontextprotocol.kotlin.sdk.types.McpJson import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities import io.modelcontextprotocol.kotlin.sdk.types.TextContent import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema @@ -228,7 +235,10 @@ fun main(args: Array) { } embeddedServer(CIO, host = "127.0.0.1", port = port) { - mcp { + install(ContentNegotiation) { + json(McpJson) + } + mcpStreamableHttp { mcpServer } }.start(wait = true) @@ -243,7 +253,7 @@ You can run the server and then connect to it using the client or test with the npx -y @modelcontextprotocol/inspector ``` -In the inspector UI, connect to `http://localhost:3000`. +In the inspector UI, connect to `http://localhost:3000/mcp`. ## Core Concepts @@ -787,6 +797,7 @@ private class MyServer : fun main() { --> + ```kotlin embeddedServer(CIO, port = 3000) { mcpStreamableHttp(path = "/api/mcp") { @@ -794,6 +805,7 @@ embeddedServer(CIO, port = 3000) { } }.start(wait = true) ``` + @@ -831,6 +843,7 @@ private class MyServer : fun main() { --> + ```kotlin embeddedServer(CIO, port = 3000) { install(SSE) @@ -841,6 +854,7 @@ embeddedServer(CIO, port = 3000) { } }.start(wait = true) ``` + @@ -880,12 +894,9 @@ allowing for easy testing of MCP functionality without the need for network setu ## Examples -| Scenario | Description | Example | -|--------------------------|-----------------------------------------------------------------|--------------------------------------------------------------------------| -| Streamable HTTP server | Full MCP server with prompts, resources, tools, completions | [samples/kotlin-mcp-server](./samples/kotlin-mcp-server) | -| STDIO weather server | Minimal STDIO transport server exposing weather info and alerts | [samples/weather-stdio-server](./samples/weather-stdio-server) | -| Interactive STDIO client | MCP client that connects over STDIO and pipes requests to LLMs | [samples/kotlin-mcp-client](./samples/kotlin-mcp-client) | -| Streamable HTTP client | MCP client demo in a runnable notebook | [samples/notebooks/McpClient.ipynb](./samples/notebooks/McpClient.ipynb) | +The [samples](./samples) directory contains runnable projects demonstrating +MCP server and client implementations with various transports. +See the [samples overview](./samples/README.md) for a comparison table and detailed descriptions. ## Documentation @@ -899,4 +910,5 @@ Please see the [contribution guide](CONTRIBUTING.md) and the [Code of conduct](C ## License -This project is licensed under Apache 2.0 for new contributions, with existing code under MIT—see the [LICENSE](LICENSE) file for details. +This project is licensed under Apache 2.0 for new contributions, with existing code under MIT—see the [LICENSE](LICENSE) +file for details. diff --git a/docs/build.gradle.kts b/docs/build.gradle.kts index ac249791a..1857ef1a3 100644 --- a/docs/build.gradle.kts +++ b/docs/build.gradle.kts @@ -6,6 +6,8 @@ plugins { dependencies { implementation(project(":kotlin-sdk")) implementation(libs.ktor.server.cio) + implementation(libs.ktor.serialization) + implementation(libs.ktor.server.content.negotiation) } tasks.matching { diff --git a/samples/README.md b/samples/README.md new file mode 100644 index 000000000..97893782d --- /dev/null +++ b/samples/README.md @@ -0,0 +1,55 @@ +# Kotlin MCP SDK Samples + +Runnable projects demonstrating MCP server and client implementations with the +[Kotlin MCP SDK](https://github.com/modelcontextprotocol/kotlin-sdk). +For background on the protocol itself, see the [MCP documentation](https://modelcontextprotocol.io/introduction). + +## Overview + +| Sample | Type | Transport | MCP Features | +|--------------------------------------------------------|-------------------|-----------------|------------------------------------| +| [simple-streamable-server](./simple-streamable-server) | Server | Streamable HTTP | Tools, Resources, Prompts, Logging | +| [kotlin-mcp-server](./kotlin-mcp-server) | Server | STDIO, SSE | Tools, Resources, Prompts | +| [weather-stdio-server](./weather-stdio-server) | Server | STDIO | Tools | +| [kotlin-mcp-client](./kotlin-mcp-client) | Client | STDIO | Tool discovery & invocation | +| [notebooks](./notebooks) | Client (Notebook) | Streamable HTTP | Tool discovery & invocation | + +## Getting Started + +- **Building a server?** Start with [simple-streamable-server](./simple-streamable-server) — it + uses the recommended Streamable HTTP transport and covers tools, resources, prompts, and logging. +- **Building a client?** Open the [notebooks](./notebooks) sample for a step-by-step walkthrough, + or see [kotlin-mcp-client](./kotlin-mcp-client) for a full CLI client with Anthropic API + integration. + +## Samples + +### Simple Streamable HTTP Server + +A minimal Streamable HTTP server with optional Bearer token authentication. Demonstrates tools +(`greet`, `multi-greet`), a prompt template, a resource, and server-to-client logging notifications. +[Read more →](./simple-streamable-server) + +### Kotlin MCP Server + +A multi-transport server supporting STDIO, SSE (plain), and SSE (Ktor plugin). Useful for exploring +different transport modes side by side. +[Read more →](./kotlin-mcp-server) + +### Weather STDIO Server + +A focused STDIO server that exposes weather forecast and alert tools backed by the weather.gov API. +Includes Claude Desktop integration instructions. +[Read more →](./weather-stdio-server) + +### Kotlin MCP Client + +An interactive CLI client that connects to any MCP server over STDIO and routes queries through +Anthropic's Claude API, bridging MCP tools with LLM conversations. +[Read more →](./kotlin-mcp-client) + +### MCP Client Notebook + +A Kotlin notebook that connects to a remote MCP server via Streamable HTTP and demonstrates ping, +tool listing, and tool invocation — all in an interactive cell-by-cell format. +[Read more →](./notebooks) diff --git a/samples/kotlin-mcp-client/README.md b/samples/kotlin-mcp-client/README.md index fd9e14313..b10f9254a 100644 --- a/samples/kotlin-mcp-client/README.md +++ b/samples/kotlin-mcp-client/README.md @@ -1,69 +1,50 @@ # Kotlin MCP Client -This project demonstrates how to build a Model Context Protocol (MCP) client in Kotlin that interacts with an MCP server -via a STDIO transport layer while leveraging Anthropic's API for natural language processing. The client uses the MCP -Kotlin SDK to communicate with an MCP server that exposes various tools, and it uses Anthropic's API to process user -queries and integrate tool responses into the conversation. - -For more information about the MCP SDK and protocol, please refer to -the [MCP documentation](https://modelcontextprotocol.io/introduction). - -## Prerequisites - -- **Java 17 or later** -- **Gradle** (or the Gradle wrapper provided with the project) -- An Anthropic API key set in your environment variable `ANTHROPIC_API_KEY` -- Basic understanding of MCP concepts and Kotlin programming +An interactive CLI client that connects to any MCP server over STDIO and pipes queries through +Anthropic's Claude API. ## Overview -The client application performs the following tasks: +This sample demonstrates a complete MCP client workflow: launching an MCP server as a subprocess, +discovering its tools, converting them to Anthropic's tool format, and running an interactive chat +loop where Claude can call server tools on behalf of the user. -- **Connecting to an MCP server** — - launches an MCP server process (implemented in JavaScript, Python, or Java) using STDIO transport. - It connects to the server, retrieves available tools, and converts them to Anthropic’s tool format. -- **Processing queries** — - accepts user queries, sends them to Anthropic’s API along with the registered tools, and handles responses. - If the response indicates a tool should be called, it invokes the corresponding MCP tool and continues the - conversation based on the tool’s result. -- **Interactive chat loop** — - runs an interactive command-line loop, allowing users to continuously submit queries and receive responses. +## Prerequisites -## Building and Running +- JDK 17+ +- An `ANTHROPIC_API_KEY` environment variable set with a valid Anthropic API key +- An MCP server script to connect to (`.js`, `.py`, or `.jar`) -Use the Gradle wrapper to build the application. In a terminal, run: +## Build & Run -```shell -./gradlew clean build -``` +Run the client, passing the path to an MCP server: -To run the client, execute the jar file and provide the path to your MCP server script. +```shell +# Connect to a JVM server +./gradlew run --args="path/to/server.jar" -To run the client with any MCP server: +# Connect to a Python server +./gradlew run --args="path/to/server.py" -```shell -java -jar build/libs/.jar path/to/server.jar # jvm server -java -jar build/libs/.jar path/to/server.py # python server -java -jar build/libs/.jar path/to/build/index.js # node server +# Connect to a Node.js server +./gradlew run --args="path/to/build/index.js" ``` > [!NOTE] -> The client uses STDIO transport, so it launches the MCP server as a separate process. +> The client uses STDIO transport, so it launches the MCP server as a subprocess. > Ensure the server script is executable and is a valid `.js`, `.py`, or `.jar` file. -## Configuration for Anthropic +## MCP Capabilities -Ensure your Anthropic API key is available in your environment: - -```shell -export ANTHROPIC_API_KEY=your_anthropic_api_key_here -``` +From the **client** perspective, this sample demonstrates: -The client uses `AnthropicOkHttpClient.fromEnv()` to automatically load the API key from `ANTHROPIC_API_KEY` and -`ANTHROPIC_AUTH_TOKEN` environment variables. +- **Tool discovery** — lists tools from the connected server and converts them to Anthropic's tool + format. +- **Tool invocation** — when Claude's response requests a tool call, the client invokes the + corresponding MCP tool and feeds the result back into the conversation. ## Additional Resources -- [MCP Specification](https://spec.modelcontextprotocol.io/) +- [MCP Specification](https://modelcontextprotocol.io/specification/latest) - [Kotlin MCP SDK](https://github.com/modelcontextprotocol/kotlin-sdk) -- [Anthropic Java SDK](https://github.com/anthropics/anthropic-sdk-java/tree/main) +- [Anthropic Java SDK](https://github.com/anthropics/anthropic-sdk-java) diff --git a/samples/kotlin-mcp-server/README.md b/samples/kotlin-mcp-server/README.md index fa56b66dc..82dc5e73e 100644 --- a/samples/kotlin-mcp-server/README.md +++ b/samples/kotlin-mcp-server/README.md @@ -1,93 +1,85 @@ -# MCP Kotlin Server Sample +# Kotlin MCP Server -A sample implementation of an MCP (Model Context Protocol) server in Kotlin that demonstrates different server -configurations and transport methods. +A sample MCP server demonstrating multiple transport modes: STDIO, SSE (plain), and SSE (Ktor +plugin). -## Features +> **Note:** The SSE transport modes are provided for backward compatibility. For new projects, +> consider using the [simple-streamable-server](../simple-streamable-server) sample, which uses the +> recommended Streamable HTTP transport. -- Multiple server operation modes: - - Standard I/O server - - SSE (Server-Sent Events) server with plain configuration - - SSE server using Ktor plugin -- Built-in capabilities for: - - Prompts management - - Resources handling - - Tools integration +## Overview -## Getting Started +This sample registers a prompt, a tool, and a resource, then lets you choose how to expose them. +STDIO mode is the default and is best for process-based clients. The two SSE modes show how to +serve MCP over HTTP using either a manual Ktor routing setup or the built-in `mcp { }` Ktor plugin. -### Running the Server +## Prerequisites -The server defaults [STDIO transport](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#stdio). +- JDK 17+ +- [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector) (optional, for testing) -You can customize the behavior using command-line arguments. -Logs are printed to [./build/stdout.log](./build/stdout.log) +## Build & Run -#### Standard I/O mode (STDIO): +### STDIO mode (default) -```bash -./gradlew clean build +```shell +./gradlew run ``` -Use the [MCP inspector](https://modelcontextprotocol.io/docs/tools/inspector) -to connect to MCP via STDIO (Click the "▶️ Connect" button): + +Or explicitly: ```shell -npx @modelcontextprotocol/inspector --config mcp-inspector-config.json --server stdio-server +./gradlew run --args="--stdio" ``` -#### SSE with plain configuration: +Connect with the MCP Inspector: -**NB!: 🐞 This configuration may not work ATM** - -```bash -./gradlew run --args="--sse-server 3001" -``` -or ```shell -./gradlew clean build -java -jar ./build/libs/kotlin-mcp-server-0.1.0-all.jar --sse-server 3001 +npx @modelcontextprotocol/inspector --config samples/kotlin-mcp-server/mcp-inspector-config.json --server stdio-server ``` -Use the [MCP inspector](https://modelcontextprotocol.io/docs/tools/inspector) -to connect to `http://localhost:3002/` via SSE Transport (Click the "▶️ Connect" button): +### SSE with Ktor plugin + ```shell -npx @modelcontextprotocol/inspector --config mcp-inspector-config.json --server sse-server +./gradlew run --args="--sse-server-ktor 3002" ``` -#### SSE with Ktor plugin: +Connect with the MCP Inspector: -```bash -./gradlew run --args="--sse-server-ktor 3002" +```shell +npx @modelcontextprotocol/inspector --config samples/kotlin-mcp-server/mcp-inspector-config.json --server sse-ktor-server ``` -or + +### SSE with plain configuration + +> **Known issue:** This mode may not work correctly at this time. + ```shell -./gradlew clean build -java -jar ./build/libs/kotlin-mcp-server-0.1.0-all.jar --sse-server-ktor 3002 +./gradlew run --args="--sse-server 3001" ``` -Use the [MCP inspector](https://modelcontextprotocol.io/docs/tools/inspector) -to connect to `http://localhost:3002/` via SSE transport (Click the "▶️ Connect" button): +Connect with the MCP Inspector: + ```shell -npx @modelcontextprotocol/inspector --config mcp-inspector-config.json --server sse-ktor-server +npx @modelcontextprotocol/inspector --config samples/kotlin-mcp-server/mcp-inspector-config.json --server sse-server ``` -## Server Capabilities +## MCP Capabilities + +### Tools -- **Prompts**: Supports prompt management with list change notifications -- **Resources**: Includes subscription support and list change notifications -- **Tools**: Supports tool management with list change notifications +| Name | Description | +|-------------------|------------------------------------------------------| +| `kotlin-sdk-tool` | A test tool that returns a "Hello, world!" greeting. | -## Implementation Details +### Prompts -The server is implemented using: -- Ktor for HTTP server functionality (SSE modes) -- Kotlin coroutines for asynchronous operations -- SSE for real-time communication in web contexts -- Standard I/O for command-line interface and process-based communication +| Name | Description | +|--------------------|------------------------------------------------------------------------------------| +| `Kotlin Developer` | Generates a prompt to develop a small Kotlin application for a given project name. | -## Example Capabilities +### Resources -The sample server demonstrates: -- **Prompt**: "Kotlin Developer" - helps develop small Kotlin applications with a configurable project name -- **Tool**: "kotlin-sdk-tool" - a simple test tool that returns a greeting -- **Resource**: "Web Search" - a placeholder resource demonstrating resource handling +| Name | URI | Description | +|--------------|-----------------------|---------------------------------------------------------| +| `Web Search` | `https://search.com/` | A placeholder resource demonstrating resource handling. | diff --git a/samples/notebooks/README.md b/samples/notebooks/README.md new file mode 100644 index 000000000..8db4f3b96 --- /dev/null +++ b/samples/notebooks/README.md @@ -0,0 +1,31 @@ +# MCP Client Notebook + +An interactive Kotlin notebook that demonstrates connecting to a remote MCP server and calling tools. + +## Overview + +This notebook walks through building an MCP client step by step: creating a Ktor HTTP client, +initializing an MCP `Client`, connecting via `StreamableHttpClientTransport`, and interacting with +the server (ping, list tools, call tools). It connects to the public +[Microsoft Learn MCP Server](https://learn.microsoft.com/api/mcp) as an example. + +## Prerequisites + +- [Kotlin Jupyter kernel](https://github.com/Kotlin/kotlin-jupyter) **or** IntelliJ IDEA with the + [Kotlin Notebook plugin](https://plugins.jetbrains.com/plugin/16340-kotlin-notebook) +- Internet access (the notebook connects to an external MCP server) + +## How to Run + +1. Open `McpClient.ipynb` in IntelliJ IDEA (with Kotlin Notebook plugin) or in Jupyter with the + Kotlin kernel installed. +2. Run cells sequentially from top to bottom. + +## What It Demonstrates + +- Adding MCP SDK dependencies in a notebook environment +- Creating a Ktor `HttpClient` with SSE and logging plugins +- Creating an MCP `Client` and connecting via `StreamableHttpClientTransport` +- Sending a `ping` request +- Listing available tools from the remote server +- Calling a tool (`microsoft_docs_search`) and displaying results \ No newline at end of file diff --git a/samples/simple-streamable-server/README.md b/samples/simple-streamable-server/README.md new file mode 100644 index 000000000..27856d724 --- /dev/null +++ b/samples/simple-streamable-server/README.md @@ -0,0 +1,75 @@ +# Simple Streamable HTTP Server + +A minimal MCP server using the recommended Streamable HTTP transport with optional Bearer token authentication. + +## Overview + +This sample demonstrates a Streamable HTTP MCP server built with Ktor. It exposes tools, a prompt +template, and a resource over HTTP, and optionally supports Bearer token authentication. The +`multi-greet` tool showcases server-to-client logging notifications with streaming delays. + +## Prerequisites + +- JDK 17+ + +## Build & Run + +### Without authentication + +```shell +./gradlew run +``` + +The server starts on `http://localhost:3001/mcp` by default. + +Connect with the [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector): + +```shell +npx @modelcontextprotocol/inspector +``` + +In the Inspector UI, select **Streamable HTTP** transport and enter `http://localhost:3001/mcp`. + +Pass a port number as an argument to change it: + +```shell +./gradlew run --args="8080" +``` + +### With authentication + +```shell +MCP_AUTH_TOKEN=my-secret ./gradlew run --args="--auth" +``` + +When `--auth` is passed, clients must include an `Authorization: Bearer ` header. +The token is read from the `MCP_AUTH_TOKEN` environment variable (required when `--auth` is used). + +### Authentication caveats + +This sample is intended **for demonstration only**. In production you should: + +- Use a proper identity provider instead of a static token. +- Restrict CORS origins to your deployment domain — the sample allows all origins. +- Serve the endpoint over HTTPS. + +## MCP Capabilities + +### Tools + +| Name | Description | +|------|-------------| +| `greet` | Returns a greeting for a given `name`. | +| `multi-greet` | Sends logging notifications between delayed greetings, demonstrating streaming. | + +### Prompts + +| Name | Description | +|------|-------------| +| `greeting-template` | Generates a friendly greeting message for a given `name`. | + +### Resources + +| Name | URI | Description | +|------|-----|-------------| +| `Default Greeting` | `https://example.com/greetings/default` | Returns a static "Hello, world!" text. | \ No newline at end of file diff --git a/samples/simple-streamable-server/build.gradle.kts b/samples/simple-streamable-server/build.gradle.kts new file mode 100644 index 000000000..8cb908e1c --- /dev/null +++ b/samples/simple-streamable-server/build.gradle.kts @@ -0,0 +1,29 @@ +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.shadow) + application +} + +group = "org.example" +version = "0.1.0" + +application { + mainClass.set("io.modelcontextprotocol.sample.server.MainKt") +} + +dependencies { + implementation(dependencies.platform(libs.ktor.bom)) + implementation(libs.mcp.kotlin.server) + implementation(libs.ktor.server.netty) + implementation(libs.ktor.server.auth) + implementation(libs.ktor.server.cors) + implementation(libs.ktor.server.content.negotiation) + implementation(libs.ktor.server.sse) + implementation(libs.ktor.serialization.kotlinx.json) + implementation(libs.slf4j.simple) +} + +kotlin { + jvmToolchain(17) +} diff --git a/samples/simple-streamable-server/gradle.properties b/samples/simple-streamable-server/gradle.properties new file mode 100644 index 000000000..32cead691 --- /dev/null +++ b/samples/simple-streamable-server/gradle.properties @@ -0,0 +1,5 @@ +org.gradle.configuration-cache=true +org.gradle.parallel=true +org.gradle.caching=true + +#mcp.kotlin.overrideVersion=0.0.1-SNAPSHOT diff --git a/samples/simple-streamable-server/gradle/libs.versions.toml b/samples/simple-streamable-server/gradle/libs.versions.toml new file mode 100644 index 000000000..7240ff23c --- /dev/null +++ b/samples/simple-streamable-server/gradle/libs.versions.toml @@ -0,0 +1,22 @@ +[versions] +kotlin = "2.2.21" +ktor = "3.2.3" +mcp-kotlin = "0.9.0" +slf4j = "2.0.17" +shadow = "9.2.2" + +[libraries] +ktor-bom = { group = "io.ktor", name = "ktor-bom", version.ref = "ktor" } +ktor-server-netty = { group = "io.ktor", name = "ktor-server-netty" } +ktor-server-cors = { group = "io.ktor", name = "ktor-server-cors" } +ktor-server-content-negotiation = { group = "io.ktor", name = "ktor-server-content-negotiation" } +ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json" } +ktor-server-auth = { group = "io.ktor", name = "ktor-server-auth" } +ktor-server-sse = { group = "io.ktor", name = "ktor-server-sse" } +mcp-kotlin-server = { group = "io.modelcontextprotocol", name = "kotlin-sdk-server", version.ref = "mcp-kotlin" } +slf4j-simple = { group = "org.slf4j", name = "slf4j-simple", version.ref = "slf4j" } + +[plugins] +kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +shadow = { id = "com.gradleup.shadow", version.ref = "shadow" } diff --git a/samples/simple-streamable-server/gradle/wrapper/gradle-wrapper.jar b/samples/simple-streamable-server/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..61285a659 Binary files /dev/null and b/samples/simple-streamable-server/gradle/wrapper/gradle-wrapper.jar differ diff --git a/samples/simple-streamable-server/gradle/wrapper/gradle-wrapper.properties b/samples/simple-streamable-server/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..37f78a6af --- /dev/null +++ b/samples/simple-streamable-server/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/samples/simple-streamable-server/gradlew b/samples/simple-streamable-server/gradlew new file mode 100755 index 000000000..adff685a0 --- /dev/null +++ b/samples/simple-streamable-server/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed 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. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/samples/simple-streamable-server/gradlew.bat b/samples/simple-streamable-server/gradlew.bat new file mode 100644 index 000000000..c4bdd3ab8 --- /dev/null +++ b/samples/simple-streamable-server/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/samples/simple-streamable-server/settings.gradle.kts b/samples/simple-streamable-server/settings.gradle.kts new file mode 100644 index 000000000..3c36808b9 --- /dev/null +++ b/samples/simple-streamable-server/settings.gradle.kts @@ -0,0 +1,25 @@ +rootProject.name = "simple-streamable-server" + +plugins { + // Apply the foojay-resolver plugin to allow automatic download of JDKs + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} + +dependencyResolutionManagement { + repositories { + mavenLocal() + mavenCentral() + } + + versionCatalogs { + create("libs") { + val mcpKotlinVersion = providers.gradleProperty( + "mcp.kotlin.overrideVersion", + ).orNull + if (mcpKotlinVersion != null) { + logger.lifecycle("Using the override version $mcpKotlinVersion of MCP Kotlin SDK") + version("mcp-kotlin", mcpKotlinVersion) + } + } + } +} diff --git a/samples/simple-streamable-server/src/main/kotlin/io/modelcontextprotocol/sample/server/main.kt b/samples/simple-streamable-server/src/main/kotlin/io/modelcontextprotocol/sample/server/main.kt new file mode 100644 index 000000000..161edc7c7 --- /dev/null +++ b/samples/simple-streamable-server/src/main/kotlin/io/modelcontextprotocol/sample/server/main.kt @@ -0,0 +1,28 @@ +package io.modelcontextprotocol.sample.server + +import io.ktor.server.engine.embeddedServer +import io.ktor.server.netty.Netty + +fun main(vararg args: String) { + val authEnabled = args.any { it == "--auth" } + val port = args.firstOrNull { it != "--auth" }?.toIntOrNull() ?: 3001 + val authToken = if (authEnabled) { + val envToken = System.getenv("MCP_AUTH_TOKEN") + requireNotNull(envToken) { + "MCP_AUTH_TOKEN environment variable must be set when using --auth" + } + envToken + } else { + null + } + + println("Starting MCP Streamable HTTP server on port $port") + println("Use MCP inspector to connect to http://localhost:$port/mcp") + if (authToken != null) { + println("Bearer auth enabled (token sourced from MCP_AUTH_TOKEN)") + } + + embeddedServer(Netty, host = "127.0.0.1", port = port) { + configureServer(authToken) + }.start(wait = true) +} diff --git a/samples/simple-streamable-server/src/main/kotlin/io/modelcontextprotocol/sample/server/server.kt b/samples/simple-streamable-server/src/main/kotlin/io/modelcontextprotocol/sample/server/server.kt new file mode 100644 index 000000000..cdffdae7f --- /dev/null +++ b/samples/simple-streamable-server/src/main/kotlin/io/modelcontextprotocol/sample/server/server.kt @@ -0,0 +1,297 @@ +package io.modelcontextprotocol.sample.server + +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpMethod +import io.ktor.http.HttpStatusCode +import io.ktor.serialization.kotlinx.json.json +import io.ktor.server.application.Application +import io.ktor.server.application.ApplicationCall +import io.ktor.server.application.install +import io.ktor.server.auth.Authentication +import io.ktor.server.auth.authenticate +import io.ktor.server.auth.bearer +import io.ktor.server.plugins.contentnegotiation.ContentNegotiation +import io.ktor.server.plugins.cors.routing.CORS +import io.ktor.server.request.header +import io.ktor.server.response.respond +import io.ktor.server.routing.delete +import io.ktor.server.routing.post +import io.ktor.server.routing.route +import io.ktor.server.routing.routing +import io.ktor.server.sse.SSE +import io.ktor.server.sse.sse +import io.ktor.util.collections.ConcurrentMap +import io.modelcontextprotocol.kotlin.sdk.server.Server +import io.modelcontextprotocol.kotlin.sdk.server.ServerOptions +import io.modelcontextprotocol.kotlin.sdk.server.StreamableHttpServerTransport +import io.modelcontextprotocol.kotlin.sdk.server.mcpStreamableHttp +import io.modelcontextprotocol.kotlin.sdk.types.CallToolResult +import io.modelcontextprotocol.kotlin.sdk.types.GetPromptResult +import io.modelcontextprotocol.kotlin.sdk.types.Implementation +import io.modelcontextprotocol.kotlin.sdk.types.LoggingLevel +import io.modelcontextprotocol.kotlin.sdk.types.LoggingMessageNotification +import io.modelcontextprotocol.kotlin.sdk.types.LoggingMessageNotificationParams +import io.modelcontextprotocol.kotlin.sdk.types.McpJson +import io.modelcontextprotocol.kotlin.sdk.types.PromptArgument +import io.modelcontextprotocol.kotlin.sdk.types.PromptMessage +import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceResult +import io.modelcontextprotocol.kotlin.sdk.types.Role +import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities +import io.modelcontextprotocol.kotlin.sdk.types.TextContent +import io.modelcontextprotocol.kotlin.sdk.types.TextResourceContents +import io.modelcontextprotocol.kotlin.sdk.types.ToolAnnotations +import io.modelcontextprotocol.kotlin.sdk.types.ToolSchema +import kotlinx.coroutines.delay +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject +import kotlin.time.Duration.Companion.milliseconds + +private const val MCP_SESSION_ID_HEADER = "mcp-session-id" + +fun Application.configureServer(authToken: String? = null) { + installCors(authEnabled = authToken != null) + install(ContentNegotiation) { + json(McpJson) + } + + if (authToken == null) { + mcpStreamableHttp { + createMcpServer() + } + } else { + configureAuthenticatedMcp(authToken) + } +} + +private fun Application.configureAuthenticatedMcp(authToken: String) { + install(SSE) + install(Authentication) { + bearer("mcp-bearer") { + authenticate { credential -> + if (credential.token == authToken) { + io.ktor.server.auth.UserIdPrincipal("mcp-client") + } else { + null + } + } + } + } + + val transports = ConcurrentMap() + + routing { + authenticate("mcp-bearer") { + route("/mcp") { + sse { + val transport = findTransport(call, transports) ?: return@sse + transport.handleRequest(this, call) + } + + post { + val transport = getOrCreateTransport(call, transports) ?: return@post + transport.handleRequest(null, call) + } + + delete { + val transport = findTransport(call, transports) ?: return@delete + transport.handleRequest(null, call) + } + } + } + } +} + +private suspend fun findTransport( + call: ApplicationCall, + transports: ConcurrentMap, +): StreamableHttpServerTransport? { + val sessionId = call.request.header(MCP_SESSION_ID_HEADER) + if (sessionId.isNullOrEmpty()) { + call.respond(HttpStatusCode.BadRequest, "Bad Request: No valid session ID provided") + return null + } + val transport = transports[sessionId] + if (transport == null) { + call.respond(HttpStatusCode.NotFound, "Session not found") + return null + } + return transport +} + +private suspend fun getOrCreateTransport( + call: ApplicationCall, + transports: ConcurrentMap, +): StreamableHttpServerTransport? { + val sessionId = call.request.header(MCP_SESSION_ID_HEADER) + if (sessionId != null) { + val transport = transports[sessionId] + if (transport == null) { + call.respond(HttpStatusCode.NotFound, "Session not found") + } + return transport + } + + val configuration = StreamableHttpServerTransport.Configuration( + enableJsonResponse = true, + ) + val transport = StreamableHttpServerTransport(configuration) + + transport.setOnSessionInitialized { initializedSessionId -> + transports[initializedSessionId] = transport + } + transport.setOnSessionClosed { closedSessionId -> + transports.remove(closedSessionId) + } + + val server = createMcpServer() + server.onClose { + transport.sessionId?.let { transports.remove(it) } + } + server.createSession(transport) + + return transport +} + +private fun Application.installCors(authEnabled: Boolean = false) { + install(CORS) { + anyHost() // Don't do this in production if possible. Try to limit it. + allowMethod(HttpMethod.Options) + allowMethod(HttpMethod.Get) + allowMethod(HttpMethod.Post) + allowMethod(HttpMethod.Delete) + allowNonSimpleContentTypes = true + allowHeader("Mcp-Session-Id") + allowHeader("Mcp-Protocol-Version") + exposeHeader("Mcp-Session-Id") + exposeHeader("Mcp-Protocol-Version") + if (authEnabled) { + allowHeader(HttpHeaders.Authorization) + } + } +} + +private fun createMcpServer(): Server { + val server = Server( + Implementation( + name = "simple-streamable-http-server", + version = "1.0.0", + ), + ServerOptions( + capabilities = ServerCapabilities( + prompts = ServerCapabilities.Prompts(listChanged = true), + resources = ServerCapabilities.Resources(subscribe = true, listChanged = true), + tools = ServerCapabilities.Tools(listChanged = true), + logging = ServerCapabilities.Logging, + ), + ), + ) + + // Tool: greet + server.addTool( + name = "greet", + description = "A simple greeting tool", + inputSchema = ToolSchema( + properties = buildJsonObject { + putJsonObject("name") { + put("type", "string") + put("description", "Name to greet") + } + }, + required = listOf("name"), + ), + ) { request -> + val name = request.arguments?.get("name")?.jsonPrimitive?.content ?: "World" + CallToolResult(content = listOf(TextContent("Hello, $name!"))) + } + + // Tool: multi-greet (demonstrates logging notifications) + server.addTool( + name = "multi-greet", + description = "A tool that sends different greetings with delays between them", + inputSchema = ToolSchema( + properties = buildJsonObject { + putJsonObject("name") { + put("type", "string") + put("description", "Name to greet") + } + }, + required = listOf("name"), + ), + toolAnnotations = ToolAnnotations(readOnlyHint = true, openWorldHint = false), + ) { request -> + val name = request.arguments?.get("name")?.jsonPrimitive?.content ?: "World" + + sendLoggingMessage( + LoggingMessageNotification( + LoggingMessageNotificationParams( + level = LoggingLevel.Debug, + data = JsonPrimitive("Starting multi-greet for $name") + ) + ) + ) + delay(1000.milliseconds) + + sendLoggingMessage( + LoggingMessageNotification( + LoggingMessageNotificationParams( + level = LoggingLevel.Info, + data = JsonPrimitive("Sending first greeting to $name") + ) + ) + ) + delay(1000.milliseconds) + + sendLoggingMessage( + LoggingMessageNotification( + LoggingMessageNotificationParams( + level = LoggingLevel.Info, + data = JsonPrimitive("Sending second greeting to $name") + ) + ) + ) + + CallToolResult(content = listOf(TextContent("Good morning, $name!"))) + } + + // Prompt: greeting-template + server.addPrompt( + name = "greeting-template", + description = "A simple greeting prompt template", + arguments = listOf( + PromptArgument( + name = "name", + description = "Name to include in greeting", + required = true, + ), + ), + ) { request -> + val name = request.arguments?.get("name") ?: "World" + GetPromptResult( + messages = listOf( + PromptMessage( + role = Role.User, + content = TextContent("Please greet $name in a friendly manner."), + ), + ), + ) + } + + // Resource: greeting-resource + server.addResource( + uri = "https://example.com/greetings/default", + name = "Default Greeting", + description = "A simple greeting resource", + mimeType = "text/plain", + ) { request -> + ReadResourceResult( + contents = listOf( + TextResourceContents("Hello, world!", request.uri, "text/plain"), + ), + ) + } + + return server +} diff --git a/samples/weather-stdio-server/README.md b/samples/weather-stdio-server/README.md index 0e8efc0ee..4c13040c8 100644 --- a/samples/weather-stdio-server/README.md +++ b/samples/weather-stdio-server/README.md @@ -1,140 +1,67 @@ -# Kotlin MCP Weather STDIO Server +# Weather STDIO Server -This project demonstrates how to build a Model Context Protocol (MCP) server in Kotlin that provides weather-related -tools by consuming the National Weather Service (weather.gov) API. The server uses STDIO as the transport layer and -leverages the Kotlin MCP SDK to expose weather forecast and alert tools. +A minimal MCP server that exposes weather forecast and alert tools using the National Weather +Service API over STDIO transport. -For more information about the MCP SDK and protocol, please refer to -the [MCP documentation](https://modelcontextprotocol.io/introduction). +## Overview -## Prerequisites - -- Java 17 or later -- Gradle (or the Gradle wrapper provided with the project) -- Basic understanding of MCP concepts -- Basic understanding of Kotlin and Kotlin ecosystems (sush as kotlinx-serialization, coroutines, ktor) - -## MCP Weather Server +This sample shows how to build a STDIO-based MCP server in Kotlin. It registers two tools that +query the [weather.gov](https://www.weather.gov/) API — one for weather forecasts by +latitude/longitude and one for active alerts by US state. Because it uses STDIO, the server is +launched as a subprocess by an MCP client or a desktop application like Claude Desktop. -The project provides: +## Prerequisites -- A lightweight MCP server built with Kotlin. -- STDIO transport layer implementation for server-client communication. -- Two weather tools: - - **Weather Forecast Tool** — returns details such as temperature, wind information, and a detailed forecast for a - given latitude/longitude. - - **Weather Alerts Tool** — returns active weather alerts for a given US state. +- JDK 17+ +- Internet access (the server calls the weather.gov API at runtime) -## Building and running +## Build & Run -Use the Gradle wrapper to build the application. In a terminal run: +Run the server (it communicates via stdin/stdout): ```shell -./gradlew clean build +./gradlew run ``` -To run the server: +### MCP Inspector -```shell -java -jar build/libs/weather-stdio-server-0.1.0-all.jar -``` - -> [!NOTE] -> The server uses STDIO transport, so it is typically launched in an environment where the client connects via standard -> input/output. - -## Tool Implementation - -The project registers two MCP tools using the Kotlin MCP SDK. Below is an overview of the core tool implementations: - -### 1. Weather Forecast Tool - -This tool fetches the weather forecast for a specific latitude and longitude using the `weather.gov` API. - -Example tool registration in Kotlin: - -```kotlin -server.addTool( - name = "get_forecast", - description = """ - Get weather forecast for a specific latitude/longitude - """.trimIndent(), - inputSchema = Tool.Input( - properties = JsonObject( - mapOf( - "latitude" to JsonObject(mapOf("type" to JsonPrimitive("number"))), - "longitude" to JsonObject(mapOf("type" to JsonPrimitive("number"))), - ) - ), - required = listOf("latitude", "longitude") - ) -) { request -> - // Implementation tool -} -``` +Build the fat JAR first, then connect with the +[MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector): -### 2. Weather Alerts Tool - -This tool retrieves active weather alerts for a US state. - -Example tool registration in Kotlin: - -```kotlin -server.addTool( - name = "get_alerts", - description = """ - Get weather alerts for a US state. Input is Two-letter US state code (e.g. CA, NY) - """.trimIndent(), - inputSchema = Tool.Input( - properties = JsonObject( - mapOf( - "state" to JsonObject( - mapOf( - "type" to JsonPrimitive("string"), - "description" to JsonPrimitive("Two-letter US state code (e.g. CA, NY)") - ) - ), - ) - ), - required = listOf("state") - ) -) { request -> - // Implementation tool -} +```shell +./gradlew build +npx @modelcontextprotocol/inspector -- java -jar samples/weather-stdio-server/build/libs/weather-stdio-server-0.1.0-all.jar ``` -## Client Integration - -### Kotlin Client Example - -Since the server uses STDIO for transport, the client typically connects via standard input/output streams. A sample -client implementation can be found in the tests, demonstrating how to send tool requests and process responses. +### Claude Desktop integration -### Claude for Desktop - -To integrate with Claude Desktop, add the following configuration to your Claude Desktop settings: +Add the following to your Claude Desktop configuration: ```json { - "mcpServers": { - "weather": { - "command": "java", - "args": [ - "-jar", - "/absolute/path/to/.jar" - ] + "mcpServers": { + "weather": { + "command": "java", + "args": [ + "-jar", + "/absolute/path/to/samples/weather-stdio-server/build/libs/weather-stdio-server-0.1.0-all.jar" + ] + } } - } } ``` -> [!NOTE] -> Replace `/absolute/path/to/.jar` with the actual absolute path to your built jar file. +## MCP Capabilities + +### Tools + +| Name | Description | +|----------------|------------------------------------------------------------------------------------------| +| `get_forecast` | Returns weather forecast for a given `latitude` / `longitude` using the weather.gov API. | +| `get_alerts` | Returns active weather alerts for a two-letter US `state` code (e.g. `CA`, `NY`). | ## Additional Resources -- [MCP Specification](https://spec.modelcontextprotocol.io/) +- [MCP Specification](https://modelcontextprotocol.io/specification/latest) - [Kotlin MCP SDK](https://github.com/modelcontextprotocol/kotlin-sdk) -- [Ktor Client Documentation](https://ktor.io/docs/welcome.html) -- [Kotlinx Serialization](https://kotlinlang.org/docs/serialization.html) - +- [National Weather Service API](https://www.weather.gov/documentation/services-web-api)