diff --git a/.prettierignore b/.prettierignore index e2a4cdb1..e6ddfc3e 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,3 +4,13 @@ examples/basic-server-react/**/*.ts examples/basic-server-react/**/*.tsx examples/basic-server-vanillajs/**/*.ts examples/basic-server-vanillajs/**/*.tsx + +# Swift package manager build artifacts +sdk/swift/.build/ +examples/basic-host-swift/.build/ +examples/basic-host-swift/build/ +examples/basic-host-kotlin/.gradle/ +examples/basic-host-kotlin/build/ + +# Swift build artifacts +swift/.build/ diff --git a/README.md b/README.md index 4ea53df7..c5c1982a 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,8 @@ Start with these foundational examples to learn the SDK: - [`examples/basic-server-vanillajs`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-vanillajs) — Example MCP server with tools that return UI Apps (vanilla JS) - [`examples/basic-server-react`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-server-react) — Example MCP server with tools that return UI Apps (React) -- [`examples/basic-host`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-host) — Bare-bones example of hosting MCP Apps +- [`examples/basic-host`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-host) — Bare-bones example of hosting MCP Apps (TypeScript/React) +- [`examples/basic-host-kotlin`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-host-kotlin) — Android example of hosting MCP Apps (Kotlin/Jetpack Compose) The [`examples/`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples) directory contains additional demo apps showcasing real-world use cases. diff --git a/examples/basic-host-kotlin/.gitignore b/examples/basic-host-kotlin/.gitignore new file mode 100644 index 00000000..bcb891fe --- /dev/null +++ b/examples/basic-host-kotlin/.gitignore @@ -0,0 +1,12 @@ +# Gradle +.gradle/ +build/ +!gradle/wrapper/gradle-wrapper.jar +!gradle/wrapper/gradle-wrapper.properties + +# IDE +.idea/ +*.iml + +# Local properties +local.properties diff --git a/examples/basic-host-kotlin/README.md b/examples/basic-host-kotlin/README.md new file mode 100644 index 00000000..c8958ee5 --- /dev/null +++ b/examples/basic-host-kotlin/README.md @@ -0,0 +1,338 @@ +# MCP Apps Basic Host - Android Example + +A minimal Android application demonstrating how to host MCP Apps in a WebView using the Kotlin SDK. + +## Overview + +This example shows the complete flow for hosting MCP Apps on Android: + +1. **Connect to MCP Server**: Establish connection using the MCP Kotlin SDK +2. **List Tools**: Discover available tools from the server +3. **Call Tool**: Execute a tool and retrieve its UI resource +4. **Load UI**: Display the tool's HTML UI in a WebView +5. **Communication**: Use AppBridge to communicate with the Guest UI + +## Architecture + +``` +┌─────────────────┐ +│ MainActivity │ (Jetpack Compose UI) +│ │ +│ ┌───────────┐ │ +│ │ViewModel │ │ (MCP connection logic) +│ └───────────┘ │ +└────────┬────────┘ + │ + ┌────▼────────────────┐ + │ MCP Client │ (Kotlin SDK) + │ + AppBridge │ + │ + WebViewTransport│ + └────┬────────────────┘ + │ + ┌────▼────────┐ + │ WebView │ (Guest UI) + └─────────────┘ +``` + +## Project Structure + +``` +examples/basic-host-kotlin/ +├── build.gradle.kts # Android app configuration +├── settings.gradle.kts # Gradle settings with SDK dependency +├── gradle.properties # Gradle properties +├── src/main/ +│ ├── AndroidManifest.xml # App manifest with permissions +│ ├── kotlin/com/example/mcpappshost/ +│ │ ├── MainActivity.kt # Main activity with Compose UI +│ │ └── McpHostViewModel.kt # ViewModel with MCP logic +│ └── res/ +│ └── values/strings.xml # String resources +└── README.md # This file +``` + +## Prerequisites + +- **Android Studio**: Hedgehog (2023.1.1) or later +- **JDK**: 17 or later +- **Android SDK**: API 26+ (Android 8.0+) +- **MCP Server**: A running MCP server with UI resources + +
+Installing Android SDK and emulator on Mac + +```bash +brew install --cask android-commandlinetools + +# Accept licenses +yes | sdkmanager --licenses + +# Install required components +sdkmanager "platform-tools" "emulator" "platforms;android-34" "system-images;android-34;google_apis;arm64-v8a" + +# Create an AVD (Android Virtual Device) +avdmanager create avd -n Pixel_8 -k "system-images;android-34;google_apis;arm64-v8a" -d pixel_8 + +# Add to PATH (add to ~/.zshrc) +export ANDROID_HOME=/opt/homebrew/share/android-commandlinetools +export PATH=$ANDROID_HOME/emulator:$ANDROID_HOME/platform-tools:$PATH + +# Start emulator +emulator -avd Pixel_8 +``` + +
+ +## Setup Instructions + +### 1. Open Project + +Open Android Studio and select "Open an Existing Project". Navigate to this directory: + +``` +/path/to/ext-apps/examples/basic-host-kotlin +``` + +### 2. Sync Gradle + +Android Studio will automatically detect the `build.gradle.kts` file and prompt you to sync. Click "Sync Now". + +The project is configured to use the local MCP Apps Kotlin SDK via composite build: + +```kotlin +includeBuild("../../kotlin") { + dependencySubstitution { + substitute(module("io.modelcontextprotocol:mcp-apps-kotlin-sdk")) + .using(project(":")) + } +} +``` + +### 3. Set Up MCP Server + +You need a running MCP server with UI resources. For testing, you can use the QR Code example: + +```bash +# In a terminal, navigate to the examples directory +cd /path/to/ext-apps/examples/qr-code + +# Install dependencies +npm install + +# Start the server (default port: 3000) +npm start +``` + +The server will be available at `http://localhost:3000/sse`. + +### 4. Configure Server URL + +When running in the Android **emulator**, use `10.0.2.2` instead of `localhost`: + +- Emulator: `http://10.0.2.2:3000/sse` +- Physical device: Use your computer's IP address (e.g., `http://192.168.1.100:3000/sse`) + +The default URL in the app is already set to `http://10.0.2.2:3000/sse` for emulator use. + +### 5. Run the App + +1. Select a device or create an emulator (API 26+) +2. Click the "Run" button (green play icon) or press `Shift + F10` +3. The app will build and launch on your device + +## Usage + +### 1. Connect to Server + +- Launch the app +- The default server URL is pre-filled: `http://10.0.2.2:3000/sse` +- Modify the URL if needed +- Tap "Connect" + +### 2. Select and Call Tool + +- Once connected, you'll see a list of available tools +- Select a tool (e.g., "generate_qr_code") +- Modify the JSON input if needed (default is `{}`) +- Tap "Call Tool" + +### 3. View Guest UI + +- The tool's UI will load in a WebView +- The AppBridge handles communication between the host and guest UI +- Logs are visible in Android Logcat (tag: `McpHostViewModel`) + +### 4. Reset + +- Tap "Back" to return to the tool selection screen +- This properly tears down the WebView and AppBridge + +## Key Components + +### MainActivity.kt + +Jetpack Compose-based UI with the following screens: + +- **IdleScreen**: Server URL input +- **ConnectedScreen**: Tool selection and input +- **AppDisplayScreen**: WebView displaying the Guest UI +- **ErrorScreen**: Error display with retry option + +### McpHostViewModel.kt + +Manages the complete MCP Apps flow: + +1. **Connection** (`connectToServer()`): + + ```kotlin + val client = Client(Implementation("MCP Apps Android Host", "1.0.0")) + client.connect(StreamableHTTPClientTransport(serverUrl)) + val tools = client.listTools() + ``` + +2. **Tool Execution** (`callTool()`): + + ```kotlin + val result = client.callTool(CallToolRequest(name, arguments)) + ``` + +3. **UI Resource Loading**: + + ```kotlin + val resource = client.readResource(ReadResourceRequest(uri = uiResourceUri)) + val html = resource.contents[0].text + ``` + +4. **AppBridge Setup** (`setupAppBridgeAndWebView()`): + + ```kotlin + val bridge = AppBridge(mcpClient, hostInfo, hostCapabilities, options) + val transport = WebViewTransport(webView, json) + transport.start() + bridge.connect(transport) + ``` + +5. **Communication**: + + ```kotlin + // Send tool input to guest UI + bridge.sendToolInput(arguments) + + // Send tool result when ready + bridge.sendToolResult(result) + + // Handle callbacks + bridge.onInitialized = { /* ... */ } + bridge.onMessage = { role, content -> /* ... */ } + bridge.onOpenLink = { url -> /* ... */ } + ``` + +## WebViewTransport + +The `WebViewTransport` class (from the SDK) provides the communication layer: + +- **postMessage API**: Uses Android WebView's message channel for bidirectional communication +- **Automatic Setup**: Injects bridge script and establishes MessagePort +- **TypeScript SDK Compatible**: Overrides `window.parent.postMessage()` for compatibility +- **JSON-RPC**: All messages use JSON-RPC 2.0 protocol + +## Debugging + +### Android Logcat + +View detailed logs in Android Studio's Logcat: + +``` +Filter: tag:McpHostViewModel +``` + +Logs include: + +- Connection status +- Tool discovery +- AppBridge lifecycle events +- Messages from Guest UI +- Errors and exceptions + +### Chrome DevTools + +Inspect the WebView remotely: + +1. Enable USB debugging on your device +2. Open Chrome on your computer +3. Navigate to `chrome://inspect` +4. Select your WebView from the list +5. Inspect HTML, console logs, and network requests + +## Troubleshooting + +### Connection Failed + +**Problem**: Cannot connect to MCP server + +**Solutions**: + +- Verify the server is running: `curl http://localhost:3000/sse` +- Use `10.0.2.2` instead of `localhost` for emulator +- Use your computer's IP for physical devices +- Check firewall settings +- Ensure `android.permission.INTERNET` is in AndroidManifest.xml + +### WebView Not Loading + +**Problem**: UI doesn't appear after calling tool + +**Solutions**: + +- Check Logcat for errors +- Verify the tool has a UI resource (look for `ui.resourceUri` in tool metadata) +- Ensure WebView JavaScript is enabled (handled by `WebViewTransport`) +- Check the HTML content is valid + +### AppBridge Timeout + +**Problem**: "AppBridge initialization timeout" in logs + +**Solutions**: + +- Verify the Guest UI includes the MCP Apps TypeScript SDK +- Check the Guest UI calls `initialize()` on load +- Inspect WebView in Chrome DevTools to see console errors +- Ensure the HTML includes proper script tags + +## SDK Dependencies + +This example uses: + +- **MCP Apps Kotlin SDK**: From `../../kotlin` (local) + - `AppBridge`: Host-side bridge for communication + - `WebViewTransport`: Android WebView transport layer + - Type definitions: `McpUiHostContext`, `McpUiHostCapabilities`, etc. + +- **MCP Kotlin SDK**: Version 0.6.0 (Maven) + - `Client`: MCP protocol client + - `StreamableHTTPClientTransport`: HTTP/SSE transport + +- **Jetpack Compose**: UI framework + - Material 3 components + - Lifecycle integration + +## Next Steps + +- **Add Error Handling**: Improve error messages and recovery +- **Support Multiple Servers**: Allow connecting to multiple servers +- **Persistent Configuration**: Save server URLs and settings +- **Advanced Features**: Implement message routing, link opening, etc. +- **Styling**: Customize the UI theme and appearance +- **Testing**: Add unit tests and integration tests + +## Resources + +- [MCP Apps Specification](../../specification/) +- [MCP Kotlin SDK](https://github.com/modelcontextprotocol/kotlin-sdk) +- [Android WebView Guide](https://developer.android.com/guide/webapps/webview) +- [Jetpack Compose](https://developer.android.com/jetpack/compose) + +## License + +See the [LICENSE](../../LICENSE) file in the root directory. diff --git a/examples/basic-host-kotlin/build.gradle.kts b/examples/basic-host-kotlin/build.gradle.kts new file mode 100644 index 00000000..037806db --- /dev/null +++ b/examples/basic-host-kotlin/build.gradle.kts @@ -0,0 +1,97 @@ +plugins { + id("com.android.application") version "8.2.0" + kotlin("android") version "2.1.0" + kotlin("plugin.serialization") version "2.1.0" + kotlin("plugin.compose") version "2.1.0" +} + +android { + namespace = "com.example.mcpappshost" + compileSdk = 34 + + defaultConfig { + applicationId = "com.example.mcpappshost" + minSdk = 26 // Android 8.0 - required for WebView features + targetSdk = 34 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + buildFeatures { + compose = true + } + + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + // MCP Apps Kotlin SDK (from local project) + implementation("io.modelcontextprotocol:mcp-apps-kotlin-sdk") + + // MCP Kotlin SDK core + implementation("io.modelcontextprotocol:kotlin-sdk:0.8.1") + + // Ktor client for HTTP transport + implementation("io.ktor:ktor-client-core:3.2.3") + implementation("io.ktor:ktor-client-cio:3.2.3") + + // Kotlin + implementation("org.jetbrains.kotlin:kotlin-stdlib:2.1.0") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") + + // AndroidX Core + implementation("androidx.core:core-ktx:1.12.0") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0") + implementation("androidx.activity:activity-compose:1.8.2") + + // Jetpack Compose + implementation(platform("androidx.compose:compose-bom:2024.02.00")) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-graphics") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + + // WebView + implementation("androidx.webkit:webkit:1.8.0") + + // Accompanist for WebView in Compose + implementation("com.google.accompanist:accompanist-webview:0.32.0") + + // Testing + testImplementation("junit:junit:4.13.2") + testImplementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") + androidTestImplementation("androidx.test.ext:junit:1.1.5") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") + androidTestImplementation(platform("androidx.compose:compose-bom:2024.02.00")) + androidTestImplementation("androidx.compose.ui:ui-test-junit4") + debugImplementation("androidx.compose.ui:ui-tooling") + debugImplementation("androidx.compose.ui:ui-test-manifest") +} diff --git a/examples/basic-host-kotlin/gradle.properties b/examples/basic-host-kotlin/gradle.properties new file mode 100644 index 00000000..3caea1a0 --- /dev/null +++ b/examples/basic-host-kotlin/gradle.properties @@ -0,0 +1,29 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. + +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html + +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 + +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true + +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true + +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official + +# Enables namespacing of each library's R class so that its R class includes only the +# resources declared in the library itself and none from the library's dependencies, +# thereby reducing the size of the R class for that library +android.nonTransitiveRClass=true diff --git a/examples/basic-host-kotlin/gradle/wrapper/gradle-wrapper.jar b/examples/basic-host-kotlin/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..f8e1ee31 Binary files /dev/null and b/examples/basic-host-kotlin/gradle/wrapper/gradle-wrapper.jar differ diff --git a/examples/basic-host-kotlin/gradle/wrapper/gradle-wrapper.properties b/examples/basic-host-kotlin/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..23449a2b --- /dev/null +++ b/examples/basic-host-kotlin/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/examples/basic-host-kotlin/gradlew b/examples/basic-host-kotlin/gradlew new file mode 100755 index 00000000..adff685a --- /dev/null +++ b/examples/basic-host-kotlin/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/examples/basic-host-kotlin/gradlew.bat b/examples/basic-host-kotlin/gradlew.bat new file mode 100644 index 00000000..e509b2dd --- /dev/null +++ b/examples/basic-host-kotlin/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/examples/basic-host-kotlin/scripts/build.sh b/examples/basic-host-kotlin/scripts/build.sh new file mode 100755 index 00000000..0e131c45 --- /dev/null +++ b/examples/basic-host-kotlin/scripts/build.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# Build the Android app +# Usage: ./scripts/build.sh [debug|release] + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +BUILD_TYPE="${1:-debug}" + +# Set up Android SDK paths +export ANDROID_HOME="${ANDROID_HOME:-/opt/homebrew/share/android-commandlinetools}" +export PATH="$ANDROID_HOME/platform-tools:$PATH" +export JAVA_HOME="${JAVA_HOME:-/opt/homebrew/opt/openjdk@21}" + +cd "$PROJECT_DIR" + +echo "🔨 Building MCP Apps Host for Android ($BUILD_TYPE)..." + +if [ "$BUILD_TYPE" = "release" ]; then + ./gradlew assembleRelease +else + ./gradlew assembleDebug +fi + +APK_DIR="$PROJECT_DIR/build/outputs/apk/$BUILD_TYPE" + +echo "" +echo "✅ Build succeeded" +echo " Output: $APK_DIR/" +ls -la "$APK_DIR"/*.apk 2>/dev/null || true diff --git a/examples/basic-host-kotlin/scripts/clean.sh b/examples/basic-host-kotlin/scripts/clean.sh new file mode 100755 index 00000000..7c458db7 --- /dev/null +++ b/examples/basic-host-kotlin/scripts/clean.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# Clean build artifacts +# Usage: ./scripts/clean.sh + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" + +# Set up Java +export JAVA_HOME="${JAVA_HOME:-/opt/homebrew/opt/openjdk@21}" + +cd "$PROJECT_DIR" + +echo "🧹 Cleaning build artifacts..." + +./gradlew clean + +rm -rf "$PROJECT_DIR/build" +rm -rf "$PROJECT_DIR/.gradle" + +echo "✅ Clean complete" diff --git a/examples/basic-host-kotlin/scripts/dev.sh b/examples/basic-host-kotlin/scripts/dev.sh new file mode 100755 index 00000000..212adf0e --- /dev/null +++ b/examples/basic-host-kotlin/scripts/dev.sh @@ -0,0 +1,118 @@ +#!/bin/bash +# Development script: builds, installs, runs, and watches for changes +# Usage: ./scripts/dev.sh [emulator-name] +# +# Requires: fswatch (brew install fswatch) + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +ROOT_DIR="$(dirname "$(dirname "$PROJECT_DIR")")" +EMULATOR_NAME="${1:-}" +PACKAGE_ID="com.example.mcpappshost" +ACTIVITY="$PACKAGE_ID.MainActivity" + +cd "$ROOT_DIR" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +log() { echo -e "${BLUE}[dev]${NC} $1"; } +success() { echo -e "${GREEN}[dev]${NC} $1"; } +warn() { echo -e "${YELLOW}[dev]${NC} $1"; } +error() { echo -e "${RED}[dev]${NC} $1"; } + +# Check for fswatch +if ! command -v fswatch &> /dev/null; then + error "fswatch is required. Install with: brew install fswatch" + exit 1 +fi + +# Check for adb +if ! command -v adb &> /dev/null; then + error "adb not found. Install Android SDK platform-tools." + exit 1 +fi + +# Start emulator if specified +start_emulator() { + if [ -n "$EMULATOR_NAME" ]; then + if ! adb devices | grep -q "emulator"; then + log "Starting emulator '$EMULATOR_NAME'..." + emulator -avd "$EMULATOR_NAME" -no-snapshot-load & + + log "Waiting for emulator to boot..." + adb wait-for-device + + while [ "$(adb shell getprop sys.boot_completed 2>/dev/null)" != "1" ]; do + sleep 1 + done + success "Emulator ready!" + fi + fi +} + +# Check for connected device +check_device() { + if ! adb devices | grep -q -E "device$"; then + error "No Android device/emulator connected." + echo "" + echo " Available emulators:" + emulator -list-avds 2>/dev/null || echo " (none found)" + echo "" + echo " Start with: ./scripts/dev.sh " + exit 1 + fi +} + +# Build the app +build_app() { + log "Building..." + if ./gradlew :examples:basic-host-kotlin:assembleDebug --quiet 2>&1; then + return 0 + fi + return 1 +} + +# Install and launch +install_and_launch() { + log "Installing..." + ./gradlew :examples:basic-host-kotlin:installDebug --quiet + + log "Launching..." + adb shell am force-stop "$PACKAGE_ID" 2>/dev/null || true + adb shell am start -n "$PACKAGE_ID/$ACTIVITY" +} + +# Full rebuild cycle +rebuild() { + echo "" + log "Rebuilding... ($(date '+%H:%M:%S'))" + + if build_app; then + success "Build succeeded" + install_and_launch + success "App reloaded!" + else + error "Build failed" + fi +} + +# Initial setup +start_emulator +check_device +rebuild + +# Watch for changes +log "Watching for changes in src/..." +log "Press Ctrl+C to stop" +echo "" + +fswatch -o "$PROJECT_DIR/src" | while read -r; do + rebuild +done diff --git a/examples/basic-host-kotlin/scripts/logs.sh b/examples/basic-host-kotlin/scripts/logs.sh new file mode 100755 index 00000000..7a31620d --- /dev/null +++ b/examples/basic-host-kotlin/scripts/logs.sh @@ -0,0 +1,38 @@ +#!/bin/bash +# Stream logs from the app running on device/emulator +# Usage: ./scripts/logs.sh [filter] + +# Set up Android SDK paths +export ANDROID_HOME="${ANDROID_HOME:-/opt/homebrew/share/android-commandlinetools}" +export PATH="$ANDROID_HOME/platform-tools:$PATH" + +PACKAGE_ID="com.example.mcpappshost" +FILTER="${1:-}" + +# Check for adb +if ! command -v adb &> /dev/null; then + echo "❌ adb not found." + echo " Install: brew install --cask android-commandlinetools" + exit 1 +fi + +echo "📋 Streaming logs from $PACKAGE_ID..." +echo " Press Ctrl+C to stop" +echo "" + +# Get the PID of our app +PID=$(adb shell pidof "$PACKAGE_ID" 2>/dev/null) + +if [ -n "$PID" ]; then + echo " App PID: $PID" + echo "" + if [ -n "$FILTER" ]; then + adb logcat --pid="$PID" | grep -i "$FILTER" + else + adb logcat --pid="$PID" + fi +else + echo " App not running, showing McpHostViewModel logs..." + echo "" + adb logcat "*:S" "McpHostViewModel:V" "WebView:V" "System.out:V" +fi diff --git a/examples/basic-host-kotlin/scripts/run.sh b/examples/basic-host-kotlin/scripts/run.sh new file mode 100755 index 00000000..25e02609 --- /dev/null +++ b/examples/basic-host-kotlin/scripts/run.sh @@ -0,0 +1,85 @@ +#!/bin/bash +# Build and run the app on Android emulator or device +# Usage: ./scripts/run.sh [emulator-name] + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +EMULATOR_NAME="${1:-Pixel_8}" +PACKAGE_ID="com.example.mcpappshost" +ACTIVITY="$PACKAGE_ID.MainActivity" + +# Set up Android SDK paths +export ANDROID_HOME="${ANDROID_HOME:-/opt/homebrew/share/android-commandlinetools}" +export PATH="$ANDROID_HOME/emulator:$ANDROID_HOME/platform-tools:$ANDROID_HOME/cmdline-tools/latest/bin:$PATH" +export JAVA_HOME="${JAVA_HOME:-/opt/homebrew/opt/openjdk@21}" + +cd "$PROJECT_DIR" + +# Check if adb is available +if ! command -v adb &> /dev/null; then + echo "❌ adb not found." + echo "" + echo " Install Android SDK:" + echo " brew install --cask android-commandlinetools" + echo "" + echo " Or set ANDROID_HOME to your SDK location" + exit 1 +fi + +# Start emulator if specified and no device is connected +start_emulator() { + if [ -n "$EMULATOR_NAME" ]; then + if ! adb devices | grep -q "device$"; then + echo "📱 Starting emulator '$EMULATOR_NAME'..." + if command -v emulator &> /dev/null; then + emulator -avd "$EMULATOR_NAME" -no-snapshot-load & + echo " Waiting for emulator to boot..." + adb wait-for-device + while [ "$(adb shell getprop sys.boot_completed 2>/dev/null)" != "1" ]; do + sleep 1 + done + echo " Emulator ready!" + else + echo "❌ emulator command not found" + echo " Available AVDs:" + avdmanager list avd 2>/dev/null | grep "Name:" || echo " (none)" + exit 1 + fi + fi + fi +} + +# Check for connected devices +check_device() { + if ! adb devices | grep -q -E "device$"; then + echo "❌ No Android device/emulator connected." + echo "" + echo " Available emulators:" + emulator -list-avds 2>/dev/null || avdmanager list avd 2>/dev/null | grep "Name:" || echo " (none found)" + echo "" + echo " Start an emulator: ./scripts/run.sh Pixel_8" + echo " Or connect a device via USB" + exit 1 + fi +} + +start_emulator +check_device + +echo "🔨 Building..." +./gradlew assembleDebug + +echo "" +echo "📦 Installing..." +./gradlew installDebug + +echo "" +echo "🚀 Launching..." +adb shell am force-stop "$PACKAGE_ID" 2>/dev/null || true +adb shell am start -n "$PACKAGE_ID/$ACTIVITY" + +echo "" +echo "✅ App is running" +echo " View logs: ./scripts/logs.sh" diff --git a/examples/basic-host-kotlin/scripts/screenshot.sh b/examples/basic-host-kotlin/scripts/screenshot.sh new file mode 100755 index 00000000..f7217ce2 --- /dev/null +++ b/examples/basic-host-kotlin/scripts/screenshot.sh @@ -0,0 +1,19 @@ +#!/bin/bash +# Take a screenshot from the device/emulator +# Usage: ./scripts/screenshot.sh [output-file] + +OUTPUT="${1:-screenshot.png}" + +echo "📸 Taking screenshot..." + +# Take screenshot on device and pull it +adb shell screencap -p /sdcard/screenshot.png +adb pull /sdcard/screenshot.png "$OUTPUT" +adb shell rm /sdcard/screenshot.png + +echo "✅ Saved to $OUTPUT" + +# Open the screenshot (macOS) +if [ "$(uname)" = "Darwin" ]; then + open "$OUTPUT" +fi diff --git a/examples/basic-host-kotlin/settings.gradle.kts b/examples/basic-host-kotlin/settings.gradle.kts new file mode 100644 index 00000000..9fc17672 --- /dev/null +++ b/examples/basic-host-kotlin/settings.gradle.kts @@ -0,0 +1,25 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "basic-host-kotlin" + +// Include the MCP Apps Kotlin SDK from the parent project +includeBuild("../../kotlin") { + dependencySubstitution { + substitute(module("io.modelcontextprotocol:mcp-apps-kotlin-sdk")) + .using(project(":")) + } +} diff --git a/examples/basic-host-kotlin/src/androidTest/kotlin/com/example/mcpappshost/McpAppBridgeInstrumentedTest.kt b/examples/basic-host-kotlin/src/androidTest/kotlin/com/example/mcpappshost/McpAppBridgeInstrumentedTest.kt new file mode 100644 index 00000000..c8e99a88 --- /dev/null +++ b/examples/basic-host-kotlin/src/androidTest/kotlin/com/example/mcpappshost/McpAppBridgeInstrumentedTest.kt @@ -0,0 +1,266 @@ +package com.example.mcpappshost + +import android.webkit.JavascriptInterface +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import kotlinx.coroutines.* +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +/** + * Instrumented tests for MCP Apps protocol communication via WebView. + * + * These tests verify: + * 1. The JavaScript interface correctly receives messages from the App + * 2. Messages dispatched via evaluateJavascript are received by the App + * 3. The protocol handshake completes successfully + * 4. Teardown flow works correctly + */ +@RunWith(AndroidJUnit4::class) +class McpAppBridgeInstrumentedTest { + + private lateinit var webView: WebView + private lateinit var protocol: McpAppBridgeProtocol + private val receivedMessages = mutableListOf() + private var initLatch = CountDownLatch(1) + + @Before + fun setUp() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + receivedMessages.clear() + initLatch = CountDownLatch(1) + + protocol = McpAppBridgeProtocol() + protocol.onInitialized = { initLatch.countDown() } + + // Set up on main thread since WebView requires it + runOnMainSync { + webView = WebView(context).apply { + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + + webViewClient = WebViewClient() + + addJavascriptInterface(object { + @JavascriptInterface + fun receiveMessage(jsonString: String) { + receivedMessages.add(jsonString) + protocol.handleMessage(jsonString) + } + }, "mcpBridge") + + protocol.onSendMessage = { msg -> + post { + val script = """ + (function() { + window.dispatchEvent(new MessageEvent('message', { + data: $msg, + origin: window.location.origin, + source: window + })); + })(); + """.trimIndent() + evaluateJavascript(script, null) + } + } + } + } + } + + private fun runOnMainSync(block: () -> Unit) { + InstrumentationRegistry.getInstrumentation().runOnMainSync(block) + } + + private fun loadTestApp() { + val testHtml = InstrumentationRegistry.getInstrumentation() + .context.assets.open("test-app.html") + .bufferedReader().readText() + + runOnMainSync { + webView.loadDataWithBaseURL(null, testHtml, "text/html", "UTF-8", null) + } + } + + @Test + fun testInitializationHandshake() { + loadTestApp() + + // Wait for initialization to complete + val initialized = initLatch.await(5, TimeUnit.SECONDS) + + assertTrue("Initialization should complete", initialized) + assertTrue("Protocol should be initialized", protocol.isInitialized) + assertTrue("Should have received messages", receivedMessages.isNotEmpty()) + + // Verify we received an initialize request + val initMsg = receivedMessages.find { it.contains("ui/initialize") } + assertNotNull("Should receive ui/initialize", initMsg) + } + + @Test + fun testToolInputNotification() { + loadTestApp() + + // Wait for initialization + assertTrue("Should initialize", initLatch.await(5, TimeUnit.SECONDS)) + + // Send tool input + val inputLatch = CountDownLatch(1) + runOnMainSync { + protocol.sendToolInput(mapOf("city" to "NYC")) + } + + // Give the WebView time to process + Thread.sleep(500) + + // The test app should have received the tool input + // (We can't easily verify this without more complex coordination, + // but at least we verify no crash) + assertTrue("Protocol should still be initialized", protocol.isInitialized) + } + + @Test + fun testTeardownFlow() { + loadTestApp() + + // Wait for initialization + assertTrue("Should initialize", initLatch.await(5, TimeUnit.SECONDS)) + + // Send teardown request + val teardownLatch = CountDownLatch(1) + protocol.onTeardownComplete = { teardownLatch.countDown() } + + runOnMainSync { + protocol.sendResourceTeardown() + } + + // Wait for teardown response + val teardownComplete = teardownLatch.await(3, TimeUnit.SECONDS) + + assertTrue("Teardown should complete", teardownComplete) + assertTrue("teardownCompleted flag should be true", protocol.teardownCompleted) + } + + @Test + fun testSizeChangedNotification() { + loadTestApp() + + // Wait for initialization + assertTrue("Should initialize", initLatch.await(5, TimeUnit.SECONDS)) + + // Track size changes + var receivedWidth: Int? = null + var receivedHeight: Int? = null + val sizeLatch = CountDownLatch(1) + protocol.onSizeChanged = { w, h -> + receivedWidth = w + receivedHeight = h + sizeLatch.countDown() + } + + // Trigger size change from the test app + runOnMainSync { + webView.evaluateJavascript("sendSizeChanged()", null) + } + + // Wait for size change + val sizeReceived = sizeLatch.await(2, TimeUnit.SECONDS) + + assertTrue("Should receive size change", sizeReceived) + assertEquals("Width should be 300", 300, receivedWidth) + assertEquals("Height should be 400", 400, receivedHeight) + } + + @Test + fun testOpenLinkRequest() { + loadTestApp() + + // Wait for initialization + assertTrue("Should initialize", initLatch.await(5, TimeUnit.SECONDS)) + + // Track open link requests + var openedUrl: String? = null + val linkLatch = CountDownLatch(1) + protocol.onOpenLink = { url -> + openedUrl = url + linkLatch.countDown() + } + + // Trigger open link from the test app + runOnMainSync { + webView.evaluateJavascript("sendOpenLink()", null) + } + + // Wait for link request + val linkReceived = linkLatch.await(2, TimeUnit.SECONDS) + + assertTrue("Should receive open link request", linkReceived) + assertEquals("URL should be example.com", "https://example.com", openedUrl) + } + + @Test + fun testMessageRequest() { + loadTestApp() + + // Wait for initialization + assertTrue("Should initialize", initLatch.await(5, TimeUnit.SECONDS)) + + // Track message requests + var receivedRole: String? = null + var receivedContent: String? = null + val messageLatch = CountDownLatch(1) + protocol.onMessage = { role, content -> + receivedRole = role + receivedContent = content + messageLatch.countDown() + } + + // Trigger message from the test app + runOnMainSync { + webView.evaluateJavascript("sendMessage()", null) + } + + // Wait for message + val messageReceived = messageLatch.await(2, TimeUnit.SECONDS) + + assertTrue("Should receive message", messageReceived) + assertEquals("Role should be 'user'", "user", receivedRole) + assertEquals("Content should be 'Hello from TestApp!'", "Hello from TestApp!", receivedContent) + } + + @Test + fun testLogNotification() { + loadTestApp() + + // Wait for initialization + assertTrue("Should initialize", initLatch.await(5, TimeUnit.SECONDS)) + + // Track log messages + var logLevel: String? = null + var logData: String? = null + val logLatch = CountDownLatch(1) + protocol.onLogMessage = { level, data -> + logLevel = level + logData = data + logLatch.countDown() + } + + // Trigger log from the test app + runOnMainSync { + webView.evaluateJavascript("sendLog()", null) + } + + // Wait for log + val logReceived = logLatch.await(2, TimeUnit.SECONDS) + + assertTrue("Should receive log", logReceived) + assertEquals("Level should be 'info'", "info", logLevel) + assertTrue("Data should contain test message", logData?.contains("Test log") == true) + } +} diff --git a/examples/basic-host-kotlin/src/main/AndroidManifest.xml b/examples/basic-host-kotlin/src/main/AndroidManifest.xml new file mode 100644 index 00000000..f2f8a653 --- /dev/null +++ b/examples/basic-host-kotlin/src/main/AndroidManifest.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + diff --git a/examples/basic-host-kotlin/src/main/assets/test-app.html b/examples/basic-host-kotlin/src/main/assets/test-app.html new file mode 100644 index 00000000..2840fb32 --- /dev/null +++ b/examples/basic-host-kotlin/src/main/assets/test-app.html @@ -0,0 +1,271 @@ + + + + + + MCP Apps Protocol Test + + + +

