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);
+}