MCP Apps Protocol Test

+ +
Disconnected
+ +
+ State: +
Host: -
+
Tool Input: -
+
Tool Result: -
+
+ +
+ + + + + + +
+ +

Protocol Log

+
+ + + + diff --git a/examples/basic-host-kotlin/src/main/kotlin/com/example/mcpappshost/MainActivity.kt b/examples/basic-host-kotlin/src/main/kotlin/com/example/mcpappshost/MainActivity.kt new file mode 100644 index 00000000..3b7c305b --- /dev/null +++ b/examples/basic-host-kotlin/src/main/kotlin/com/example/mcpappshost/MainActivity.kt @@ -0,0 +1,682 @@ +package com.example.mcpappshost + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.webkit.JavascriptInterface +import android.webkit.WebView +import android.webkit.WebViewClient +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import kotlinx.coroutines.launch +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.viewmodel.compose.viewModel +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.contentOrNull + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + MaterialTheme { + Surface(modifier = Modifier.fillMaxSize()) { + McpHostApp() + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun McpHostApp(viewModel: McpHostViewModel = viewModel()) { + val toolCalls by viewModel.toolCalls.collectAsState() + val connectionState by viewModel.connectionState.collectAsState() + val tools by viewModel.tools.collectAsState() + val selectedTool by viewModel.selectedTool.collectAsState() + val selectedServerIndex by viewModel.selectedServerIndex.collectAsState() + val toolInputJson by viewModel.toolInputJson.collectAsState() + val discoveredServers by viewModel.discoveredServers.collectAsState() + val isDiscovering by viewModel.isDiscovering.collectAsState() + + var isInputExpanded by remember { mutableStateOf(false) } + val listState = rememberLazyListState() + + // Auto-scroll to new tool calls + LaunchedEffect(toolCalls.size) { + if (toolCalls.isNotEmpty()) { + listState.animateScrollToItem(toolCalls.size - 1) + } + } + + Scaffold( + topBar = { + TopAppBar(title = { Text("MCP Host") }) + }, + bottomBar = { + BottomToolbar( + connectionState = connectionState, + selectedServerIndex = selectedServerIndex, + discoveredServers = discoveredServers, + isDiscovering = isDiscovering, + tools = tools, + selectedTool = selectedTool, + toolInputJson = toolInputJson, + isInputExpanded = isInputExpanded, + onServerSelect = { viewModel.switchServer(it) }, + onToolSelect = { viewModel.selectTool(it) }, + onInputChange = { viewModel.updateToolInput(it) }, + onExpandToggle = { isInputExpanded = !isInputExpanded }, + onCallTool = { viewModel.callTool() }, + onRescan = { viewModel.discoverServers() } + ) + } + ) { paddingValues -> + if (toolCalls.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize().padding(paddingValues), + contentAlignment = Alignment.Center + ) { + Text("No active tool calls", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } else { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize().padding(paddingValues), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(toolCalls, key = { it.id }) { toolCall -> + ToolCallCard( + toolCall = toolCall, + onRequestClose = { viewModel.requestClose(toolCall) }, + onCloseComplete = { viewModel.completeClose(toolCall.id) }, + onToolCall = { name, args -> viewModel.forwardToolCall(name, args) } + ) + } + } + } + } +} + +@Composable +fun BottomToolbar( + connectionState: ConnectionState, + selectedServerIndex: Int, + discoveredServers: List, + isDiscovering: Boolean, + tools: List, + selectedTool: ToolInfo?, + toolInputJson: String, + isInputExpanded: Boolean, + onServerSelect: (Int) -> Unit, + onToolSelect: (ToolInfo) -> Unit, + onInputChange: (String) -> Unit, + onExpandToggle: () -> Unit, + onCallTool: () -> Unit, + onRescan: () -> Unit +) { + val isConnected = connectionState is ConnectionState.Connected + + Column(modifier = Modifier.fillMaxWidth()) { + AnimatedVisibility(visible = isInputExpanded && isConnected) { + Column(modifier = Modifier.padding(16.dp)) { + Text("Input (JSON)", style = MaterialTheme.typography.labelSmall) + Spacer(modifier = Modifier.height(4.dp)) + OutlinedTextField( + value = toolInputJson, + onValueChange = onInputChange, + modifier = Modifier.fillMaxWidth().height(100.dp), + textStyle = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace) + ) + } + } + + HorizontalDivider() + + Row( + modifier = Modifier.fillMaxWidth().padding(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + ServerPicker( + discoveredServers = discoveredServers, + selectedServerIndex = selectedServerIndex, + isDiscovering = isDiscovering, + connectionState = connectionState, + onServerSelect = onServerSelect, + onRescan = onRescan, + modifier = Modifier.weight(1f) + ) + + if (isConnected) { + ToolPicker(tools, selectedTool, onToolSelect, Modifier.weight(1f)) + + IconButton(onClick = onExpandToggle) { + Icon( + if (isInputExpanded) Icons.Default.KeyboardArrowDown else Icons.Default.KeyboardArrowUp, + contentDescription = "Toggle input" + ) + } + + Button(onClick = onCallTool, enabled = selectedTool != null) { + Text("Call") + } + } + } + } +} + +@Composable +fun ServerPicker( + discoveredServers: List, + selectedServerIndex: Int, + isDiscovering: Boolean, + connectionState: ConnectionState, + onServerSelect: (Int) -> Unit, + onRescan: () -> Unit, + modifier: Modifier = Modifier +) { + var expanded by remember { mutableStateOf(false) } + + Box(modifier = modifier.clickable { expanded = true }.padding(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = when { + isDiscovering -> "Scanning..." + selectedServerIndex in discoveredServers.indices -> discoveredServers[selectedServerIndex].name + discoveredServers.isEmpty() -> "No servers" + else -> "Select server" + }, + style = MaterialTheme.typography.bodySmall + ) + Icon(Icons.Default.ArrowDropDown, contentDescription = null, modifier = Modifier.size(16.dp)) + if (isDiscovering || connectionState is ConnectionState.Connecting) { + Spacer(modifier = Modifier.width(4.dp)) + CircularProgressIndicator(modifier = Modifier.size(12.dp), strokeWidth = 2.dp) + } + } + + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + if (discoveredServers.isEmpty() && !isDiscovering) { + DropdownMenuItem( + text = { Text("No servers found") }, + onClick = { }, + enabled = false + ) + } + discoveredServers.forEachIndexed { index, server -> + DropdownMenuItem( + text = { Text(server.name) }, + onClick = { expanded = false; onServerSelect(index) }, + leadingIcon = if (index == selectedServerIndex && connectionState is ConnectionState.Connected) { + { Icon(Icons.Default.Check, contentDescription = null) } + } else null + ) + } + HorizontalDivider() + DropdownMenuItem( + text = { Text("Re-scan servers") }, + onClick = { expanded = false; onRescan() }, + leadingIcon = { Icon(Icons.Default.Refresh, contentDescription = "Re-scan") } + ) + } + } +} + +@Composable +fun ToolPicker( + tools: List, + selectedTool: ToolInfo?, + onToolSelect: (ToolInfo) -> Unit, + modifier: Modifier = Modifier +) { + var expanded by remember { mutableStateOf(false) } + + Box(modifier = modifier.clickable(enabled = tools.isNotEmpty()) { expanded = true }.padding(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(selectedTool?.name ?: "Select tool", style = MaterialTheme.typography.bodySmall) + Icon(Icons.Default.ArrowDropDown, contentDescription = null, modifier = Modifier.size(16.dp)) + } + + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + tools.forEach { tool -> + DropdownMenuItem(text = { Text(tool.name) }, onClick = { expanded = false; onToolSelect(tool) }) + } + } + } +} + +@Composable +fun ToolCallCard( + toolCall: ToolCallState, + onRequestClose: () -> Unit, + onCloseComplete: () -> Unit, + onToolCall: (suspend (name: String, arguments: Map?) -> String)? = null +) { + var isInputExpanded by remember { mutableStateOf(false) } + var webViewHeight by remember { mutableIntStateOf(toolCall.preferredHeight) } + + // Dimmed appearance when destroying (waiting for teardown) + val cardAlpha = if (toolCall.isDestroying) 0.5f else 1f + + Card( + modifier = Modifier + .fillMaxWidth() + .alpha(cardAlpha) + ) { + Column(modifier = Modifier.padding(12.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text(toolCall.serverName, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary) + Text(toolCall.toolName, style = MaterialTheme.typography.titleSmall) + } + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + val (color, text) = when { + toolCall.isDestroying -> MaterialTheme.colorScheme.tertiary to "Closing" + toolCall.state == ToolCallState.State.CALLING -> MaterialTheme.colorScheme.tertiary to "Calling" + toolCall.state == ToolCallState.State.LOADING_UI -> MaterialTheme.colorScheme.tertiary to "Loading" + toolCall.state == ToolCallState.State.READY -> MaterialTheme.colorScheme.primary to "Ready" + toolCall.state == ToolCallState.State.COMPLETED -> MaterialTheme.colorScheme.primary to "Done" + toolCall.state == ToolCallState.State.ERROR -> MaterialTheme.colorScheme.error to "Error" + else -> MaterialTheme.colorScheme.tertiary to "Unknown" + } + Surface(color = color.copy(alpha = 0.15f), shape = MaterialTheme.shapes.small) { + Text(text, color = color, style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(4.dp)) + } + + IconButton(onClick = { isInputExpanded = !isInputExpanded }, modifier = Modifier.size(24.dp)) { + Icon(if (isInputExpanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, contentDescription = "Toggle") + } + + // Disable close button while destroying + IconButton( + onClick = onRequestClose, + modifier = Modifier.size(24.dp), + enabled = !toolCall.isDestroying + ) { + Icon(Icons.Default.Close, contentDescription = "Close") + } + } + } + + AnimatedVisibility(visible = isInputExpanded) { + Surface(color = MaterialTheme.colorScheme.surfaceVariant, shape = MaterialTheme.shapes.small, modifier = Modifier.padding(top = 8.dp)) { + Text(toolCall.input, style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), modifier = Modifier.padding(8.dp)) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + when { + toolCall.error != null -> { + Surface(color = MaterialTheme.colorScheme.errorContainer, shape = MaterialTheme.shapes.small) { + Text(toolCall.error, color = MaterialTheme.colorScheme.onErrorContainer, modifier = Modifier.padding(8.dp)) + } + } + toolCall.state == ToolCallState.State.READY && toolCall.htmlContent != null -> { + // WebView for UI resource with full AppBridge protocol + McpAppWebView( + toolCall = toolCall, + isDestroying = toolCall.isDestroying, + onTeardownComplete = onCloseComplete, + onToolCall = onToolCall, + onSizeChanged = { height -> webViewHeight = height }, + modifier = Modifier + .fillMaxWidth() + .height(webViewHeight.dp) + ) + } + toolCall.state == ToolCallState.State.COMPLETED && toolCall.result != null -> { + Surface(color = MaterialTheme.colorScheme.surfaceVariant, shape = MaterialTheme.shapes.small) { + Text(toolCall.result, style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), modifier = Modifier.padding(8.dp)) + } + } + toolCall.state == ToolCallState.State.CALLING || toolCall.state == ToolCallState.State.LOADING_UI -> { + Row(modifier = Modifier.fillMaxWidth().padding(16.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator(modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text("Loading...", style = MaterialTheme.typography.bodySmall) + } + } + } + } + } +} + +/** + * WebView composable that handles full MCP Apps protocol communication. + * + * Implements proper lifecycle management to handle: + * - Activity pause/resume (calls WebView.onPause/onResume) + * - Composable disposal (cleans up WebView references) + * - LazyColumn recycling (preserves WebView state via key) + */ +@Composable +fun McpAppWebView( + toolCall: ToolCallState, + isDestroying: Boolean = false, + onTeardownComplete: (() -> Unit)? = null, + onToolCall: (suspend (name: String, arguments: Map?) -> String)? = null, + onSizeChanged: ((height: Int) -> Unit)? = null, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val coroutineScope = rememberCoroutineScope() + val json = remember { kotlinx.serialization.json.Json { ignoreUnknownKeys = true } } + var webViewRef by remember { mutableStateOf(null) } + var initialized by remember { mutableStateOf(false) } + var teardownRequestId by remember { mutableStateOf(0) } + var teardownCompleted by remember { mutableStateOf(false) } + var currentHeight by remember { mutableIntStateOf(toolCall.preferredHeight) } + + // Track whether the WebView is currently paused + var isPaused by remember { mutableStateOf(false) } + + // Lifecycle observer to pause/resume WebView when activity lifecycle changes + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_PAUSE -> { + android.util.Log.d("McpAppWebView", "Lifecycle ON_PAUSE - pausing WebView") + webViewRef?.onPause() + isPaused = true + } + Lifecycle.Event.ON_RESUME -> { + android.util.Log.d("McpAppWebView", "Lifecycle ON_RESUME - resuming WebView") + if (isPaused) { + webViewRef?.onResume() + isPaused = false + } + } + Lifecycle.Event.ON_DESTROY -> { + android.util.Log.d("McpAppWebView", "Lifecycle ON_DESTROY - cleaning up WebView") + webViewRef?.let { wv -> + wv.stopLoading() + wv.destroy() + } + webViewRef = null + } + else -> {} + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + android.util.Log.d("McpAppWebView", "DisposableEffect onDispose - cleaning up") + lifecycleOwner.lifecycle.removeObserver(observer) + // Clean up WebView when composable is disposed + webViewRef?.let { wv -> + wv.stopLoading() + // Don't destroy here - just clear the reference + // The WebView will be garbage collected or reused + } + webViewRef = null + } + } + + // Inject bridge script into HTML + val injectedHtml = remember(toolCall.htmlContent) { + injectBridgeScript(toolCall.htmlContent!!) + } + + // Function to send JSON-RPC message to WebView + fun sendToWebView(message: String) { + webViewRef?.let { wv -> + val script = """ + (function() { + try { + const msg = $message; + window.dispatchEvent(new MessageEvent('message', { + data: msg, + origin: window.location.origin, + source: window + })); + } catch (e) { + console.error('Failed to dispatch:', e); + } + })(); + """.trimIndent() + wv.post { wv.evaluateJavascript(script, null) } + } + } + + // Send tool input and result after initialization + LaunchedEffect(initialized) { + if (initialized) { + android.util.Log.i("McpAppWebView", "Sending tool input and result") + + // Send tool input + val inputArgs = toolCall.inputArgs ?: emptyMap() + val toolInputMsg = buildString { + append("""{"jsonrpc":"2.0","method":"ui/notifications/tool-input","params":{"arguments":""") + append(json.encodeToString(kotlinx.serialization.json.JsonObject.serializer(), + kotlinx.serialization.json.buildJsonObject { + inputArgs.forEach { (k, v) -> + put(k, kotlinx.serialization.json.JsonPrimitive(v.toString())) + } + } + )) + append("}}") + } + sendToWebView(toolInputMsg) + + // Send tool result + if (toolCall.toolResult != null) { + val toolResultMsg = """{"jsonrpc":"2.0","method":"ui/notifications/tool-result","params":${toolCall.toolResult}}""" + sendToWebView(toolResultMsg) + } + } + } + + // Graceful teardown: send ui/resource-teardown when isDestroying becomes true + // Per spec: "Host SHOULD wait for a response before tearing down the resource" + LaunchedEffect(isDestroying) { + if (isDestroying && !teardownCompleted) { + if (webViewRef == null || !initialized) { + // WebView not ready, complete immediately + android.util.Log.i("McpAppWebView", "Teardown: WebView not ready, completing immediately") + onTeardownComplete?.invoke() + return@LaunchedEffect + } + + // Generate unique request ID for teardown + val requestId = System.currentTimeMillis().toInt() + teardownRequestId = requestId + android.util.Log.i("McpAppWebView", "Sending teardown request (id=$requestId)") + + val teardownMsg = """{"jsonrpc":"2.0","id":$requestId,"method":"ui/resource-teardown","params":{}}""" + sendToWebView(teardownMsg) + + // Poll for completion with timeout (500ms total, checking every 50ms) + var elapsed = 0 + while (!teardownCompleted && elapsed < 500) { + kotlinx.coroutines.delay(50) + elapsed += 50 + } + if (!teardownCompleted) { + android.util.Log.w("McpAppWebView", "Teardown timeout after ${elapsed}ms, completing anyway") + teardownCompleted = true + onTeardownComplete?.invoke() + } + } + } + + AndroidView( + factory = { context -> + WebView(context).apply { + webViewRef = this + webViewClient = WebViewClient() + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + settings.allowFileAccess = false + settings.allowContentAccess = false + + // JavaScript interface for receiving messages from App + addJavascriptInterface(object { + @JavascriptInterface + fun receiveMessage(jsonString: String) { + android.util.Log.d("McpAppWebView", "Received: $jsonString") + try { + val msg = json.parseToJsonElement(jsonString).jsonObject + val method = msg["method"]?.jsonPrimitive?.contentOrNull + val id = msg["id"] + + // Check for teardown response (has result but no method) + if (method == null && msg.containsKey("result")) { + val responseId = id?.jsonPrimitive?.intOrNull + if (responseId == teardownRequestId && !teardownCompleted) { + android.util.Log.i("McpAppWebView", "Teardown response received") + teardownCompleted = true + post { onTeardownComplete?.invoke() } + } + return + } + + when (method) { + "ui/initialize" -> { + // Respond to initialize request + val response = buildString { + append("""{"jsonrpc":"2.0","id":""") + append(id) + append(""","result":{""") + append(""""protocolVersion":"2025-11-21",""") + append(""""hostInfo":{"name":"BasicHostKotlin","version":"1.0.0"},""") + append(""""hostCapabilities":{"openLinks":{},"serverTools":{},"logging":{}},""") + append(""""hostContext":{"theme":"light","platform":"mobile"}""") + append("}}") + } + post { sendToWebView(response) } + } + "ui/notifications/initialized" -> { + android.util.Log.i("McpAppWebView", "App initialized!") + initialized = true + } + "ui/notifications/size-changed" -> { + val params = msg["params"]?.jsonObject + val height = params?.get("height")?.jsonPrimitive?.intOrNull + if (height != null && height > 0) { + android.util.Log.i("McpAppWebView", "Size changed: height=$height") + currentHeight = height + onSizeChanged?.invoke(height) + } + } + "ui/message" -> { + val params = msg["params"]?.jsonObject + val role = params?.get("role")?.jsonPrimitive?.contentOrNull ?: "user" + val content = params?.get("content")?.jsonArray?.firstOrNull() + ?.jsonObject?.get("text")?.jsonPrimitive?.contentOrNull ?: "" + android.util.Log.i("McpAppWebView", "Message from app: $content") + post { + Toast.makeText(context, "[$role] $content", Toast.LENGTH_LONG).show() + sendToWebView("""{"jsonrpc":"2.0","id":$id,"result":{}}""") + } + } + "ui/open-link" -> { + val url = msg["params"]?.jsonObject?.get("url")?.jsonPrimitive?.contentOrNull + android.util.Log.i("McpAppWebView", "Open link: $url") + post { + if (url != null) { + try { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + context.startActivity(intent) + } catch (e: Exception) { + Toast.makeText(context, "Cannot open: $url", Toast.LENGTH_SHORT).show() + } + } + sendToWebView("""{"jsonrpc":"2.0","id":$id,"result":{}}""") + } + } + "notifications/message" -> { + // Logging from app + val params = msg["params"]?.jsonObject + val level = params?.get("level")?.jsonPrimitive?.contentOrNull ?: "info" + val data = params?.get("data")?.jsonPrimitive?.contentOrNull ?: "" + android.util.Log.i("McpAppWebView", "Log [$level]: $data") + post { + Toast.makeText(context, "Log: $data", Toast.LENGTH_SHORT).show() + } + } + "tools/call" -> { + // App wants to call a server tool (e.g., Get Server Time) + val params = msg["params"]?.jsonObject + val toolName = params?.get("name")?.jsonPrimitive?.contentOrNull ?: "" + val args = params?.get("arguments")?.jsonObject?.let { argsObj -> + argsObj.mapValues { (_, v) -> v.jsonPrimitive.contentOrNull ?: "" } + } + android.util.Log.i("McpAppWebView", "Tool call: $toolName with args: $args") + + if (onToolCall != null) { + coroutineScope.launch { + try { + val result = onToolCall(toolName, args) + post { sendToWebView("""{"jsonrpc":"2.0","id":$id,"result":$result}""") } + } catch (e: Exception) { + android.util.Log.e("McpAppWebView", "Tool call failed", e) + post { sendToWebView("""{"jsonrpc":"2.0","id":$id,"error":{"code":-32603,"message":"${e.message}"}}""") } + } + } + } else { + post { + Toast.makeText(context, "Tool call: $toolName (no handler)", Toast.LENGTH_SHORT).show() + sendToWebView("""{"jsonrpc":"2.0","id":$id,"error":{"code":-32601,"message":"Tool call handler not configured"}}""") + } + } + } + else -> { + android.util.Log.w("McpAppWebView", "Unknown method: $method") + } + } + } catch (e: Exception) { + android.util.Log.e("McpAppWebView", "Error parsing message", e) + } + } + }, "mcpBridge") + + loadDataWithBaseURL(null, injectedHtml, "text/html", "UTF-8", null) + } + }, + update = { webView -> + // Update webViewRef if it changed (e.g., after recreation) + if (webViewRef != webView) { + android.util.Log.d("McpAppWebView", "WebView reference updated") + webViewRef = webView + } + // Sync paused state with current lifecycle + if (isPaused && lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) { + android.util.Log.d("McpAppWebView", "Resuming WebView after lifecycle sync") + webView.onResume() + isPaused = false + } + }, + modifier = modifier + ) +} diff --git a/examples/basic-host-kotlin/src/main/kotlin/com/example/mcpappshost/McpAppBridgeProtocol.kt b/examples/basic-host-kotlin/src/main/kotlin/com/example/mcpappshost/McpAppBridgeProtocol.kt new file mode 100644 index 00000000..30fee28a --- /dev/null +++ b/examples/basic-host-kotlin/src/main/kotlin/com/example/mcpappshost/McpAppBridgeProtocol.kt @@ -0,0 +1,203 @@ +package com.example.mcpappshost + +import kotlinx.serialization.json.* + +/** + * MCP Apps protocol handler that can be tested independently of WebView. + * + * This class handles the JSON-RPC message parsing and protocol state machine, + * delegating actual I/O to the provided callbacks. + */ +class McpAppBridgeProtocol( + private val hostInfo: HostInfo = HostInfo("BasicHostKotlin", "1.0.0"), + private val hostCapabilities: HostCapabilities = HostCapabilities() +) { + private val json = Json { ignoreUnknownKeys = true } + + // Protocol state + var isInitialized: Boolean = false + private set + var appInfo: AppInfo? = null + private set + var teardownRequestId: Int? = null + private set + var teardownCompleted: Boolean = false + + // Callbacks for Host -> App communication + var onSendMessage: ((String) -> Unit)? = null + + // Callbacks for protocol events + var onInitialized: (() -> Unit)? = null + var onSizeChanged: ((width: Int?, height: Int?) -> Unit)? = null + var onMessage: ((role: String, content: String) -> Unit)? = null + var onOpenLink: ((url: String) -> Unit)? = null + var onLogMessage: ((level: String, data: String) -> Unit)? = null + var onToolCall: ((name: String, arguments: Map?) -> String)? = null + var onTeardownComplete: (() -> Unit)? = null + + data class HostInfo(val name: String, val version: String) + data class HostCapabilities( + val openLinks: Boolean = true, + val serverTools: Boolean = true, + val logging: Boolean = true + ) + data class AppInfo(val name: String, val version: String) + + /** + * Handle an incoming JSON-RPC message from the App. + * Returns true if the message was handled. + */ + fun handleMessage(jsonString: String): Boolean { + return try { + val msg = json.parseToJsonElement(jsonString).jsonObject + val method = msg["method"]?.jsonPrimitive?.contentOrNull + val id = msg["id"] + + // Check for teardown response (has result but no method) + if (method == null && msg.containsKey("result")) { + val responseId = id?.jsonPrimitive?.intOrNull + if (responseId == teardownRequestId && !teardownCompleted) { + teardownCompleted = true + onTeardownComplete?.invoke() + } + return true + } + + when (method) { + "ui/initialize" -> handleInitialize(id) + "ui/notifications/initialized" -> handleInitializedNotification() + "ui/notifications/size-changed" -> handleSizeChanged(msg) + "ui/message" -> handleMessageRequest(id, msg) + "ui/open-link" -> handleOpenLink(id, msg) + "notifications/message" -> handleLogNotification(msg) + "tools/call" -> handleToolCall(id, msg) + else -> { + // Unknown method + false + } + } + } catch (e: Exception) { + false + } + } + + private fun handleInitialize(id: JsonElement?): Boolean { + val response = buildString { + append("""{"jsonrpc":"2.0","id":""") + append(id) + append(""","result":{""") + append(""""protocolVersion":"2025-11-21",""") + append(""""hostInfo":{"name":"${hostInfo.name}","version":"${hostInfo.version}"},""") + append(""""hostCapabilities":{""") + append(""""openLinks":${if (hostCapabilities.openLinks) "{}" else "null"},""") + append(""""serverTools":${if (hostCapabilities.serverTools) "{}" else "null"},""") + append(""""logging":${if (hostCapabilities.logging) "{}" else "null"}""") + append("""},""") + append(""""hostContext":{"theme":"light","platform":"mobile"}""") + append("}}") + } + onSendMessage?.invoke(response) + return true + } + + private fun handleInitializedNotification(): Boolean { + isInitialized = true + onInitialized?.invoke() + return true + } + + private fun handleSizeChanged(msg: JsonObject): Boolean { + val params = msg["params"]?.jsonObject + val width = params?.get("width")?.jsonPrimitive?.intOrNull + val height = params?.get("height")?.jsonPrimitive?.intOrNull + onSizeChanged?.invoke(width, height) + return true + } + + private fun handleMessageRequest(id: JsonElement?, msg: JsonObject): Boolean { + val params = msg["params"]?.jsonObject + val role = params?.get("role")?.jsonPrimitive?.contentOrNull ?: "user" + val content = params?.get("content")?.jsonArray?.firstOrNull() + ?.jsonObject?.get("text")?.jsonPrimitive?.contentOrNull ?: "" + onMessage?.invoke(role, content) + onSendMessage?.invoke("""{"jsonrpc":"2.0","id":$id,"result":{}}""") + return true + } + + private fun handleOpenLink(id: JsonElement?, msg: JsonObject): Boolean { + val url = msg["params"]?.jsonObject?.get("url")?.jsonPrimitive?.contentOrNull + if (url != null) { + onOpenLink?.invoke(url) + } + onSendMessage?.invoke("""{"jsonrpc":"2.0","id":$id,"result":{}}""") + return true + } + + private fun handleLogNotification(msg: JsonObject): Boolean { + val params = msg["params"]?.jsonObject + val level = params?.get("level")?.jsonPrimitive?.contentOrNull ?: "info" + val data = params?.get("data")?.jsonPrimitive?.contentOrNull ?: "" + onLogMessage?.invoke(level, data) + return true + } + + private fun handleToolCall(id: JsonElement?, msg: JsonObject): Boolean { + val params = msg["params"]?.jsonObject + val toolName = params?.get("name")?.jsonPrimitive?.contentOrNull ?: "" + val args = params?.get("arguments")?.jsonObject?.let { argsObj -> + argsObj.mapValues { (_, v) -> v.jsonPrimitive.contentOrNull ?: "" } + } + + val handler = onToolCall + if (handler != null) { + try { + val result = handler(toolName, args) + onSendMessage?.invoke("""{"jsonrpc":"2.0","id":$id,"result":$result}""") + } catch (e: Exception) { + onSendMessage?.invoke("""{"jsonrpc":"2.0","id":$id,"error":{"code":-32603,"message":"${e.message}"}}""") + } + } else { + onSendMessage?.invoke("""{"jsonrpc":"2.0","id":$id,"error":{"code":-32601,"message":"Tool call handler not configured"}}""") + } + return true + } + + // ========== Host -> App methods ========== + + /** + * Send tool input notification to App. + */ + fun sendToolInput(arguments: Map) { + val argsJson = json.encodeToString(JsonObject.serializer(), buildJsonObject { + arguments.forEach { (k, v) -> put(k, JsonPrimitive(v.toString())) } + }) + onSendMessage?.invoke("""{"jsonrpc":"2.0","method":"ui/notifications/tool-input","params":{"arguments":$argsJson}}""") + } + + /** + * Send tool result notification to App. + */ + fun sendToolResult(resultJson: String) { + onSendMessage?.invoke("""{"jsonrpc":"2.0","method":"ui/notifications/tool-result","params":$resultJson}""") + } + + /** + * Send tool cancelled notification to App. + */ + fun sendToolCancelled(reason: String? = null) { + val params = if (reason != null) """{"reason":"$reason"}""" else "{}" + onSendMessage?.invoke("""{"jsonrpc":"2.0","method":"ui/notifications/tool-cancelled","params":$params}""") + } + + /** + * Send resource teardown request to App. + * Returns the request ID for tracking the response. + */ + fun sendResourceTeardown(): Int { + val requestId = System.currentTimeMillis().toInt() + teardownRequestId = requestId + teardownCompleted = false + onSendMessage?.invoke("""{"jsonrpc":"2.0","id":$requestId,"method":"ui/resource-teardown","params":{}}""") + return requestId + } +} diff --git a/examples/basic-host-kotlin/src/main/kotlin/com/example/mcpappshost/McpHostViewModel.kt b/examples/basic-host-kotlin/src/main/kotlin/com/example/mcpappshost/McpHostViewModel.kt new file mode 100644 index 00000000..8dcbb466 --- /dev/null +++ b/examples/basic-host-kotlin/src/main/kotlin/com/example/mcpappshost/McpHostViewModel.kt @@ -0,0 +1,445 @@ +package com.example.mcpappshost + +import android.util.Log +import android.webkit.WebView +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import io.modelcontextprotocol.apps.generated.* +import io.modelcontextprotocol.kotlin.sdk.client.Client +import io.modelcontextprotocol.kotlin.sdk.client.SseClientTransport +import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceRequest +import io.modelcontextprotocol.kotlin.sdk.types.ReadResourceRequestParams +import io.modelcontextprotocol.kotlin.sdk.types.TextResourceContents +import io.modelcontextprotocol.kotlin.sdk.types.BlobResourceContents +import io.modelcontextprotocol.kotlin.sdk.types.TextContent +import io.ktor.client.* +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.sse.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.* +import kotlinx.serialization.encodeToString + +private const val TAG = "McpHostViewModel" + +data class DiscoveredServer(val name: String, val url: String) + +data class ToolInfo( + val name: String, + val description: String?, + val inputSchema: JsonElement?, + val uiResourceUri: String? = null // From _meta["ui/resourceUri"] +) + +sealed class ConnectionState { + data object Disconnected : ConnectionState() + data object Connecting : ConnectionState() + data object Connected : ConnectionState() + data class Error(val message: String) : ConnectionState() +} + +data class ToolCallState( + val id: String = java.util.UUID.randomUUID().toString(), + val serverName: String, + val toolName: String, + val input: String, + val inputArgs: Map? = null, + val state: State = State.CALLING, + val result: String? = null, + val toolResult: String? = null, // Raw tool result for AppBridge + val error: String? = null, + val htmlContent: String? = null, + var webView: WebView? = null, + var preferredHeight: Int = 350, + var appBridgeConnected: Boolean = false, + val isDestroying: Boolean = false // Two-phase teardown: true while waiting for app response +) { + enum class State { CALLING, LOADING_UI, READY, COMPLETED, ERROR } + + val hasApp: Boolean get() = htmlContent != null && state == State.READY +} + +class McpHostViewModel : ViewModel() { + private val json = Json { ignoreUnknownKeys = true; isLenient = true } + + companion object { + const val BASE_PORT = 3101 + const val DISCOVERY_TIMEOUT_MS = 1000L + // Android emulator uses 10.0.2.2 for host machine's localhost + const val BASE_HOST = "10.0.2.2" + } + + // Connection state + private val _connectionState = MutableStateFlow(ConnectionState.Disconnected) + val connectionState: StateFlow = _connectionState.asStateFlow() + + // Server discovery + private val _discoveredServers = MutableStateFlow>(emptyList()) + val discoveredServers: StateFlow> = _discoveredServers.asStateFlow() + + private val _isDiscovering = MutableStateFlow(false) + val isDiscovering: StateFlow = _isDiscovering.asStateFlow() + + // Tools + private val _tools = MutableStateFlow>(emptyList()) + val tools: StateFlow> = _tools.asStateFlow() + + private val _selectedTool = MutableStateFlow(null) + val selectedTool: StateFlow = _selectedTool.asStateFlow() + + // Server selection + private val _selectedServerIndex = MutableStateFlow(0) + val selectedServerIndex: StateFlow = _selectedServerIndex.asStateFlow() + + // Tool input + private val _toolInputJson = MutableStateFlow("{}") + val toolInputJson: StateFlow = _toolInputJson.asStateFlow() + + // Active tool calls + private val _toolCalls = MutableStateFlow>(emptyList()) + val toolCalls: StateFlow> = _toolCalls.asStateFlow() + + private var mcpClient: Client? = null + + private val hostInfo = Implementation(name = "BasicHostKotlin", version = "1.0.0") + private val hostCapabilities = McpUiHostCapabilities( + openLinks = EmptyCapability, + serverTools = McpUiHostCapabilitiesServerTools(), + serverResources = McpUiHostCapabilitiesServerResources(), + logging = EmptyCapability + ) + + init { + // Start discovery on launch + discoverServers() + } + + fun discoverServers() { + viewModelScope.launch { + _isDiscovering.value = true + _discoveredServers.value = emptyList() + + val discovered = mutableListOf() + var port = BASE_PORT + + while (true) { + val url = "http://$BASE_HOST:$port/sse" + val serverName = tryConnect(url) + + if (serverName != null) { + discovered.add(DiscoveredServer(serverName, url)) + _discoveredServers.value = discovered.toList() + Log.i(TAG, "Discovered server: $serverName at $url") + port++ + } else { + Log.i(TAG, "No server at port $port, stopping discovery") + break + } + } + + _isDiscovering.value = false + Log.i(TAG, "Discovery complete, found ${discovered.size} servers") + + // Auto-connect to first discovered server + if (discovered.isNotEmpty()) { + _selectedServerIndex.value = 0 + connect() + } + } + } + + private suspend fun tryConnect(url: String): String? { + return try { + withTimeout(DISCOVERY_TIMEOUT_MS) { + val httpClient = HttpClient(CIO) { + install(SSE) + } + try { + val transport = SseClientTransport(httpClient, url) + val client = Client( + clientInfo = io.modelcontextprotocol.kotlin.sdk.types.Implementation( + name = "BasicHostKotlin", + version = "1.0.0" + ) + ) + client.connect(transport) + val serverName = client.serverVersion?.name ?: url + serverName + } finally { + httpClient.close() + } + } + } catch (e: Exception) { + Log.d(TAG, "Discovery failed for $url: ${e.message}") + null + } + } + + fun selectTool(tool: ToolInfo) { + _selectedTool.value = tool + // Generate default input from schema + _toolInputJson.value = generateDefaultInput(tool) + } + + fun updateToolInput(input: String) { + _toolInputJson.value = input + } + + fun switchServer(index: Int) { + if (index == _selectedServerIndex.value && _connectionState.value is ConnectionState.Connected) { + return + } + viewModelScope.launch { + disconnect() + _selectedServerIndex.value = index + connect() + } + } + + fun connect() { + val servers = _discoveredServers.value + val serverUrl = if (_selectedServerIndex.value >= 0 && _selectedServerIndex.value < servers.size) { + servers[_selectedServerIndex.value].url + } else { + return + } + + viewModelScope.launch { + try { + _connectionState.value = ConnectionState.Connecting + Log.i(TAG, "Connecting to $serverUrl") + + val httpClient = HttpClient(CIO) { + install(SSE) + } + val transport = SseClientTransport(httpClient, serverUrl) + + val client = Client( + clientInfo = io.modelcontextprotocol.kotlin.sdk.types.Implementation( + name = "BasicHostKotlin", + version = "1.0.0" + ) + ) + client.connect(transport) + + mcpClient = client + + // List tools + val result = client.listTools() + _tools.value = result.tools.map { tool -> + // Extract UI resource URI from _meta (JsonObject) + val meta = tool.meta as? JsonObject + val uiResourceUri = meta?.get("ui/resourceUri")?.let { element -> + (element as? JsonPrimitive)?.contentOrNull + } + Log.d(TAG, "Tool ${tool.name} uiResourceUri: $uiResourceUri") + ToolInfo( + name = tool.name, + description = tool.description, + inputSchema = null, + uiResourceUri = uiResourceUri + ) + } + + if (_tools.value.isNotEmpty()) { + selectTool(_tools.value.first()) + } + + _connectionState.value = ConnectionState.Connected + Log.i(TAG, "Connected, found ${_tools.value.size} tools") + + } catch (e: Exception) { + Log.e(TAG, "Connection failed", e) + _connectionState.value = ConnectionState.Error(e.message ?: "Unknown error") + } + } + } + + fun disconnect() { + mcpClient = null + _tools.value = emptyList() + _selectedTool.value = null + _connectionState.value = ConnectionState.Disconnected + } + + fun callTool() { + val tool = _selectedTool.value ?: return + val client = mcpClient ?: return + + val servers = _discoveredServers.value + val serverName = if (_selectedServerIndex.value in servers.indices) { + servers[_selectedServerIndex.value].name + } else "Custom" + + val toolCall = ToolCallState( + serverName = serverName, + toolName = tool.name, + input = _toolInputJson.value + ) + _toolCalls.value = _toolCalls.value + toolCall + + viewModelScope.launch { + try { + // Parse input JSON + val inputArgs = try { + json.parseToJsonElement(_toolInputJson.value) as? JsonObject + } catch (e: Exception) { + null + } + + // Call the tool (name, arguments, meta, options) + val callResult = client.callTool(tool.name, emptyMap(), emptyMap()) + + // Check for UI resource + if (tool.uiResourceUri != null) { + updateToolCall(toolCall.id) { it.copy(state = ToolCallState.State.LOADING_UI) } + Log.i(TAG, "Reading UI resource: ${tool.uiResourceUri}") + + try { + // Read the UI resource + val request = ReadResourceRequest(ReadResourceRequestParams(uri = tool.uiResourceUri)) + val resourceResult = client.readResource(request) + val htmlContent = resourceResult.contents.firstOrNull()?.let { content -> + when (content) { + is TextResourceContents -> content.text + is BlobResourceContents -> { + String(android.util.Base64.decode(content.blob, android.util.Base64.DEFAULT)) + } + else -> null + } + } + + if (htmlContent != null) { + Log.i(TAG, "Loaded UI resource (${htmlContent.length} chars)") + // Store both HTML and tool result for AppBridge + val toolResultJson = json.encodeToString( + kotlinx.serialization.json.JsonObject.serializer(), + buildJsonObject { + put("content", buildJsonArray { + callResult.content.forEach { block -> + // Extract text properly from content block + val text = when (block) { + is TextContent -> block.text + else -> block.toString() + } + add(buildJsonObject { + put("type", JsonPrimitive("text")) + put("text", JsonPrimitive(text)) + }) + } + }) + put("isError", JsonPrimitive(callResult.isError ?: false)) + } + ) + updateToolCall(toolCall.id) { it.copy( + state = ToolCallState.State.READY, + htmlContent = htmlContent, + toolResult = toolResultJson, + inputArgs = inputArgs?.let { args -> + args.mapValues { (_, v) -> v.toString() } + } + )} + } else { + Log.w(TAG, "No HTML content in resource") + val resultText = callResult.content.joinToString("\n") { it.toString() } + updateToolCall(toolCall.id) { it.copy( + state = ToolCallState.State.COMPLETED, + result = resultText + )} + } + } catch (e: Exception) { + Log.e(TAG, "Failed to read UI resource: ${e.message}", e) + val resultText = callResult.content.joinToString("\n") { it.toString() } + updateToolCall(toolCall.id) { it.copy( + state = ToolCallState.State.COMPLETED, + result = resultText + )} + } + } else { + // No UI resource, show text result + val resultText = callResult.content.joinToString("\n") { it.toString() } + Log.i(TAG, "Tool result (no UI): $resultText") + updateToolCall(toolCall.id) { it.copy( + state = ToolCallState.State.COMPLETED, + result = resultText + )} + } + + } catch (e: Exception) { + Log.e(TAG, "Tool call failed", e) + updateToolCall(toolCall.id) { it.copy( + state = ToolCallState.State.ERROR, + error = e.message + )} + } + } + } + + /** + * Request to close a tool call. For apps, this marks as destroying and waits + * for teardown. For non-app results, removes immediately. + */ + fun requestClose(toolCall: ToolCallState) { + if (toolCall.hasApp) { + // Mark as destroying - the WebView will send teardown and call completeClose + updateToolCall(toolCall.id) { it.copy(isDestroying = true) } + } else { + // Non-app results close immediately + completeClose(toolCall.id) + } + } + + /** + * Complete the close after teardown response (or immediately for non-apps). + */ + fun completeClose(id: String) { + _toolCalls.value = _toolCalls.value.filter { it.id != id } + } + + /** + * Forward a tool call from the WebView App to the MCP server. + * Returns the result as a JSON string. + */ + suspend fun forwardToolCall(name: String, arguments: Map?): String { + val client = mcpClient ?: throw IllegalStateException("Not connected") + + Log.i(TAG, "Forwarding tool call: $name with args: $arguments") + + val callResult = client.callTool(name, emptyMap(), emptyMap()) + + // Format result as JSON for the App + val resultJson = json.encodeToString( + kotlinx.serialization.json.JsonObject.serializer(), + buildJsonObject { + put("content", buildJsonArray { + callResult.content.forEach { block -> + val text = when (block) { + is TextContent -> block.text + else -> block.toString() + } + add(buildJsonObject { + put("type", JsonPrimitive("text")) + put("text", JsonPrimitive(text)) + }) + } + }) + put("isError", JsonPrimitive(callResult.isError ?: false)) + } + ) + + Log.i(TAG, "Tool call result: $resultJson") + return resultJson + } + + private fun updateToolCall(id: String, update: (ToolCallState) -> ToolCallState) { + _toolCalls.value = _toolCalls.value.map { if (it.id == id) update(it) else it } + } + + private fun generateDefaultInput(tool: ToolInfo): String { + // TODO: Parse inputSchema and generate defaults + return "{}" + } +} diff --git a/examples/basic-host-kotlin/src/main/kotlin/com/example/mcpappshost/WebViewTransport.kt b/examples/basic-host-kotlin/src/main/kotlin/com/example/mcpappshost/WebViewTransport.kt new file mode 100644 index 00000000..1ef3a19a --- /dev/null +++ b/examples/basic-host-kotlin/src/main/kotlin/com/example/mcpappshost/WebViewTransport.kt @@ -0,0 +1,92 @@ +package com.example.mcpappshost + +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.webkit.JavascriptInterface +import android.webkit.WebView +import io.modelcontextprotocol.apps.protocol.JSONRPCMessage +import io.modelcontextprotocol.apps.transport.McpAppsTransport +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.serialization.json.Json + +private const val TAG = "WebViewTransport" + +/** + * Transport for MCP Apps communication using Android WebView. + */ +class WebViewTransport( + private val webView: WebView, + private val handlerName: String = "mcpBridge" +) : McpAppsTransport { + + private val json = Json { ignoreUnknownKeys = true; isLenient = true } + private val mainHandler = Handler(Looper.getMainLooper()) + + private val _incoming = MutableSharedFlow() + private val _errors = MutableSharedFlow() + + override val incoming: Flow = _incoming + override val errors: Flow = _errors + + @JavascriptInterface + fun receiveMessage(jsonString: String) { + Log.d(TAG, "Received from JS: $jsonString") + try { + val message = json.decodeFromString(jsonString) + mainHandler.post { _incoming.tryEmit(message) } + } catch (e: Exception) { + Log.e(TAG, "Failed to parse message", e) + mainHandler.post { _errors.tryEmit(e) } + } + } + + override suspend fun start() { + mainHandler.post { + webView.addJavascriptInterface(this, handlerName) + val bridgeScript = """ + (function() { + window.parent = window.parent || {}; + window.parent.postMessage = function(message, targetOrigin) { + if (window.$handlerName) { + window.$handlerName.receiveMessage(JSON.stringify(message)); + } + }; + window.dispatchEvent(new Event('mcp-bridge-ready')); + })(); + """.trimIndent() + webView.evaluateJavascript(bridgeScript, null) + } + } + + override suspend fun send(message: JSONRPCMessage) { + val jsonString = json.encodeToString(JSONRPCMessage.serializer(), message) + val script = """ + (function() { + const msg = $jsonString; + window.dispatchEvent(new MessageEvent('message', { data: msg })); + })(); + """.trimIndent() + mainHandler.post { webView.evaluateJavascript(script, null) } + } + + override suspend fun close() { + mainHandler.post { webView.removeJavascriptInterface(handlerName) } + } +} + +/** Injects bridge script into HTML before loading */ +fun injectBridgeScript(html: String, handlerName: String = "mcpBridge"): String { + val script = """ + + """.trimIndent() + return if (html.contains("", true)) html.replaceFirst("", "$script", true) + else script + html +} diff --git a/examples/basic-host-kotlin/src/main/res/values/strings.xml b/examples/basic-host-kotlin/src/main/res/values/strings.xml new file mode 100644 index 00000000..0ca8c7d3 --- /dev/null +++ b/examples/basic-host-kotlin/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + MCP Apps Host + diff --git a/examples/basic-host-kotlin/src/test/kotlin/com/example/mcpappshost/McpAppBridgeProtocolTest.kt b/examples/basic-host-kotlin/src/test/kotlin/com/example/mcpappshost/McpAppBridgeProtocolTest.kt new file mode 100644 index 00000000..6526dfbb --- /dev/null +++ b/examples/basic-host-kotlin/src/test/kotlin/com/example/mcpappshost/McpAppBridgeProtocolTest.kt @@ -0,0 +1,254 @@ +package com.example.mcpappshost + +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test + +/** + * Unit tests for McpAppBridgeProtocol. + * These tests verify the JSON-RPC protocol handling without any Android dependencies. + */ +class McpAppBridgeProtocolTest { + + private lateinit var protocol: McpAppBridgeProtocol + private val sentMessages = mutableListOf() + + @Before + fun setUp() { + protocol = McpAppBridgeProtocol( + hostInfo = McpAppBridgeProtocol.HostInfo("TestHost", "1.0.0"), + hostCapabilities = McpAppBridgeProtocol.HostCapabilities() + ) + sentMessages.clear() + protocol.onSendMessage = { sentMessages.add(it) } + } + + // ========== Initialization Tests ========== + + @Test + fun `handleMessage processes ui-initialize request`() { + val initMsg = """{"jsonrpc":"2.0","id":1,"method":"ui/initialize","params":{"protocolVersion":"2025-11-21","appInfo":{"name":"TestApp","version":"1.0.0"},"appCapabilities":{}}}""" + + val handled = protocol.handleMessage(initMsg) + + assertTrue("Message should be handled", handled) + assertEquals("Should send one response", 1, sentMessages.size) + val response = sentMessages[0] + assertTrue("Response should contain id:1", response.contains(""""id":1""")) + assertTrue("Response should contain hostInfo", response.contains(""""hostInfo":{"name":"TestHost"""")) + assertTrue("Response should contain protocolVersion", response.contains(""""protocolVersion":"2025-11-21"""")) + } + + @Test + fun `handleMessage processes ui-notifications-initialized`() { + var initializedCalled = false + protocol.onInitialized = { initializedCalled = true } + + val initNotification = """{"jsonrpc":"2.0","method":"ui/notifications/initialized","params":{}}""" + + val handled = protocol.handleMessage(initNotification) + + assertTrue("Message should be handled", handled) + assertTrue("onInitialized should be called", initializedCalled) + assertTrue("isInitialized should be true", protocol.isInitialized) + } + + // ========== App -> Host Notification Tests ========== + + @Test + fun `handleMessage processes size-changed notification`() { + var receivedWidth: Int? = null + var receivedHeight: Int? = null + protocol.onSizeChanged = { w, h -> receivedWidth = w; receivedHeight = h } + + val msg = """{"jsonrpc":"2.0","method":"ui/notifications/size-changed","params":{"width":400,"height":600}}""" + + val handled = protocol.handleMessage(msg) + + assertTrue("Message should be handled", handled) + assertEquals("Width should be 400", 400, receivedWidth) + assertEquals("Height should be 600", 600, receivedHeight) + } + + @Test + fun `handleMessage processes ui-message request`() { + var receivedRole: String? = null + var receivedContent: String? = null + protocol.onMessage = { role, content -> receivedRole = role; receivedContent = content } + + val msg = """{"jsonrpc":"2.0","id":42,"method":"ui/message","params":{"role":"user","content":[{"type":"text","text":"Hello!"}]}}""" + + val handled = protocol.handleMessage(msg) + + assertTrue("Message should be handled", handled) + assertEquals("Role should be 'user'", "user", receivedRole) + assertEquals("Content should be 'Hello!'", "Hello!", receivedContent) + assertEquals("Should send one response", 1, sentMessages.size) + assertTrue("Response should contain id:42", sentMessages[0].contains(""""id":42""")) + } + + @Test + fun `handleMessage processes ui-open-link request`() { + var openedUrl: String? = null + protocol.onOpenLink = { url -> openedUrl = url } + + val msg = """{"jsonrpc":"2.0","id":5,"method":"ui/open-link","params":{"url":"https://example.com"}}""" + + val handled = protocol.handleMessage(msg) + + assertTrue("Message should be handled", handled) + assertEquals("URL should be 'https://example.com'", "https://example.com", openedUrl) + assertEquals("Should send one response", 1, sentMessages.size) + } + + @Test + fun `handleMessage processes notifications-message (logging)`() { + var logLevel: String? = null + var logData: String? = null + protocol.onLogMessage = { level, data -> logLevel = level; logData = data } + + val msg = """{"jsonrpc":"2.0","method":"notifications/message","params":{"level":"info","data":"Test log"}}""" + + val handled = protocol.handleMessage(msg) + + assertTrue("Message should be handled", handled) + assertEquals("Level should be 'info'", "info", logLevel) + assertEquals("Data should be 'Test log'", "Test log", logData) + } + + @Test + fun `handleMessage processes tools-call request`() { + var calledTool: String? = null + var calledArgs: Map? = null + protocol.onToolCall = { name, args -> + calledTool = name + calledArgs = args + """{"content":[{"type":"text","text":"Tool result"}]}""" + } + + val msg = """{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"get_weather","arguments":{"city":"NYC"}}}""" + + val handled = protocol.handleMessage(msg) + + assertTrue("Message should be handled", handled) + assertEquals("Tool name should be 'get_weather'", "get_weather", calledTool) + assertEquals("Args should contain city=NYC", "NYC", calledArgs?.get("city")) + assertTrue("Response should contain result", sentMessages[0].contains(""""result":""")) + } + + @Test + fun `handleMessage returns error when tool handler throws`() { + protocol.onToolCall = { _, _ -> throw RuntimeException("Tool failed") } + + val msg = """{"jsonrpc":"2.0","id":11,"method":"tools/call","params":{"name":"failing_tool","arguments":{}}}""" + + val handled = protocol.handleMessage(msg) + + assertTrue("Message should be handled", handled) + assertTrue("Response should contain error", sentMessages[0].contains(""""error":""")) + assertTrue("Response should contain error message", sentMessages[0].contains("Tool failed")) + } + + // ========== Teardown Tests ========== + + @Test + fun `sendResourceTeardown sends request and tracks state`() { + val requestId = protocol.sendResourceTeardown() + + assertEquals("Should send one message", 1, sentMessages.size) + assertTrue("Message should contain ui/resource-teardown", sentMessages[0].contains("ui/resource-teardown")) + assertEquals("teardownRequestId should be set", requestId, protocol.teardownRequestId) + assertFalse("teardownCompleted should be false", protocol.teardownCompleted) + } + + @Test + fun `handleMessage processes teardown response`() { + var teardownComplete = false + protocol.onTeardownComplete = { teardownComplete = true } + + val requestId = protocol.sendResourceTeardown() + sentMessages.clear() + + // Simulate app response + val response = """{"jsonrpc":"2.0","id":$requestId,"result":{}}""" + val handled = protocol.handleMessage(response) + + assertTrue("Response should be handled", handled) + assertTrue("teardownCompleted should be true", protocol.teardownCompleted) + assertTrue("onTeardownComplete should be called", teardownComplete) + } + + @Test + fun `teardown response with wrong id is ignored`() { + var teardownComplete = false + protocol.onTeardownComplete = { teardownComplete = true } + + protocol.sendResourceTeardown() + sentMessages.clear() + + // Response with wrong id + val response = """{"jsonrpc":"2.0","id":99999,"result":{}}""" + protocol.handleMessage(response) + + assertFalse("teardownCompleted should still be false", protocol.teardownCompleted) + assertFalse("onTeardownComplete should not be called", teardownComplete) + } + + // ========== Host -> App Notification Tests ========== + + @Test + fun `sendToolInput sends correct notification`() { + protocol.sendToolInput(mapOf("city" to "NYC", "units" to "celsius")) + + assertEquals("Should send one message", 1, sentMessages.size) + val msg = sentMessages[0] + assertTrue("Should contain tool-input method", msg.contains("ui/notifications/tool-input")) + assertTrue("Should contain city argument", msg.contains("NYC")) + } + + @Test + fun `sendToolResult sends correct notification`() { + protocol.sendToolResult("""{"content":[{"type":"text","text":"Result"}]}""") + + assertEquals("Should send one message", 1, sentMessages.size) + assertTrue("Should contain tool-result method", sentMessages[0].contains("ui/notifications/tool-result")) + } + + @Test + fun `sendToolCancelled sends notification with reason`() { + protocol.sendToolCancelled("User cancelled") + + assertEquals("Should send one message", 1, sentMessages.size) + val msg = sentMessages[0] + assertTrue("Should contain tool-cancelled method", msg.contains("ui/notifications/tool-cancelled")) + assertTrue("Should contain reason", msg.contains("User cancelled")) + } + + @Test + fun `sendToolCancelled sends notification without reason`() { + protocol.sendToolCancelled() + + assertEquals("Should send one message", 1, sentMessages.size) + val msg = sentMessages[0] + assertTrue("Should contain tool-cancelled method", msg.contains("ui/notifications/tool-cancelled")) + assertTrue("Should contain empty params", msg.contains(""""params":{}""")) + } + + // ========== Edge Cases ========== + + @Test + fun `handleMessage returns false for unknown method`() { + val msg = """{"jsonrpc":"2.0","method":"unknown/method","params":{}}""" + + val handled = protocol.handleMessage(msg) + + assertFalse("Unknown method should not be handled", handled) + } + + @Test + fun `handleMessage returns false for malformed JSON`() { + val handled = protocol.handleMessage("not valid json") + + assertFalse("Malformed JSON should not be handled", handled) + } +} diff --git a/examples/basic-server-react/src/server-utils.ts b/examples/basic-server-react/src/server-utils.ts index 40524237..f480c60e 100644 --- a/examples/basic-server-react/src/server-utils.ts +++ b/examples/basic-server-react/src/server-utils.ts @@ -1,18 +1,30 @@ /** * Shared utilities for running MCP servers with various transports. + * + * Supports: + * - Stdio transport (--stdio flag) + * - Streamable HTTP transport (/mcp) - stateless mode + * - Legacy SSE transport (/sse, /messages) - for older clients (e.g., Kotlin SDK) */ import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import cors from "cors"; import type { Request, Response } from "express"; +/** Active SSE sessions: sessionId -> { server, transport } */ +const sseSessions = new Map< + string, + { server: McpServer; transport: SSEServerTransport } +>(); + /** * Starts an MCP server using the appropriate transport based on command-line arguments. * - * If `--stdio` is passed, uses stdio transport. Otherwise, uses Streamable HTTP transport. + * If `--stdio` is passed, uses stdio transport. Otherwise, uses HTTP transports. * * @param createServer - Factory function that creates a new McpServer instance. */ @@ -23,7 +35,7 @@ export async function startServer( if (process.argv.includes("--stdio")) { await startStdioServer(createServer); } else { - await startStreamableHttpServer(createServer); + await startHttpServer(createServer); } } catch (e) { console.error(e); @@ -43,17 +55,15 @@ export async function startStdioServer( } /** - * Starts an MCP server with Streamable HTTP transport in stateless mode. - * - * Each request creates a fresh server and transport instance, which are - * closed when the response ends (no session tracking). + * Starts an MCP server with HTTP transports (Streamable HTTP + legacy SSE). * - * The server listens on the port specified by the PORT environment variable, - * defaulting to 3001 if not set. + * Provides: + * - /mcp (GET/POST/DELETE): Streamable HTTP transport (stateless mode) + * - /sse (GET) + /messages (POST): Legacy SSE transport for older clients * - * @param createServer - Factory function that creates a new McpServer instance per request. + * @param createServer - Factory function that creates a new McpServer instance. */ -export async function startStreamableHttpServer( +export async function startHttpServer( createServer: () => McpServer, ): Promise { const port = parseInt(process.env.PORT ?? "3001", 10); @@ -62,6 +72,7 @@ export async function startStreamableHttpServer( const expressApp = createMcpExpressApp({ host: "0.0.0.0" }); expressApp.use(cors()); + // Streamable HTTP transport (stateless mode) expressApp.all("/mcp", async (req: Request, res: Response) => { // Create fresh server and transport for each request (stateless mode) const server = createServer(); @@ -90,16 +101,70 @@ export async function startStreamableHttpServer( } }); + // Legacy SSE transport - stream endpoint + expressApp.get("/sse", async (_req: Request, res: Response) => { + try { + const server = createServer(); + const transport = new SSEServerTransport("/messages", res); + sseSessions.set(transport.sessionId, { server, transport }); + + res.on("close", () => { + sseSessions.delete(transport.sessionId); + transport.close().catch(() => {}); + server.close().catch(() => {}); + }); + + await server.connect(transport); + } catch (error) { + console.error("SSE error:", error); + if (!res.headersSent) res.status(500).end(); + } + }); + + // Legacy SSE transport - message endpoint + expressApp.post("/messages", async (req: Request, res: Response) => { + try { + const sessionId = req.query.sessionId as string; + const session = sseSessions.get(sessionId); + + if (!session) { + return res.status(404).json({ + jsonrpc: "2.0", + error: { code: -32001, message: "Session not found" }, + id: null, + }); + } + + await session.transport.handlePostMessage(req, res, req.body); + } catch (error) { + console.error("Message error:", error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: "2.0", + error: { code: -32603, message: "Internal server error" }, + id: null, + }); + } + } + }); + const { promise, resolve, reject } = Promise.withResolvers(); const httpServer = expressApp.listen(port, (err?: Error) => { if (err) return reject(err); console.log(`Server listening on http://localhost:${port}/mcp`); + console.log(` SSE endpoint: http://localhost:${port}/sse`); resolve(); }); const shutdown = () => { console.log("\nShutting down..."); + // Clean up all SSE sessions + sseSessions.forEach(({ server, transport }) => { + transport.close().catch(() => {}); + server.close().catch(() => {}); + }); + sseSessions.clear(); httpServer.close(() => process.exit(0)); }; @@ -108,3 +173,8 @@ export async function startStreamableHttpServer( return promise; } + +/** + * @deprecated Use startHttpServer instead + */ +export const startStreamableHttpServer = startHttpServer; diff --git a/examples/basic-server-vanillajs/src/server-utils.ts b/examples/basic-server-vanillajs/src/server-utils.ts index 40524237..f480c60e 100644 --- a/examples/basic-server-vanillajs/src/server-utils.ts +++ b/examples/basic-server-vanillajs/src/server-utils.ts @@ -1,18 +1,30 @@ /** * Shared utilities for running MCP servers with various transports. + * + * Supports: + * - Stdio transport (--stdio flag) + * - Streamable HTTP transport (/mcp) - stateless mode + * - Legacy SSE transport (/sse, /messages) - for older clients (e.g., Kotlin SDK) */ import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import cors from "cors"; import type { Request, Response } from "express"; +/** Active SSE sessions: sessionId -> { server, transport } */ +const sseSessions = new Map< + string, + { server: McpServer; transport: SSEServerTransport } +>(); + /** * Starts an MCP server using the appropriate transport based on command-line arguments. * - * If `--stdio` is passed, uses stdio transport. Otherwise, uses Streamable HTTP transport. + * If `--stdio` is passed, uses stdio transport. Otherwise, uses HTTP transports. * * @param createServer - Factory function that creates a new McpServer instance. */ @@ -23,7 +35,7 @@ export async function startServer( if (process.argv.includes("--stdio")) { await startStdioServer(createServer); } else { - await startStreamableHttpServer(createServer); + await startHttpServer(createServer); } } catch (e) { console.error(e); @@ -43,17 +55,15 @@ export async function startStdioServer( } /** - * Starts an MCP server with Streamable HTTP transport in stateless mode. - * - * Each request creates a fresh server and transport instance, which are - * closed when the response ends (no session tracking). + * Starts an MCP server with HTTP transports (Streamable HTTP + legacy SSE). * - * The server listens on the port specified by the PORT environment variable, - * defaulting to 3001 if not set. + * Provides: + * - /mcp (GET/POST/DELETE): Streamable HTTP transport (stateless mode) + * - /sse (GET) + /messages (POST): Legacy SSE transport for older clients * - * @param createServer - Factory function that creates a new McpServer instance per request. + * @param createServer - Factory function that creates a new McpServer instance. */ -export async function startStreamableHttpServer( +export async function startHttpServer( createServer: () => McpServer, ): Promise { const port = parseInt(process.env.PORT ?? "3001", 10); @@ -62,6 +72,7 @@ export async function startStreamableHttpServer( const expressApp = createMcpExpressApp({ host: "0.0.0.0" }); expressApp.use(cors()); + // Streamable HTTP transport (stateless mode) expressApp.all("/mcp", async (req: Request, res: Response) => { // Create fresh server and transport for each request (stateless mode) const server = createServer(); @@ -90,16 +101,70 @@ export async function startStreamableHttpServer( } }); + // Legacy SSE transport - stream endpoint + expressApp.get("/sse", async (_req: Request, res: Response) => { + try { + const server = createServer(); + const transport = new SSEServerTransport("/messages", res); + sseSessions.set(transport.sessionId, { server, transport }); + + res.on("close", () => { + sseSessions.delete(transport.sessionId); + transport.close().catch(() => {}); + server.close().catch(() => {}); + }); + + await server.connect(transport); + } catch (error) { + console.error("SSE error:", error); + if (!res.headersSent) res.status(500).end(); + } + }); + + // Legacy SSE transport - message endpoint + expressApp.post("/messages", async (req: Request, res: Response) => { + try { + const sessionId = req.query.sessionId as string; + const session = sseSessions.get(sessionId); + + if (!session) { + return res.status(404).json({ + jsonrpc: "2.0", + error: { code: -32001, message: "Session not found" }, + id: null, + }); + } + + await session.transport.handlePostMessage(req, res, req.body); + } catch (error) { + console.error("Message error:", error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: "2.0", + error: { code: -32603, message: "Internal server error" }, + id: null, + }); + } + } + }); + const { promise, resolve, reject } = Promise.withResolvers(); const httpServer = expressApp.listen(port, (err?: Error) => { if (err) return reject(err); console.log(`Server listening on http://localhost:${port}/mcp`); + console.log(` SSE endpoint: http://localhost:${port}/sse`); resolve(); }); const shutdown = () => { console.log("\nShutting down..."); + // Clean up all SSE sessions + sseSessions.forEach(({ server, transport }) => { + transport.close().catch(() => {}); + server.close().catch(() => {}); + }); + sseSessions.clear(); httpServer.close(() => process.exit(0)); }; @@ -108,3 +173,8 @@ export async function startStreamableHttpServer( return promise; } + +/** + * @deprecated Use startHttpServer instead + */ +export const startStreamableHttpServer = startHttpServer; diff --git a/examples/budget-allocator-server/src/server-utils.ts b/examples/budget-allocator-server/src/server-utils.ts index 40524237..f480c60e 100644 --- a/examples/budget-allocator-server/src/server-utils.ts +++ b/examples/budget-allocator-server/src/server-utils.ts @@ -1,18 +1,30 @@ /** * Shared utilities for running MCP servers with various transports. + * + * Supports: + * - Stdio transport (--stdio flag) + * - Streamable HTTP transport (/mcp) - stateless mode + * - Legacy SSE transport (/sse, /messages) - for older clients (e.g., Kotlin SDK) */ import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import cors from "cors"; import type { Request, Response } from "express"; +/** Active SSE sessions: sessionId -> { server, transport } */ +const sseSessions = new Map< + string, + { server: McpServer; transport: SSEServerTransport } +>(); + /** * Starts an MCP server using the appropriate transport based on command-line arguments. * - * If `--stdio` is passed, uses stdio transport. Otherwise, uses Streamable HTTP transport. + * If `--stdio` is passed, uses stdio transport. Otherwise, uses HTTP transports. * * @param createServer - Factory function that creates a new McpServer instance. */ @@ -23,7 +35,7 @@ export async function startServer( if (process.argv.includes("--stdio")) { await startStdioServer(createServer); } else { - await startStreamableHttpServer(createServer); + await startHttpServer(createServer); } } catch (e) { console.error(e); @@ -43,17 +55,15 @@ export async function startStdioServer( } /** - * Starts an MCP server with Streamable HTTP transport in stateless mode. - * - * Each request creates a fresh server and transport instance, which are - * closed when the response ends (no session tracking). + * Starts an MCP server with HTTP transports (Streamable HTTP + legacy SSE). * - * The server listens on the port specified by the PORT environment variable, - * defaulting to 3001 if not set. + * Provides: + * - /mcp (GET/POST/DELETE): Streamable HTTP transport (stateless mode) + * - /sse (GET) + /messages (POST): Legacy SSE transport for older clients * - * @param createServer - Factory function that creates a new McpServer instance per request. + * @param createServer - Factory function that creates a new McpServer instance. */ -export async function startStreamableHttpServer( +export async function startHttpServer( createServer: () => McpServer, ): Promise { const port = parseInt(process.env.PORT ?? "3001", 10); @@ -62,6 +72,7 @@ export async function startStreamableHttpServer( const expressApp = createMcpExpressApp({ host: "0.0.0.0" }); expressApp.use(cors()); + // Streamable HTTP transport (stateless mode) expressApp.all("/mcp", async (req: Request, res: Response) => { // Create fresh server and transport for each request (stateless mode) const server = createServer(); @@ -90,16 +101,70 @@ export async function startStreamableHttpServer( } }); + // Legacy SSE transport - stream endpoint + expressApp.get("/sse", async (_req: Request, res: Response) => { + try { + const server = createServer(); + const transport = new SSEServerTransport("/messages", res); + sseSessions.set(transport.sessionId, { server, transport }); + + res.on("close", () => { + sseSessions.delete(transport.sessionId); + transport.close().catch(() => {}); + server.close().catch(() => {}); + }); + + await server.connect(transport); + } catch (error) { + console.error("SSE error:", error); + if (!res.headersSent) res.status(500).end(); + } + }); + + // Legacy SSE transport - message endpoint + expressApp.post("/messages", async (req: Request, res: Response) => { + try { + const sessionId = req.query.sessionId as string; + const session = sseSessions.get(sessionId); + + if (!session) { + return res.status(404).json({ + jsonrpc: "2.0", + error: { code: -32001, message: "Session not found" }, + id: null, + }); + } + + await session.transport.handlePostMessage(req, res, req.body); + } catch (error) { + console.error("Message error:", error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: "2.0", + error: { code: -32603, message: "Internal server error" }, + id: null, + }); + } + } + }); + const { promise, resolve, reject } = Promise.withResolvers(); const httpServer = expressApp.listen(port, (err?: Error) => { if (err) return reject(err); console.log(`Server listening on http://localhost:${port}/mcp`); + console.log(` SSE endpoint: http://localhost:${port}/sse`); resolve(); }); const shutdown = () => { console.log("\nShutting down..."); + // Clean up all SSE sessions + sseSessions.forEach(({ server, transport }) => { + transport.close().catch(() => {}); + server.close().catch(() => {}); + }); + sseSessions.clear(); httpServer.close(() => process.exit(0)); }; @@ -108,3 +173,8 @@ export async function startStreamableHttpServer( return promise; } + +/** + * @deprecated Use startHttpServer instead + */ +export const startStreamableHttpServer = startHttpServer; diff --git a/examples/cohort-heatmap-server/src/server-utils.ts b/examples/cohort-heatmap-server/src/server-utils.ts index 40524237..f480c60e 100644 --- a/examples/cohort-heatmap-server/src/server-utils.ts +++ b/examples/cohort-heatmap-server/src/server-utils.ts @@ -1,18 +1,30 @@ /** * Shared utilities for running MCP servers with various transports. + * + * Supports: + * - Stdio transport (--stdio flag) + * - Streamable HTTP transport (/mcp) - stateless mode + * - Legacy SSE transport (/sse, /messages) - for older clients (e.g., Kotlin SDK) */ import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import cors from "cors"; import type { Request, Response } from "express"; +/** Active SSE sessions: sessionId -> { server, transport } */ +const sseSessions = new Map< + string, + { server: McpServer; transport: SSEServerTransport } +>(); + /** * Starts an MCP server using the appropriate transport based on command-line arguments. * - * If `--stdio` is passed, uses stdio transport. Otherwise, uses Streamable HTTP transport. + * If `--stdio` is passed, uses stdio transport. Otherwise, uses HTTP transports. * * @param createServer - Factory function that creates a new McpServer instance. */ @@ -23,7 +35,7 @@ export async function startServer( if (process.argv.includes("--stdio")) { await startStdioServer(createServer); } else { - await startStreamableHttpServer(createServer); + await startHttpServer(createServer); } } catch (e) { console.error(e); @@ -43,17 +55,15 @@ export async function startStdioServer( } /** - * Starts an MCP server with Streamable HTTP transport in stateless mode. - * - * Each request creates a fresh server and transport instance, which are - * closed when the response ends (no session tracking). + * Starts an MCP server with HTTP transports (Streamable HTTP + legacy SSE). * - * The server listens on the port specified by the PORT environment variable, - * defaulting to 3001 if not set. + * Provides: + * - /mcp (GET/POST/DELETE): Streamable HTTP transport (stateless mode) + * - /sse (GET) + /messages (POST): Legacy SSE transport for older clients * - * @param createServer - Factory function that creates a new McpServer instance per request. + * @param createServer - Factory function that creates a new McpServer instance. */ -export async function startStreamableHttpServer( +export async function startHttpServer( createServer: () => McpServer, ): Promise { const port = parseInt(process.env.PORT ?? "3001", 10); @@ -62,6 +72,7 @@ export async function startStreamableHttpServer( const expressApp = createMcpExpressApp({ host: "0.0.0.0" }); expressApp.use(cors()); + // Streamable HTTP transport (stateless mode) expressApp.all("/mcp", async (req: Request, res: Response) => { // Create fresh server and transport for each request (stateless mode) const server = createServer(); @@ -90,16 +101,70 @@ export async function startStreamableHttpServer( } }); + // Legacy SSE transport - stream endpoint + expressApp.get("/sse", async (_req: Request, res: Response) => { + try { + const server = createServer(); + const transport = new SSEServerTransport("/messages", res); + sseSessions.set(transport.sessionId, { server, transport }); + + res.on("close", () => { + sseSessions.delete(transport.sessionId); + transport.close().catch(() => {}); + server.close().catch(() => {}); + }); + + await server.connect(transport); + } catch (error) { + console.error("SSE error:", error); + if (!res.headersSent) res.status(500).end(); + } + }); + + // Legacy SSE transport - message endpoint + expressApp.post("/messages", async (req: Request, res: Response) => { + try { + const sessionId = req.query.sessionId as string; + const session = sseSessions.get(sessionId); + + if (!session) { + return res.status(404).json({ + jsonrpc: "2.0", + error: { code: -32001, message: "Session not found" }, + id: null, + }); + } + + await session.transport.handlePostMessage(req, res, req.body); + } catch (error) { + console.error("Message error:", error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: "2.0", + error: { code: -32603, message: "Internal server error" }, + id: null, + }); + } + } + }); + const { promise, resolve, reject } = Promise.withResolvers(); const httpServer = expressApp.listen(port, (err?: Error) => { if (err) return reject(err); console.log(`Server listening on http://localhost:${port}/mcp`); + console.log(` SSE endpoint: http://localhost:${port}/sse`); resolve(); }); const shutdown = () => { console.log("\nShutting down..."); + // Clean up all SSE sessions + sseSessions.forEach(({ server, transport }) => { + transport.close().catch(() => {}); + server.close().catch(() => {}); + }); + sseSessions.clear(); httpServer.close(() => process.exit(0)); }; @@ -108,3 +173,8 @@ export async function startStreamableHttpServer( return promise; } + +/** + * @deprecated Use startHttpServer instead + */ +export const startStreamableHttpServer = startHttpServer; diff --git a/examples/customer-segmentation-server/src/server-utils.ts b/examples/customer-segmentation-server/src/server-utils.ts index 40524237..f480c60e 100644 --- a/examples/customer-segmentation-server/src/server-utils.ts +++ b/examples/customer-segmentation-server/src/server-utils.ts @@ -1,18 +1,30 @@ /** * Shared utilities for running MCP servers with various transports. + * + * Supports: + * - Stdio transport (--stdio flag) + * - Streamable HTTP transport (/mcp) - stateless mode + * - Legacy SSE transport (/sse, /messages) - for older clients (e.g., Kotlin SDK) */ import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import cors from "cors"; import type { Request, Response } from "express"; +/** Active SSE sessions: sessionId -> { server, transport } */ +const sseSessions = new Map< + string, + { server: McpServer; transport: SSEServerTransport } +>(); + /** * Starts an MCP server using the appropriate transport based on command-line arguments. * - * If `--stdio` is passed, uses stdio transport. Otherwise, uses Streamable HTTP transport. + * If `--stdio` is passed, uses stdio transport. Otherwise, uses HTTP transports. * * @param createServer - Factory function that creates a new McpServer instance. */ @@ -23,7 +35,7 @@ export async function startServer( if (process.argv.includes("--stdio")) { await startStdioServer(createServer); } else { - await startStreamableHttpServer(createServer); + await startHttpServer(createServer); } } catch (e) { console.error(e); @@ -43,17 +55,15 @@ export async function startStdioServer( } /** - * Starts an MCP server with Streamable HTTP transport in stateless mode. - * - * Each request creates a fresh server and transport instance, which are - * closed when the response ends (no session tracking). + * Starts an MCP server with HTTP transports (Streamable HTTP + legacy SSE). * - * The server listens on the port specified by the PORT environment variable, - * defaulting to 3001 if not set. + * Provides: + * - /mcp (GET/POST/DELETE): Streamable HTTP transport (stateless mode) + * - /sse (GET) + /messages (POST): Legacy SSE transport for older clients * - * @param createServer - Factory function that creates a new McpServer instance per request. + * @param createServer - Factory function that creates a new McpServer instance. */ -export async function startStreamableHttpServer( +export async function startHttpServer( createServer: () => McpServer, ): Promise { const port = parseInt(process.env.PORT ?? "3001", 10); @@ -62,6 +72,7 @@ export async function startStreamableHttpServer( const expressApp = createMcpExpressApp({ host: "0.0.0.0" }); expressApp.use(cors()); + // Streamable HTTP transport (stateless mode) expressApp.all("/mcp", async (req: Request, res: Response) => { // Create fresh server and transport for each request (stateless mode) const server = createServer(); @@ -90,16 +101,70 @@ export async function startStreamableHttpServer( } }); + // Legacy SSE transport - stream endpoint + expressApp.get("/sse", async (_req: Request, res: Response) => { + try { + const server = createServer(); + const transport = new SSEServerTransport("/messages", res); + sseSessions.set(transport.sessionId, { server, transport }); + + res.on("close", () => { + sseSessions.delete(transport.sessionId); + transport.close().catch(() => {}); + server.close().catch(() => {}); + }); + + await server.connect(transport); + } catch (error) { + console.error("SSE error:", error); + if (!res.headersSent) res.status(500).end(); + } + }); + + // Legacy SSE transport - message endpoint + expressApp.post("/messages", async (req: Request, res: Response) => { + try { + const sessionId = req.query.sessionId as string; + const session = sseSessions.get(sessionId); + + if (!session) { + return res.status(404).json({ + jsonrpc: "2.0", + error: { code: -32001, message: "Session not found" }, + id: null, + }); + } + + await session.transport.handlePostMessage(req, res, req.body); + } catch (error) { + console.error("Message error:", error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: "2.0", + error: { code: -32603, message: "Internal server error" }, + id: null, + }); + } + } + }); + const { promise, resolve, reject } = Promise.withResolvers(); const httpServer = expressApp.listen(port, (err?: Error) => { if (err) return reject(err); console.log(`Server listening on http://localhost:${port}/mcp`); + console.log(` SSE endpoint: http://localhost:${port}/sse`); resolve(); }); const shutdown = () => { console.log("\nShutting down..."); + // Clean up all SSE sessions + sseSessions.forEach(({ server, transport }) => { + transport.close().catch(() => {}); + server.close().catch(() => {}); + }); + sseSessions.clear(); httpServer.close(() => process.exit(0)); }; @@ -108,3 +173,8 @@ export async function startStreamableHttpServer( return promise; } + +/** + * @deprecated Use startHttpServer instead + */ +export const startStreamableHttpServer = startHttpServer; diff --git a/examples/integration-server/src/server-utils.ts b/examples/integration-server/src/server-utils.ts index 40524237..f480c60e 100644 --- a/examples/integration-server/src/server-utils.ts +++ b/examples/integration-server/src/server-utils.ts @@ -1,18 +1,30 @@ /** * Shared utilities for running MCP servers with various transports. + * + * Supports: + * - Stdio transport (--stdio flag) + * - Streamable HTTP transport (/mcp) - stateless mode + * - Legacy SSE transport (/sse, /messages) - for older clients (e.g., Kotlin SDK) */ import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import cors from "cors"; import type { Request, Response } from "express"; +/** Active SSE sessions: sessionId -> { server, transport } */ +const sseSessions = new Map< + string, + { server: McpServer; transport: SSEServerTransport } +>(); + /** * Starts an MCP server using the appropriate transport based on command-line arguments. * - * If `--stdio` is passed, uses stdio transport. Otherwise, uses Streamable HTTP transport. + * If `--stdio` is passed, uses stdio transport. Otherwise, uses HTTP transports. * * @param createServer - Factory function that creates a new McpServer instance. */ @@ -23,7 +35,7 @@ export async function startServer( if (process.argv.includes("--stdio")) { await startStdioServer(createServer); } else { - await startStreamableHttpServer(createServer); + await startHttpServer(createServer); } } catch (e) { console.error(e); @@ -43,17 +55,15 @@ export async function startStdioServer( } /** - * Starts an MCP server with Streamable HTTP transport in stateless mode. - * - * Each request creates a fresh server and transport instance, which are - * closed when the response ends (no session tracking). + * Starts an MCP server with HTTP transports (Streamable HTTP + legacy SSE). * - * The server listens on the port specified by the PORT environment variable, - * defaulting to 3001 if not set. + * Provides: + * - /mcp (GET/POST/DELETE): Streamable HTTP transport (stateless mode) + * - /sse (GET) + /messages (POST): Legacy SSE transport for older clients * - * @param createServer - Factory function that creates a new McpServer instance per request. + * @param createServer - Factory function that creates a new McpServer instance. */ -export async function startStreamableHttpServer( +export async function startHttpServer( createServer: () => McpServer, ): Promise { const port = parseInt(process.env.PORT ?? "3001", 10); @@ -62,6 +72,7 @@ export async function startStreamableHttpServer( const expressApp = createMcpExpressApp({ host: "0.0.0.0" }); expressApp.use(cors()); + // Streamable HTTP transport (stateless mode) expressApp.all("/mcp", async (req: Request, res: Response) => { // Create fresh server and transport for each request (stateless mode) const server = createServer(); @@ -90,16 +101,70 @@ export async function startStreamableHttpServer( } }); + // Legacy SSE transport - stream endpoint + expressApp.get("/sse", async (_req: Request, res: Response) => { + try { + const server = createServer(); + const transport = new SSEServerTransport("/messages", res); + sseSessions.set(transport.sessionId, { server, transport }); + + res.on("close", () => { + sseSessions.delete(transport.sessionId); + transport.close().catch(() => {}); + server.close().catch(() => {}); + }); + + await server.connect(transport); + } catch (error) { + console.error("SSE error:", error); + if (!res.headersSent) res.status(500).end(); + } + }); + + // Legacy SSE transport - message endpoint + expressApp.post("/messages", async (req: Request, res: Response) => { + try { + const sessionId = req.query.sessionId as string; + const session = sseSessions.get(sessionId); + + if (!session) { + return res.status(404).json({ + jsonrpc: "2.0", + error: { code: -32001, message: "Session not found" }, + id: null, + }); + } + + await session.transport.handlePostMessage(req, res, req.body); + } catch (error) { + console.error("Message error:", error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: "2.0", + error: { code: -32603, message: "Internal server error" }, + id: null, + }); + } + } + }); + const { promise, resolve, reject } = Promise.withResolvers(); const httpServer = expressApp.listen(port, (err?: Error) => { if (err) return reject(err); console.log(`Server listening on http://localhost:${port}/mcp`); + console.log(` SSE endpoint: http://localhost:${port}/sse`); resolve(); }); const shutdown = () => { console.log("\nShutting down..."); + // Clean up all SSE sessions + sseSessions.forEach(({ server, transport }) => { + transport.close().catch(() => {}); + server.close().catch(() => {}); + }); + sseSessions.clear(); httpServer.close(() => process.exit(0)); }; @@ -108,3 +173,8 @@ export async function startStreamableHttpServer( return promise; } + +/** + * @deprecated Use startHttpServer instead + */ +export const startStreamableHttpServer = startHttpServer; diff --git a/examples/scenario-modeler-server/src/server-utils.ts b/examples/scenario-modeler-server/src/server-utils.ts index 40524237..f480c60e 100644 --- a/examples/scenario-modeler-server/src/server-utils.ts +++ b/examples/scenario-modeler-server/src/server-utils.ts @@ -1,18 +1,30 @@ /** * Shared utilities for running MCP servers with various transports. + * + * Supports: + * - Stdio transport (--stdio flag) + * - Streamable HTTP transport (/mcp) - stateless mode + * - Legacy SSE transport (/sse, /messages) - for older clients (e.g., Kotlin SDK) */ import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import cors from "cors"; import type { Request, Response } from "express"; +/** Active SSE sessions: sessionId -> { server, transport } */ +const sseSessions = new Map< + string, + { server: McpServer; transport: SSEServerTransport } +>(); + /** * Starts an MCP server using the appropriate transport based on command-line arguments. * - * If `--stdio` is passed, uses stdio transport. Otherwise, uses Streamable HTTP transport. + * If `--stdio` is passed, uses stdio transport. Otherwise, uses HTTP transports. * * @param createServer - Factory function that creates a new McpServer instance. */ @@ -23,7 +35,7 @@ export async function startServer( if (process.argv.includes("--stdio")) { await startStdioServer(createServer); } else { - await startStreamableHttpServer(createServer); + await startHttpServer(createServer); } } catch (e) { console.error(e); @@ -43,17 +55,15 @@ export async function startStdioServer( } /** - * Starts an MCP server with Streamable HTTP transport in stateless mode. - * - * Each request creates a fresh server and transport instance, which are - * closed when the response ends (no session tracking). + * Starts an MCP server with HTTP transports (Streamable HTTP + legacy SSE). * - * The server listens on the port specified by the PORT environment variable, - * defaulting to 3001 if not set. + * Provides: + * - /mcp (GET/POST/DELETE): Streamable HTTP transport (stateless mode) + * - /sse (GET) + /messages (POST): Legacy SSE transport for older clients * - * @param createServer - Factory function that creates a new McpServer instance per request. + * @param createServer - Factory function that creates a new McpServer instance. */ -export async function startStreamableHttpServer( +export async function startHttpServer( createServer: () => McpServer, ): Promise { const port = parseInt(process.env.PORT ?? "3001", 10); @@ -62,6 +72,7 @@ export async function startStreamableHttpServer( const expressApp = createMcpExpressApp({ host: "0.0.0.0" }); expressApp.use(cors()); + // Streamable HTTP transport (stateless mode) expressApp.all("/mcp", async (req: Request, res: Response) => { // Create fresh server and transport for each request (stateless mode) const server = createServer(); @@ -90,16 +101,70 @@ export async function startStreamableHttpServer( } }); + // Legacy SSE transport - stream endpoint + expressApp.get("/sse", async (_req: Request, res: Response) => { + try { + const server = createServer(); + const transport = new SSEServerTransport("/messages", res); + sseSessions.set(transport.sessionId, { server, transport }); + + res.on("close", () => { + sseSessions.delete(transport.sessionId); + transport.close().catch(() => {}); + server.close().catch(() => {}); + }); + + await server.connect(transport); + } catch (error) { + console.error("SSE error:", error); + if (!res.headersSent) res.status(500).end(); + } + }); + + // Legacy SSE transport - message endpoint + expressApp.post("/messages", async (req: Request, res: Response) => { + try { + const sessionId = req.query.sessionId as string; + const session = sseSessions.get(sessionId); + + if (!session) { + return res.status(404).json({ + jsonrpc: "2.0", + error: { code: -32001, message: "Session not found" }, + id: null, + }); + } + + await session.transport.handlePostMessage(req, res, req.body); + } catch (error) { + console.error("Message error:", error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: "2.0", + error: { code: -32603, message: "Internal server error" }, + id: null, + }); + } + } + }); + const { promise, resolve, reject } = Promise.withResolvers(); const httpServer = expressApp.listen(port, (err?: Error) => { if (err) return reject(err); console.log(`Server listening on http://localhost:${port}/mcp`); + console.log(` SSE endpoint: http://localhost:${port}/sse`); resolve(); }); const shutdown = () => { console.log("\nShutting down..."); + // Clean up all SSE sessions + sseSessions.forEach(({ server, transport }) => { + transport.close().catch(() => {}); + server.close().catch(() => {}); + }); + sseSessions.clear(); httpServer.close(() => process.exit(0)); }; @@ -108,3 +173,8 @@ export async function startStreamableHttpServer( return promise; } + +/** + * @deprecated Use startHttpServer instead + */ +export const startStreamableHttpServer = startHttpServer; diff --git a/examples/sheet-music-server/src/server-utils.ts b/examples/sheet-music-server/src/server-utils.ts index 40524237..f480c60e 100644 --- a/examples/sheet-music-server/src/server-utils.ts +++ b/examples/sheet-music-server/src/server-utils.ts @@ -1,18 +1,30 @@ /** * Shared utilities for running MCP servers with various transports. + * + * Supports: + * - Stdio transport (--stdio flag) + * - Streamable HTTP transport (/mcp) - stateless mode + * - Legacy SSE transport (/sse, /messages) - for older clients (e.g., Kotlin SDK) */ import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import cors from "cors"; import type { Request, Response } from "express"; +/** Active SSE sessions: sessionId -> { server, transport } */ +const sseSessions = new Map< + string, + { server: McpServer; transport: SSEServerTransport } +>(); + /** * Starts an MCP server using the appropriate transport based on command-line arguments. * - * If `--stdio` is passed, uses stdio transport. Otherwise, uses Streamable HTTP transport. + * If `--stdio` is passed, uses stdio transport. Otherwise, uses HTTP transports. * * @param createServer - Factory function that creates a new McpServer instance. */ @@ -23,7 +35,7 @@ export async function startServer( if (process.argv.includes("--stdio")) { await startStdioServer(createServer); } else { - await startStreamableHttpServer(createServer); + await startHttpServer(createServer); } } catch (e) { console.error(e); @@ -43,17 +55,15 @@ export async function startStdioServer( } /** - * Starts an MCP server with Streamable HTTP transport in stateless mode. - * - * Each request creates a fresh server and transport instance, which are - * closed when the response ends (no session tracking). + * Starts an MCP server with HTTP transports (Streamable HTTP + legacy SSE). * - * The server listens on the port specified by the PORT environment variable, - * defaulting to 3001 if not set. + * Provides: + * - /mcp (GET/POST/DELETE): Streamable HTTP transport (stateless mode) + * - /sse (GET) + /messages (POST): Legacy SSE transport for older clients * - * @param createServer - Factory function that creates a new McpServer instance per request. + * @param createServer - Factory function that creates a new McpServer instance. */ -export async function startStreamableHttpServer( +export async function startHttpServer( createServer: () => McpServer, ): Promise { const port = parseInt(process.env.PORT ?? "3001", 10); @@ -62,6 +72,7 @@ export async function startStreamableHttpServer( const expressApp = createMcpExpressApp({ host: "0.0.0.0" }); expressApp.use(cors()); + // Streamable HTTP transport (stateless mode) expressApp.all("/mcp", async (req: Request, res: Response) => { // Create fresh server and transport for each request (stateless mode) const server = createServer(); @@ -90,16 +101,70 @@ export async function startStreamableHttpServer( } }); + // Legacy SSE transport - stream endpoint + expressApp.get("/sse", async (_req: Request, res: Response) => { + try { + const server = createServer(); + const transport = new SSEServerTransport("/messages", res); + sseSessions.set(transport.sessionId, { server, transport }); + + res.on("close", () => { + sseSessions.delete(transport.sessionId); + transport.close().catch(() => {}); + server.close().catch(() => {}); + }); + + await server.connect(transport); + } catch (error) { + console.error("SSE error:", error); + if (!res.headersSent) res.status(500).end(); + } + }); + + // Legacy SSE transport - message endpoint + expressApp.post("/messages", async (req: Request, res: Response) => { + try { + const sessionId = req.query.sessionId as string; + const session = sseSessions.get(sessionId); + + if (!session) { + return res.status(404).json({ + jsonrpc: "2.0", + error: { code: -32001, message: "Session not found" }, + id: null, + }); + } + + await session.transport.handlePostMessage(req, res, req.body); + } catch (error) { + console.error("Message error:", error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: "2.0", + error: { code: -32603, message: "Internal server error" }, + id: null, + }); + } + } + }); + const { promise, resolve, reject } = Promise.withResolvers(); const httpServer = expressApp.listen(port, (err?: Error) => { if (err) return reject(err); console.log(`Server listening on http://localhost:${port}/mcp`); + console.log(` SSE endpoint: http://localhost:${port}/sse`); resolve(); }); const shutdown = () => { console.log("\nShutting down..."); + // Clean up all SSE sessions + sseSessions.forEach(({ server, transport }) => { + transport.close().catch(() => {}); + server.close().catch(() => {}); + }); + sseSessions.clear(); httpServer.close(() => process.exit(0)); }; @@ -108,3 +173,8 @@ export async function startStreamableHttpServer( return promise; } + +/** + * @deprecated Use startHttpServer instead + */ +export const startStreamableHttpServer = startHttpServer; diff --git a/examples/system-monitor-server/src/server-utils.ts b/examples/system-monitor-server/src/server-utils.ts index 40524237..f480c60e 100644 --- a/examples/system-monitor-server/src/server-utils.ts +++ b/examples/system-monitor-server/src/server-utils.ts @@ -1,18 +1,30 @@ /** * Shared utilities for running MCP servers with various transports. + * + * Supports: + * - Stdio transport (--stdio flag) + * - Streamable HTTP transport (/mcp) - stateless mode + * - Legacy SSE transport (/sse, /messages) - for older clients (e.g., Kotlin SDK) */ import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import cors from "cors"; import type { Request, Response } from "express"; +/** Active SSE sessions: sessionId -> { server, transport } */ +const sseSessions = new Map< + string, + { server: McpServer; transport: SSEServerTransport } +>(); + /** * Starts an MCP server using the appropriate transport based on command-line arguments. * - * If `--stdio` is passed, uses stdio transport. Otherwise, uses Streamable HTTP transport. + * If `--stdio` is passed, uses stdio transport. Otherwise, uses HTTP transports. * * @param createServer - Factory function that creates a new McpServer instance. */ @@ -23,7 +35,7 @@ export async function startServer( if (process.argv.includes("--stdio")) { await startStdioServer(createServer); } else { - await startStreamableHttpServer(createServer); + await startHttpServer(createServer); } } catch (e) { console.error(e); @@ -43,17 +55,15 @@ export async function startStdioServer( } /** - * Starts an MCP server with Streamable HTTP transport in stateless mode. - * - * Each request creates a fresh server and transport instance, which are - * closed when the response ends (no session tracking). + * Starts an MCP server with HTTP transports (Streamable HTTP + legacy SSE). * - * The server listens on the port specified by the PORT environment variable, - * defaulting to 3001 if not set. + * Provides: + * - /mcp (GET/POST/DELETE): Streamable HTTP transport (stateless mode) + * - /sse (GET) + /messages (POST): Legacy SSE transport for older clients * - * @param createServer - Factory function that creates a new McpServer instance per request. + * @param createServer - Factory function that creates a new McpServer instance. */ -export async function startStreamableHttpServer( +export async function startHttpServer( createServer: () => McpServer, ): Promise { const port = parseInt(process.env.PORT ?? "3001", 10); @@ -62,6 +72,7 @@ export async function startStreamableHttpServer( const expressApp = createMcpExpressApp({ host: "0.0.0.0" }); expressApp.use(cors()); + // Streamable HTTP transport (stateless mode) expressApp.all("/mcp", async (req: Request, res: Response) => { // Create fresh server and transport for each request (stateless mode) const server = createServer(); @@ -90,16 +101,70 @@ export async function startStreamableHttpServer( } }); + // Legacy SSE transport - stream endpoint + expressApp.get("/sse", async (_req: Request, res: Response) => { + try { + const server = createServer(); + const transport = new SSEServerTransport("/messages", res); + sseSessions.set(transport.sessionId, { server, transport }); + + res.on("close", () => { + sseSessions.delete(transport.sessionId); + transport.close().catch(() => {}); + server.close().catch(() => {}); + }); + + await server.connect(transport); + } catch (error) { + console.error("SSE error:", error); + if (!res.headersSent) res.status(500).end(); + } + }); + + // Legacy SSE transport - message endpoint + expressApp.post("/messages", async (req: Request, res: Response) => { + try { + const sessionId = req.query.sessionId as string; + const session = sseSessions.get(sessionId); + + if (!session) { + return res.status(404).json({ + jsonrpc: "2.0", + error: { code: -32001, message: "Session not found" }, + id: null, + }); + } + + await session.transport.handlePostMessage(req, res, req.body); + } catch (error) { + console.error("Message error:", error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: "2.0", + error: { code: -32603, message: "Internal server error" }, + id: null, + }); + } + } + }); + const { promise, resolve, reject } = Promise.withResolvers(); const httpServer = expressApp.listen(port, (err?: Error) => { if (err) return reject(err); console.log(`Server listening on http://localhost:${port}/mcp`); + console.log(` SSE endpoint: http://localhost:${port}/sse`); resolve(); }); const shutdown = () => { console.log("\nShutting down..."); + // Clean up all SSE sessions + sseSessions.forEach(({ server, transport }) => { + transport.close().catch(() => {}); + server.close().catch(() => {}); + }); + sseSessions.clear(); httpServer.close(() => process.exit(0)); }; @@ -108,3 +173,8 @@ export async function startStreamableHttpServer( return promise; } + +/** + * @deprecated Use startHttpServer instead + */ +export const startStreamableHttpServer = startHttpServer; diff --git a/examples/threejs-server/src/server-utils.ts b/examples/threejs-server/src/server-utils.ts index 40524237..f480c60e 100644 --- a/examples/threejs-server/src/server-utils.ts +++ b/examples/threejs-server/src/server-utils.ts @@ -1,18 +1,30 @@ /** * Shared utilities for running MCP servers with various transports. + * + * Supports: + * - Stdio transport (--stdio flag) + * - Streamable HTTP transport (/mcp) - stateless mode + * - Legacy SSE transport (/sse, /messages) - for older clients (e.g., Kotlin SDK) */ import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import cors from "cors"; import type { Request, Response } from "express"; +/** Active SSE sessions: sessionId -> { server, transport } */ +const sseSessions = new Map< + string, + { server: McpServer; transport: SSEServerTransport } +>(); + /** * Starts an MCP server using the appropriate transport based on command-line arguments. * - * If `--stdio` is passed, uses stdio transport. Otherwise, uses Streamable HTTP transport. + * If `--stdio` is passed, uses stdio transport. Otherwise, uses HTTP transports. * * @param createServer - Factory function that creates a new McpServer instance. */ @@ -23,7 +35,7 @@ export async function startServer( if (process.argv.includes("--stdio")) { await startStdioServer(createServer); } else { - await startStreamableHttpServer(createServer); + await startHttpServer(createServer); } } catch (e) { console.error(e); @@ -43,17 +55,15 @@ export async function startStdioServer( } /** - * Starts an MCP server with Streamable HTTP transport in stateless mode. - * - * Each request creates a fresh server and transport instance, which are - * closed when the response ends (no session tracking). + * Starts an MCP server with HTTP transports (Streamable HTTP + legacy SSE). * - * The server listens on the port specified by the PORT environment variable, - * defaulting to 3001 if not set. + * Provides: + * - /mcp (GET/POST/DELETE): Streamable HTTP transport (stateless mode) + * - /sse (GET) + /messages (POST): Legacy SSE transport for older clients * - * @param createServer - Factory function that creates a new McpServer instance per request. + * @param createServer - Factory function that creates a new McpServer instance. */ -export async function startStreamableHttpServer( +export async function startHttpServer( createServer: () => McpServer, ): Promise { const port = parseInt(process.env.PORT ?? "3001", 10); @@ -62,6 +72,7 @@ export async function startStreamableHttpServer( const expressApp = createMcpExpressApp({ host: "0.0.0.0" }); expressApp.use(cors()); + // Streamable HTTP transport (stateless mode) expressApp.all("/mcp", async (req: Request, res: Response) => { // Create fresh server and transport for each request (stateless mode) const server = createServer(); @@ -90,16 +101,70 @@ export async function startStreamableHttpServer( } }); + // Legacy SSE transport - stream endpoint + expressApp.get("/sse", async (_req: Request, res: Response) => { + try { + const server = createServer(); + const transport = new SSEServerTransport("/messages", res); + sseSessions.set(transport.sessionId, { server, transport }); + + res.on("close", () => { + sseSessions.delete(transport.sessionId); + transport.close().catch(() => {}); + server.close().catch(() => {}); + }); + + await server.connect(transport); + } catch (error) { + console.error("SSE error:", error); + if (!res.headersSent) res.status(500).end(); + } + }); + + // Legacy SSE transport - message endpoint + expressApp.post("/messages", async (req: Request, res: Response) => { + try { + const sessionId = req.query.sessionId as string; + const session = sseSessions.get(sessionId); + + if (!session) { + return res.status(404).json({ + jsonrpc: "2.0", + error: { code: -32001, message: "Session not found" }, + id: null, + }); + } + + await session.transport.handlePostMessage(req, res, req.body); + } catch (error) { + console.error("Message error:", error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: "2.0", + error: { code: -32603, message: "Internal server error" }, + id: null, + }); + } + } + }); + const { promise, resolve, reject } = Promise.withResolvers(); const httpServer = expressApp.listen(port, (err?: Error) => { if (err) return reject(err); console.log(`Server listening on http://localhost:${port}/mcp`); + console.log(` SSE endpoint: http://localhost:${port}/sse`); resolve(); }); const shutdown = () => { console.log("\nShutting down..."); + // Clean up all SSE sessions + sseSessions.forEach(({ server, transport }) => { + transport.close().catch(() => {}); + server.close().catch(() => {}); + }); + sseSessions.clear(); httpServer.close(() => process.exit(0)); }; @@ -108,3 +173,8 @@ export async function startStreamableHttpServer( return promise; } + +/** + * @deprecated Use startHttpServer instead + */ +export const startStreamableHttpServer = startHttpServer; diff --git a/examples/video-resource-server/src/server-utils.ts b/examples/video-resource-server/src/server-utils.ts index 40524237..f480c60e 100644 --- a/examples/video-resource-server/src/server-utils.ts +++ b/examples/video-resource-server/src/server-utils.ts @@ -1,18 +1,30 @@ /** * Shared utilities for running MCP servers with various transports. + * + * Supports: + * - Stdio transport (--stdio flag) + * - Streamable HTTP transport (/mcp) - stateless mode + * - Legacy SSE transport (/sse, /messages) - for older clients (e.g., Kotlin SDK) */ import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import cors from "cors"; import type { Request, Response } from "express"; +/** Active SSE sessions: sessionId -> { server, transport } */ +const sseSessions = new Map< + string, + { server: McpServer; transport: SSEServerTransport } +>(); + /** * Starts an MCP server using the appropriate transport based on command-line arguments. * - * If `--stdio` is passed, uses stdio transport. Otherwise, uses Streamable HTTP transport. + * If `--stdio` is passed, uses stdio transport. Otherwise, uses HTTP transports. * * @param createServer - Factory function that creates a new McpServer instance. */ @@ -23,7 +35,7 @@ export async function startServer( if (process.argv.includes("--stdio")) { await startStdioServer(createServer); } else { - await startStreamableHttpServer(createServer); + await startHttpServer(createServer); } } catch (e) { console.error(e); @@ -43,17 +55,15 @@ export async function startStdioServer( } /** - * Starts an MCP server with Streamable HTTP transport in stateless mode. - * - * Each request creates a fresh server and transport instance, which are - * closed when the response ends (no session tracking). + * Starts an MCP server with HTTP transports (Streamable HTTP + legacy SSE). * - * The server listens on the port specified by the PORT environment variable, - * defaulting to 3001 if not set. + * Provides: + * - /mcp (GET/POST/DELETE): Streamable HTTP transport (stateless mode) + * - /sse (GET) + /messages (POST): Legacy SSE transport for older clients * - * @param createServer - Factory function that creates a new McpServer instance per request. + * @param createServer - Factory function that creates a new McpServer instance. */ -export async function startStreamableHttpServer( +export async function startHttpServer( createServer: () => McpServer, ): Promise { const port = parseInt(process.env.PORT ?? "3001", 10); @@ -62,6 +72,7 @@ export async function startStreamableHttpServer( const expressApp = createMcpExpressApp({ host: "0.0.0.0" }); expressApp.use(cors()); + // Streamable HTTP transport (stateless mode) expressApp.all("/mcp", async (req: Request, res: Response) => { // Create fresh server and transport for each request (stateless mode) const server = createServer(); @@ -90,16 +101,70 @@ export async function startStreamableHttpServer( } }); + // Legacy SSE transport - stream endpoint + expressApp.get("/sse", async (_req: Request, res: Response) => { + try { + const server = createServer(); + const transport = new SSEServerTransport("/messages", res); + sseSessions.set(transport.sessionId, { server, transport }); + + res.on("close", () => { + sseSessions.delete(transport.sessionId); + transport.close().catch(() => {}); + server.close().catch(() => {}); + }); + + await server.connect(transport); + } catch (error) { + console.error("SSE error:", error); + if (!res.headersSent) res.status(500).end(); + } + }); + + // Legacy SSE transport - message endpoint + expressApp.post("/messages", async (req: Request, res: Response) => { + try { + const sessionId = req.query.sessionId as string; + const session = sseSessions.get(sessionId); + + if (!session) { + return res.status(404).json({ + jsonrpc: "2.0", + error: { code: -32001, message: "Session not found" }, + id: null, + }); + } + + await session.transport.handlePostMessage(req, res, req.body); + } catch (error) { + console.error("Message error:", error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: "2.0", + error: { code: -32603, message: "Internal server error" }, + id: null, + }); + } + } + }); + const { promise, resolve, reject } = Promise.withResolvers(); const httpServer = expressApp.listen(port, (err?: Error) => { if (err) return reject(err); console.log(`Server listening on http://localhost:${port}/mcp`); + console.log(` SSE endpoint: http://localhost:${port}/sse`); resolve(); }); const shutdown = () => { console.log("\nShutting down..."); + // Clean up all SSE sessions + sseSessions.forEach(({ server, transport }) => { + transport.close().catch(() => {}); + server.close().catch(() => {}); + }); + sseSessions.clear(); httpServer.close(() => process.exit(0)); }; @@ -108,3 +173,8 @@ export async function startStreamableHttpServer( return promise; } + +/** + * @deprecated Use startHttpServer instead + */ +export const startStreamableHttpServer = startHttpServer; diff --git a/examples/wiki-explorer-server/src/server-utils.ts b/examples/wiki-explorer-server/src/server-utils.ts index 40524237..f480c60e 100644 --- a/examples/wiki-explorer-server/src/server-utils.ts +++ b/examples/wiki-explorer-server/src/server-utils.ts @@ -1,18 +1,30 @@ /** * Shared utilities for running MCP servers with various transports. + * + * Supports: + * - Stdio transport (--stdio flag) + * - Streamable HTTP transport (/mcp) - stateless mode + * - Legacy SSE transport (/sse, /messages) - for older clients (e.g., Kotlin SDK) */ import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import cors from "cors"; import type { Request, Response } from "express"; +/** Active SSE sessions: sessionId -> { server, transport } */ +const sseSessions = new Map< + string, + { server: McpServer; transport: SSEServerTransport } +>(); + /** * Starts an MCP server using the appropriate transport based on command-line arguments. * - * If `--stdio` is passed, uses stdio transport. Otherwise, uses Streamable HTTP transport. + * If `--stdio` is passed, uses stdio transport. Otherwise, uses HTTP transports. * * @param createServer - Factory function that creates a new McpServer instance. */ @@ -23,7 +35,7 @@ export async function startServer( if (process.argv.includes("--stdio")) { await startStdioServer(createServer); } else { - await startStreamableHttpServer(createServer); + await startHttpServer(createServer); } } catch (e) { console.error(e); @@ -43,17 +55,15 @@ export async function startStdioServer( } /** - * Starts an MCP server with Streamable HTTP transport in stateless mode. - * - * Each request creates a fresh server and transport instance, which are - * closed when the response ends (no session tracking). + * Starts an MCP server with HTTP transports (Streamable HTTP + legacy SSE). * - * The server listens on the port specified by the PORT environment variable, - * defaulting to 3001 if not set. + * Provides: + * - /mcp (GET/POST/DELETE): Streamable HTTP transport (stateless mode) + * - /sse (GET) + /messages (POST): Legacy SSE transport for older clients * - * @param createServer - Factory function that creates a new McpServer instance per request. + * @param createServer - Factory function that creates a new McpServer instance. */ -export async function startStreamableHttpServer( +export async function startHttpServer( createServer: () => McpServer, ): Promise { const port = parseInt(process.env.PORT ?? "3001", 10); @@ -62,6 +72,7 @@ export async function startStreamableHttpServer( const expressApp = createMcpExpressApp({ host: "0.0.0.0" }); expressApp.use(cors()); + // Streamable HTTP transport (stateless mode) expressApp.all("/mcp", async (req: Request, res: Response) => { // Create fresh server and transport for each request (stateless mode) const server = createServer(); @@ -90,16 +101,70 @@ export async function startStreamableHttpServer( } }); + // Legacy SSE transport - stream endpoint + expressApp.get("/sse", async (_req: Request, res: Response) => { + try { + const server = createServer(); + const transport = new SSEServerTransport("/messages", res); + sseSessions.set(transport.sessionId, { server, transport }); + + res.on("close", () => { + sseSessions.delete(transport.sessionId); + transport.close().catch(() => {}); + server.close().catch(() => {}); + }); + + await server.connect(transport); + } catch (error) { + console.error("SSE error:", error); + if (!res.headersSent) res.status(500).end(); + } + }); + + // Legacy SSE transport - message endpoint + expressApp.post("/messages", async (req: Request, res: Response) => { + try { + const sessionId = req.query.sessionId as string; + const session = sseSessions.get(sessionId); + + if (!session) { + return res.status(404).json({ + jsonrpc: "2.0", + error: { code: -32001, message: "Session not found" }, + id: null, + }); + } + + await session.transport.handlePostMessage(req, res, req.body); + } catch (error) { + console.error("Message error:", error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: "2.0", + error: { code: -32603, message: "Internal server error" }, + id: null, + }); + } + } + }); + const { promise, resolve, reject } = Promise.withResolvers(); const httpServer = expressApp.listen(port, (err?: Error) => { if (err) return reject(err); console.log(`Server listening on http://localhost:${port}/mcp`); + console.log(` SSE endpoint: http://localhost:${port}/sse`); resolve(); }); const shutdown = () => { console.log("\nShutting down..."); + // Clean up all SSE sessions + sseSessions.forEach(({ server, transport }) => { + transport.close().catch(() => {}); + server.close().catch(() => {}); + }); + sseSessions.clear(); httpServer.close(() => process.exit(0)); }; @@ -108,3 +173,8 @@ export async function startStreamableHttpServer( return promise; } + +/** + * @deprecated Use startHttpServer instead + */ +export const startStreamableHttpServer = startHttpServer; diff --git a/kotlin/.gitignore b/kotlin/.gitignore new file mode 100644 index 00000000..bcb891fe --- /dev/null +++ b/kotlin/.gitignore @@ -0,0 +1,12 @@ +# Gradle +.gradle/ +build/ +!gradle/wrapper/gradle-wrapper.jar +!gradle/wrapper/gradle-wrapper.properties + +# IDE +.idea/ +*.iml + +# Local properties +local.properties diff --git a/kotlin/.prettierignore b/kotlin/.prettierignore new file mode 100644 index 00000000..cc8fd18b --- /dev/null +++ b/kotlin/.prettierignore @@ -0,0 +1,17 @@ + +# Swift build artifacts +swift/.build/ +examples/basic-host-swift/.build/ +examples/basic-host-swift/build/ + +# Kotlin build artifacts +examples/basic-host-kotlin/build/ +examples/basic-host-kotlin/.gradle/ +kotlin/.gradle/ +kotlin/build/ + +# Kotlin build artifacts +kotlin/.gradle/ +kotlin/build/ +examples/basic-host-kotlin/.gradle/ +examples/basic-host-kotlin/build/ diff --git a/kotlin/README.md b/kotlin/README.md new file mode 100644 index 00000000..4adad558 --- /dev/null +++ b/kotlin/README.md @@ -0,0 +1,175 @@ +# MCP Apps Kotlin SDK + +Kotlin Multiplatform SDK for hosting MCP Apps in native applications. + +## Overview + +This SDK enables native applications (Android, iOS, Desktop) to host MCP Apps (interactive UIs) in WebViews. It provides the `AppBridge` class that handles: + +- Initialization handshake with the Guest UI +- Sending tool input and results +- Receiving messages and link open requests +- Host context updates (theme, viewport, etc.) + +## Installation + +Add to your `build.gradle.kts`: + +```kotlin +dependencies { + implementation("io.modelcontextprotocol:mcp-apps-sdk:0.1.0") +} +``` + +## Usage + +### Basic Setup + +```kotlin +import io.modelcontextprotocol.apps.AppBridge +import io.modelcontextprotocol.apps.types.* +import io.modelcontextprotocol.kotlin.sdk.Implementation + +// Create the AppBridge +val bridge = AppBridge( + mcpClient = mcpClient, // Your MCP client connected to the server + hostInfo = Implementation(name = "MyApp", version = "1.0.0"), + hostCapabilities = McpUiHostCapabilities( + openLinks = emptyMap(), + serverTools = ServerToolsCapability(), + logging = emptyMap() + ) +) + +// Set up callbacks +bridge.onInitialized = { + println("Guest UI initialized") + // Now safe to send tool input + scope.launch { + bridge.sendToolInput(mapOf( + "location" to JsonPrimitive("NYC") + )) + } +} + +bridge.onSizeChange = { width, height -> + println("UI size changed: ${width}x${height}") +} + +bridge.onMessage = { role, content -> + println("Message from UI: $role - $content") + McpUiMessageResult() // Return success +} + +bridge.onOpenLink = { url -> + // Open URL in system browser + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) + McpUiOpenLinkResult() // Return success +} + +// Connect to the Guest UI via transport +bridge.connect(webViewTransport) +``` + +### Sending Tool Data + +```kotlin +// Send complete tool arguments +bridge.sendToolInput(mapOf( + "query" to JsonPrimitive("weather forecast"), + "units" to JsonPrimitive("metric") +)) + +// Send streaming partial arguments +bridge.sendToolInputPartial(mapOf( + "query" to JsonPrimitive("weather") +)) + +// Send tool result +bridge.sendToolResult(CallToolResult( + content = listOf(TextContent(text = "Sunny, 72°F")) +)) +``` + +### Updating Host Context + +```kotlin +bridge.setHostContext(McpUiHostContext( + theme = McpUiTheme.DARK, + displayMode = McpUiDisplayMode.INLINE, + viewport = Viewport(width = 800, height = 600), + locale = "en-US", + platform = McpUiPlatform.MOBILE +)) +``` + +### Graceful Shutdown + +```kotlin +// Before removing the WebView +val result = bridge.sendResourceTeardown() +// Now safe to remove WebView +``` + +## Platform Support + +- JVM (Android, Desktop) +- iOS (via Kotlin/Native) +- macOS (via Kotlin/Native) +- WebAssembly (experimental) + +## Building + +```bash +# First time: generate gradle wrapper +gradle wrapper + +# Build all targets +./gradlew build + +# Run tests +./gradlew test + +# Build specific targets +./gradlew jvmJar # JVM/Android +./gradlew iosArm64Main # iOS ARM64 +./gradlew macosArm64Main # macOS ARM64 +``` + +## Types + +### Host Context + +The `McpUiHostContext` provides rich environment information to the Guest UI: + +| Field | Type | Description | +| -------------------- | -------------------- | -------------------------------- | +| `theme` | `McpUiTheme` | `LIGHT` or `DARK` | +| `displayMode` | `McpUiDisplayMode` | `INLINE`, `FULLSCREEN`, or `PIP` | +| `viewport` | `Viewport` | Current dimensions | +| `locale` | `String` | BCP 47 locale (e.g., "en-US") | +| `timeZone` | `String` | IANA timezone | +| `platform` | `McpUiPlatform` | `WEB`, `DESKTOP`, or `MOBILE` | +| `deviceCapabilities` | `DeviceCapabilities` | Touch/hover support | +| `safeAreaInsets` | `SafeAreaInsets` | Safe area boundaries | + +### Host Capabilities + +Declare what features your host supports: + +```kotlin +McpUiHostCapabilities( + openLinks = emptyMap(), // Can open external URLs + serverTools = ServerToolsCapability(listChanged = true), + serverResources = ServerResourcesCapability(listChanged = true), + logging = emptyMap() // Accepts log messages +) +``` + +## Integration with MCP SDK + +This SDK is designed to work with the official [Kotlin MCP SDK](https://github.com/modelcontextprotocol/kotlin-sdk). The `AppBridge` takes an MCP `Client` for proxying tool calls and resource reads to the MCP server. + +## License + +MIT License - see the main repository for details. diff --git a/kotlin/WebViewTransport-README.md b/kotlin/WebViewTransport-README.md new file mode 100644 index 00000000..339c0346 --- /dev/null +++ b/kotlin/WebViewTransport-README.md @@ -0,0 +1,233 @@ +# WebView Transport for Kotlin SDK + +The WebView transport enables bidirectional communication between a Kotlin/Android host application and a JavaScript guest UI running in an Android WebView. + +## Features + +- **Seamless Integration**: Implements the `McpAppsTransport` interface for use with MCP Apps +- **TypeScript SDK Compatibility**: Overrides `window.parent.postMessage()` to work with the TypeScript SDK +- **Bidirectional Communication**: Supports both sending and receiving JSON-RPC messages +- **Thread-Safe**: Uses coroutine flows and proper Android threading (WebView operations on main thread) + +## Setup + +### 1. Add Dependencies + +The WebView transport is included in the Kotlin SDK JVM target. Make sure you have the dependency in your `build.gradle.kts`: + +```kotlin +dependencies { + implementation("io.modelcontextprotocol:kotlin-sdk-apps:0.1.0-SNAPSHOT") +} +``` + +### 2. Basic Usage + +```kotlin +import android.webkit.WebView +import io.modelcontextprotocol.apps.AppBridge +import io.modelcontextprotocol.apps.transport.WebViewTransport +import io.modelcontextprotocol.kotlin.sdk.Client +import io.modelcontextprotocol.kotlin.sdk.Implementation + +class MyActivity : AppCompatActivity() { + private lateinit var webView: WebView + private lateinit var transport: WebViewTransport + private lateinit var bridge: AppBridge + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Setup WebView + webView = findViewById(R.id.webView) + + // Create MCP client (your server connection) + val mcpClient = Client(/* your client configuration */) + + // Create transport + transport = WebViewTransport(webView) + + // Create bridge with host info and capabilities + bridge = AppBridge( + mcpClient = mcpClient, + hostInfo = Implementation(name = "MyApp", version = "1.0.0"), + hostCapabilities = McpUiHostCapabilities( + serverTools = ServerToolsCapability(), + openLinks = emptyMap() + ) + ) + + // Set up callbacks + bridge.onInitialized = { + Log.d("MCP", "Guest UI initialized") + } + + bridge.onSizeChange = { width, height -> + Log.d("MCP", "Guest UI size changed: ${width}x${height}") + } + + // Connect bridge to transport + lifecycleScope.launch { + bridge.connect(transport) + + // Load your guest UI + webView.loadUrl("file:///android_asset/guest-ui.html") + } + } + + override fun onDestroy() { + super.onDestroy() + lifecycleScope.launch { + bridge.close() + } + } +} +``` + +## JavaScript Side + +Your guest UI HTML file needs to work with the MCP Apps TypeScript SDK. The transport automatically provides compatibility. + +### Using TypeScript SDK + +```html + + + + My Guest UI + + + +

My Guest UI

+ + +``` + +### Using Direct Bridge API + +If you're not using the TypeScript SDK, you can directly use the bridge: + +```javascript +// Send message to Kotlin host +window.mcpBridge.send({ + jsonrpc: "2.0", + method: "ui/initialize", + id: 1, + params: { + appInfo: { name: "MyApp", version: "1.0.0" }, + appCapabilities: {}, + protocolVersion: "2025-11-21", + }, +}); + +// Receive messages from Kotlin host +window.addEventListener("message", (event) => { + const message = event.data; + console.log("Received message:", message); + + // Handle different message types + if (message.method === "ui/notifications/tool-input") { + // Handle tool input + } +}); +``` + +## Architecture + +The transport works through three layers: + +1. **Kotlin Side** (`WebViewTransport`): + - Implements `McpAppsTransport` interface + - Uses `@JavascriptInterface` to receive messages from JavaScript + - Uses `webView.evaluateJavascript()` to send messages to JavaScript + +2. **Bridge Script** (injected into WebView): + - Creates `window.mcpBridge.send()` for JS → Kotlin communication + - Overrides `window.parent.postMessage()` for TypeScript SDK compatibility + - Dispatches `MessageEvent` on window for Kotlin → JS communication + +3. **JavaScript Side** (your guest UI): + - Can use TypeScript SDK (recommended) + - Or use `window.mcpBridge.send()` directly + - Listens to `message` events for incoming messages + +## Configuration + +### Custom JSON Serializer + +You can provide a custom JSON serializer: + +```kotlin +val customJson = Json { + ignoreUnknownKeys = true + prettyPrint = true +} + +val transport = WebViewTransport(webView, json = customJson) +``` + +### WebView Settings + +The transport automatically enables JavaScript on the WebView. You may want to configure additional settings: + +```kotlin +webView.settings.apply { + domStorageEnabled = true + databaseEnabled = true + // Add other settings as needed +} +``` + +## Error Handling + +The transport provides an `errors` flow for monitoring errors: + +```kotlin +lifecycleScope.launch { + transport.errors.collect { error -> + Log.e("MCP", "Transport error: ${error.message}", error) + // Handle error (e.g., show user notification) + } +} +``` + +## Thread Safety + +All WebView operations are automatically dispatched to the main thread using `webView.post()`. The transport is safe to use from any coroutine context. + +## Testing + +See `WebViewTransportTest.kt` for examples of testing with mocked WebViews using Mockito. + +## Troubleshooting + +### Messages Not Being Received + +1. Ensure JavaScript is enabled on the WebView +2. Check that the bridge script has been injected (look for console logs in WebView) +3. Verify that messages conform to JSON-RPC 2.0 format + +### TypeScript SDK Not Working + +1. Ensure you're loading the TypeScript SDK correctly +2. Check that `window.parent.postMessage` override is active +3. Verify the guest UI is calling `app.connect()` after page load + +### Memory Leaks + +Always call `bridge.close()` when your activity/fragment is destroyed to properly clean up resources. diff --git a/kotlin/build.gradle.kts b/kotlin/build.gradle.kts new file mode 100644 index 00000000..7a73ab57 --- /dev/null +++ b/kotlin/build.gradle.kts @@ -0,0 +1,43 @@ +plugins { + kotlin("jvm") version "2.1.0" + kotlin("plugin.serialization") version "2.1.0" + id("maven-publish") +} + +group = "io.modelcontextprotocol" +version = "0.1.0-SNAPSHOT" + +repositories { + mavenCentral() + google() +} + +// Target Java 21 (Kotlin doesn't support 25 yet) +tasks.withType { + kotlinOptions.jvmTarget = "21" +} + +tasks.withType { + sourceCompatibility = "21" + targetCompatibility = "21" +} + +dependencies { + // MCP SDK core types + implementation("io.modelcontextprotocol:kotlin-sdk:0.8.1") + + // Kotlin serialization for JSON + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") + + // Coroutines + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0") + + // Testing + testImplementation(kotlin("test")) + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0") + testImplementation("org.junit.jupiter:junit-jupiter:5.10.0") +} + +tasks.test { + useJUnitPlatform() +} diff --git a/kotlin/gradle.properties b/kotlin/gradle.properties new file mode 100644 index 00000000..5b52f144 --- /dev/null +++ b/kotlin/gradle.properties @@ -0,0 +1,2 @@ +kotlin.code.style=official +kotlin.mpp.stability.nowarn=true diff --git a/kotlin/gradle/wrapper/gradle-wrapper.jar b/kotlin/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..f8e1ee31 Binary files /dev/null and b/kotlin/gradle/wrapper/gradle-wrapper.jar differ diff --git a/kotlin/gradle/wrapper/gradle-wrapper.properties b/kotlin/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..23449a2b --- /dev/null +++ b/kotlin/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/kotlin/gradlew b/kotlin/gradlew new file mode 100755 index 00000000..adff685a --- /dev/null +++ b/kotlin/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/kotlin/gradlew.bat b/kotlin/gradlew.bat new file mode 100644 index 00000000..e509b2dd --- /dev/null +++ b/kotlin/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/kotlin/settings.gradle.kts b/kotlin/settings.gradle.kts new file mode 100644 index 00000000..7e5ec220 --- /dev/null +++ b/kotlin/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "mcp-apps-sdk" diff --git a/kotlin/src/main/kotlin/io/modelcontextprotocol/apps/AppBridge.kt b/kotlin/src/main/kotlin/io/modelcontextprotocol/apps/AppBridge.kt new file mode 100644 index 00000000..c84c6f09 --- /dev/null +++ b/kotlin/src/main/kotlin/io/modelcontextprotocol/apps/AppBridge.kt @@ -0,0 +1,236 @@ +package io.modelcontextprotocol.apps + +import io.modelcontextprotocol.apps.protocol.* +import io.modelcontextprotocol.apps.transport.McpAppsTransport + +import io.modelcontextprotocol.apps.generated.* +import kotlinx.serialization.json.* +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +/** + * Options for configuring AppBridge behavior. + */ +data class HostOptions( + val hostContext: McpUiHostContext = McpUiHostContext() +) + +/** + * Host-side bridge for communicating with a single Guest UI (App). + * + * @param hostInfo Host application identification + * @param hostCapabilities Features the host supports + * @param options Configuration options + */ +class AppBridge( + private val hostInfo: Implementation, + private val hostCapabilities: McpUiHostCapabilities, + private val options: HostOptions = HostOptions() +) : Protocol() { + + private var appCapabilities: McpUiAppCapabilities? = null + private var appInfo: Implementation? = null + private var hostContext: McpUiHostContext = options.hostContext + private var isInitialized: Boolean = false + + // Callbacks + var onInitialized: (() -> Unit)? = null + var onSizeChange: ((width: Int?, height: Int?) -> Unit)? = null + var onMessage: (suspend (role: String, content: List) -> McpUiMessageResult)? = null + var onOpenLink: (suspend (url: String) -> McpUiOpenLinkResult)? = null + var onLoggingMessage: ((level: String, data: JsonElement, logger: String?) -> Unit)? = null + var onPing: (() -> Unit)? = null + + // MCP Server forwarding callbacks + var onToolCall: (suspend (name: String, arguments: JsonObject?) -> JsonElement)? = null + var onResourceRead: (suspend (uri: String) -> JsonElement)? = null + + init { + setupHandlers() + } + + private fun setupHandlers() { + // Handle ui/initialize request + setRequestHandler( + method = "ui/initialize", + paramsDeserializer = { params -> + json.decodeFromJsonElement(params ?: JsonObject(emptyMap())) + }, + resultSerializer = { result: McpUiInitializeResult -> + json.encodeToJsonElement(result) + } + ) { params -> + handleInitialize(params) + } + + // Handle ui/notifications/initialized notification + setNotificationHandler( + method = "ui/notifications/initialized", + paramsDeserializer = { Unit } + ) { + isInitialized = true + onInitialized?.invoke() + } + + // Handle ui/notifications/size-changed notification + setNotificationHandler( + method = "ui/notifications/size-changed", + paramsDeserializer = { params -> + json.decodeFromJsonElement(params ?: JsonObject(emptyMap())) + } + ) { params -> + onSizeChange?.invoke(params.width?.toInt(), params.height?.toInt()) + } + + // Handle ui/message request + setRequestHandler( + method = "ui/message", + paramsDeserializer = { params -> + json.decodeFromJsonElement(params ?: JsonObject(emptyMap())) + }, + resultSerializer = { result: McpUiMessageResult -> + json.encodeToJsonElement(result) + } + ) { params -> + onMessage?.invoke(params.role, params.content) ?: McpUiMessageResult(isError = true) + } + + // Handle ui/open-link request + setRequestHandler( + method = "ui/open-link", + paramsDeserializer = { params -> + json.decodeFromJsonElement(params ?: JsonObject(emptyMap())) + }, + resultSerializer = { result: McpUiOpenLinkResult -> + json.encodeToJsonElement(result) + } + ) { params -> + onOpenLink?.invoke(params.url) ?: McpUiOpenLinkResult(isError = true) + } + + // Handle notifications/message (logging) + setNotificationHandler( + method = "notifications/message", + paramsDeserializer = { params -> + json.decodeFromJsonElement(params ?: JsonObject(emptyMap())) + } + ) { params -> + onLoggingMessage?.invoke(params.level.name.lowercase(), params.data, params.logger) + } + + // Handle ping request + setRequestHandler( + method = "ping", + paramsDeserializer = { Unit }, + resultSerializer = { JsonObject(emptyMap()) } + ) { + onPing?.invoke() + } + + // Handle tools/call - forward to callback + setRequestHandler( + method = "tools/call", + paramsDeserializer = { it }, + resultSerializer = { it ?: JsonObject(emptyMap()) } + ) { params -> + val callback = onToolCall ?: throw IllegalStateException("tools/call not configured") + val name = (params?.get("name") as? JsonPrimitive)?.content + ?: throw IllegalArgumentException("Missing tool name") + val arguments = params?.get("arguments") as? JsonObject + callback(name, arguments) + } + + // Handle resources/read - forward to callback + setRequestHandler( + method = "resources/read", + paramsDeserializer = { it }, + resultSerializer = { it ?: JsonObject(emptyMap()) } + ) { params -> + val callback = onResourceRead ?: throw IllegalStateException("resources/read not configured") + val uri = (params?.get("uri") as? JsonPrimitive)?.content + ?: throw IllegalArgumentException("Missing resource URI") + callback(uri) + } + } + + private fun handleInitialize(params: McpUiInitializeParams): McpUiInitializeResult { + appCapabilities = params.appCapabilities + appInfo = params.appInfo + + val requestedVersion = params.protocolVersion + val protocolVersion = if (McpAppsConfig.SUPPORTED_PROTOCOL_VERSIONS.contains(requestedVersion)) { + requestedVersion + } else { + McpAppsConfig.LATEST_PROTOCOL_VERSION + } + + return McpUiInitializeResult( + protocolVersion = protocolVersion, + hostInfo = hostInfo, + hostCapabilities = hostCapabilities, + hostContext = hostContext + ) + } + + fun getAppCapabilities(): McpUiAppCapabilities? = appCapabilities + fun getAppVersion(): Implementation? = appInfo + fun isReady(): Boolean = isInitialized + + suspend fun setHostContext(newContext: McpUiHostContext) { + if (newContext != hostContext) { + hostContext = newContext + notification( + method = "ui/notifications/host-context-changed", + params = newContext, + paramsSerializer = { json.encodeToJsonElement(it) as JsonObject } + ) + } + } + + suspend fun sendToolInput(arguments: Map?) { + notification( + method = "ui/notifications/tool-input", + params = McpUiToolInputParams(arguments = arguments), + paramsSerializer = { json.encodeToJsonElement(it) as JsonObject } + ) + } + + suspend fun sendToolInputPartial(arguments: Map?) { + notification( + method = "ui/notifications/tool-input-partial", + params = McpUiToolInputPartialParams(arguments = arguments), + paramsSerializer = { json.encodeToJsonElement(it) as JsonObject } + ) + } + + suspend fun sendToolResult(result: JsonObject) { + notification( + method = "ui/notifications/tool-result", + params = result, + paramsSerializer = { it } + ) + } + + suspend fun sendSandboxResourceReady(html: String, sandbox: String? = null, csp: CspConfig? = null) { + notification( + method = "ui/notifications/sandbox-resource-ready", + params = McpUiSandboxResourceReadyParams(html = html, sandbox = sandbox, csp = csp), + paramsSerializer = { json.encodeToJsonElement(it) as JsonObject } + ) + } + + /** + * Request the App to perform cleanup before the resource is torn down. + * + * @param timeout Maximum time to wait for the App to respond (default 500ms) + */ + suspend fun sendResourceTeardown(timeout: Duration = 500.milliseconds): McpUiResourceTeardownResult { + return request( + method = "ui/resource-teardown", + params = McpUiResourceTeardownParams(), + paramsSerializer = { JsonObject(emptyMap()) }, + resultDeserializer = { McpUiResourceTeardownResult() }, + timeout = timeout + ) + } +} diff --git a/kotlin/src/main/kotlin/io/modelcontextprotocol/apps/Config.kt b/kotlin/src/main/kotlin/io/modelcontextprotocol/apps/Config.kt new file mode 100644 index 00000000..41f899a3 --- /dev/null +++ b/kotlin/src/main/kotlin/io/modelcontextprotocol/apps/Config.kt @@ -0,0 +1,29 @@ +package io.modelcontextprotocol.apps + +/** + * MCP Apps SDK configuration and constants. + */ +object McpAppsConfig { + /** + * Current protocol version supported by this SDK. + * + * The SDK automatically handles version negotiation during initialization. + * Apps and hosts don't need to manage protocol versions manually. + */ + const val LATEST_PROTOCOL_VERSION = "2025-11-21" + + /** + * Supported protocol versions for negotiation. + */ + val SUPPORTED_PROTOCOL_VERSIONS = listOf(LATEST_PROTOCOL_VERSION) + + /** + * MIME type for MCP UI resources. + */ + const val RESOURCE_MIME_TYPE = "text/html;profile=mcp-app" + + /** + * Metadata key for associating a resource URI with a tool call. + */ + const val RESOURCE_URI_META_KEY = "ui/resourceUri" +} diff --git a/kotlin/src/main/kotlin/io/modelcontextprotocol/apps/generated/SchemaTypes.kt b/kotlin/src/main/kotlin/io/modelcontextprotocol/apps/generated/SchemaTypes.kt new file mode 100644 index 00000000..45505ce2 --- /dev/null +++ b/kotlin/src/main/kotlin/io/modelcontextprotocol/apps/generated/SchemaTypes.kt @@ -0,0 +1,459 @@ +// Generated from src/generated/schema.json +// DO NOT EDIT - Run: npx tsx scripts/generate-kotlin-types.ts + +package io.modelcontextprotocol.apps.generated + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +// MARK: - Helper Types + +/** Empty capability marker (matches TypeScript `{}`) */ +@Serializable +object EmptyCapability + +/** Application/host identification */ +@Serializable +data class Implementation( + val name: String, + val version: String, + val title: String? = null +) + +/** Log level */ +@Serializable +enum class LogLevel { + @SerialName("debug") DEBUG, + @SerialName("info") INFO, + @SerialName("notice") NOTICE, + @SerialName("warning") WARNING, + @SerialName("error") ERROR, + @SerialName("critical") CRITICAL, + @SerialName("alert") ALERT, + @SerialName("emergency") EMERGENCY +} + +// Type aliases for compatibility +typealias McpUiInitializeParams = McpUiInitializeRequestParams +typealias McpUiMessageParams = McpUiMessageRequestParams +typealias McpUiOpenLinkParams = McpUiOpenLinkRequestParams + +// MARK: - Generated Types + +/** App exposes MCP-style tools that the host can call. */ +@Serializable +data class McpUiAppCapabilitiesTools( + /** App supports tools/list_changed notifications. */ + val listChanged: Boolean? = null +) + +@Serializable +data class McpUiAppCapabilities( + /** Experimental features (structure TBD). */ + val experimental: EmptyCapability? = null, + /** App exposes MCP-style tools that the host can call. */ + val tools: McpUiAppCapabilitiesTools? = null +) + +/** Display mode for UI presentation. */ +@Serializable +enum class McpUiDisplayMode { + @SerialName("inline") INLINE, + @SerialName("fullscreen") FULLSCREEN, + @SerialName("pip") PIP +} + +/** Host can proxy tool calls to the MCP server. */ +@Serializable +data class McpUiHostCapabilitiesServerTools( + /** Host supports tools/list_changed notifications. */ + val listChanged: Boolean? = null +) + +/** Host can proxy resource reads to the MCP server. */ +@Serializable +data class McpUiHostCapabilitiesServerResources( + /** Host supports resources/list_changed notifications. */ + val listChanged: Boolean? = null +) + +@Serializable +data class McpUiHostCapabilities( + /** Experimental features (structure TBD). */ + val experimental: EmptyCapability? = null, + /** Host supports opening external URLs. */ + val openLinks: EmptyCapability? = null, + /** Host can proxy tool calls to the MCP server. */ + val serverTools: McpUiHostCapabilitiesServerTools? = null, + /** Host can proxy resource reads to the MCP server. */ + val serverResources: McpUiHostCapabilitiesServerResources? = null, + /** Host accepts log messages. */ + val logging: EmptyCapability? = null +) + +@Serializable +data class McpUiHostContextChangedNotificationParamsToolInfoToolIconsItem( + val src: String, + val mimeType: String? = null, + val sizes: List? = null +) + +@Serializable +data class McpUiHostContextChangedNotificationParamsToolInfoToolInputSchema( + val type: String, + val properties: Map? = null, + val required: List? = null +) + +@Serializable +data class McpUiHostContextChangedNotificationParamsToolInfoToolOutputSchema( + val type: String, + val properties: Map? = null, + val required: List? = null +) + +@Serializable +data class McpUiHostContextChangedNotificationParamsToolInfoToolAnnotations( + val title: String? = null, + val readOnlyHint: Boolean? = null, + val destructiveHint: Boolean? = null, + val idempotentHint: Boolean? = null, + val openWorldHint: Boolean? = null +) + +@Serializable +data class McpUiHostContextChangedNotificationParamsToolInfoToolExecution( + val taskSupport: String? = null +) + +/** Tool definition including name, inputSchema, etc. */ +@Serializable +data class McpUiHostContextChangedNotificationParamsToolInfoTool( + val name: String, + val title: String? = null, + val icons: List? = null, + val description: String? = null, + val inputSchema: McpUiHostContextChangedNotificationParamsToolInfoToolInputSchema, + val outputSchema: McpUiHostContextChangedNotificationParamsToolInfoToolOutputSchema? = null, + val annotations: McpUiHostContextChangedNotificationParamsToolInfoToolAnnotations? = null, + val execution: McpUiHostContextChangedNotificationParamsToolInfoToolExecution? = null, + val _meta: Map? = null +) + +/** Metadata of the tool call that instantiated this App. */ +@Serializable +data class McpUiHostContextChangedNotificationParamsToolInfo( + /** JSON-RPC id of the tools/call request. */ + val id: JsonElement, + /** Tool definition including name, inputSchema, etc. */ + val tool: McpUiHostContextChangedNotificationParamsToolInfoTool +) + +/** Current color theme preference. */ +@Serializable +enum class McpUiTheme { + @SerialName("light") LIGHT, + @SerialName("dark") DARK +} + +/** Current and maximum dimensions available to the UI. */ +@Serializable +data class Viewport( + /** Current viewport width in pixels. */ + val width: Double, + /** Current viewport height in pixels. */ + val height: Double, + /** Maximum available height in pixels (if constrained). */ + val maxHeight: Double? = null, + /** Maximum available width in pixels (if constrained). */ + val maxWidth: Double? = null +) + +/** Platform type for responsive design decisions. */ +@Serializable +enum class McpUiPlatform { + @SerialName("web") WEB, + @SerialName("desktop") DESKTOP, + @SerialName("mobile") MOBILE +} + +/** Device input capabilities. */ +@Serializable +data class DeviceCapabilities( + /** Whether the device supports touch input. */ + val touch: Boolean? = null, + /** Whether the device supports hover interactions. */ + val hover: Boolean? = null +) + +/** Mobile safe area boundaries in pixels. */ +@Serializable +data class SafeAreaInsets( + /** Top safe area inset in pixels. */ + val top: Double, + /** Right safe area inset in pixels. */ + val right: Double, + /** Bottom safe area inset in pixels. */ + val bottom: Double, + /** Left safe area inset in pixels. */ + val left: Double +) + +/** Partial context update containing only changed fields. */ +@Serializable +data class McpUiHostContext( + /** Metadata of the tool call that instantiated this App. */ + val toolInfo: McpUiHostContextChangedNotificationParamsToolInfo? = null, + /** Current color theme preference. */ + val theme: McpUiTheme? = null, + /** How the UI is currently displayed. */ + val displayMode: McpUiDisplayMode? = null, + /** Display modes the host supports. */ + val availableDisplayModes: List? = null, + /** Current and maximum dimensions available to the UI. */ + val viewport: Viewport? = null, + /** User's language and region preference in BCP 47 format. */ + val locale: String? = null, + /** User's timezone in IANA format. */ + val timeZone: String? = null, + /** Host application identifier. */ + val userAgent: String? = null, + /** Platform type for responsive design decisions. */ + val platform: McpUiPlatform? = null, + /** Device input capabilities. */ + val deviceCapabilities: DeviceCapabilities? = null, + /** Mobile safe area boundaries in pixels. */ + val safeAreaInsets: SafeAreaInsets? = null +) + +@Serializable +data class McpUiHostContextChangedNotification( + val method: String, + /** Partial context update containing only changed fields. */ + val params: McpUiHostContext +) + +@Serializable +data class McpUiInitializeRequestParams( + /** App identification (name and version). */ + val appInfo: Implementation, + /** Features and capabilities this app provides. */ + val appCapabilities: McpUiAppCapabilities, + /** Protocol version this app supports. */ + val protocolVersion: String +) + +@Serializable +data class McpUiInitializeRequest( + val method: String, + val params: McpUiInitializeRequestParams +) + +@Serializable +data class McpUiInitializeResult( + /** Negotiated protocol version string (e.g., "2025-11-21"). */ + val protocolVersion: String, + /** Host application identification and version. */ + val hostInfo: Implementation, + /** Features and capabilities provided by the host. */ + val hostCapabilities: McpUiHostCapabilities, + /** Rich context about the host environment. */ + val hostContext: McpUiHostContext +) + +@Serializable +data class McpUiInitializedNotification( + val method: String, + val params: EmptyCapability? = null +) + +@Serializable +data class McpUiMessageRequestParams( + /** Message role, currently only "user" is supported. */ + val role: String, + /** Message content blocks (text, image, etc.). */ + val content: List +) + +@Serializable +data class McpUiMessageRequest( + val method: String, + val params: McpUiMessageRequestParams +) + +@Serializable +data class McpUiMessageResult( + /** True if the host rejected or failed to deliver the message. */ + val isError: Boolean? = null +) + +@Serializable +data class McpUiOpenLinkRequestParams( + /** URL to open in the host's browser */ + val url: String +) + +@Serializable +data class McpUiOpenLinkRequest( + val method: String, + val params: McpUiOpenLinkRequestParams +) + +@Serializable +data class McpUiOpenLinkResult( + /** True if the host failed to open the URL (e.g., due to security policy). */ + val isError: Boolean? = null +) + +@Serializable +data class McpUiResourceCsp( + /** Origins for network requests (fetch/XHR/WebSocket). */ + val connectDomains: List? = null, + /** Origins for static resources (scripts, images, styles, fonts). */ + val resourceDomains: List? = null +) + +/** Content Security Policy configuration. */ +@Serializable +data class McpUiResourceMetaCsp( + /** Origins for network requests (fetch/XHR/WebSocket). */ + val connectDomains: List? = null, + /** Origins for static resources (scripts, images, styles, fonts). */ + val resourceDomains: List? = null +) + +@Serializable +data class McpUiResourceMeta( + /** Content Security Policy configuration. */ + val csp: McpUiResourceMetaCsp? = null, + /** Dedicated origin for widget sandbox. */ + val domain: String? = null, + /** Visual boundary preference - true if UI prefers a visible border. */ + val prefersBorder: Boolean? = null +) + +@Serializable +data class McpUiResourceTeardownRequest( + val method: String, + val params: EmptyCapability +) + +@Serializable +data class McpUiResourceTeardownResult(val _placeholder: Unit = Unit + +) + +@Serializable +data class McpUiSandboxProxyReadyNotification( + val method: String, + val params: EmptyCapability +) + +/** CSP configuration from resource metadata. */ +@Serializable +data class McpUiSandboxResourceReadyNotificationParamsCsp( + /** Origins for network requests (fetch/XHR/WebSocket). */ + val connectDomains: List? = null, + /** Origins for static resources (scripts, images, styles, fonts). */ + val resourceDomains: List? = null +) + +@Serializable +data class McpUiSandboxResourceReadyNotificationParams( + /** HTML content to load into the inner iframe. */ + val html: String, + /** Optional override for the inner iframe's sandbox attribute. */ + val sandbox: String? = null, + /** CSP configuration from resource metadata. */ + val csp: McpUiSandboxResourceReadyNotificationParamsCsp? = null +) + +@Serializable +data class McpUiSandboxResourceReadyNotification( + val method: String, + val params: McpUiSandboxResourceReadyNotificationParams +) + +@Serializable +data class McpUiSizeChangedNotificationParams( + /** New width in pixels. */ + val width: Double? = null, + /** New height in pixels. */ + val height: Double? = null +) + +@Serializable +data class McpUiSizeChangedNotification( + val method: String, + val params: McpUiSizeChangedNotificationParams +) + +@Serializable +data class McpUiToolInputNotificationParams( + /** Complete tool call arguments as key-value pairs. */ + val arguments: Map? = null +) + +@Serializable +data class McpUiToolInputNotification( + val method: String, + val params: McpUiToolInputNotificationParams +) + +@Serializable +data class McpUiToolInputPartialNotificationParams( + /** Partial tool call arguments (incomplete, may change). */ + val arguments: Map? = null +) + +@Serializable +data class McpUiToolInputPartialNotification( + val method: String, + val params: McpUiToolInputPartialNotificationParams +) + +@Serializable +data class McpUiToolResultNotificationParams_metaIo_modelcontextprotocol_related_task( + val taskId: String +) + +@Serializable +data class McpUiToolResultNotificationParams_meta( + @SerialName("io.modelcontextprotocol/related-task") + val io_modelcontextprotocol_related_task: McpUiToolResultNotificationParams_metaIo_modelcontextprotocol_related_task? = null +) + +/** Standard MCP tool execution result. */ +@Serializable +data class McpUiToolResultNotificationParams( + val _meta: McpUiToolResultNotificationParams_meta? = null, + val content: List, + val structuredContent: Map? = null, + val isError: Boolean? = null +) + +@Serializable +data class McpUiToolResultNotification( + val method: String, + /** Standard MCP tool execution result. */ + val params: McpUiToolResultNotificationParams +) + +// Additional type aliases for compatibility +typealias McpUiSizeChangedParams = McpUiSizeChangedNotificationParams +typealias McpUiToolInputParams = McpUiToolInputNotificationParams +typealias McpUiToolInputPartialParams = McpUiToolInputPartialNotificationParams +typealias McpUiSandboxResourceReadyParams = McpUiSandboxResourceReadyNotificationParams +@Serializable +data class McpUiResourceTeardownParams(val _placeholder: Unit = Unit) +typealias CspConfig = McpUiSandboxResourceReadyNotificationParamsCsp + +// Logging message params (standard MCP type) +@Serializable +data class LoggingMessageParams( + val level: LogLevel, + val data: JsonElement, + val logger: String? = null +) + diff --git a/kotlin/src/main/kotlin/io/modelcontextprotocol/apps/protocol/JSONRPCMessage.kt b/kotlin/src/main/kotlin/io/modelcontextprotocol/apps/protocol/JSONRPCMessage.kt new file mode 100644 index 00000000..61f62ee4 --- /dev/null +++ b/kotlin/src/main/kotlin/io/modelcontextprotocol/apps/protocol/JSONRPCMessage.kt @@ -0,0 +1,113 @@ +package io.modelcontextprotocol.apps.protocol + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject + +/** + * JSON-RPC 2.0 message types for MCP Apps communication. + * + * These types follow the JSON-RPC 2.0 specification and are used + * for all communication between Guest UIs and Hosts. + */ + +/** + * Base sealed class for all JSON-RPC messages. + */ +@Serializable +sealed class JSONRPCMessage { + abstract val jsonrpc: String +} + +/** + * JSON-RPC request message. + * + * A request expects a response from the peer. + */ +@Serializable +@SerialName("request") +data class JSONRPCRequest( + override val jsonrpc: String = "2.0", + /** Unique identifier for this request */ + val id: JsonElement, + /** Method name to invoke */ + val method: String, + /** Optional parameters for the method */ + val params: JsonObject? = null +) : JSONRPCMessage() + +/** + * JSON-RPC notification message. + * + * A notification does not expect a response. + */ +@Serializable +@SerialName("notification") +data class JSONRPCNotification( + override val jsonrpc: String = "2.0", + /** Method name for this notification */ + val method: String, + /** Optional parameters for the notification */ + val params: JsonObject? = null +) : JSONRPCMessage() + +/** + * JSON-RPC success response message. + */ +@Serializable +@SerialName("response") +data class JSONRPCResponse( + override val jsonrpc: String = "2.0", + /** ID matching the original request */ + val id: JsonElement, + /** Result of the method invocation */ + val result: JsonElement +) : JSONRPCMessage() + +/** + * JSON-RPC error response message. + */ +@Serializable +@SerialName("error") +data class JSONRPCErrorResponse( + override val jsonrpc: String = "2.0", + /** ID matching the original request (may be null if request ID couldn't be determined) */ + val id: JsonElement?, + /** Error details */ + val error: JSONRPCError +) : JSONRPCMessage() + +/** + * JSON-RPC error object. + */ +@Serializable +data class JSONRPCError( + /** Error code */ + val code: Int, + /** Human-readable error message */ + val message: String, + /** Optional additional error data */ + val data: JsonElement? = null +) { + companion object { + // Standard JSON-RPC error codes + const val PARSE_ERROR = -32700 + const val INVALID_REQUEST = -32600 + const val METHOD_NOT_FOUND = -32601 + const val INVALID_PARAMS = -32602 + const val INTERNAL_ERROR = -32603 + + // MCP-specific error codes (-32000 to -32099 reserved for implementation) + const val MCP_ERROR = -32000 + } +} + +/** + * Helper to create a request ID. + */ +object RequestId { + private var counter = 0L + + fun next(): JsonElement = kotlinx.serialization.json.JsonPrimitive(++counter) +} diff --git a/kotlin/src/main/kotlin/io/modelcontextprotocol/apps/protocol/Protocol.kt b/kotlin/src/main/kotlin/io/modelcontextprotocol/apps/protocol/Protocol.kt new file mode 100644 index 00000000..eea3ef0e --- /dev/null +++ b/kotlin/src/main/kotlin/io/modelcontextprotocol/apps/protocol/Protocol.kt @@ -0,0 +1,224 @@ +package io.modelcontextprotocol.apps.protocol + +import io.modelcontextprotocol.apps.transport.McpAppsTransport +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.serialization.json.* +import kotlin.coroutines.cancellation.CancellationException +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +/** + * Handler for incoming requests. + */ +typealias RequestHandler = suspend (params: P) -> R + +/** + * Handler for incoming notifications. + */ +typealias NotificationHandler

= suspend (params: P) -> Unit + +/** + * Core protocol handler for JSON-RPC communication. + * + * Manages request/response correlation, timeout handling, and handler dispatch. + */ +abstract class Protocol( + private val defaultTimeout: Duration = 30.seconds +) { + protected val json = Json { + ignoreUnknownKeys = true + encodeDefaults = false + isLenient = true + } + + private var transport: McpAppsTransport? = null + private var scope: CoroutineScope? = null + + private val pendingRequests = mutableMapOf>() + private val requestHandlers = mutableMapOf JsonElement>() + private val notificationHandlers = mutableMapOf Unit>() + + /** + * Connect to a transport and start processing messages. + */ + suspend fun connect(transport: McpAppsTransport) { + this.transport = transport + this.scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + + transport.start() + + // Process incoming messages + transport.incoming + .onEach { message -> handleMessage(message) } + .launchIn(scope!!) + } + + /** + * Disconnect from the transport. + */ + suspend fun close() { + scope?.cancel() + transport?.close() + pendingRequests.values.forEach { it.cancel() } + pendingRequests.clear() + } + + /** + * Register a handler for a request method. + */ + protected fun setRequestHandler( + method: String, + paramsDeserializer: (JsonObject?) -> P, + resultSerializer: (R) -> JsonElement, + handler: RequestHandler + ) { + requestHandlers[method] = { params -> + val typedParams = paramsDeserializer(params) + val result = handler(typedParams) + resultSerializer(result) + } + } + + /** + * Register a handler for a notification method. + */ + protected fun

setNotificationHandler( + method: String, + paramsDeserializer: (JsonObject?) -> P, + handler: NotificationHandler

+ ) { + notificationHandlers[method] = { params -> + val typedParams = paramsDeserializer(params) + handler(typedParams) + } + } + + /** + * Send a request and wait for response. + */ + protected suspend fun request( + method: String, + params: P, + paramsSerializer: (P) -> JsonObject?, + resultDeserializer: (JsonElement) -> R, + timeout: Duration = defaultTimeout + ): R { + val id = RequestId.next() + val idString = when (id) { + is JsonPrimitive -> id.content + else -> id.toString() + } + + val deferred = CompletableDeferred() + pendingRequests[idString] = deferred + + try { + val request = JSONRPCRequest( + id = id, + method = method, + params = paramsSerializer(params) + ) + + transport?.send(request) ?: throw IllegalStateException("Not connected") + + val result = withTimeout(timeout) { + deferred.await() + } + + return resultDeserializer(result) + } finally { + pendingRequests.remove(idString) + } + } + + /** + * Send a notification (no response expected). + */ + protected suspend fun

notification( + method: String, + params: P, + paramsSerializer: (P) -> JsonObject? + ) { + val notification = JSONRPCNotification( + method = method, + params = paramsSerializer(params) + ) + transport?.send(notification) ?: throw IllegalStateException("Not connected") + } + + private suspend fun handleMessage(message: JSONRPCMessage) { + when (message) { + is JSONRPCRequest -> handleRequest(message) + is JSONRPCNotification -> handleNotification(message) + is JSONRPCResponse -> handleResponse(message) + is JSONRPCErrorResponse -> handleErrorResponse(message) + } + } + + private suspend fun handleRequest(request: JSONRPCRequest) { + val handler = requestHandlers[request.method] + if (handler == null) { + sendError(request.id, JSONRPCError.METHOD_NOT_FOUND, "Method not found: ${request.method}") + return + } + + try { + val result = handler(request.params) + val response = JSONRPCResponse(id = request.id, result = result) + transport?.send(response) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + sendError(request.id, JSONRPCError.INTERNAL_ERROR, e.message ?: "Internal error") + } + } + + private suspend fun handleNotification(notification: JSONRPCNotification) { + val handler = notificationHandlers[notification.method] ?: return + try { + handler(notification.params) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + // Log but don't propagate notification handler errors + println("Error handling notification ${notification.method}: ${e.message}") + } + } + + private fun handleResponse(response: JSONRPCResponse) { + val idString = when (val id = response.id) { + is JsonPrimitive -> id.content + else -> id.toString() + } + + pendingRequests[idString]?.complete(response.result) + } + + private fun handleErrorResponse(response: JSONRPCErrorResponse) { + val idString = when (val id = response.id) { + is JsonPrimitive -> id?.content + else -> id?.toString() + } + + if (idString != null) { + pendingRequests[idString]?.completeExceptionally( + JSONRPCException(response.error) + ) + } + } + + private suspend fun sendError(id: JsonElement, code: Int, message: String) { + val error = JSONRPCErrorResponse( + id = id, + error = JSONRPCError(code = code, message = message) + ) + transport?.send(error) + } +} + +/** + * Exception representing a JSON-RPC error response. + */ +class JSONRPCException(val error: JSONRPCError) : Exception(error.message) diff --git a/kotlin/src/main/kotlin/io/modelcontextprotocol/apps/transport/Transport.kt b/kotlin/src/main/kotlin/io/modelcontextprotocol/apps/transport/Transport.kt new file mode 100644 index 00000000..a820133c --- /dev/null +++ b/kotlin/src/main/kotlin/io/modelcontextprotocol/apps/transport/Transport.kt @@ -0,0 +1,91 @@ +package io.modelcontextprotocol.apps.transport + +import io.modelcontextprotocol.apps.protocol.JSONRPCMessage +import kotlinx.coroutines.flow.Flow + +/** + * Transport interface for MCP Apps communication. + * + * This interface abstracts the underlying message transport mechanism, + * allowing different implementations for various platforms: + * - WebView (Android/iOS) using JavaScript bridges + * - In-memory for testing + * - postMessage for web (via TypeScript SDK) + */ +interface McpAppsTransport { + /** + * Start the transport and begin listening for messages. + * + * This should set up any necessary listeners or bridges. + */ + suspend fun start() + + /** + * Send a JSON-RPC message to the peer. + * + * @param message The JSON-RPC message to send + */ + suspend fun send(message: JSONRPCMessage) + + /** + * Close the transport and cleanup resources. + */ + suspend fun close() + + /** + * Flow of incoming JSON-RPC messages from the peer. + */ + val incoming: Flow + + /** + * Flow of transport errors. + */ + val errors: Flow +} + +/** + * In-memory transport for testing. + * + * Creates a pair of linked transports that forward messages to each other. + */ +class InMemoryTransport private constructor( + private val peer: InMemoryTransport? +) : McpAppsTransport { + + private val _incoming = kotlinx.coroutines.flow.MutableSharedFlow() + private val _errors = kotlinx.coroutines.flow.MutableSharedFlow() + + private var _peer: InMemoryTransport? = peer + + override val incoming: Flow = _incoming + override val errors: Flow = _errors + + override suspend fun start() { + // Nothing to do for in-memory transport + } + + override suspend fun send(message: JSONRPCMessage) { + _peer?._incoming?.emit(message) + ?: throw IllegalStateException("Transport not connected to peer") + } + + override suspend fun close() { + _peer = null + } + + companion object { + /** + * Create a linked pair of transports for testing. + * + * Messages sent on one transport are received on the other. + * + * @return A pair of linked transports (first, second) + */ + fun createLinkedPair(): Pair { + val first = InMemoryTransport(null) + val second = InMemoryTransport(first) + first._peer = second + return first to second + } + } +} diff --git a/kotlin/src/test/kotlin/io/modelcontextprotocol/apps/AppBridgeTest.kt b/kotlin/src/test/kotlin/io/modelcontextprotocol/apps/AppBridgeTest.kt new file mode 100644 index 00000000..9320b227 --- /dev/null +++ b/kotlin/src/test/kotlin/io/modelcontextprotocol/apps/AppBridgeTest.kt @@ -0,0 +1,36 @@ +package io.modelcontextprotocol.apps + +import io.modelcontextprotocol.apps.generated.* +import io.modelcontextprotocol.apps.transport.InMemoryTransport +import kotlinx.coroutines.test.runTest +import kotlin.test.* + +class AppBridgeTest { + private val testHostInfo = Implementation(name = "TestHost", version = "1.0.0") + private val testHostCapabilities = McpUiHostCapabilities( + openLinks = EmptyCapability, + serverTools = McpUiHostCapabilitiesServerTools(), + logging = EmptyCapability + ) + + @Test + fun testAppBridgeCreation() { + val bridge = AppBridge( + hostInfo = testHostInfo, + hostCapabilities = testHostCapabilities + ) + assertNotNull(bridge) + assertFalse(bridge.isReady()) + } + + @Test + fun testMessageTypes() { + val initParams = McpUiInitializeRequestParams( + appInfo = Implementation(name = "TestApp", version = "1.0.0"), + appCapabilities = McpUiAppCapabilities(), + protocolVersion = "2025-11-21" + ) + assertEquals("TestApp", initParams.appInfo.name) + assertEquals("2025-11-21", initParams.protocolVersion) + } +} diff --git a/scripts/generate-kotlin-types.ts b/scripts/generate-kotlin-types.ts new file mode 100644 index 00000000..cd401223 --- /dev/null +++ b/scripts/generate-kotlin-types.ts @@ -0,0 +1,296 @@ +#!/usr/bin/env npx tsx +/** + * Generate Kotlin types from MCP Apps JSON Schema + * + * Usage: npx tsx scripts/generate-kotlin-types.ts + */ + +import { readFileSync, writeFileSync, mkdirSync } from "fs"; +import { dirname, join } from "path"; +import { fileURLToPath } from "url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const PROJECT_DIR = join(__dirname, ".."); +const SCHEMA_FILE = join(PROJECT_DIR, "src/generated/schema.json"); +const OUTPUT_FILE = join( + PROJECT_DIR, + "kotlin/src/main/kotlin/io/modelcontextprotocol/apps/generated/SchemaTypes.kt", +); + +interface JsonSchema { + type?: string; + const?: string; + description?: string; + properties?: Record; + additionalProperties?: boolean | JsonSchema; + required?: string[]; + anyOf?: JsonSchema[]; + $ref?: string; + items?: JsonSchema; + enum?: string[]; + default?: unknown; +} + +interface SchemaDoc { + $defs: Record; +} + +// Type name mapping for consistency +const CANONICAL_NAMES: Record = { + McpUiInitializeResultHostCapabilities: "McpUiHostCapabilities", + McpUiInitializeResultHostCapabilitiesServerTools: "ServerToolsCapability", + McpUiInitializeResultHostCapabilitiesServerResources: + "ServerResourcesCapability", + McpUiInitializeRequestParamsAppCapabilities: "McpUiAppCapabilities", + McpUiInitializeRequestParamsAppCapabilitiesTools: "AppToolsCapability", + McpUiInitializeResultHostContext: "McpUiHostContext", + McpUiHostContextChangedNotificationParams: "McpUiHostContext", + McpUiInitializeResultHostContextTheme: "McpUiTheme", + McpUiHostContextChangedNotificationParamsTheme: "McpUiTheme", + McpUiInitializeResultHostContextDisplayMode: "McpUiDisplayMode", + McpUiHostContextChangedNotificationParamsDisplayMode: "McpUiDisplayMode", + McpUiInitializeResultHostContextPlatform: "McpUiPlatform", + McpUiHostContextChangedNotificationParamsPlatform: "McpUiPlatform", + McpUiInitializeResultHostContextViewport: "Viewport", + McpUiHostContextChangedNotificationParamsViewport: "Viewport", + McpUiInitializeResultHostContextSafeAreaInsets: "SafeAreaInsets", + McpUiHostContextChangedNotificationParamsSafeAreaInsets: "SafeAreaInsets", + McpUiInitializeResultHostContextDeviceCapabilities: "DeviceCapabilities", + McpUiHostContextChangedNotificationParamsDeviceCapabilities: + "DeviceCapabilities", + McpUiInitializeResultHostInfo: "Implementation", + McpUiInitializeRequestParamsAppInfo: "Implementation", +}; + +const HEADER_DEFINED_TYPES = new Set([ + "EmptyCapability", + "Implementation", + "LogLevel", +]); + +const EMPTY_TYPES = new Set(); +const generatedTypes = new Set(); +const typeDefinitions: string[] = []; + +function toKotlinPropertyName(name: string): string { + return name.replace(/[.\/:]/g, "_").replace(/-/g, "_"); +} + +function getCanonicalName(name: string): string { + return CANONICAL_NAMES[name] || name; +} + +function isEmptyObject(schema: JsonSchema): boolean { + return ( + schema.type === "object" && + schema.additionalProperties === false && + (!schema.properties || Object.keys(schema.properties).length === 0) + ); +} + +function toKotlinType( + schema: JsonSchema, + contextName: string, + defs: Record, +): string { + if (schema.$ref) { + const refName = schema.$ref.replace("#/$defs/", ""); + return getCanonicalName(refName); + } + + if (schema.anyOf) { + const allConsts = schema.anyOf.every((s) => s.const !== undefined); + if (allConsts) { + const canonical = getCanonicalName(contextName); + if (!generatedTypes.has(canonical)) { + generateEnum(contextName, schema); + } + return canonical; + } + return "JsonElement"; + } + + if (schema.const) { + return "String"; + } + + switch (schema.type) { + case "string": + return "String"; + case "number": + return "Double"; + case "integer": + return "Int"; + case "boolean": + return "Boolean"; + case "array": + if (schema.items) { + return `List<${toKotlinType(schema.items, contextName + "Item", defs)}>`; + } + return "List"; + case "object": + if (isEmptyObject(schema)) { + EMPTY_TYPES.add(contextName); + return "EmptyCapability"; + } + if (schema.properties && Object.keys(schema.properties).length > 0) { + const canonical = getCanonicalName(contextName); + if (!generatedTypes.has(canonical)) { + generateDataClass(contextName, schema, defs); + } + return canonical; + } + if (schema.additionalProperties) { + return "Map"; + } + return "Map"; + default: + return "JsonElement"; + } +} + +function generateEnum(name: string, schema: JsonSchema): void { + const canonical = getCanonicalName(name); + if (generatedTypes.has(canonical)) return; + if (HEADER_DEFINED_TYPES.has(canonical)) return; + generatedTypes.add(canonical); + + const cases = schema + .anyOf!.filter((s) => s.const) + .map((s) => { + const value = s.const as string; + const caseName = value + .toUpperCase() + .replace(/-/g, "_") + .replace(/\//g, "_"); + return ` @SerialName("${value}") ${caseName}`; + }); + + const desc = schema.description ? `/** ${schema.description} */\n` : ""; + typeDefinitions.push(`${desc}@Serializable +enum class ${canonical} { +${cases.join(",\n")} +}`); +} + +function generateDataClass( + name: string, + schema: JsonSchema, + defs: Record, +): void { + const canonical = getCanonicalName(name); + if (generatedTypes.has(canonical)) return; + if (EMPTY_TYPES.has(name)) return; + if (HEADER_DEFINED_TYPES.has(canonical)) return; + generatedTypes.add(canonical); + + const props = schema.properties || {}; + const required = new Set(schema.required || []); + + const properties: string[] = []; + + for (const [propName, propSchema] of Object.entries(props)) { + const kotlinName = toKotlinPropertyName(propName); + const contextTypeName = name + capitalize(kotlinName); + + let kotlinType: string; + if (isEmptyObject(propSchema)) { + kotlinType = "EmptyCapability"; + } else { + kotlinType = toKotlinType(propSchema, contextTypeName, defs); + } + + const isOptional = !required.has(propName); + const typeDecl = isOptional ? `${kotlinType}? = null` : kotlinType; + const desc = propSchema.description + ? ` /** ${propSchema.description} */\n` + : ""; + const serialName = + kotlinName !== propName ? ` @SerialName("${propName}")\n` : ""; + + properties.push(`${desc}${serialName} val ${kotlinName}: ${typeDecl}`); + } + + const desc = schema.description ? `/** ${schema.description} */\n` : ""; + typeDefinitions.push(`${desc}@Serializable +data class ${canonical}( +${properties.join(",\n")} +)`); +} + +function capitalize(s: string): string { + return s.charAt(0).toUpperCase() + s.slice(1); +} + +function generate(): string { + const schema: SchemaDoc = JSON.parse(readFileSync(SCHEMA_FILE, "utf-8")); + const defs = schema.$defs; + + const header = `// Generated from src/generated/schema.json +// DO NOT EDIT - Run: npx tsx scripts/generate-kotlin-types.ts + +package io.modelcontextprotocol.apps.generated + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +// MARK: - Helper Types + +/** Empty capability marker (matches TypeScript \`{}\`) */ +@Serializable +object EmptyCapability + +/** Application/host identification */ +@Serializable +data class Implementation( + val name: String, + val version: String, + val title: String? = null +) + +/** Log level */ +@Serializable +enum class LogLevel { + @SerialName("debug") DEBUG, + @SerialName("info") INFO, + @SerialName("notice") NOTICE, + @SerialName("warning") WARNING, + @SerialName("error") ERROR, + @SerialName("critical") CRITICAL, + @SerialName("alert") ALERT, + @SerialName("emergency") EMERGENCY +} + +// Type aliases for compatibility +typealias McpUiInitializeParams = McpUiInitializeRequestParams +typealias McpUiMessageParams = McpUiMessageRequestParams +typealias McpUiOpenLinkParams = McpUiOpenLinkRequestParams + +// MARK: - Generated Types +`; + + for (const [name, defSchema] of Object.entries(defs)) { + if (defSchema.anyOf && defSchema.anyOf.every((s) => s.const)) { + generateEnum(name, defSchema); + } else if (defSchema.type === "object") { + generateDataClass(name, defSchema, defs); + } + } + + return header + "\n" + typeDefinitions.join("\n\n") + "\n"; +} + +try { + console.log("🔧 Generating Kotlin types from schema.json..."); + const code = generate(); + + mkdirSync(dirname(OUTPUT_FILE), { recursive: true }); + writeFileSync(OUTPUT_FILE, code); + + console.log(`✅ Generated: ${OUTPUT_FILE}`); + console.log(` Types: ${generatedTypes.size}`); +} catch (error) { + console.error("❌ Generation failed:", error); + process.exit(1); +}