From 57d7c86702d13f31f12509c3f0c916b99ec2cc5e Mon Sep 17 00:00:00 2001 From: Sanjay Sharma Date: Sat, 29 Aug 2026 23:32:21 +0530 Subject: [PATCH 1/5] mcp support changes --- CHANGELOG.md | 15 + DEVELOPMENT.md | 21 +- FEATURES.md | 1 + README.md | 1 + .../kotlin/com/reqlab/core/model/McpModels.kt | 413 ++++++ .../com/reqlab/core/model/McpOAuthModels.kt | 111 ++ .../core/model/McpModelsSerializationTest.kt | 111 ++ .../reqlab/core/model/McpOAuthModelsTest.kt | 70 + .../core/network/mcp/McpPlatform.android.kt | 20 + .../com/reqlab/core/network/StreamSupport.kt | 13 +- .../network/mcp/LegacyHttpSseTransport.kt | 109 ++ .../com/reqlab/core/network/mcp/McpClient.kt | 419 ++++++ .../com/reqlab/core/network/mcp/McpCrypto.kt | 114 ++ .../com/reqlab/core/network/mcp/McpJson.kt | 128 ++ .../com/reqlab/core/network/mcp/McpOAuth.kt | 295 ++++ .../reqlab/core/network/mcp/McpTransport.kt | 60 + .../core/network/mcp/NdjsonStdioTransport.kt | 52 + .../network/mcp/StreamableHttpTransport.kt | 162 +++ .../com/reqlab/core/network/SseParserTest.kt | 13 + .../core/network/mcp/JsonRpcFramingTest.kt | 39 + .../network/mcp/LegacyHttpSseTransportTest.kt | 61 + .../network/mcp/McpClientHandshakeTest.kt | 99 ++ .../core/network/mcp/McpConfigResolveTest.kt | 35 + .../core/network/mcp/OAuthTokenFlowTest.kt | 100 ++ .../com/reqlab/core/network/mcp/PkceTest.kt | 19 + .../mcp/StreamableHttpTransportTest.kt | 158 +++ .../core/network/mcp/McpPlatform.desktop.kt | 98 ++ .../core/network/mcp/McpPlatform.ios.kt | 16 + .../reqlab/core/network/mcp/McpPlatform.js.kt | 16 + .../core/network/mcp/McpPlatform.wasmJs.kt | 16 + docs/architecture.md | 4 +- docs/testing.md | 1 + docs/tests.md | 6 + gradle.properties | 2 +- qa-tests/collection-validator.mjs | 57 +- qa-tests/fixtures/reqlab-test-collection.json | 90 ++ .../fixtures/reqlab-test-environment.json | 12 +- .../kotlin/com/reqlab/qa/McpHttpE2ETest.kt | 396 ++++++ .../kotlin/com/reqlab/server/Application.kt | 11 +- .../main/kotlin/com/reqlab/server/McpMock.kt | 355 +++++ .../kotlin/com/reqlab/server/McpRoutes.kt | 325 +++++ .../ImportExportFixturesIntegrationTest.kt | 5 + .../kotlin/com/reqlab/ui/shared/MainScreen.kt | 26 +- .../ui/shared/components/HelpAboutDialog.kt | 2 +- .../ui/shared/components/KeyValueEditor.kt | 44 +- .../reqlab/ui/shared/components/McpPanel.kt | 1218 +++++++++++++++++ .../ui/shared/components/RequestTabsBar.kt | 12 +- .../ui/shared/components/ResponseViewer.kt | 23 +- .../reqlab/ui/shared/components/Sidebar.kt | 22 + .../reqlab/ui/shared/components/SplitPane.kt | 22 +- .../reqlab/ui/shared/mcp/McpSessionState.kt | 510 +++++++ .../persistence/ImportExportRepository.kt | 57 + .../ui/shared/persistence/TabsRepository.kt | 15 +- .../ui/shared/platform/PlatformScrollbar.kt | 28 + .../com/reqlab/ui/shared/state/AppState.kt | 63 + .../src/commonMain/resources/i18n/de.json | 66 +- .../src/commonMain/resources/i18n/en.json | 66 +- .../src/commonMain/resources/i18n/es.json | 66 +- .../src/commonMain/resources/i18n/fr.json | 66 +- .../ui/shared/i18n/I18nCompletenessTest.kt | 10 + .../ui/shared/mcp/McpSessionStateTest.kt | 310 +++++ .../McpWorkspaceBackwardCompatTest.kt | 52 + .../ui/shared/state/AppStateBehaviorTest.kt | 13 + .../platform/PlatformScrollbar.desktop.kt | 49 + .../platform/PlatformScrollbar.wasmJs.kt | 22 + 65 files changed, 6770 insertions(+), 41 deletions(-) create mode 100644 core-model/src/commonMain/kotlin/com/reqlab/core/model/McpModels.kt create mode 100644 core-model/src/commonMain/kotlin/com/reqlab/core/model/McpOAuthModels.kt create mode 100644 core-model/src/commonTest/kotlin/com/reqlab/core/model/McpModelsSerializationTest.kt create mode 100644 core-model/src/commonTest/kotlin/com/reqlab/core/model/McpOAuthModelsTest.kt create mode 100644 core-network/src/androidMain/kotlin/com/reqlab/core/network/mcp/McpPlatform.android.kt create mode 100644 core-network/src/commonMain/kotlin/com/reqlab/core/network/mcp/LegacyHttpSseTransport.kt create mode 100644 core-network/src/commonMain/kotlin/com/reqlab/core/network/mcp/McpClient.kt create mode 100644 core-network/src/commonMain/kotlin/com/reqlab/core/network/mcp/McpCrypto.kt create mode 100644 core-network/src/commonMain/kotlin/com/reqlab/core/network/mcp/McpJson.kt create mode 100644 core-network/src/commonMain/kotlin/com/reqlab/core/network/mcp/McpOAuth.kt create mode 100644 core-network/src/commonMain/kotlin/com/reqlab/core/network/mcp/McpTransport.kt create mode 100644 core-network/src/commonMain/kotlin/com/reqlab/core/network/mcp/NdjsonStdioTransport.kt create mode 100644 core-network/src/commonMain/kotlin/com/reqlab/core/network/mcp/StreamableHttpTransport.kt create mode 100644 core-network/src/commonTest/kotlin/com/reqlab/core/network/mcp/JsonRpcFramingTest.kt create mode 100644 core-network/src/commonTest/kotlin/com/reqlab/core/network/mcp/LegacyHttpSseTransportTest.kt create mode 100644 core-network/src/commonTest/kotlin/com/reqlab/core/network/mcp/McpClientHandshakeTest.kt create mode 100644 core-network/src/commonTest/kotlin/com/reqlab/core/network/mcp/McpConfigResolveTest.kt create mode 100644 core-network/src/commonTest/kotlin/com/reqlab/core/network/mcp/OAuthTokenFlowTest.kt create mode 100644 core-network/src/commonTest/kotlin/com/reqlab/core/network/mcp/PkceTest.kt create mode 100644 core-network/src/commonTest/kotlin/com/reqlab/core/network/mcp/StreamableHttpTransportTest.kt create mode 100644 core-network/src/desktopMain/kotlin/com/reqlab/core/network/mcp/McpPlatform.desktop.kt create mode 100644 core-network/src/iosMain/kotlin/com/reqlab/core/network/mcp/McpPlatform.ios.kt create mode 100644 core-network/src/jsMain/kotlin/com/reqlab/core/network/mcp/McpPlatform.js.kt create mode 100644 core-network/src/wasmJsMain/kotlin/com/reqlab/core/network/mcp/McpPlatform.wasmJs.kt create mode 100644 qa-tests/src/test/kotlin/com/reqlab/qa/McpHttpE2ETest.kt create mode 100644 sample-server/src/main/kotlin/com/reqlab/server/McpMock.kt create mode 100644 sample-server/src/main/kotlin/com/reqlab/server/McpRoutes.kt create mode 100644 ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/McpPanel.kt create mode 100644 ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/mcp/McpSessionState.kt create mode 100644 ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/platform/PlatformScrollbar.kt create mode 100644 ui-shared/src/commonTest/kotlin/com/reqlab/ui/shared/mcp/McpSessionStateTest.kt create mode 100644 ui-shared/src/commonTest/kotlin/com/reqlab/ui/shared/persistence/McpWorkspaceBackwardCompatTest.kt create mode 100644 ui-shared/src/desktopMain/kotlin/com/reqlab/ui/shared/platform/PlatformScrollbar.desktop.kt create mode 100644 ui-shared/src/wasmJsMain/kotlin/com/reqlab/ui/shared/platform/PlatformScrollbar.wasmJs.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 86d4356..a06b998 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ All notable changes to ReqLab are documented here. --- +## [1.18.0] — 2026-08-29 + +### Added + +- **MCP client**: saved MCP connections as first-class collection/tab items. Connect over Streamable HTTP (2025-06-18, stateful or stateless), legacy HTTP+SSE (2024-11-05, auto-detected), or stdio (desktop only). Browse and call tools, resources, and prompts; subscribe to resource updates; inspect notifications, progress, and a JSON-RPC timeline. Auth, headers, and params reuse the REST editors; tool/prompt arguments and results use the shared code editor and response viewer (body, headers, timing). Activity is mirrored to the Console. +- **Bidirectional MCP**: the client answers `sampling/createMessage`, `roots/list`, and `elicitation/create` so you can test servers that call back. +- **OAuth 2.1 for MCP**: metadata discovery, Dynamic Client Registration, PKCE S256, client-credentials, refresh, and an OAuth debugger log. Interactive authorization-code is desktop-first (loopback); the browser uses paste/non-interactive grants. +- **Sample-server MCP mock**: `POST /mcp`, `POST /mcp/authed` (Bearer + API key), legacy `GET /mcp/sse`, OAuth-protected `POST /mcp/secure`, and `sample-server --stdio` with deterministic tools (`echo`, `add`, `fail`, `slow`, triggers). + +### Changed + +- MCP workspace uses the same request/response split as HTTP. Connections survive tab switches and disconnect only when the tab is closed. + +--- + ## [1.17.0] — 2026-08-29 ### Added diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index b18eb07..5a89a28 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -168,8 +168,24 @@ Available endpoints (selected): | POST | `/v1/chat/ndjson` | Ollama-style NDJSON chat stream | | POST | `/v1/embeddings` | Fixed-length embedding vector | | GET | `/v1/chat/slow` | Delayed non-stream chat completion | +| POST | `/mcp` | MCP Streamable HTTP (JSON-RPC) | +| POST | `/mcp/authed` | MCP requiring Bearer `reqlab-mcp-token` and `X-Api-Key: reqlab-key` | +| GET | `/mcp` | MCP GET SSE (server-initiated) | +| DELETE | `/mcp` | MCP session terminate | +| GET | `/mcp/sse` | Legacy MCP HTTP+SSE | +| POST | `/mcp/messages` | Legacy MCP POST | +| POST | `/mcp/secure` | MCP with Bearer `mcp-oauth-token` | +| GET | `/.well-known/oauth-*` | MCP OAuth 2.1 metadata | +| POST | `/oauth/register` `/oauth/token` | Dynamic registration + token | +| GET | `/oauth/authorize` | Authorization (auto-approve for tests) | | WS | `/ws` | WebSocket echo | +MCP stdio mock: + +```bash +./gradlew :sample-server:run --args='--stdio' +``` + LLM mock query parameters: - `?demo=true` — longer assistant reply; streaming uses ~200ms per token (visible typewriter) @@ -244,7 +260,10 @@ GitHub Actions release packaging is defined in [`.github/workflows/release.yml`] - **Push to `main`** — runs a quality gate first, then builds desktop artifacts for macOS/Linux/Windows. - **Push tag `v*`** — runs the same quality gate, then builds artifacts and publishes a GitHub release. - **Manual dispatch** — allows on-demand artifact builds. - +``` +git tag -a v1.18.0 -m "" +git push origin v1.18.0 +``` --- ## Project Architecture diff --git a/FEATURES.md b/FEATURES.md index ff0d354..b115765 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -25,6 +25,7 @@ ReqLab supports end-to-end API testing with: - Authentication modes: None, Basic, Bearer, API Key, JWT (OAuth2 planned) - Retry controls and timeout behavior - HTTP streaming for SSE (`text/event-stream`) and NDJSON (OpenAI-style `"stream": true`) +- **MCP client** for Streamable HTTP, legacy HTTP+SSE, and desktop stdio (tools/resources/prompts, subscriptions, OAuth 2.1). Auth (Bearer, API key, Basic), custom headers, query params, `{{var}}` URL editing, and formatted request/response editors match the REST workspace. - Copy request as `curl` ### Response Validation and Inspection diff --git a/README.md b/README.md index 09df711..68c735b 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,7 @@ The collection includes an **LLM (OpenAI-compatible)** folder. Start the sample - Auth: None, Basic, Bearer Token, API Key, JWT - Retry controls and per-request timeout - HTTP streaming: SSE (`text/event-stream`) and NDJSON on a single request (OpenAI-style `"stream": true`) +- MCP client: Streamable HTTP, legacy HTTP+SSE, and desktop stdio; tools/resources/prompts; OAuth 2.1 - Copy request as `curl` ### 📬 Response Inspection diff --git a/core-model/src/commonMain/kotlin/com/reqlab/core/model/McpModels.kt b/core-model/src/commonMain/kotlin/com/reqlab/core/model/McpModels.kt new file mode 100644 index 0000000..e052e18 --- /dev/null +++ b/core-model/src/commonMain/kotlin/com/reqlab/core/model/McpModels.kt @@ -0,0 +1,413 @@ +package com.reqlab.core.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +const val MCP_PROTOCOL_VERSION = "2025-06-18" +const val MCP_PROTOCOL_VERSION_LEGACY = "2024-11-05" + +object JsonRpcErrorCodes { + 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 +} + +@Serializable +data class JsonRpcError( + val code: Int, + val message: String, + val data: JsonElement? = null, +) + +/** + * Generic JSON-RPC 2.0 envelope. Frames are classified by field presence: + * request = method + id, notification = method and no id, response = id + result/error. + */ +@Serializable +data class JsonRpcEnvelope( + val jsonrpc: String = "2.0", + val id: JsonElement? = null, + val method: String? = null, + val params: JsonElement? = null, + val result: JsonElement? = null, + val error: JsonRpcError? = null, +) { + fun isNotification(): Boolean = method != null && id == null + fun isRequest(): Boolean = method != null && id != null && id !is JsonNull + fun isResponse(): Boolean = method == null && id != null && id !is JsonNull + fun idKey(): String? = jsonRpcIdKey(id) +} + +fun jsonRpcIdKey(id: JsonElement?): String? { + if (id == null || id is JsonNull) return null + val primitive = id as? JsonPrimitive ?: return id.toString() + return primitive.content +} + +fun jsonRpcId(value: String): JsonPrimitive = JsonPrimitive(value) +fun jsonRpcId(value: Long): JsonPrimitive = JsonPrimitive(value) +fun jsonRpcId(value: Int): JsonPrimitive = JsonPrimitive(value) + +@Serializable +enum class RequestKind { HTTP, MCP } + +@Serializable +enum class McpTransportType { STREAMABLE_HTTP, STDIO } + +@Serializable +enum class McpHttpMode { AUTO, STREAMABLE_2025_06_18, LEGACY_2024_11_05 } + +@Serializable +enum class McpSamplingMode { MANUAL, MOCK, FORWARD_LLM } + +@Serializable +enum class McpConnectionState { DISCONNECTED, CONNECTING, CONNECTED, ERROR } + +@Serializable +data class McpImplementation( + val name: String, + val version: String, + val title: String? = null, +) + +@Serializable +data class McpToolsCapability( + val listChanged: Boolean? = null, +) + +@Serializable +data class McpResourcesCapability( + val subscribe: Boolean? = null, + val listChanged: Boolean? = null, +) + +@Serializable +data class McpPromptsCapability( + val listChanged: Boolean? = null, +) + +@Serializable +class McpLoggingCapability + +@Serializable +class McpCompletionsCapability + +@Serializable +class McpSamplingCapability + +@Serializable +data class McpRootsCapability( + val listChanged: Boolean? = null, +) + +@Serializable +class McpElicitationCapability + +@Serializable +data class McpClientCapabilities( + val sampling: McpSamplingCapability? = McpSamplingCapability(), + val roots: McpRootsCapability? = McpRootsCapability(listChanged = true), + val elicitation: McpElicitationCapability? = McpElicitationCapability(), +) + +@Serializable +data class McpServerCapabilities( + val tools: McpToolsCapability? = null, + val resources: McpResourcesCapability? = null, + val prompts: McpPromptsCapability? = null, + val logging: McpLoggingCapability? = null, + val completions: McpCompletionsCapability? = null, +) + +@Serializable +data class McpInitializeParams( + val protocolVersion: String = MCP_PROTOCOL_VERSION, + val capabilities: McpClientCapabilities = McpClientCapabilities(), + val clientInfo: McpImplementation = McpImplementation(name = "ReqLab", version = "1.18.0"), +) + +@Serializable +data class McpInitializeResult( + val protocolVersion: String = MCP_PROTOCOL_VERSION, + val capabilities: McpServerCapabilities = McpServerCapabilities(), + val serverInfo: McpImplementation = McpImplementation(name = "unknown", version = "0"), + val instructions: String? = null, +) + +@Serializable +data class McpTool( + val name: String, + val description: String? = null, + val inputSchema: JsonObject = JsonObject(emptyMap()), + val title: String? = null, + val annotations: JsonObject? = null, +) + +@Serializable +data class McpListToolsResult( + val tools: List = emptyList(), + val nextCursor: String? = null, +) + +@Serializable +data class McpResource( + val uri: String, + val name: String, + val description: String? = null, + val mimeType: String? = null, + val title: String? = null, + val size: Long? = null, +) + +@Serializable +data class McpResourceTemplate( + val uriTemplate: String, + val name: String, + val description: String? = null, + val mimeType: String? = null, + val title: String? = null, +) + +@Serializable +data class McpListResourcesResult( + val resources: List = emptyList(), + val nextCursor: String? = null, +) + +@Serializable +data class McpListResourceTemplatesResult( + val resourceTemplates: List = emptyList(), + val nextCursor: String? = null, +) + +@Serializable +data class McpPromptArgument( + val name: String, + val description: String? = null, + val required: Boolean? = null, +) + +@Serializable +data class McpPrompt( + val name: String, + val description: String? = null, + val arguments: List = emptyList(), + val title: String? = null, +) + +@Serializable +data class McpListPromptsResult( + val prompts: List = emptyList(), + val nextCursor: String? = null, +) + +@Serializable +enum class McpContentType { + @SerialName("text") TEXT, + @SerialName("image") IMAGE, + @SerialName("audio") AUDIO, + @SerialName("resource") RESOURCE, + @SerialName("resource_link") RESOURCE_LINK, +} + +@Serializable +data class McpContent( + val type: String, + val text: String? = null, + val data: String? = null, + val mimeType: String? = null, + val uri: String? = null, + val name: String? = null, + val description: String? = null, + val resource: JsonObject? = null, + val annotations: JsonObject? = null, +) + +@Serializable +data class McpToolResult( + val content: List = emptyList(), + val isError: Boolean = false, + val structuredContent: JsonElement? = null, +) + +@Serializable +data class McpReadResourceResult( + val contents: List = emptyList(), +) + +@Serializable +data class McpResourceContents( + val uri: String, + val mimeType: String? = null, + val text: String? = null, + val blob: String? = null, +) + +@Serializable +data class McpGetPromptResult( + val description: String? = null, + val messages: List = emptyList(), +) + +@Serializable +data class McpPromptMessage( + val role: String, + val content: McpContent, +) + +@Serializable +data class McpRoot( + val uri: String, + val name: String? = null, +) + +@Serializable +data class McpListRootsResult( + val roots: List = emptyList(), +) + +@Serializable +data class McpSamplingMessage( + val role: String, + val content: McpContent, +) + +@Serializable +data class McpModelPreferences( + val hints: List = emptyList(), + val costPriority: Double? = null, + val speedPriority: Double? = null, + val intelligencePriority: Double? = null, +) + +@Serializable +data class McpModelHint( + val name: String? = null, +) + +@Serializable +data class McpCreateMessageRequest( + val messages: List = emptyList(), + val modelPreferences: McpModelPreferences? = null, + val systemPrompt: String? = null, + val includeContext: String? = null, + val temperature: Double? = null, + val maxTokens: Int = 256, + val stopSequences: List? = null, + val metadata: JsonObject? = null, +) + +@Serializable +data class McpCreateMessageResult( + val role: String = "assistant", + val content: McpContent = McpContent(type = "text", text = ""), + val model: String = "mock", + val stopReason: String? = "endTurn", +) + +@Serializable +data class McpElicitRequest( + val message: String, + val requestedSchema: JsonObject = JsonObject(emptyMap()), +) + +@Serializable +enum class McpElicitAction { + @SerialName("accept") ACCEPT, + @SerialName("decline") DECLINE, + @SerialName("cancel") CANCEL, +} + +@Serializable +data class McpElicitResult( + val action: McpElicitAction = McpElicitAction.DECLINE, + val content: JsonObject? = null, +) + +@Serializable +data class McpProgressNotification( + val progressToken: JsonElement, + val progress: Double, + val total: Double? = null, + val message: String? = null, +) + +@Serializable +enum class McpLogLevel { + @SerialName("debug") DEBUG, + @SerialName("info") INFO, + @SerialName("notice") NOTICE, + @SerialName("warning") WARNING, + @SerialName("error") ERROR, + @SerialName("critical") CRITICAL, + @SerialName("alert") ALERT, + @SerialName("emergency") EMERGENCY, +} + +@Serializable +data class McpLoggingMessageNotification( + val level: McpLogLevel = McpLogLevel.INFO, + val logger: String? = null, + val data: JsonElement? = null, +) + +@Serializable +data class McpCompleteRequest( + val ref: JsonObject, + val argument: McpCompleteArgument, +) + +@Serializable +data class McpCompleteArgument( + val name: String, + val value: String, +) + +@Serializable +data class McpCompleteResult( + val completion: McpCompletion, +) + +@Serializable +data class McpCompletion( + val values: List = emptyList(), + val total: Int? = null, + val hasMore: Boolean? = null, +) + +@Serializable +enum class McpLogEntryKind { SENT, RECEIVED, NOTIFICATION, STATE, ERROR, OAUTH } + +@Serializable +data class McpLogEntry( + val timestampEpochMillis: Long, + val kind: McpLogEntryKind, + val summary: String, + val payload: String? = null, + val method: String? = null, + val id: String? = null, +) + +@Serializable +data class McpConnectionConfig( + val transport: McpTransportType = McpTransportType.STREAMABLE_HTTP, + val httpMode: McpHttpMode = McpHttpMode.AUTO, + val url: String = "", + val headers: List = emptyList(), + val auth: AuthConfig = AuthConfig(), + val oauth: McpOAuthConfig? = null, + val command: String = "", + val args: List = emptyList(), + val env: Map = emptyMap(), + val workingDir: String? = null, + val roots: List = emptyList(), + val samplingMode: McpSamplingMode = McpSamplingMode.MOCK, + val samplingForwardUrl: String? = null, + val autoRespondElicitation: Boolean = true, +) diff --git a/core-model/src/commonMain/kotlin/com/reqlab/core/model/McpOAuthModels.kt b/core-model/src/commonMain/kotlin/com/reqlab/core/model/McpOAuthModels.kt new file mode 100644 index 0000000..3df3dae --- /dev/null +++ b/core-model/src/commonMain/kotlin/com/reqlab/core/model/McpOAuthModels.kt @@ -0,0 +1,111 @@ +package com.reqlab.core.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +@Serializable +enum class McpOAuthGrantType { + @SerialName("authorization_code") AUTHORIZATION_CODE, + @SerialName("client_credentials") CLIENT_CREDENTIALS, + @SerialName("refresh_token") REFRESH_TOKEN, + @SerialName("paste_token") PASTE_TOKEN, +} + +@Serializable +data class McpOAuthConfig( + val authServerUrl: String? = null, + val clientId: String? = null, + val clientSecret: String? = null, + val scopes: List = emptyList(), + val redirectPort: Int = 8099, + val redirectUri: String? = null, + val useDcr: Boolean = true, + val useDiscovery: Boolean = true, + val grantType: McpOAuthGrantType = McpOAuthGrantType.AUTHORIZATION_CODE, + val accessToken: String? = null, + val refreshToken: String? = null, + val tokenType: String? = "Bearer", + val expiresAtEpochMillis: Long? = null, + val resource: String? = null, +) + +@Serializable +data class OAuthProtectedResourceMetadata( + val resource: String? = null, + @SerialName("authorization_servers") val authorizationServers: List = emptyList(), + @SerialName("bearer_methods_supported") val bearerMethodsSupported: List? = null, + @SerialName("scopes_supported") val scopesSupported: List? = null, +) + +@Serializable +data class OAuthAuthorizationServerMetadata( + val issuer: String? = null, + @SerialName("authorization_endpoint") val authorizationEndpoint: String? = null, + @SerialName("token_endpoint") val tokenEndpoint: String? = null, + @SerialName("registration_endpoint") val registrationEndpoint: String? = null, + @SerialName("revocation_endpoint") val revocationEndpoint: String? = null, + @SerialName("jwks_uri") val jwksUri: String? = null, + @SerialName("scopes_supported") val scopesSupported: List? = null, + @SerialName("response_types_supported") val responseTypesSupported: List? = null, + @SerialName("grant_types_supported") val grantTypesSupported: List? = null, + @SerialName("token_endpoint_auth_methods_supported") val tokenEndpointAuthMethodsSupported: List? = null, + @SerialName("code_challenge_methods_supported") val codeChallengeMethodsSupported: List? = null, +) + +@Serializable +data class OAuthDynamicClientRegistrationRequest( + @SerialName("client_name") val clientName: String = "ReqLab", + @SerialName("redirect_uris") val redirectUris: List = emptyList(), + @SerialName("grant_types") val grantTypes: List = listOf("authorization_code", "refresh_token"), + @SerialName("response_types") val responseTypes: List = listOf("code"), + @SerialName("token_endpoint_auth_method") val tokenEndpointAuthMethod: String = "none", + @SerialName("scope") val scope: String? = null, +) + +@Serializable +data class OAuthDynamicClientRegistrationResponse( + @SerialName("client_id") val clientId: String, + @SerialName("client_secret") val clientSecret: String? = null, + @SerialName("client_id_issued_at") val clientIdIssuedAt: Long? = null, + @SerialName("client_secret_expires_at") val clientSecretExpiresAt: Long? = null, + @SerialName("redirect_uris") val redirectUris: List? = null, + @SerialName("grant_types") val grantTypes: List? = null, +) + +@Serializable +data class OAuthTokenResponse( + @SerialName("access_token") val accessToken: String, + @SerialName("token_type") val tokenType: String = "Bearer", + @SerialName("expires_in") val expiresIn: Long? = null, + @SerialName("refresh_token") val refreshToken: String? = null, + val scope: String? = null, +) + +@Serializable +data class OAuthError( + val error: String, + @SerialName("error_description") val errorDescription: String? = null, + @SerialName("error_uri") val errorUri: String? = null, +) + +@Serializable +enum class McpOAuthPhase { + DISCOVERY, + DCR, + AUTHORIZE, + TOKEN, + REFRESH, + RETRY, +} + +@Serializable +data class McpOAuthDebugEntry( + val phase: McpOAuthPhase, + val timestampEpochMillis: Long, + val requestSummary: String, + val responseSummary: String? = null, + val statusCode: Int? = null, + val error: String? = null, + val payload: JsonElement? = null, +) diff --git a/core-model/src/commonTest/kotlin/com/reqlab/core/model/McpModelsSerializationTest.kt b/core-model/src/commonTest/kotlin/com/reqlab/core/model/McpModelsSerializationTest.kt new file mode 100644 index 0000000..ea09441 --- /dev/null +++ b/core-model/src/commonTest/kotlin/com/reqlab/core/model/McpModelsSerializationTest.kt @@ -0,0 +1,111 @@ +package com.reqlab.core.model + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class McpModelsSerializationTest { + + private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } + + @Test + fun json_rpc_request_round_trip_string_id() { + val envelope = JsonRpcEnvelope( + id = jsonRpcId("abc-1"), + method = "tools/call", + params = buildJsonObject { put("name", "echo") }, + ) + val encoded = json.encodeToString(JsonRpcEnvelope.serializer(), envelope) + val decoded = json.decodeFromString(JsonRpcEnvelope.serializer(), encoded) + assertTrue(decoded.isRequest()) + assertFalse(decoded.isNotification()) + assertEquals("abc-1", decoded.idKey()) + assertEquals("tools/call", decoded.method) + } + + @Test + fun json_rpc_response_number_id_correlates_as_string() { + val envelope = JsonRpcEnvelope( + id = jsonRpcId(42), + result = buildJsonObject { put("ok", true) }, + ) + val encoded = json.encodeToString(JsonRpcEnvelope.serializer(), envelope) + val decoded = json.decodeFromString(JsonRpcEnvelope.serializer(), encoded) + assertTrue(decoded.isResponse()) + assertEquals("42", decoded.idKey()) + assertEquals("42", jsonRpcIdKey(JsonPrimitive(42))) + assertEquals("42", jsonRpcIdKey(JsonPrimitive("42"))) + } + + @Test + fun notification_has_no_id() { + val envelope = JsonRpcEnvelope(method = "notifications/initialized") + assertTrue(envelope.isNotification()) + assertNull(envelope.idKey()) + } + + @Test + fun error_codes_and_isError_are_distinct() { + val rpcError = JsonRpcError(JsonRpcErrorCodes.METHOD_NOT_FOUND, "Method not found") + val toolResult = McpToolResult( + content = listOf(McpContent(type = "text", text = "boom")), + isError = true, + ) + val encoded = json.encodeToString(McpToolResult.serializer(), toolResult) + val decoded = json.decodeFromString(McpToolResult.serializer(), encoded) + assertTrue(decoded.isError) + assertEquals(-32601, rpcError.code) + assertEquals("boom", decoded.content.single().text) + } + + @Test + fun initialize_result_unknown_keys_are_ignored() { + val raw = """ + {"protocolVersion":"2025-06-18", + "capabilities":{"tools":{"listChanged":true},"resources":{"subscribe":true},"unknownCap":true}, + "serverInfo":{"name":"demo","version":"1","extra":"x"}, + "surprise":1} + """.trimIndent() + val decoded = json.decodeFromString(McpInitializeResult.serializer(), raw) + assertEquals(MCP_PROTOCOL_VERSION, decoded.protocolVersion) + assertEquals(true, decoded.capabilities.tools?.listChanged) + assertEquals(true, decoded.capabilities.resources?.subscribe) + assertEquals("demo", decoded.serverInfo.name) + } + + @Test + fun connection_config_defaults_round_trip() { + val config = McpConnectionConfig( + url = "http://localhost:8080/mcp", + roots = listOf(McpRoot("file:///tmp", "tmp")), + ) + val encoded = json.encodeToString(McpConnectionConfig.serializer(), config) + val decoded = json.decodeFromString(McpConnectionConfig.serializer(), encoded) + assertEquals(McpTransportType.STREAMABLE_HTTP, decoded.transport) + assertEquals(McpHttpMode.AUTO, decoded.httpMode) + assertEquals(McpSamplingMode.MOCK, decoded.samplingMode) + assertEquals("file:///tmp", decoded.roots.single().uri) + } + + @Test + fun content_variants_round_trip() { + val contents = listOf( + McpContent(type = "text", text = "hello"), + McpContent(type = "image", data = "AAA", mimeType = "image/png"), + McpContent(type = "audio", data = "BBB", mimeType = "audio/wav"), + McpContent(type = "resource_link", uri = "file:///a", name = "a"), + McpContent(type = "resource", resource = JsonObject(mapOf("uri" to JsonPrimitive("file:///b")))), + ) + contents.forEach { original -> + val decoded = json.decodeFromString(McpContent.serializer(), json.encodeToString(McpContent.serializer(), original)) + assertEquals(original.type, decoded.type) + } + } +} diff --git a/core-model/src/commonTest/kotlin/com/reqlab/core/model/McpOAuthModelsTest.kt b/core-model/src/commonTest/kotlin/com/reqlab/core/model/McpOAuthModelsTest.kt new file mode 100644 index 0000000..7591560 --- /dev/null +++ b/core-model/src/commonTest/kotlin/com/reqlab/core/model/McpOAuthModelsTest.kt @@ -0,0 +1,70 @@ +package com.reqlab.core.model + +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class McpOAuthModelsTest { + + private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } + + @Test + fun config_round_trip_and_secret_fields() { + val config = McpOAuthConfig( + authServerUrl = "http://localhost:8080", + clientId = "abc", + scopes = listOf("mcp"), + useDcr = true, + accessToken = "tok", + refreshToken = "ref", + ) + val decoded = json.decodeFromString(McpOAuthConfig.serializer(), json.encodeToString(McpOAuthConfig.serializer(), config)) + assertEquals("abc", decoded.clientId) + assertEquals("tok", decoded.accessToken) + assertEquals(McpOAuthGrantType.AUTHORIZATION_CODE, decoded.grantType) + } + + @Test + fun metadata_snake_case_round_trip() { + val raw = """ + {"issuer":"http://localhost:8080", + "authorization_endpoint":"http://localhost:8080/oauth/authorize", + "token_endpoint":"http://localhost:8080/oauth/token", + "registration_endpoint":"http://localhost:8080/oauth/register", + "code_challenge_methods_supported":["S256"], + "extra":true} + """.trimIndent() + val decoded = json.decodeFromString(OAuthAuthorizationServerMetadata.serializer(), raw) + assertEquals("http://localhost:8080/oauth/token", decoded.tokenEndpoint) + assertEquals(listOf("S256"), decoded.codeChallengeMethodsSupported) + } + + @Test + fun token_response_and_error() { + val token = json.decodeFromString( + OAuthTokenResponse.serializer(), + """{"access_token":"a","token_type":"Bearer","expires_in":3600,"refresh_token":"r"}""", + ) + assertEquals("a", token.accessToken) + assertEquals(3600, token.expiresIn) + val error = json.decodeFromString(OAuthError.serializer(), """{"error":"invalid_grant","error_description":"bad"}""") + assertEquals("invalid_grant", error.error) + assertEquals("bad", error.errorDescription) + } + + @Test + fun dcr_defaults() { + val req = OAuthDynamicClientRegistrationRequest(redirectUris = listOf("http://127.0.0.1:8099/callback")) + val encoded = json.encodeToString(OAuthDynamicClientRegistrationRequest.serializer(), req) + assertTrue(encoded.contains("client_name")) + assertTrue(encoded.contains("authorization_code")) + val resp = json.decodeFromString( + OAuthDynamicClientRegistrationResponse.serializer(), + """{"client_id":"cid","client_secret":null}""", + ) + assertEquals("cid", resp.clientId) + assertNull(resp.clientSecret) + } +} diff --git a/core-network/src/androidMain/kotlin/com/reqlab/core/network/mcp/McpPlatform.android.kt b/core-network/src/androidMain/kotlin/com/reqlab/core/network/mcp/McpPlatform.android.kt new file mode 100644 index 0000000..465e19e --- /dev/null +++ b/core-network/src/androidMain/kotlin/com/reqlab/core/network/mcp/McpPlatform.android.kt @@ -0,0 +1,20 @@ +package com.reqlab.core.network.mcp + +import com.reqlab.core.model.McpConnectionConfig +import java.security.SecureRandom + +actual val mcpStdioSupported: Boolean = false + +actual fun createStdioTransport(config: McpConnectionConfig): McpTransport = + throw UnsupportedOperationException("MCP stdio is not supported on Android") + +actual fun mcpSecureRandomBytes(size: Int): ByteArray { + val bytes = ByteArray(size) + SecureRandom().nextBytes(bytes) + return bytes +} + +actual val mcpInteractiveOAuthSupported: Boolean = false + +actual suspend fun mcpOpenAuthorizeUrlAndAwaitCode(authorizeUrl: String, redirectPort: Int): String = + throw UnsupportedOperationException("Interactive OAuth is not supported on Android in v1") diff --git a/core-network/src/commonMain/kotlin/com/reqlab/core/network/StreamSupport.kt b/core-network/src/commonMain/kotlin/com/reqlab/core/network/StreamSupport.kt index 97077ff..d496e04 100644 --- a/core-network/src/commonMain/kotlin/com/reqlab/core/network/StreamSupport.kt +++ b/core-network/src/commonMain/kotlin/com/reqlab/core/network/StreamSupport.kt @@ -39,6 +39,7 @@ fun requestLooksLikeStreaming(request: RequestDefinition): Boolean { data class SseEvent( val data: String, val eventType: String = "message", + val id: String? = null, val isDone: Boolean = false, ) @@ -55,6 +56,7 @@ data class SseEvent( */ class SseParser { private var eventType: String = "" + private var eventId: String? = null private val data = StringBuilder() fun feedLine(line: String): SseEvent? { @@ -65,27 +67,34 @@ class SseParser { eventType = stripOneLeadingSpace(normalized.removePrefix("event:")) return null } + normalized.startsWith("id:") -> { + eventId = stripOneLeadingSpace(normalized.removePrefix("id:")) + return null + } normalized.startsWith("data:") -> { val payload = stripOneLeadingSpace(normalized.removePrefix("data:")) if (data.isNotEmpty()) data.append('\n') data.append(payload) return null } - normalized.isBlank() && data.isNotEmpty() -> return dispatch() + normalized.isBlank() && (data.isNotEmpty() || eventType.isNotEmpty() || eventId != null) -> return dispatch() else -> return null } } - fun flush(): SseEvent? = if (data.isNotEmpty()) dispatch() else null + fun flush(): SseEvent? = if (data.isNotEmpty() || eventType.isNotEmpty() || eventId != null) dispatch() else null private fun dispatch(): SseEvent { val payload = data.toString() val type = eventType.ifEmpty { "message" } + val id = eventId data.clear() eventType = "" + eventId = null return SseEvent( data = payload, eventType = type, + id = id, isDone = payload.trim() == "[DONE]", ) } diff --git a/core-network/src/commonMain/kotlin/com/reqlab/core/network/mcp/LegacyHttpSseTransport.kt b/core-network/src/commonMain/kotlin/com/reqlab/core/network/mcp/LegacyHttpSseTransport.kt new file mode 100644 index 0000000..479951a --- /dev/null +++ b/core-network/src/commonMain/kotlin/com/reqlab/core/network/mcp/LegacyHttpSseTransport.kt @@ -0,0 +1,109 @@ +package com.reqlab.core.network.mcp + +import com.reqlab.core.model.JsonRpcEnvelope +import com.reqlab.core.model.MCP_PROTOCOL_VERSION_LEGACY +import com.reqlab.core.model.McpConnectionConfig +import com.reqlab.core.model.McpHttpMode +import com.reqlab.core.network.SseParser +import io.ktor.client.HttpClient +import io.ktor.client.request.accept +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsChannel +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.contentType +import io.ktor.utils.io.readUTF8Line +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.launch + +/** + * MCP 2024-11-05 HTTP+SSE transport: GET opens the SSE stream, first `endpoint` + * event provides the POST URL, responses arrive on the SSE stream. + */ +class LegacyHttpSseTransport( + private val httpClient: HttpClient, + private val config: McpConnectionConfig, + private val scope: CoroutineScope, +) : McpTransport { + private val _incoming = MutableSharedFlow(extraBufferCapacity = 256) + override val incoming: SharedFlow = _incoming + + override var sessionId: String? = null + private set + override var protocolVersion: String = MCP_PROTOCOL_VERSION_LEGACY + override var lastEventId: String? = null + private set + override var lastResponseHeaders: Map>? = null + private set + override val negotiatedHttpMode: McpHttpMode = McpHttpMode.LEGACY_2024_11_05 + + private var postUrl: String? = null + private var sseJob: Job? = null + private val endpointReady = CompletableDeferred() + + override suspend fun start() { + sseJob = scope.launch { openSse() } + postUrl = awaitWithWallClockTimeout(endpointReady, 15_000) { + McpTransportException("Timed out waiting for legacy SSE endpoint event") + } + } + + override suspend fun send(message: JsonRpcEnvelope) { + val target = postUrl ?: throw McpTransportException("Legacy SSE endpoint URL not ready") + val body = mcpJson.encodeToString(JsonRpcEnvelope.serializer(), message) + val response = httpClient.post(target) { + contentType(ContentType.Application.Json) + applyMcpHeaders(config.headers, config.auth, config.oauth, sessionId, protocolVersion, lastEventId) + setBody(body) + } + lastResponseHeaders = response.headers.entries().associate { it.key to it.value } + } + + override suspend fun close() { + sseJob?.cancel() + sseJob = null + postUrl = null + } + + private suspend fun openSse() { + val response = httpClient.get(config.url) { + accept(ContentType.Text.EventStream) + header(HttpHeaders.CacheControl, "no-cache") + applyMcpHeaders(config.headers, config.auth, config.oauth, sessionId, protocolVersion, lastEventId) + } + val channel = response.bodyAsChannel() + val parser = SseParser() + while (!channel.isClosedForRead) { + val line = channel.readUTF8Line() ?: break + val event = parser.feedLine(line) ?: continue + if (!event.id.isNullOrBlank()) lastEventId = event.id + if (event.eventType == "endpoint") { + val relative = event.data.trim() + val resolved = resolveEndpoint(config.url, relative) + postUrl = resolved + if (!endpointReady.isCompleted) endpointReady.complete(resolved) + } else { + parseJsonRpc(event.data)?.let { _incoming.emit(it) } + } + } + parser.flush()?.let { event -> + parseJsonRpc(event.data)?.let { _incoming.emit(it) } + } + } + + companion object { + fun resolveEndpoint(baseUrl: String, endpoint: String): String { + if (endpoint.startsWith("http://") || endpoint.startsWith("https://")) return endpoint + val slash = baseUrl.indexOf('/', baseUrl.indexOf("://") + 3) + val origin = if (slash < 0) baseUrl.trimEnd('/') else baseUrl.substring(0, slash) + return if (endpoint.startsWith("/")) origin + endpoint else "$origin/$endpoint" + } + } +} diff --git a/core-network/src/commonMain/kotlin/com/reqlab/core/network/mcp/McpClient.kt b/core-network/src/commonMain/kotlin/com/reqlab/core/network/mcp/McpClient.kt new file mode 100644 index 0000000..5f81d3e --- /dev/null +++ b/core-network/src/commonMain/kotlin/com/reqlab/core/network/mcp/McpClient.kt @@ -0,0 +1,419 @@ +package com.reqlab.core.network.mcp + +import com.reqlab.core.model.JsonRpcEnvelope +import com.reqlab.core.model.JsonRpcError +import com.reqlab.core.model.JsonRpcErrorCodes +import com.reqlab.core.model.MCP_PROTOCOL_VERSION +import com.reqlab.core.model.McpClientCapabilities +import com.reqlab.core.model.McpCompleteRequest +import com.reqlab.core.model.McpCompleteResult +import com.reqlab.core.model.McpConnectionConfig +import com.reqlab.core.model.McpConnectionState +import com.reqlab.core.model.McpCreateMessageRequest +import com.reqlab.core.model.McpCreateMessageResult +import com.reqlab.core.model.McpElicitAction +import com.reqlab.core.model.McpElicitRequest +import com.reqlab.core.model.McpElicitResult +import com.reqlab.core.model.McpGetPromptResult +import com.reqlab.core.model.McpHttpMode +import com.reqlab.core.model.McpImplementation +import com.reqlab.core.model.McpInitializeParams +import com.reqlab.core.model.McpInitializeResult +import com.reqlab.core.model.McpListPromptsResult +import com.reqlab.core.model.McpListResourceTemplatesResult +import com.reqlab.core.model.McpListResourcesResult +import com.reqlab.core.model.McpListRootsResult +import com.reqlab.core.model.McpListToolsResult +import com.reqlab.core.model.McpLogEntry +import com.reqlab.core.model.McpLogEntryKind +import com.reqlab.core.model.McpLogLevel +import com.reqlab.core.model.McpOAuthDebugEntry +import com.reqlab.core.model.McpProgressNotification +import com.reqlab.core.model.McpPrompt +import com.reqlab.core.model.McpReadResourceResult +import com.reqlab.core.model.McpResource +import com.reqlab.core.model.McpResourceTemplate +import com.reqlab.core.model.McpRoot +import com.reqlab.core.model.McpTool +import com.reqlab.core.model.McpToolResult +import com.reqlab.core.model.McpTransportType +import com.reqlab.core.model.jsonRpcId +import com.reqlab.core.network.createPlatformHttpClient +import io.ktor.client.HttpClient +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.plugins.HttpTimeoutConfig +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.datetime.Clock +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +data class McpClientHandlers( + var onSampling: suspend (McpCreateMessageRequest) -> McpCreateMessageResult = { + McpCreateMessageResult( + content = com.reqlab.core.model.McpContent(type = "text", text = "mock reply from ReqLab"), + ) + }, + var onRoots: suspend () -> List = { emptyList() }, + var onElicit: suspend (McpElicitRequest) -> McpElicitResult = { + McpElicitResult(action = McpElicitAction.ACCEPT, content = JsonObject(emptyMap())) + }, +) + +class McpClient( + private val scope: CoroutineScope, + private val httpClient: HttpClient = defaultMcpHttpClient(), + val handlers: McpClientHandlers = McpClientHandlers(), + private val callTimeoutMs: Long = 30_000L, + private val oauthClient: McpOAuthClient? = null, + private val stdioFactory: (McpConnectionConfig) -> McpTransport = { createStdioTransport(it) }, +) { + private val pending = linkedMapOf>() + private val pendingMutex = Mutex() + private var nextId = 1L + private var transport: McpTransport? = null + private var inboundJob: Job? = null + var config: McpConnectionConfig = McpConnectionConfig() + private set + var initializeResult: McpInitializeResult? = null + private set + val oauthDebug: List + get() = oauthClient?.debugLog.orEmpty() + + private val _state = MutableStateFlow(McpConnectionState.DISCONNECTED) + val state: StateFlow = _state + private val _logs = MutableSharedFlow(replay = 64, extraBufferCapacity = 256) + val logs: SharedFlow = _logs + private val _notifications = MutableSharedFlow(extraBufferCapacity = 256) + val notifications: SharedFlow = _notifications + private val _progress = MutableSharedFlow(extraBufferCapacity = 32) + val progress: SharedFlow = _progress + + val negotiatedHttpMode: McpHttpMode? get() = transport?.negotiatedHttpMode + val sessionId: String? get() = transport?.sessionId + val protocolVersion: String get() = transport?.protocolVersion ?: MCP_PROTOCOL_VERSION + val lastResponseHeaders: Map>? get() = transport?.lastResponseHeaders + + /** Exact JSON-RPC frame from the most recent inbound response (used by the Response pane). */ + var lastReceivedPayload: String? = null + private set + + suspend fun connect( + connection: McpConnectionConfig, + variableLayers: List> = emptyList(), + oauthRetry: Boolean = true, + ): McpInitializeResult { + disconnect() + config = resolveMcpConfig(connection, variableLayers) + handlers.onRoots = { config.roots } + _state.value = McpConnectionState.CONNECTING + log(McpLogEntryKind.STATE, "Connecting via ${config.transport} ${config.httpMode}") + try { + val created = createTransport(config) + transport = created + inboundJob = scope.launch { created.incoming.collect { routeInbound(it) } } + created.start() + val result = handshake(created) + created.onHandshakeComplete() + initializeResult = result + _state.value = McpConnectionState.CONNECTED + log(McpLogEntryKind.STATE, "Connected ${result.serverInfo.name} ${result.protocolVersion}") + return result + } catch (e: McpUnauthorizedException) { + val oauth = oauthClient + val oauthConfig = config.oauth + if (oauthRetry && oauth != null && oauthConfig != null) { + val updated = oauth.authorize(config.url, oauthConfig, e.wwwAuthenticate) + config = config.copy(oauth = updated) + return connect(config, emptyList(), oauthRetry = false) + } + _state.value = McpConnectionState.ERROR + log(McpLogEntryKind.ERROR, e.message ?: "Unauthorized") + throw e + } catch (e: Exception) { + _state.value = McpConnectionState.ERROR + log(McpLogEntryKind.ERROR, e.message ?: e.toString()) + throw e + } + } + + suspend fun disconnect() { + failPending("Disconnected") + inboundJob?.cancel() + inboundJob = null + runCatching { transport?.close() } + transport = null + initializeResult = null + lastReceivedPayload = null + _state.value = McpConnectionState.DISCONNECTED + log(McpLogEntryKind.STATE, "Disconnected") + } + + suspend fun listTools(): List = paginate("tools/list") { cursor -> + val result = request("tools/list", cursorParams(cursor)) + result.tools to result.nextCursor + } + + suspend fun callTool(name: String, arguments: JsonElement? = null, progressToken: String? = null): McpToolResult { + val params = buildJsonObject { + put("name", name) + if (arguments != null) put("arguments", arguments) + if (progressToken != null) { + put("_meta", buildJsonObject { put("progressToken", progressToken) }) + } + } + return request("tools/call", params) + } + + suspend fun listResources(): List = paginate("resources/list") { cursor -> + val result = request("resources/list", cursorParams(cursor)) + result.resources to result.nextCursor + } + + suspend fun listResourceTemplates(): List = paginate("resources/templates/list") { cursor -> + val result = request("resources/templates/list", cursorParams(cursor)) + result.resourceTemplates to result.nextCursor + } + + suspend fun readResource(uri: String): McpReadResourceResult = + request("resources/read", buildJsonObject { put("uri", uri) }) + + suspend fun subscribeResource(uri: String) { + val env = rpcCall("resources/subscribe", buildJsonObject { put("uri", uri) }) + env.error?.let { throw McpProtocolException("${it.code} ${it.message}") } + } + + suspend fun unsubscribeResource(uri: String) { + val env = rpcCall("resources/unsubscribe", buildJsonObject { put("uri", uri) }) + env.error?.let { throw McpProtocolException("${it.code} ${it.message}") } + } + + suspend fun listPrompts(): List = paginate("prompts/list") { cursor -> + val result = request("prompts/list", cursorParams(cursor)) + result.prompts to result.nextCursor + } + + suspend fun getPrompt(name: String, arguments: Map = emptyMap()): McpGetPromptResult { + val params = buildJsonObject { + put("name", name) + if (arguments.isNotEmpty()) { + put("arguments", buildJsonObject { arguments.forEach { (k, v) -> put(k, v) } }) + } + } + return request("prompts/get", params) + } + + suspend fun complete(ref: JsonObject, argumentName: String, argumentValue: String): McpCompleteResult { + val req = McpCompleteRequest(ref, com.reqlab.core.model.McpCompleteArgument(argumentName, argumentValue)) + return request("completion/complete", encodeParams(req)) + } + + suspend fun setLogLevel(level: McpLogLevel) { + rpcCall("logging/setLevel", buildJsonObject { put("level", level.name.lowercase()) }) + } + + suspend fun cancel(id: String, reason: String? = null) { + val params = buildJsonObject { + put("requestId", id) + if (reason != null) put("reason", reason) + } + notify("notifications/cancelled", params) + pendingMutex.withLock { + pending.remove(id)?.completeExceptionally(kotlinx.coroutines.CancellationException(reason ?: "cancelled")) + } + } + + suspend fun notifyRootsChanged() { + notify("notifications/roots/list_changed", null) + } + + private suspend fun handshake(active: McpTransport): McpInitializeResult { + val params = McpInitializeParams( + protocolVersion = MCP_PROTOCOL_VERSION, + capabilities = McpClientCapabilities(), + clientInfo = McpImplementation(name = "ReqLab", version = "1.18.0"), + ) + val result = try { + request("initialize", encodeParams(params)) + } catch (e: McpLegacyHintException) { + if (config.httpMode != McpHttpMode.AUTO) throw e + log(McpLogEntryKind.STATE, "Auto-detect falling back to legacy HTTP+SSE") + inboundJob?.cancel() + runCatching { active.close() } + val legacy = LegacyHttpSseTransport(httpClient, config.copy(url = config.url), scope) + transport = legacy + legacy.start() + inboundJob = scope.launch { legacy.incoming.collect { routeInbound(it) } } + request("initialize", encodeParams(params)) + } + transport?.protocolVersion = result.protocolVersion.ifBlank { MCP_PROTOCOL_VERSION } + notify("notifications/initialized", null) + return result + } + + private suspend fun createTransport(cfg: McpConnectionConfig): McpTransport { + return when (cfg.transport) { + McpTransportType.STDIO -> stdioFactory(cfg) + McpTransportType.STREAMABLE_HTTP -> when (cfg.httpMode) { + McpHttpMode.LEGACY_2024_11_05 -> LegacyHttpSseTransport(httpClient, cfg, scope) + else -> StreamableHttpTransport(httpClient, cfg, scope) + } + } + } + + private suspend inline fun request(method: String, params: JsonElement?): T { + val envelope = rpcCall(method, params) + val error = envelope.error + if (error != null) { + throw McpProtocolException("${error.code} ${error.message}") + } + return decodeResult(envelope.result) + } + + private suspend fun rpcCall(method: String, params: JsonElement?): JsonRpcEnvelope { + val active = transport ?: throw McpProtocolException("Not connected") + val idValue = pendingMutex.withLock { + val id = nextId++ + val key = id.toString() + pending[key] = CompletableDeferred() + id + } + val key = idValue.toString() + val message = JsonRpcEnvelope(id = jsonRpcId(idValue), method = method, params = params) + log(McpLogEntryKind.SENT, method, mcpJson.encodeToString(JsonRpcEnvelope.serializer(), message), method, key) + try { + active.send(message) + } catch (e: McpSessionExpiredException) { + log(McpLogEntryKind.STATE, "Session expired; re-initialize") + handshake(active) + active.send(message) + } + val deferred = pendingMutex.withLock { pending[key] } ?: throw McpProtocolException("Lost pending id $key") + return try { + awaitWithWallClockTimeout(deferred, callTimeoutMs) { + McpTimeoutException("Timed out waiting for $method") + } + } catch (e: McpTimeoutException) { + pendingMutex.withLock { pending.remove(key) } + throw e + } + } + + private suspend fun notify(method: String, params: JsonElement?) { + val active = transport ?: throw McpProtocolException("Not connected") + val message = JsonRpcEnvelope(method = method, params = params) + log(McpLogEntryKind.SENT, method, mcpJson.encodeToString(JsonRpcEnvelope.serializer(), message), method, null) + active.send(message) + } + + private suspend fun routeInbound(message: JsonRpcEnvelope) { + val encoded = mcpJson.encodeToString(JsonRpcEnvelope.serializer(), message) + when { + message.isResponse() -> { + val key = message.idKey() ?: return + lastReceivedPayload = encoded + log(McpLogEntryKind.RECEIVED, "response $key", encoded, null, key) + val deferred = pendingMutex.withLock { pending.remove(key) } + deferred?.complete(message) + } + message.isRequest() -> { + log(McpLogEntryKind.RECEIVED, message.method.orEmpty(), encoded, message.method, message.idKey()) + handleServerRequest(message) + } + message.isNotification() -> { + log(McpLogEntryKind.NOTIFICATION, message.method.orEmpty(), encoded, message.method, null) + _notifications.emit(message) + if (message.method == "notifications/progress") { + runCatching { + decodeResult(message.params) + }.getOrNull()?.let { _progress.emit(it) } + } + } + } + } + + private suspend fun handleServerRequest(message: JsonRpcEnvelope) { + val id = message.id ?: return + try { + val result: JsonElement = when (message.method) { + "sampling/createMessage" -> { + val req = decodeResult(message.params) + encodeParams(handlers.onSampling(req)) + } + "roots/list" -> encodeParams(McpListRootsResult(handlers.onRoots())) + "elicitation/create" -> { + val req = decodeResult(message.params) + encodeParams(handlers.onElicit(req)) + } + "ping" -> JsonObject(emptyMap()) + else -> { + sendError(id, JsonRpcError(JsonRpcErrorCodes.METHOD_NOT_FOUND, "Method not found")) + return + } + } + val response = JsonRpcEnvelope(id = id, result = result) + transport?.send(response) + log(McpLogEntryKind.SENT, "result ${message.method}", mcpJson.encodeToString(JsonRpcEnvelope.serializer(), response), message.method, message.idKey()) + } catch (e: Exception) { + sendError(id, JsonRpcError(JsonRpcErrorCodes.INTERNAL_ERROR, e.message ?: "internal error")) + } + } + + private suspend fun sendError(id: JsonElement, error: JsonRpcError) { + val response = JsonRpcEnvelope(id = id, error = error) + transport?.send(response) + } + + private suspend fun failPending(reason: String) { + pendingMutex.withLock { + pending.values.forEach { it.completeExceptionally(McpProtocolException(reason)) } + pending.clear() + } + } + + private suspend fun paginate(label: String, page: suspend (String?) -> Pair, String?>): List { + val all = mutableListOf() + var cursor: String? = null + do { + val (items, next) = page(cursor) + all += items + cursor = next + } while (!cursor.isNullOrBlank()) + log(McpLogEntryKind.STATE, "$label returned ${all.size}") + return all + } + + private fun cursorParams(cursor: String?): JsonElement? = + if (cursor.isNullOrBlank()) null else buildJsonObject { put("cursor", cursor) } + + private fun log(kind: McpLogEntryKind, summary: String, payload: String? = null, method: String? = null, id: String? = null) { + _logs.tryEmit( + McpLogEntry( + timestampEpochMillis = Clock.System.now().toEpochMilliseconds(), + kind = kind, + summary = summary, + payload = payload, + method = method, + id = id, + ) + ) + } +} + +internal fun defaultMcpHttpClient(): HttpClient = createPlatformHttpClient { + install(HttpTimeout) { + requestTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS + socketTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS + } + expectSuccess = false +} diff --git a/core-network/src/commonMain/kotlin/com/reqlab/core/network/mcp/McpCrypto.kt b/core-network/src/commonMain/kotlin/com/reqlab/core/network/mcp/McpCrypto.kt new file mode 100644 index 0000000..ba06040 --- /dev/null +++ b/core-network/src/commonMain/kotlin/com/reqlab/core/network/mcp/McpCrypto.kt @@ -0,0 +1,114 @@ +package com.reqlab.core.network.mcp + +/** + * SHA-256 digest used for PKCE S256. Implemented in common code so wasm/js/ios + * don't need a platform crypto actual for hashing. + */ +internal fun sha256(input: ByteArray): ByteArray { + val k = intArrayOf( + 0x428a2f98, 0x71374491, -0x4a3f0431, -0x164a245b, 0x3956c25b, 0x59f111f1, -0x6dc07d5c, -0x54e3a12b, + -0x27f85568, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, -0x7f214e02, -0x6423f959, -0x3e640e8c, + -0x1b64963f, -0x1041b87a, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + -0x67c1aeae, -0x57ce3993, -0x4ffcd838, -0x40a68039, -0x391ff40d, -0x2a586eb9, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, -0x7e3d36d2, -0x6d8dd37b, + -0x5d40175f, -0x57e599b5, -0x3db47490, -0x3893ae5d, -0x2e6d17e7, -0x2966f9dc, -0xbf1ca7b, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, -0x7b3787ec, -0x7338fdf8, -0x6f410006, -0x5baf9315, -0x41065c09, -0x398e870e, + ) + var h0 = 0x6a09e667 + var h1 = -0x4498517b + var h2 = 0x3c6ef372 + var h3 = -0x5ab00ac6 + var h4 = 0x510e527f + var h5 = -0x64fa9774 + var h6 = 0x1f83d9ab + var h7 = 0x5be0cd19 + + val bitLen = input.size.toLong() * 8 + val paddedSize = ((input.size + 9 + 63) / 64) * 64 + val padded = ByteArray(paddedSize) + input.copyInto(padded) + padded[input.size] = 0x80.toByte() + for (i in 0 until 8) { + padded[paddedSize - 1 - i] = ((bitLen ushr (8 * i)) and 0xFF).toByte() + } + + val w = IntArray(64) + var offset = 0 + while (offset < paddedSize) { + for (i in 0 until 16) { + val j = offset + i * 4 + w[i] = ((padded[j].toInt() and 0xFF) shl 24) or + ((padded[j + 1].toInt() and 0xFF) shl 16) or + ((padded[j + 2].toInt() and 0xFF) shl 8) or + (padded[j + 3].toInt() and 0xFF) + } + for (i in 16 until 64) { + val s0 = rotr(w[i - 15], 7) xor rotr(w[i - 15], 18) xor (w[i - 15] ushr 3) + val s1 = rotr(w[i - 2], 17) xor rotr(w[i - 2], 19) xor (w[i - 2] ushr 10) + w[i] = w[i - 16] + s0 + w[i - 7] + s1 + } + var a = h0 + var b = h1 + var c = h2 + var d = h3 + var e = h4 + var f = h5 + var g = h6 + var h = h7 + for (i in 0 until 64) { + val s1 = rotr(e, 6) xor rotr(e, 11) xor rotr(e, 25) + val ch = (e and f) xor (e.inv() and g) + val temp1 = h + s1 + ch + k[i] + w[i] + val s0 = rotr(a, 2) xor rotr(a, 13) xor rotr(a, 22) + val maj = (a and b) xor (a and c) xor (b and c) + val temp2 = s0 + maj + h = g + g = f + f = e + e = d + temp1 + d = c + c = b + b = a + a = temp1 + temp2 + } + h0 += a + h1 += b + h2 += c + h3 += d + h4 += e + h5 += f + h6 += g + h7 += h + offset += 64 + } + + val out = ByteArray(32) + fun write(value: Int, index: Int) { + out[index] = (value ushr 24).toByte() + out[index + 1] = (value ushr 16).toByte() + out[index + 2] = (value ushr 8).toByte() + out[index + 3] = value.toByte() + } + write(h0, 0) + write(h1, 4) + write(h2, 8) + write(h3, 12) + write(h4, 16) + write(h5, 20) + write(h6, 24) + write(h7, 28) + return out +} + +private fun rotr(value: Int, bits: Int): Int = (value ushr bits) or (value shl (32 - bits)) + +expect fun mcpSecureRandomBytes(size: Int): ByteArray + +expect val mcpStdioSupported: Boolean + +expect fun createStdioTransport(config: com.reqlab.core.model.McpConnectionConfig): McpTransport + +expect val mcpInteractiveOAuthSupported: Boolean + +expect suspend fun mcpOpenAuthorizeUrlAndAwaitCode(authorizeUrl: String, redirectPort: Int): String diff --git a/core-network/src/commonMain/kotlin/com/reqlab/core/network/mcp/McpJson.kt b/core-network/src/commonMain/kotlin/com/reqlab/core/network/mcp/McpJson.kt new file mode 100644 index 0000000..00b66a6 --- /dev/null +++ b/core-network/src/commonMain/kotlin/com/reqlab/core/network/mcp/McpJson.kt @@ -0,0 +1,128 @@ +package com.reqlab.core.network.mcp + +import com.reqlab.core.model.AuthConfig +import com.reqlab.core.model.AuthType +import com.reqlab.core.model.JsonRpcEnvelope +import com.reqlab.core.model.KeyValueEntry +import com.reqlab.core.model.McpConnectionConfig +import com.reqlab.core.model.McpOAuthConfig +import com.reqlab.core.network.VariableResolver +import io.ktor.client.request.HttpRequestBuilder +import io.ktor.client.request.header +import io.ktor.http.HttpHeaders +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement + +internal val mcpJson = Json { + ignoreUnknownKeys = true + encodeDefaults = true + explicitNulls = false + isLenient = true +} + +internal fun resolveMcpConfig( + config: McpConnectionConfig, + variableLayers: List>, +): McpConnectionConfig { + fun r(value: String) = VariableResolver.resolve(value, variableLayers) + val oauth = config.oauth + return config.copy( + url = r(config.url), + headers = config.headers.map { it.copy(key = r(it.key), value = r(it.value)) }, + auth = config.auth.copy(params = config.auth.params.mapValues { r(it.value) }), + oauth = oauth?.copy( + authServerUrl = oauth.authServerUrl?.let(::r), + clientId = oauth.clientId?.let(::r), + clientSecret = oauth.clientSecret?.let(::r), + accessToken = oauth.accessToken?.let(::r), + refreshToken = oauth.refreshToken?.let(::r), + resource = oauth.resource?.let(::r), + ), + command = r(config.command), + args = config.args.map(::r), + env = config.env.mapValues { r(it.value) }, + workingDir = config.workingDir?.let(::r), + samplingForwardUrl = config.samplingForwardUrl?.let(::r), + ) +} + +internal fun HttpRequestBuilder.applyMcpHeaders( + headers: List, + auth: AuthConfig, + oauth: McpOAuthConfig?, + sessionId: String?, + protocolVersion: String?, + lastEventId: String? = null, +) { + header(HttpHeaders.Accept, "application/json, text/event-stream") + header("Accept-Language", "en") + headers.filter { it.enabled && it.key.isNotBlank() }.forEach { header(it.key, it.value) } + applyAuth(auth, oauth) + if (!sessionId.isNullOrBlank()) header("Mcp-Session-Id", sessionId) + if (!protocolVersion.isNullOrBlank()) header("MCP-Protocol-Version", protocolVersion) + if (!lastEventId.isNullOrBlank()) header("Last-Event-ID", lastEventId) +} + +internal fun HttpRequestBuilder.applyAuth(auth: AuthConfig, oauth: McpOAuthConfig?) { + val oauthToken = oauth?.accessToken + if (!oauthToken.isNullOrBlank()) { + header(HttpHeaders.Authorization, "Bearer $oauthToken") + return + } + when (auth.type) { + AuthType.NONE -> Unit + AuthType.BASIC -> { + val username = auth.params["username"].orEmpty() + val password = auth.params["password"].orEmpty() + header(HttpHeaders.Authorization, "Basic ${"$username:$password".encodeToByteArray().mcpBase64()}") + } + AuthType.BEARER, AuthType.JWT, AuthType.OAUTH2 -> { + val token = auth.params["token"] ?: auth.params["accessToken"].orEmpty() + if (token.isNotBlank()) header(HttpHeaders.Authorization, "Bearer $token") + } + AuthType.API_KEY -> { + val key = auth.params["key"].orEmpty() + val value = auth.params["value"].orEmpty() + if (key.isNotBlank()) header(key, value) + } + } +} + +internal fun ByteArray.mcpBase64(urlSafe: Boolean = false, padding: Boolean = true): String { + val alphabet = if (urlSafe) { + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" + } else { + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" + } + val result = StringBuilder((size + 2) / 3 * 4) + var i = 0 + while (i < size) { + val b0 = this[i].toInt() and 0xFF + val b1 = if (i + 1 < size) this[i + 1].toInt() and 0xFF else -1 + val b2 = if (i + 2 < size) this[i + 2].toInt() and 0xFF else -1 + result.append(alphabet[b0 ushr 2]) + result.append(alphabet[((b0 and 0x03) shl 4) or (if (b1 >= 0) b1 ushr 4 else 0)]) + if (b1 >= 0) { + result.append(alphabet[((b1 and 0x0F) shl 2) or (if (b2 >= 0) b2 ushr 6 else 0)]) + } else if (padding) result.append('=') else { /* skip */ } + if (b2 >= 0) { + result.append(alphabet[b2 and 0x3F]) + } else if (padding) result.append('=') else { /* skip */ } + i += 3 + } + return result.toString() +} + +internal fun parseJsonRpc(text: String): JsonRpcEnvelope? { + val trimmed = text.trim() + if (trimmed.isEmpty() || trimmed == "[DONE]") return null + return runCatching { mcpJson.decodeFromString(JsonRpcEnvelope.serializer(), trimmed) }.getOrNull() +} + +internal inline fun decodeResult(element: JsonElement?): T { + requireNotNull(element) { "Missing JSON-RPC result" } + return mcpJson.decodeFromJsonElement(kotlinx.serialization.serializer(), element) +} + +internal inline fun encodeParams(value: T): JsonElement = + mcpJson.encodeToJsonElement(kotlinx.serialization.serializer(), value) diff --git a/core-network/src/commonMain/kotlin/com/reqlab/core/network/mcp/McpOAuth.kt b/core-network/src/commonMain/kotlin/com/reqlab/core/network/mcp/McpOAuth.kt new file mode 100644 index 0000000..1abfc63 --- /dev/null +++ b/core-network/src/commonMain/kotlin/com/reqlab/core/network/mcp/McpOAuth.kt @@ -0,0 +1,295 @@ +package com.reqlab.core.network.mcp + +import com.reqlab.core.model.McpOAuthConfig +import com.reqlab.core.model.McpOAuthDebugEntry +import com.reqlab.core.model.McpOAuthGrantType +import com.reqlab.core.model.McpOAuthPhase +import com.reqlab.core.model.OAuthAuthorizationServerMetadata +import com.reqlab.core.model.OAuthDynamicClientRegistrationRequest +import com.reqlab.core.model.OAuthDynamicClientRegistrationResponse +import com.reqlab.core.model.OAuthProtectedResourceMetadata +import com.reqlab.core.model.OAuthTokenResponse +import io.ktor.client.HttpClient +import io.ktor.client.request.forms.submitForm +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.Parameters +import io.ktor.http.contentType +import kotlinx.datetime.Clock + +class McpOAuthClient( + private val httpClient: HttpClient, + private val randomBytes: (Int) -> ByteArray = { mcpSecureRandomBytes(it) }, + private val openAuthorize: suspend (url: String, redirectPort: Int) -> String = + { url, port -> mcpOpenAuthorizeUrlAndAwaitCode(url, port) }, +) { + val debugLog = mutableListOf() + + fun pkceVerifier(): String = randomBytes(32).mcpBase64(urlSafe = true, padding = false) + + fun pkceChallengeS256(verifier: String): String = + sha256(verifier.encodeToByteArray()).mcpBase64(urlSafe = true, padding = false) + + suspend fun authorize( + resourceUrl: String, + config: McpOAuthConfig, + wwwAuthenticate: String? = null, + preissuedCode: String? = null, + preissuedVerifier: String? = null, + ): McpOAuthConfig { + debugLog.clear() + var working = config + val now = { Clock.System.now().toEpochMilliseconds() } + + if (working.grantType == McpOAuthGrantType.PASTE_TOKEN && !working.accessToken.isNullOrBlank()) { + return working + } + + val metadataUrl = parseResourceMetadataUrl(wwwAuthenticate) + ?: defaultProtectedResourceUrl(working.authServerUrl ?: resourceUrl) + val resourceMeta = fetchProtectedResource(metadataUrl) + val authServer = working.authServerUrl + ?: resourceMeta.authorizationServers.firstOrNull() + ?: originOf(resourceUrl) + val asMeta = fetchAuthorizationServer(authServer) + val redirectUri = working.redirectUri ?: "http://127.0.0.1:${working.redirectPort}/callback" + + if (working.useDcr && working.clientId.isNullOrBlank()) { + val registrationEndpoint = asMeta.registrationEndpoint + ?: throw McpProtocolException("Authorization server has no registration_endpoint") + val dcr = registerClient(registrationEndpoint, redirectUri, working.scopes) + working = working.copy(clientId = dcr.clientId, clientSecret = dcr.clientSecret ?: working.clientSecret) + record(McpOAuthPhase.DCR, now(), "POST $registrationEndpoint", "client_id=${dcr.clientId}", 201) + } + + val clientId = working.clientId ?: throw McpProtocolException("OAuth client_id is required") + val tokenEndpoint = asMeta.tokenEndpoint + ?: throw McpProtocolException("Authorization server has no token_endpoint") + + if (working.grantType == McpOAuthGrantType.CLIENT_CREDENTIALS) { + val token = exchangeToken( + tokenEndpoint, + clientId, + working.clientSecret, + Parameters.build { + append("grant_type", "client_credentials") + if (working.scopes.isNotEmpty()) append("scope", working.scopes.joinToString(" ")) + append("resource", working.resource ?: resourceUrl) + }, + McpOAuthPhase.TOKEN, + ) + return applyToken(working, token, now()) + } + + if (!working.refreshToken.isNullOrBlank() && working.grantType == McpOAuthGrantType.REFRESH_TOKEN) { + return refresh(working, tokenEndpoint, resourceUrl) + } + + val verifier = preissuedVerifier ?: pkceVerifier() + val challenge = pkceChallengeS256(verifier) + val code = preissuedCode ?: run { + val authorizeEndpoint = asMeta.authorizationEndpoint + ?: throw McpProtocolException("Authorization server has no authorization_endpoint") + val url = buildAuthorizeUrl( + authorizeEndpoint, clientId, redirectUri, working.scopes, challenge, working.resource ?: resourceUrl, + ) + record(McpOAuthPhase.AUTHORIZE, now(), url) + val captured = openAuthorize(url, working.redirectPort) + record(McpOAuthPhase.AUTHORIZE, now(), url, "code captured") + captured + } + + val token = exchangeToken( + tokenEndpoint, + clientId, + working.clientSecret, + Parameters.build { + append("grant_type", "authorization_code") + append("code", code) + append("redirect_uri", redirectUri) + append("code_verifier", verifier) + append("resource", working.resource ?: resourceUrl) + }, + McpOAuthPhase.TOKEN, + ) + return applyToken(working, token, now()) + } + + suspend fun refresh(config: McpOAuthConfig, tokenEndpoint: String, resourceUrl: String): McpOAuthConfig { + val refreshToken = config.refreshToken ?: throw McpProtocolException("No refresh_token") + val clientId = config.clientId ?: throw McpProtocolException("OAuth client_id is required") + val token = exchangeToken( + tokenEndpoint, + clientId, + config.clientSecret, + Parameters.build { + append("grant_type", "refresh_token") + append("refresh_token", refreshToken) + append("resource", config.resource ?: resourceUrl) + }, + McpOAuthPhase.REFRESH, + ) + return applyToken(config, token, Clock.System.now().toEpochMilliseconds()) + } + + private suspend fun fetchProtectedResource(url: String): OAuthProtectedResourceMetadata { + record(McpOAuthPhase.DISCOVERY, Clock.System.now().toEpochMilliseconds(), "GET $url") + val text = httpClient.get(url).bodyAsText() + val decoded = mcpJson.decodeFromString(OAuthProtectedResourceMetadata.serializer(), text) + record(McpOAuthPhase.DISCOVERY, Clock.System.now().toEpochMilliseconds(), "GET $url", text.take(400), 200) + return decoded + } + + private suspend fun fetchAuthorizationServer(authServer: String): OAuthAuthorizationServerMetadata { + val candidates = listOf( + "${authServer.trimEnd('/')}/.well-known/oauth-authorization-server", + "${authServer.trimEnd('/')}/.well-known/openid-configuration", + ) + var lastError: Exception? = null + for (url in candidates) { + try { + record(McpOAuthPhase.DISCOVERY, Clock.System.now().toEpochMilliseconds(), "GET $url") + val text = httpClient.get(url).bodyAsText() + val decoded = mcpJson.decodeFromString(OAuthAuthorizationServerMetadata.serializer(), text) + record(McpOAuthPhase.DISCOVERY, Clock.System.now().toEpochMilliseconds(), "GET $url", text.take(400), 200) + return decoded + } catch (e: Exception) { + lastError = e + record(McpOAuthPhase.DISCOVERY, Clock.System.now().toEpochMilliseconds(), "GET $url", error = e.message) + } + } + throw lastError ?: McpProtocolException("Unable to fetch authorization-server metadata") + } + + private suspend fun registerClient( + endpoint: String, + redirectUri: String, + scopes: List, + ): OAuthDynamicClientRegistrationResponse { + val request = OAuthDynamicClientRegistrationRequest( + redirectUris = listOf(redirectUri), + scope = scopes.joinToString(" ").ifBlank { null }, + ) + val response = httpClient.post(endpoint) { + contentType(ContentType.Application.Json) + setBody(mcpJson.encodeToString(OAuthDynamicClientRegistrationRequest.serializer(), request)) + } + val text = response.bodyAsText() + return mcpJson.decodeFromString(OAuthDynamicClientRegistrationResponse.serializer(), text) + } + + private suspend fun exchangeToken( + tokenEndpoint: String, + clientId: String, + clientSecret: String?, + params: Parameters, + phase: McpOAuthPhase, + ): OAuthTokenResponse { + record(phase, Clock.System.now().toEpochMilliseconds(), "POST $tokenEndpoint") + val response = httpClient.submitForm(tokenEndpoint, formParameters = Parameters.build { + appendAll(params) + append("client_id", clientId) + if (!clientSecret.isNullOrBlank()) append("client_secret", clientSecret) + }) { + header(HttpHeaders.Accept, "application/json") + } + val text = response.bodyAsText() + if (response.status.value >= 400) { + record(phase, Clock.System.now().toEpochMilliseconds(), "POST $tokenEndpoint", text.take(400), response.status.value, text) + throw McpProtocolException("Token endpoint failed: ${response.status} $text") + } + record(phase, Clock.System.now().toEpochMilliseconds(), "POST $tokenEndpoint", "token issued", response.status.value) + return mcpJson.decodeFromString(OAuthTokenResponse.serializer(), text) + } + + private fun applyToken(config: McpOAuthConfig, token: OAuthTokenResponse, now: Long): McpOAuthConfig { + val expiresAt = token.expiresIn?.let { now + it * 1000 } + return config.copy( + accessToken = token.accessToken, + refreshToken = token.refreshToken ?: config.refreshToken, + tokenType = token.tokenType, + expiresAtEpochMillis = expiresAt, + ) + } + + private fun record( + phase: McpOAuthPhase, + at: Long, + request: String, + response: String? = null, + status: Int? = null, + error: String? = null, + ) { + debugLog += McpOAuthDebugEntry(phase, at, request, response, status, error) + } + + companion object { + fun parseResourceMetadataUrl(wwwAuthenticate: String?): String? { + if (wwwAuthenticate.isNullOrBlank()) return null + val marker = "resource_metadata" + val idx = wwwAuthenticate.indexOf(marker, ignoreCase = true) + if (idx < 0) return null + var i = idx + marker.length + if (wwwAuthenticate.startsWith("_uri", i, ignoreCase = true)) i += 4 + while (i < wwwAuthenticate.length && wwwAuthenticate[i].isWhitespace()) i++ + if (i >= wwwAuthenticate.length || wwwAuthenticate[i] != '=') return null + i++ + while (i < wwwAuthenticate.length && wwwAuthenticate[i].isWhitespace()) i++ + if (i >= wwwAuthenticate.length) return null + return if (wwwAuthenticate[i] == '"') { + val end = wwwAuthenticate.indexOf('"', i + 1) + if (end < 0) null else wwwAuthenticate.substring(i + 1, end) + } else { + val end = wwwAuthenticate.indexOfAny(charArrayOf(',', ' '), i).let { + if (it < 0) wwwAuthenticate.length else it + } + wwwAuthenticate.substring(i, end).trim().ifBlank { null } + } + } + + fun defaultProtectedResourceUrl(base: String): String { + val origin = originOf(base) + return "$origin/.well-known/oauth-protected-resource" + } + + fun originOf(url: String): String { + val slash = url.indexOf('/', url.indexOf("://").let { if (it < 0) 0 else it + 3 }) + return if (slash < 0) url.trimEnd('/') else url.substring(0, slash) + } + + fun buildAuthorizeUrl( + endpoint: String, + clientId: String, + redirectUri: String, + scopes: List, + challenge: String, + resource: String, + ): String { + val params = buildString { + append("response_type=code") + append("&client_id=").append(encodeQuery(clientId)) + append("&redirect_uri=").append(encodeQuery(redirectUri)) + append("&code_challenge=").append(encodeQuery(challenge)) + append("&code_challenge_method=S256") + if (scopes.isNotEmpty()) append("&scope=").append(encodeQuery(scopes.joinToString(" "))) + append("&resource=").append(encodeQuery(resource)) + } + return if (endpoint.contains('?')) "$endpoint&$params" else "$endpoint?$params" + } + + private fun encodeQuery(value: String): String = buildString { + value.forEach { c -> + when { + c.isLetterOrDigit() || c in "-._~" -> append(c) + c == ' ' -> append("%20") + else -> append('%').append(c.code.toString(16).uppercase().padStart(2, '0')) + } + } + } + } +} diff --git a/core-network/src/commonMain/kotlin/com/reqlab/core/network/mcp/McpTransport.kt b/core-network/src/commonMain/kotlin/com/reqlab/core/network/mcp/McpTransport.kt new file mode 100644 index 0000000..815c203 --- /dev/null +++ b/core-network/src/commonMain/kotlin/com/reqlab/core/network/mcp/McpTransport.kt @@ -0,0 +1,60 @@ +package com.reqlab.core.network.mcp + +import com.reqlab.core.model.JsonRpcEnvelope +import com.reqlab.core.model.McpHttpMode +import com.reqlab.core.model.McpOAuthConfig +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.launch + +/** + * Await [deferred] on the caller dispatcher while the timeout itself uses wall-clock + * [Dispatchers.Default]. `withTimeout` on a test dispatcher is virtual and can fire + * immediately; awaiting on Default deadlocks inbound jobs that stay on the test scheduler. + */ +internal suspend fun awaitWithWallClockTimeout( + deferred: CompletableDeferred, + timeoutMs: Long, + timeoutException: () -> Exception, +): T { + if (timeoutMs <= 0L) return deferred.await() + val watcher = CoroutineScope(Dispatchers.Default).launch { + delay(timeoutMs) + deferred.completeExceptionally(timeoutException()) + } + try { + return deferred.await() + } finally { + watcher.cancel() + } +} + +interface McpTransport { + val incoming: SharedFlow + val sessionId: String? + var protocolVersion: String + val lastEventId: String? + val negotiatedHttpMode: McpHttpMode? + + /** Response headers from the most recent HTTP exchange, or `null` for non-HTTP transports (stdio). */ + val lastResponseHeaders: Map>? get() = null + + suspend fun start() + suspend fun send(message: JsonRpcEnvelope) + suspend fun onHandshakeComplete() {} + suspend fun close() +} + +class McpSessionExpiredException(message: String = "MCP session expired") : Exception(message) +class McpUnauthorizedException( + message: String = "Unauthorized", + val wwwAuthenticate: String? = null, + val oauth: McpOAuthConfig? = null, +) : Exception(message) +class McpLegacyHintException(message: String = "Server appears to use legacy HTTP+SSE transport") : Exception(message) +class McpTransportException(message: String, cause: Throwable? = null) : Exception(message, cause) +class McpTimeoutException(message: String) : Exception(message) +class McpProtocolException(message: String) : Exception(message) diff --git a/core-network/src/commonMain/kotlin/com/reqlab/core/network/mcp/NdjsonStdioTransport.kt b/core-network/src/commonMain/kotlin/com/reqlab/core/network/mcp/NdjsonStdioTransport.kt new file mode 100644 index 0000000..1fff529 --- /dev/null +++ b/core-network/src/commonMain/kotlin/com/reqlab/core/network/mcp/NdjsonStdioTransport.kt @@ -0,0 +1,52 @@ +package com.reqlab.core.network.mcp + +import com.reqlab.core.model.JsonRpcEnvelope +import com.reqlab.core.model.MCP_PROTOCOL_VERSION +import com.reqlab.core.model.McpHttpMode +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +/** + * Newline-delimited JSON transport used by stdio and in-process tests. + */ +class NdjsonStdioTransport( + private val scope: CoroutineScope, + private val incomingLines: Channel, + private val writeLine: suspend (String) -> Unit, + private val onClose: suspend () -> Unit = {}, +) : McpTransport { + private val _incoming = MutableSharedFlow(extraBufferCapacity = 256) + override val incoming: SharedFlow = _incoming + override var sessionId: String? = null + override var protocolVersion: String = MCP_PROTOCOL_VERSION + override var lastEventId: String? = null + override val negotiatedHttpMode: McpHttpMode? = null + + private var reader: Job? = null + + override suspend fun start() { + reader = scope.launch { + for (line in incomingLines) { + if (!isActive) break + parseJsonRpc(line)?.let { _incoming.emit(it) } + } + } + } + + override suspend fun send(message: JsonRpcEnvelope) { + val compact = mcpJson.encodeToString(JsonRpcEnvelope.serializer(), message) + require('\n' !in compact) { "JSON-RPC stdio frames must not contain embedded newlines" } + writeLine(compact) + } + + override suspend fun close() { + reader?.cancel() + incomingLines.close() + onClose() + } +} diff --git a/core-network/src/commonMain/kotlin/com/reqlab/core/network/mcp/StreamableHttpTransport.kt b/core-network/src/commonMain/kotlin/com/reqlab/core/network/mcp/StreamableHttpTransport.kt new file mode 100644 index 0000000..a252f95 --- /dev/null +++ b/core-network/src/commonMain/kotlin/com/reqlab/core/network/mcp/StreamableHttpTransport.kt @@ -0,0 +1,162 @@ +package com.reqlab.core.network.mcp + +import com.reqlab.core.model.JsonRpcEnvelope +import com.reqlab.core.model.MCP_PROTOCOL_VERSION +import com.reqlab.core.model.McpConnectionConfig +import com.reqlab.core.model.McpHttpMode +import com.reqlab.core.network.SseParser +import com.reqlab.core.network.isSseContentType +import io.ktor.client.HttpClient +import io.ktor.client.request.accept +import io.ktor.client.request.delete +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.HttpResponse +import io.ktor.client.statement.bodyAsChannel +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +import io.ktor.utils.io.readUTF8Line +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +class StreamableHttpTransport( + private val httpClient: HttpClient, + private val config: McpConnectionConfig, + private val scope: CoroutineScope, +) : McpTransport { + private val _incoming = MutableSharedFlow(extraBufferCapacity = 256) + override val incoming: SharedFlow = _incoming + + override var sessionId: String? = null + private set + override var protocolVersion: String = MCP_PROTOCOL_VERSION + override var lastEventId: String? = null + private set + override var lastResponseHeaders: Map>? = null + private set + override val negotiatedHttpMode: McpHttpMode = McpHttpMode.STREAMABLE_2025_06_18 + + private val mutex = Mutex() + private var getJob: Job? = null + private var closed = false + + override suspend fun start() = Unit + + override suspend fun onHandshakeComplete() { + openGetStream() + } + + override suspend fun send(message: JsonRpcEnvelope) { + val body = mcpJson.encodeToString(JsonRpcEnvelope.serializer(), message) + val response = httpClient.post(config.url) { + contentType(ContentType.Application.Json) + applyMcpHeaders(config.headers, config.auth, config.oauth, sessionId, protocolVersion, lastEventId) + setBody(body) + } + handleResponse(response, isInitialize = message.method == "initialize") + } + + override suspend fun close() { + closed = true + getJob?.cancel() + getJob = null + val sid = sessionId + if (!sid.isNullOrBlank()) { + runCatching { + httpClient.delete(config.url) { + applyMcpHeaders(config.headers, config.auth, config.oauth, sid, protocolVersion) + } + } + } + sessionId = null + } + + private suspend fun handleResponse(response: HttpResponse, isInitialize: Boolean) { + lastResponseHeaders = response.headers.entries().associate { it.key to it.value } + val sid = response.headers["Mcp-Session-Id"] ?: response.headers["mcp-session-id"] + if (!sid.isNullOrBlank()) sessionId = sid + when (response.status) { + HttpStatusCode.Unauthorized -> throw McpUnauthorizedException( + wwwAuthenticate = response.headers[HttpHeaders.WWWAuthenticate], + oauth = config.oauth, + ) + HttpStatusCode.NotFound -> throw McpSessionExpiredException() + HttpStatusCode.MethodNotAllowed -> throw McpLegacyHintException() + HttpStatusCode.Accepted -> return + else -> Unit + } + if (response.status.value >= 400 && response.status != HttpStatusCode.NotFound) { + val text = runCatching { response.bodyAsText() }.getOrNull().orEmpty() + if (response.status == HttpStatusCode.BadRequest && text.contains("endpoint", ignoreCase = true)) { + throw McpLegacyHintException(text) + } + throw McpProtocolException("HTTP ${response.status.value}: $text") + } + val contentType = response.headers[HttpHeaders.ContentType] + if (isSseContentType(contentType)) { + drainSse(response) + } else { + val text = response.bodyAsText() + parseJsonRpc(text)?.let { _incoming.emit(it) } + } + if (isInitialize && contentType != null && isSseContentType(contentType) && sessionId == null) { + // still valid; stateless + } + } + + private suspend fun drainSse(response: HttpResponse) { + val channel = response.bodyAsChannel() + val parser = SseParser() + while (!channel.isClosedForRead) { + val line = channel.readUTF8Line() ?: break + val event = parser.feedLine(line) ?: continue + if (!event.id.isNullOrBlank()) lastEventId = event.id + parseJsonRpc(event.data)?.let { _incoming.emit(it) } + } + parser.flush()?.let { event -> + if (!event.id.isNullOrBlank()) lastEventId = event.id + parseJsonRpc(event.data)?.let { _incoming.emit(it) } + } + } + + private fun openGetStream() { + if (closed) return + getJob?.cancel() + getJob = scope.launch { + try { + val response = httpClient.get(config.url) { + accept(ContentType.Text.EventStream) + header(HttpHeaders.CacheControl, "no-cache") + applyMcpHeaders(config.headers, config.auth, config.oauth, sessionId, protocolVersion, lastEventId) + } + if (response.status == HttpStatusCode.MethodNotAllowed || + response.status == HttpStatusCode.NotFound + ) { + return@launch + } + if (response.status == HttpStatusCode.Unauthorized) return@launch + if (isSseContentType(response.headers[HttpHeaders.ContentType])) { + drainSse(response) + } + } catch (_: CancellationException) { + } catch (_: Exception) { + } + } + } + + suspend fun reconnectGetStream() { + mutex.withLock { openGetStream() } + } +} diff --git a/core-network/src/commonTest/kotlin/com/reqlab/core/network/SseParserTest.kt b/core-network/src/commonTest/kotlin/com/reqlab/core/network/SseParserTest.kt index 5d19e7a..c546009 100644 --- a/core-network/src/commonTest/kotlin/com/reqlab/core/network/SseParserTest.kt +++ b/core-network/src/commonTest/kotlin/com/reqlab/core/network/SseParserTest.kt @@ -42,6 +42,19 @@ class SseParserTest { assertEquals(1, events.size) assertEquals("hello", events[0].data) assertFalse(events[0].isDone) + assertNull(events[0].id) + } + + @Test + fun parses_event_id_for_resumability() { + val parser = SseParser() + var event: SseEvent? = null + listOf("id: 42", "event: message", "data: {\"ok\":true}", "").forEach { line -> + parser.feedLine(line)?.let { event = it } + } + assertEquals("42", event?.id) + assertEquals("message", event?.eventType) + assertEquals("""{"ok":true}""", event?.data) } @Test diff --git a/core-network/src/commonTest/kotlin/com/reqlab/core/network/mcp/JsonRpcFramingTest.kt b/core-network/src/commonTest/kotlin/com/reqlab/core/network/mcp/JsonRpcFramingTest.kt new file mode 100644 index 0000000..b0665e3 --- /dev/null +++ b/core-network/src/commonTest/kotlin/com/reqlab/core/network/mcp/JsonRpcFramingTest.kt @@ -0,0 +1,39 @@ +package com.reqlab.core.network.mcp + +import com.reqlab.core.model.JsonRpcEnvelope +import com.reqlab.core.model.jsonRpcId +import com.reqlab.core.model.jsonRpcIdKey +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class JsonRpcFramingTest { + @Test + fun classifies_request_response_and_notification() { + val request = JsonRpcEnvelope(id = jsonRpcId(1), method = "tools/list") + val notification = JsonRpcEnvelope(method = "notifications/initialized") + val response = JsonRpcEnvelope(id = jsonRpcId(1), result = buildJsonObject { put("ok", true) }) + assertTrue(request.isRequest()) + assertTrue(notification.isNotification()) + assertTrue(response.isResponse()) + assertFalse(notification.isRequest()) + assertNull(notification.idKey()) + assertEquals("1", jsonRpcIdKey(JsonPrimitive(1))) + assertEquals("1", jsonRpcIdKey(JsonPrimitive("1"))) + } + + @Test + fun compact_json_has_no_newlines() { + val encoded = mcpJson.encodeToString( + JsonRpcEnvelope.serializer(), + JsonRpcEnvelope(id = jsonRpcId(1), method = "ping"), + ) + assertFalse('\n' in encoded) + assertTrue(encoded.contains("\"method\":\"ping\"")) + } +} diff --git a/core-network/src/commonTest/kotlin/com/reqlab/core/network/mcp/LegacyHttpSseTransportTest.kt b/core-network/src/commonTest/kotlin/com/reqlab/core/network/mcp/LegacyHttpSseTransportTest.kt new file mode 100644 index 0000000..693a8e4 --- /dev/null +++ b/core-network/src/commonTest/kotlin/com/reqlab/core/network/mcp/LegacyHttpSseTransportTest.kt @@ -0,0 +1,61 @@ +package com.reqlab.core.network.mcp + +import com.reqlab.core.model.JsonRpcEnvelope +import com.reqlab.core.model.McpConnectionConfig +import com.reqlab.core.model.jsonRpcId +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpMethod +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import io.ktor.utils.io.ByteReadChannel +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class LegacyHttpSseTransportTest { + @Test + fun endpoint_event_then_post() = runTest { + var postedTo = "" + val sse = "event: endpoint\ndata: /mcp/messages?sessionId=abc\n\n" + + "id: 1\ndata: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"ok\":true}}\n\n" + val engine = MockEngine { request -> + if (request.method == HttpMethod.Get) { + respond( + ByteReadChannel(sse), + HttpStatusCode.OK, + headersOf(HttpHeaders.ContentType, "text/event-stream"), + ) + } else { + postedTo = request.url.encodedPath + respond("", HttpStatusCode.Accepted) + } + } + val transport = LegacyHttpSseTransport( + HttpClient(engine) { expectSuccess = false }, + McpConnectionConfig(url = "https://example.com/mcp/sse"), + this, + ) + val received = async { transport.incoming.first() } + transport.start() + transport.send(JsonRpcEnvelope(id = jsonRpcId(1), method = "ping")) + received.await() + assertEquals("/mcp/messages", postedTo.substringBefore("?").ifBlank { postedTo }) + assertTrue(postedTo.contains("/mcp/messages")) + assertEquals("1", transport.lastEventId) + transport.close() + } + + @Test + fun resolve_relative_endpoint() { + assertEquals( + "https://example.com/mcp/messages?sessionId=1", + LegacyHttpSseTransport.resolveEndpoint("https://example.com/mcp/sse", "/mcp/messages?sessionId=1"), + ) + } +} diff --git a/core-network/src/commonTest/kotlin/com/reqlab/core/network/mcp/McpClientHandshakeTest.kt b/core-network/src/commonTest/kotlin/com/reqlab/core/network/mcp/McpClientHandshakeTest.kt new file mode 100644 index 0000000..0fdc9f0 --- /dev/null +++ b/core-network/src/commonTest/kotlin/com/reqlab/core/network/mcp/McpClientHandshakeTest.kt @@ -0,0 +1,99 @@ +package com.reqlab.core.network.mcp + +import com.reqlab.core.model.McpConnectionConfig +import com.reqlab.core.model.McpContent +import com.reqlab.core.model.McpCreateMessageResult +import com.reqlab.core.model.McpElicitAction +import com.reqlab.core.model.McpHttpMode +import com.reqlab.core.model.McpTransportType +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.engine.mock.toByteArray +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpMethod +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class McpClientHandshakeTest { + + @Test + fun initialize_and_list_tools() = runTest { + val engine = MockEngine { request -> + if (request.method == HttpMethod.Get || request.method == HttpMethod.Delete) { + return@MockEngine respond("", HttpStatusCode.MethodNotAllowed) + } + val body = request.body.toByteArray().decodeToString() + val envelope = mcpJson.decodeFromString(com.reqlab.core.model.JsonRpcEnvelope.serializer(), body) + val result = when (envelope.method) { + "initialize" -> """{"protocolVersion":"2025-06-18","capabilities":{"tools":{"listChanged":true},"resources":{"subscribe":true},"prompts":{}},"serverInfo":{"name":"mock","version":"1"}}""" + "tools/list" -> """{"tools":[{"name":"echo","description":"echo","inputSchema":{"type":"object"}}]}""" + "notifications/initialized" -> null + else -> error(envelope.method.orEmpty()) + } + if (result == null) { + respond("", HttpStatusCode.Accepted) + } else { + respond( + """{"jsonrpc":"2.0","id":${envelope.id},"result":$result}""", + HttpStatusCode.OK, + headersOf( + HttpHeaders.ContentType to listOf("application/json"), + "Mcp-Session-Id" to listOf("s1"), + ), + ) + } + } + val client = McpClient(this, HttpClient(engine) { expectSuccess = false }) + val init = client.connect(McpConnectionConfig(url = "https://example/mcp", httpMode = McpHttpMode.STREAMABLE_2025_06_18)) + assertEquals("mock", init.serverInfo.name) + assertEquals("s1", client.sessionId) + val tools = client.listTools() + assertEquals(listOf("echo"), tools.map { it.name }) + client.disconnect() + } + + @Test + fun inbound_sampling_and_elicitation() = runTest { + val inbound = Channel(Channel.UNLIMITED) + val written = Channel(Channel.UNLIMITED) + val transport = NdjsonStdioTransport(this, inbound, { written.send(it) }) + val client = McpClient( + scope = this, + stdioFactory = { transport }, + callTimeoutMs = 5_000, + handlers = McpClientHandlers( + onSampling = { + McpCreateMessageResult(content = McpContent(type = "text", text = "hello")) + }, + onElicit = { com.reqlab.core.model.McpElicitResult(action = McpElicitAction.ACCEPT, content = buildJsonObject { put("ok", true) }) }, + ), + ) + val connect = async { + client.connect(McpConnectionConfig(transport = McpTransportType.STDIO, command = "unused")) + } + val initLine = written.receive() + assertTrue(initLine.contains("initialize")) + val initId = mcpJson.decodeFromString( + com.reqlab.core.model.JsonRpcEnvelope.serializer(), + initLine, + ).id + inbound.send("""{"jsonrpc":"2.0","id":$initId,"result":{"protocolVersion":"2025-06-18","capabilities":{},"serverInfo":{"name":"t","version":"1"}}}""") + written.receive() // notifications/initialized + connect.await() + inbound.send("""{"jsonrpc":"2.0","id":"srv-1","method":"sampling/createMessage","params":{"messages":[],"maxTokens":16}}""") + inbound.send("""{"jsonrpc":"2.0","id":"srv-2","method":"elicitation/create","params":{"message":"hi","requestedSchema":{"type":"object"}}}""") + val replies = listOf(written.receive(), written.receive()) + assertTrue(replies.any { it.contains("hello") && it.contains("srv-1") }) + assertTrue(replies.any { it.contains("accept") && it.contains("srv-2") }) + client.disconnect() + } +} diff --git a/core-network/src/commonTest/kotlin/com/reqlab/core/network/mcp/McpConfigResolveTest.kt b/core-network/src/commonTest/kotlin/com/reqlab/core/network/mcp/McpConfigResolveTest.kt new file mode 100644 index 0000000..c07dfcd --- /dev/null +++ b/core-network/src/commonTest/kotlin/com/reqlab/core/network/mcp/McpConfigResolveTest.kt @@ -0,0 +1,35 @@ +package com.reqlab.core.network.mcp + +import com.reqlab.core.model.AuthConfig +import com.reqlab.core.model.AuthType +import com.reqlab.core.model.KeyValueEntry +import com.reqlab.core.model.McpConnectionConfig +import kotlin.test.Test +import kotlin.test.assertEquals + +class McpConfigResolveTest { + @Test + fun interpolates_url_auth_and_headers() { + val resolved = resolveMcpConfig( + McpConnectionConfig( + url = "{{base}}/mcp", + auth = AuthConfig(AuthType.BEARER, mapOf("token" to "{{tok}}")), + headers = listOf(KeyValueEntry("X-Api-Key", "{{key}}")), + ), + listOf(mapOf("base" to "https://example", "tok" to "secret-token", "key" to "k1")), + ) + assertEquals("https://example/mcp", resolved.url) + assertEquals("secret-token", resolved.auth.params["token"]) + assertEquals("k1", resolved.headers.single().value) + assertEquals("X-Api-Key", resolved.headers.single().key) + } + + @Test + fun interpolates_query_params_on_mcp_url() { + val resolved = resolveMcpConfig( + McpConnectionConfig(url = "{{base}}/mcp?requireTenant=true&tenant={{tenant}}"), + listOf(mapOf("base" to "https://example", "tenant" to "acme")), + ) + assertEquals("https://example/mcp?requireTenant=true&tenant=acme", resolved.url) + } +} diff --git a/core-network/src/commonTest/kotlin/com/reqlab/core/network/mcp/OAuthTokenFlowTest.kt b/core-network/src/commonTest/kotlin/com/reqlab/core/network/mcp/OAuthTokenFlowTest.kt new file mode 100644 index 0000000..1b9efbf --- /dev/null +++ b/core-network/src/commonTest/kotlin/com/reqlab/core/network/mcp/OAuthTokenFlowTest.kt @@ -0,0 +1,100 @@ +package com.reqlab.core.network.mcp + +import com.reqlab.core.model.McpOAuthConfig +import com.reqlab.core.model.McpOAuthGrantType +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import io.ktor.http.HttpHeaders +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class OAuthTokenFlowTest { + @Test + fun discovery_dcr_and_token() = runTest { + val engine = MockEngine { request -> + val path = request.url.encodedPath + when { + path.endsWith("/.well-known/oauth-protected-resource") -> respond( + """{"resource":"https://example/mcp","authorization_servers":["https://auth.example"]}""", + HttpStatusCode.OK, + headersOf(HttpHeaders.ContentType, "application/json"), + ) + path.endsWith("/.well-known/oauth-authorization-server") -> respond( + """{"issuer":"https://auth.example","authorization_endpoint":"https://auth.example/authorize","token_endpoint":"https://auth.example/token","registration_endpoint":"https://auth.example/register","code_challenge_methods_supported":["S256"]}""", + HttpStatusCode.OK, + headersOf(HttpHeaders.ContentType, "application/json"), + ) + path.endsWith("/register") -> respond( + """{"client_id":"cid-1"}""", + HttpStatusCode.Created, + headersOf(HttpHeaders.ContentType, "application/json"), + ) + path.endsWith("/token") -> respond( + """{"access_token":"atk","refresh_token":"rtk","token_type":"Bearer","expires_in":3600}""", + HttpStatusCode.OK, + headersOf(HttpHeaders.ContentType, "application/json"), + ) + else -> respond("no $path", HttpStatusCode.NotFound) + } + } + val oauth = McpOAuthClient( + HttpClient(engine) { expectSuccess = false }, + randomBytes = { ByteArray(it) { 1 } }, + openAuthorize = { _, _ -> "splendid-code" }, + ) + val result = oauth.authorize( + resourceUrl = "https://example/mcp", + config = McpOAuthConfig(useDcr = true, scopes = listOf("mcp")), + wwwAuthenticate = """Bearer realm="mcp", resource_metadata="https://example/.well-known/oauth-protected-resource"""", + ) + assertEquals("atk", result.accessToken) + assertEquals("rtk", result.refreshToken) + assertEquals("cid-1", result.clientId) + assertTrue(oauth.debugLog.any { it.phase.name == "DCR" }) + assertTrue(oauth.debugLog.any { it.phase.name == "TOKEN" }) + } + + @Test + fun client_credentials_skips_browser() = runTest { + val engine = MockEngine { request -> + val path = request.url.encodedPath + when { + path.contains("oauth-protected-resource") -> respond( + """{"authorization_servers":["https://auth.example"]}""", + ) + path.contains("oauth-authorization-server") -> respond( + """{"token_endpoint":"https://auth.example/token","registration_endpoint":"https://auth.example/register"}""", + ) + path.endsWith("/register") -> respond("""{"client_id":"cid"}""") + path.endsWith("/token") -> respond("""{"access_token":"cc-token","token_type":"Bearer"}""") + else -> respond("no", HttpStatusCode.NotFound) + } + } + val oauth = McpOAuthClient(HttpClient(engine) { expectSuccess = false }) + val result = oauth.authorize( + "https://example/mcp", + McpOAuthConfig(grantType = McpOAuthGrantType.CLIENT_CREDENTIALS, useDcr = true), + ) + assertEquals("cc-token", result.accessToken) + } + + @Test + fun parse_www_authenticate_resource_metadata() { + val header = + "Bearer realm=\"mcp\", resource_metadata=\"https://mcp.example/.well-known/oauth-protected-resource\", error=\"invalid_token\"" + assertEquals( + "https://mcp.example/.well-known/oauth-protected-resource", + McpOAuthClient.parseResourceMetadataUrl(header), + ) + val uriForm = "Bearer resource_metadata_uri=\"https://mcp.example/.well-known/oauth-protected-resource\"" + assertEquals( + "https://mcp.example/.well-known/oauth-protected-resource", + McpOAuthClient.parseResourceMetadataUrl(uriForm), + ) + } +} diff --git a/core-network/src/commonTest/kotlin/com/reqlab/core/network/mcp/PkceTest.kt b/core-network/src/commonTest/kotlin/com/reqlab/core/network/mcp/PkceTest.kt new file mode 100644 index 0000000..ce4fcf6 --- /dev/null +++ b/core-network/src/commonTest/kotlin/com/reqlab/core/network/mcp/PkceTest.kt @@ -0,0 +1,19 @@ +package com.reqlab.core.network.mcp + +import kotlin.test.Test +import kotlin.test.assertEquals + +class PkceTest { + @Test + fun sha256_empty_string() { + val digest = sha256(ByteArray(0)).joinToString("") { it.toUByte().toString(16).padStart(2, '0') } + assertEquals("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", digest) + } + + @Test + fun rfc7636_s256_challenge() { + val verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + val challenge = sha256(verifier.encodeToByteArray()).mcpBase64(urlSafe = true, padding = false) + assertEquals("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", challenge) + } +} diff --git a/core-network/src/commonTest/kotlin/com/reqlab/core/network/mcp/StreamableHttpTransportTest.kt b/core-network/src/commonTest/kotlin/com/reqlab/core/network/mcp/StreamableHttpTransportTest.kt new file mode 100644 index 0000000..e54b8b5 --- /dev/null +++ b/core-network/src/commonTest/kotlin/com/reqlab/core/network/mcp/StreamableHttpTransportTest.kt @@ -0,0 +1,158 @@ +package com.reqlab.core.network.mcp + +import com.reqlab.core.model.JsonRpcEnvelope +import com.reqlab.core.model.McpConnectionConfig +import com.reqlab.core.model.jsonRpcId +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.engine.mock.toByteArray +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpMethod +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import io.ktor.utils.io.ByteReadChannel +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class StreamableHttpTransportTest { + + @Test + fun json_response_and_session_header() = runTest { + val engine = MockEngine { request -> + if (request.method == HttpMethod.Get) { + return@MockEngine respond("no", HttpStatusCode.MethodNotAllowed) + } + val body = request.body.toByteArray().decodeToString() + assertTrue(body.contains("initialize")) + assertEquals("application/json, text/event-stream", request.headers[HttpHeaders.Accept]) + respond( + content = """{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{"tools":{}},"serverInfo":{"name":"mock","version":"1"}}}""", + status = HttpStatusCode.OK, + headers = headersOf( + HttpHeaders.ContentType to listOf("application/json"), + "Mcp-Session-Id" to listOf("sess-1"), + ), + ) + } + val transport = StreamableHttpTransport(HttpClient(engine) { expectSuccess = false }, McpConnectionConfig(url = "https://example/mcp"), this) + transport.start() + val received = async { transport.incoming.first() } + transport.send(JsonRpcEnvelope(id = jsonRpcId(1), method = "initialize")) + val msg = received.await() + assertEquals("sess-1", transport.sessionId) + assertTrue(msg.isResponse()) + transport.close() + } + + @Test + fun sse_response_captures_event_id() = runTest { + val sse = "id: 7\nevent: message\ndata: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"ok\":true}}\n\n" + val engine = MockEngine { + respond( + content = ByteReadChannel(sse), + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "text/event-stream"), + ) + } + val transport = StreamableHttpTransport(HttpClient(engine) { expectSuccess = false }, McpConnectionConfig(url = "https://example/mcp"), this) + val received = async { transport.incoming.first() } + transport.send(JsonRpcEnvelope(id = jsonRpcId(1), method = "ping")) + received.await() + assertEquals("7", transport.lastEventId) + transport.close() + } + + @Test + fun captures_response_headers() = runTest { + val engine = MockEngine { request -> + if (request.method == HttpMethod.Get) { + return@MockEngine respond("no", HttpStatusCode.MethodNotAllowed) + } + respond( + content = """{"jsonrpc":"2.0","id":1,"result":{"ok":true}}""", + status = HttpStatusCode.OK, + headers = headersOf( + HttpHeaders.ContentType to listOf("application/json"), + "Mcp-Session-Id" to listOf("sess-9"), + "X-Custom" to listOf("hello"), + ), + ) + } + val transport = StreamableHttpTransport(HttpClient(engine) { expectSuccess = false }, McpConnectionConfig(url = "https://example/mcp"), this) + val received = async { transport.incoming.first() } + transport.send(JsonRpcEnvelope(id = jsonRpcId(1), method = "tools/list")) + received.await() + val headers = transport.lastResponseHeaders + assertTrue(headers != null, "lastResponseHeaders should be captured") + assertEquals(listOf("hello"), headers!!["X-Custom"]) + assertEquals(listOf("sess-9"), headers["Mcp-Session-Id"]) + transport.close() + } + + @Test + fun sends_bearer_auth_and_custom_headers() = runTest { + var auth: String? = null + var apiKey: String? = null + val engine = MockEngine { request -> + if (request.method == HttpMethod.Get) { + return@MockEngine respond("no", HttpStatusCode.MethodNotAllowed) + } + auth = request.headers[HttpHeaders.Authorization] + apiKey = request.headers["X-Api-Key"] + respond( + content = """{"jsonrpc":"2.0","id":1,"result":{"ok":true}}""", + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + } + val config = McpConnectionConfig( + url = "https://example/mcp", + auth = com.reqlab.core.model.AuthConfig( + type = com.reqlab.core.model.AuthType.BEARER, + params = mapOf("token" to "abc"), + ), + headers = listOf(com.reqlab.core.model.KeyValueEntry("X-Api-Key", "k1")), + ) + val transport = StreamableHttpTransport(HttpClient(engine) { expectSuccess = false }, config, this) + val received = async { transport.incoming.first() } + transport.send(JsonRpcEnvelope(id = jsonRpcId(1), method = "initialize")) + received.await() + assertEquals("Bearer abc", auth) + assertEquals("k1", apiKey) + transport.close() + } + + @Test + fun accepted_notification_does_not_emit() = runTest { + val engine = MockEngine { respond("", HttpStatusCode.Accepted) } + val transport = StreamableHttpTransport(HttpClient(engine) { expectSuccess = false }, McpConnectionConfig(url = "https://example/mcp"), this) + transport.send(JsonRpcEnvelope(method = "notifications/initialized")) + assertNull(transport.incoming.replayCache.firstOrNull()) + transport.close() + } + + @Test + fun not_found_is_session_expired() = runTest { + val engine = MockEngine { respond("gone", HttpStatusCode.NotFound) } + val transport = StreamableHttpTransport(HttpClient(engine) { expectSuccess = false }, McpConnectionConfig(url = "https://example/mcp"), this) + val thrown = runCatching { transport.send(JsonRpcEnvelope(id = jsonRpcId(1), method = "tools/list")) }.exceptionOrNull() + assertTrue(thrown is McpSessionExpiredException) + transport.close() + } + + @Test + fun method_not_allowed_hints_legacy() = runTest { + val engine = MockEngine { respond("", HttpStatusCode.MethodNotAllowed) } + val transport = StreamableHttpTransport(HttpClient(engine) { expectSuccess = false }, McpConnectionConfig(url = "https://example/mcp"), this) + val thrown = runCatching { transport.send(JsonRpcEnvelope(id = jsonRpcId(1), method = "initialize")) }.exceptionOrNull() + assertTrue(thrown is McpLegacyHintException) + transport.close() + } +} diff --git a/core-network/src/desktopMain/kotlin/com/reqlab/core/network/mcp/McpPlatform.desktop.kt b/core-network/src/desktopMain/kotlin/com/reqlab/core/network/mcp/McpPlatform.desktop.kt new file mode 100644 index 0000000..a23f1cf --- /dev/null +++ b/core-network/src/desktopMain/kotlin/com/reqlab/core/network/mcp/McpPlatform.desktop.kt @@ -0,0 +1,98 @@ +package com.reqlab.core.network.mcp + +import com.reqlab.core.model.McpConnectionConfig +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch +import java.awt.Desktop +import java.net.ServerSocket +import java.net.URI +import java.security.SecureRandom +import kotlin.concurrent.thread + +actual val mcpStdioSupported: Boolean = true + +actual fun createStdioTransport(config: McpConnectionConfig): McpTransport { + val command = config.command.trim() + require(command.isNotBlank()) { "stdio command is required" } + val builder = ProcessBuilder(listOf(command) + config.args) + if (!config.workingDir.isNullOrBlank()) builder.directory(java.io.File(config.workingDir)) + val env = builder.environment() + config.env.forEach { (k, v) -> env[k] = v } + builder.redirectErrorStream(false) + val process = builder.start() + val lines = Channel(Channel.UNLIMITED) + val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + val stdoutJob = scope.launch { + process.inputStream.bufferedReader(Charsets.UTF_8).use { reader -> + while (true) { + val line = reader.readLine() ?: break + lines.send(line) + } + } + lines.close() + } + val stderrJob = scope.launch { + process.errorStream.bufferedReader(Charsets.UTF_8).use { reader -> + while (true) { + reader.readLine() ?: break + } + } + } + val hook = thread(start = false, isDaemon = true, name = "mcp-stdio-shutdown") { + process.destroy() + } + runCatching { Runtime.getRuntime().addShutdownHook(hook) } + return NdjsonStdioTransport( + scope = scope, + incomingLines = lines, + writeLine = { line -> + process.outputStream.write((line + "\n").toByteArray(Charsets.UTF_8)) + process.outputStream.flush() + }, + onClose = { + stdoutJob.cancel() + stderrJob.cancel() + process.destroy() + runCatching { Runtime.getRuntime().removeShutdownHook(hook) } + }, + ) +} + +actual fun mcpSecureRandomBytes(size: Int): ByteArray { + val bytes = ByteArray(size) + SecureRandom().nextBytes(bytes) + return bytes +} + +actual val mcpInteractiveOAuthSupported: Boolean = true + +actual suspend fun mcpOpenAuthorizeUrlAndAwaitCode(authorizeUrl: String, redirectPort: Int): String { + val server = ServerSocket(redirectPort) + try { + if (Desktop.isDesktopSupported()) { + runCatching { Desktop.getDesktop().browse(URI(authorizeUrl)) } + } + val socket = server.accept() + socket.soTimeout = 30_000 + val reader = socket.getInputStream().bufferedReader() + val requestLine = reader.readLine().orEmpty() + val query = requestLine.substringAfter("?", "").substringBefore(" ") + val code = query.split("&").map { it.split("=", limit = 2) } + .firstOrNull { it.firstOrNull() == "code" } + ?.getOrNull(1) + ?.let { java.net.URLDecoder.decode(it, Charsets.UTF_8) } + ?: throw McpProtocolException("No authorization code in redirect") + val body = "ReqLab authorized. You can close this window." + socket.getOutputStream().write( + "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: ${body.length}\r\nConnection: close\r\n\r\n$body" + .toByteArray(), + ) + socket.close() + return code + } finally { + server.close() + } +} diff --git a/core-network/src/iosMain/kotlin/com/reqlab/core/network/mcp/McpPlatform.ios.kt b/core-network/src/iosMain/kotlin/com/reqlab/core/network/mcp/McpPlatform.ios.kt new file mode 100644 index 0000000..4be46c9 --- /dev/null +++ b/core-network/src/iosMain/kotlin/com/reqlab/core/network/mcp/McpPlatform.ios.kt @@ -0,0 +1,16 @@ +package com.reqlab.core.network.mcp + +import com.reqlab.core.model.McpConnectionConfig +import kotlin.random.Random + +actual val mcpStdioSupported: Boolean = false + +actual fun createStdioTransport(config: McpConnectionConfig): McpTransport = + throw UnsupportedOperationException("MCP stdio is not supported on iOS") + +actual fun mcpSecureRandomBytes(size: Int): ByteArray = Random.Default.nextBytes(size) + +actual val mcpInteractiveOAuthSupported: Boolean = false + +actual suspend fun mcpOpenAuthorizeUrlAndAwaitCode(authorizeUrl: String, redirectPort: Int): String = + throw UnsupportedOperationException("Interactive OAuth is not supported on iOS in v1") diff --git a/core-network/src/jsMain/kotlin/com/reqlab/core/network/mcp/McpPlatform.js.kt b/core-network/src/jsMain/kotlin/com/reqlab/core/network/mcp/McpPlatform.js.kt new file mode 100644 index 0000000..34d6e11 --- /dev/null +++ b/core-network/src/jsMain/kotlin/com/reqlab/core/network/mcp/McpPlatform.js.kt @@ -0,0 +1,16 @@ +package com.reqlab.core.network.mcp + +import com.reqlab.core.model.McpConnectionConfig +import kotlin.random.Random + +actual val mcpStdioSupported: Boolean = false + +actual fun createStdioTransport(config: McpConnectionConfig): McpTransport = + throw UnsupportedOperationException("MCP stdio is not supported in the browser") + +actual fun mcpSecureRandomBytes(size: Int): ByteArray = Random.Default.nextBytes(size) + +actual val mcpInteractiveOAuthSupported: Boolean = false + +actual suspend fun mcpOpenAuthorizeUrlAndAwaitCode(authorizeUrl: String, redirectPort: Int): String = + throw UnsupportedOperationException("Paste the redirected URL / authorization code to complete OAuth in the browser") diff --git a/core-network/src/wasmJsMain/kotlin/com/reqlab/core/network/mcp/McpPlatform.wasmJs.kt b/core-network/src/wasmJsMain/kotlin/com/reqlab/core/network/mcp/McpPlatform.wasmJs.kt new file mode 100644 index 0000000..34d6e11 --- /dev/null +++ b/core-network/src/wasmJsMain/kotlin/com/reqlab/core/network/mcp/McpPlatform.wasmJs.kt @@ -0,0 +1,16 @@ +package com.reqlab.core.network.mcp + +import com.reqlab.core.model.McpConnectionConfig +import kotlin.random.Random + +actual val mcpStdioSupported: Boolean = false + +actual fun createStdioTransport(config: McpConnectionConfig): McpTransport = + throw UnsupportedOperationException("MCP stdio is not supported in the browser") + +actual fun mcpSecureRandomBytes(size: Int): ByteArray = Random.Default.nextBytes(size) + +actual val mcpInteractiveOAuthSupported: Boolean = false + +actual suspend fun mcpOpenAuthorizeUrlAndAwaitCode(authorizeUrl: String, redirectPort: Int): String = + throw UnsupportedOperationException("Paste the redirected URL / authorization code to complete OAuth in the browser") diff --git a/docs/architecture.md b/docs/architecture.md index bb398c7..e18fb07 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -13,7 +13,7 @@ ``` core-model/ Shared domain models — no runtime deps -core-network/ Ktor HTTP engine, auth, retry, WebSocket, interceptors +core-network/ Ktor HTTP engine, auth, retry, WebSocket, interceptors, MCP client core-storage/ Persistence contracts + JSON file adapter core-scripting/ Script runtime contracts (pre-request / post-request JS) editor-core/ Pure-Kotlin editor engine: document model, lexer tokens, @@ -122,7 +122,7 @@ NONE, BASIC, BEARER, API_KEY, OAUTH2, JWT | Module | Responsibility | |---|---| -| `core-network` | `KtorApiClient` executing HTTP requests; SSE/NDJSON streaming via `NetworkEvent.Chunk` (`SseParser`, `LlmTextAssembler`); auth schemes (Basic, Bearer, API Key, OAuth2, JWT); retry; `{{variable}}` interpolation in URL/headers/body; WebSocket; `NetworkInterceptor` interface | +| `core-network` | `KtorApiClient` executing HTTP requests; SSE/NDJSON streaming via `NetworkEvent.Chunk` (`SseParser`, `LlmTextAssembler`); MCP client (`McpClient`, Streamable HTTP, legacy HTTP+SSE, stdio); OAuth 2.1 for MCP; auth schemes (Basic, Bearer, API Key, OAuth2, JWT); retry; `{{variable}}` interpolation in URL/headers/body; WebSocket; `NetworkInterceptor` interface | | `core-storage` | `PlatformStorage` abstraction; workspace, tab, settings, and environment JSON persistence | | `core-scripting` | JavaScript runtime contracts; pre-request / post-request execution; variable scope injection (`environment`, `globals`, `collectionVariables`); `pm.*` → `reqlab.*` API rewriter | | `feature-requests` | `RequestExecutionService` — orchestrates an HTTP round-trip: resolves variables → runs pre-request script → dispatches via `KtorApiClient` → feeds response into post-request scripts | diff --git a/docs/testing.md b/docs/testing.md index a38b998..b250ca0 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -156,6 +156,7 @@ JVM integration and E2E tests against the running `sample-server`. - `NetworkClientWsAndMultipartE2ETest` — WebSocket connect/send/receive/disconnect; multipart upload - `SampleCollectionE2ETest` — scripted collection run against the sample collection fixture - `LlmApiE2ETest` — OpenAI-compatible chat, SSE/NDJSON assembly, embeddings, tools, JSON mode, error statuses, visible `demo=true` stream +- `McpHttpE2ETest` — MCP Streamable HTTP handshake, tools, resources, prompts, subscribe notifications, Bearer+API key auth, variable interpolation, OAuth Bearer, GET SSE notifications - `ScriptingDocsIntegrationTest` — scripting doc examples execute correctly end-to-end - `WebSocketE2ETest` — WebSocket lifecycle: connect, send, receive, close, reconnect diff --git a/docs/tests.md b/docs/tests.md index 5ad3d8e..368dc76 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -37,6 +37,12 @@ LLM streaming E2E against the embedded sample-server: ./gradlew :qa-tests:jvmTest --tests com.reqlab.qa.LlmApiE2ETest ``` +MCP Streamable HTTP + OAuth E2E: + +```bash +./gradlew :qa-tests:jvmTest --tests com.reqlab.qa.McpHttpE2ETest +``` + Run full project checks: ```bash diff --git a/gradle.properties b/gradle.properties index 9b3e15c..9c5b969 100644 --- a/gradle.properties +++ b/gradle.properties @@ -8,4 +8,4 @@ android.useAndroidX=true # ─── Single source of truth for all artifact versions ───────────────────────── # Must satisfy jpackage format: MAJOR[.MINOR][.PATCH] with MAJOR > 0 -appVersion=1.17.0 +appVersion=1.18.0 diff --git a/qa-tests/collection-validator.mjs b/qa-tests/collection-validator.mjs index a2b03d3..f0e591f 100644 --- a/qa-tests/collection-validator.mjs +++ b/qa-tests/collection-validator.mjs @@ -298,7 +298,7 @@ async function main() { const user = resolveTemplate(req.auth.username ?? '', runtimeVars); const pass = resolveTemplate(req.auth.password ?? '', runtimeVars); requestHeaders.set('Authorization', `Basic ${toBase64(`${user}:${pass}`)}`); - } else if (req.auth?.type === 'BEARER') { + } else if (req.auth?.type === 'BEARER' || req.auth?.type === 'JWT') { const token = resolveTemplate(req.auth.token ?? '', runtimeVars); requestHeaders.set('Authorization', `Bearer ${token}`); } else if (req.auth?.type === 'API_KEY') { @@ -335,6 +335,61 @@ async function main() { let headers; let bodyText; + if ((req.kind || '').toUpperCase() === 'MCP') { + const started = performance.now(); + const mcpHeaders = { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + }; + for (const [k, v] of requestHeaders.entries()) { + mcpHeaders[k] = v; + } + const initBody = JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2025-06-18', + capabilities: {}, + clientInfo: { name: 'reqlab-validator', version: '1.18.0' }, + }, + }); + const initRes = await fetch(resolvedUrl, { + method: 'POST', + headers: mcpHeaders, + body: initBody, + }); + const initText = await initRes.text(); + const listHeaders = { ...mcpHeaders }; + const sessionId = initRes.headers.get('mcp-session-id'); + if (sessionId) listHeaders['Mcp-Session-Id'] = sessionId; + const listRes = await fetch(resolvedUrl, { + method: 'POST', + headers: listHeaders, + body: JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list' }), + }); + const listText = await listRes.text(); + elapsed = performance.now() - started; + status = listRes.status; + headers = listRes.headers; + bodyText = `${initText}\n${listText}`; + size = Buffer.byteLength(bodyText, 'utf8'); + const toolsOk = listText.includes('"echo"') && initText.includes('ReqLab MCP Mock'); + const passed = initRes.ok && listRes.ok && toolsOk; + results.push({ + name: req.name, + path: req.__path, + method: 'MCP', + url: resolvedUrl, + status, + responseTimeMs: Number(elapsed.toFixed(2)), + responseSizeBytes: size, + passed, + errors: passed ? [] : [`MCP handshake failed init=${initRes.status} list=${status}`], + }); + continue; + } + if (resolvedUrl.startsWith('ws://') || resolvedUrl.startsWith('wss://')) { const wsResult = await runWebSocket(resolvedUrl); status = wsResult.status; diff --git a/qa-tests/fixtures/reqlab-test-collection.json b/qa-tests/fixtures/reqlab-test-collection.json index f195a6b..bdc7687 100644 --- a/qa-tests/fixtures/reqlab-test-collection.json +++ b/qa-tests/fixtures/reqlab-test-collection.json @@ -1013,6 +1013,96 @@ "testScript": "reqlab.test(\"status is 200\", function() {\n reqlab.expect(reqlab.response.code).to.equal(200);\n});\nreqlab.test(\"partial assembled text\", function() {\n reqlab.expect(reqlab.response.llm.assembledText).to.include(\"Hello\");\n});" } ] + }, + { + "name": "MCP (Model Context Protocol)", + "folders": [], + "requests": [ + { + "name": "MCP Initialize + tools/list", + "kind": "MCP", + "method": "POST", + "url": "{{mcpBaseUrl}}", + "mcpTransport": "STREAMABLE_HTTP", + "mcpHttpMode": "AUTO" + }, + { + "name": "MCP Authed Initialize + tools/list", + "kind": "MCP", + "method": "POST", + "url": "{{mcpAuthedUrl}}", + "mcpTransport": "STREAMABLE_HTTP", + "mcpHttpMode": "AUTO", + "auth": { + "type": "BEARER", + "token": "{{mcpBearerToken}}" + }, + "headers": [ + { + "key": "X-Api-Key", + "value": "{{mcpApiKey}}" + } + ] + }, + { + "name": "MCP Bearer", + "kind": "MCP", + "method": "POST", + "url": "{{mcpBearerUrl}}", + "mcpTransport": "STREAMABLE_HTTP", + "mcpHttpMode": "AUTO", + "auth": { + "type": "BEARER", + "token": "{{mcpBearerToken}}" + } + }, + { + "name": "MCP Basic", + "kind": "MCP", + "method": "POST", + "url": "{{mcpBasicUrl}}", + "mcpTransport": "STREAMABLE_HTTP", + "mcpHttpMode": "AUTO", + "auth": { + "type": "BASIC", + "username": "{{basicUser}}", + "password": "{{basicPassword}}" + } + }, + { + "name": "MCP API Key", + "kind": "MCP", + "method": "POST", + "url": "{{mcpApiKeyUrl}}", + "mcpTransport": "STREAMABLE_HTTP", + "mcpHttpMode": "AUTO", + "auth": { + "type": "API_KEY", + "apiKey": "X-Api-Key", + "apiValue": "{{mcpApiKey}}" + } + }, + { + "name": "MCP JWT", + "kind": "MCP", + "method": "POST", + "url": "{{mcpJwtUrl}}", + "mcpTransport": "STREAMABLE_HTTP", + "mcpHttpMode": "AUTO", + "auth": { + "type": "JWT", + "token": "{{mcpJwtToken}}" + } + }, + { + "name": "MCP URL params", + "kind": "MCP", + "method": "POST", + "url": "{{mcpTenantUrl}}", + "mcpTransport": "STREAMABLE_HTTP", + "mcpHttpMode": "AUTO" + } + ] } ], "requests": [ diff --git a/qa-tests/fixtures/reqlab-test-environment.json b/qa-tests/fixtures/reqlab-test-environment.json index 52c84f5..9b660fb 100644 --- a/qa-tests/fixtures/reqlab-test-environment.json +++ b/qa-tests/fixtures/reqlab-test-environment.json @@ -14,6 +14,16 @@ "lastRunId": "run-env-default", "llmBaseUrl": "http://localhost:8080", "llmApiKey": "llm-test-key", - "llmModel": "mock-gpt" + "llmModel": "mock-gpt", + "mcpBaseUrl": "http://localhost:8080/mcp", + "mcpAuthedUrl": "http://localhost:8080/mcp/authed", + "mcpBearerUrl": "http://localhost:8080/mcp/auth/bearer", + "mcpBasicUrl": "http://localhost:8080/mcp/auth/basic", + "mcpApiKeyUrl": "http://localhost:8080/mcp/auth/apikey", + "mcpJwtUrl": "http://localhost:8080/mcp/auth/jwt", + "mcpTenantUrl": "http://localhost:8080/mcp?requireTenant=true&tenant=acme", + "mcpBearerToken": "reqlab-mcp-token", + "mcpApiKey": "reqlab-key", + "mcpJwtToken": "reqlab-mcp-jwt" } } diff --git a/qa-tests/src/test/kotlin/com/reqlab/qa/McpHttpE2ETest.kt b/qa-tests/src/test/kotlin/com/reqlab/qa/McpHttpE2ETest.kt new file mode 100644 index 0000000..abee0cb --- /dev/null +++ b/qa-tests/src/test/kotlin/com/reqlab/qa/McpHttpE2ETest.kt @@ -0,0 +1,396 @@ +package com.reqlab.qa + +import com.reqlab.core.model.AuthConfig +import com.reqlab.core.model.AuthType +import com.reqlab.core.model.KeyValueEntry +import com.reqlab.core.model.McpConnectionConfig +import com.reqlab.core.model.McpHttpMode +import com.reqlab.core.model.McpOAuthConfig +import com.reqlab.core.model.McpOAuthGrantType +import com.reqlab.core.network.mcp.McpClient +import com.reqlab.core.network.mcp.McpOAuthClient +import com.reqlab.core.network.mcp.McpUnauthorizedException +import com.reqlab.core.network.mcp.NdjsonStdioTransport +import com.reqlab.core.network.mcp.mcpStdioSupported +import com.reqlab.server.module +import io.ktor.client.HttpClient +import io.ktor.client.engine.cio.CIO +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.plugins.HttpTimeoutConfig +import io.ktor.server.engine.EmbeddedServer +import io.ktor.server.engine.embeddedServer +import io.ktor.server.netty.Netty +import io.ktor.server.netty.NettyApplicationEngine +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import org.junit.AfterClass +import org.junit.BeforeClass +import org.junit.Test +import java.net.ServerSocket +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class McpHttpE2ETest { + + private fun http() = HttpClient(CIO) { + expectSuccess = false + install(HttpTimeout) { + requestTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS + socketTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS + } + } + + @Test + fun initialize_list_call_and_resource() = runBlocking { + val client = McpClient(this, http(), callTimeoutMs = 10_000) + val init = client.connect( + McpConnectionConfig(url = "$BASE_URL/mcp", httpMode = McpHttpMode.STREAMABLE_2025_06_18), + ) + assertEquals("ReqLab MCP Mock", init.serverInfo.name) + assertTrue(client.sessionId != null) + val tools = client.listTools().map { it.name } + assertTrue(tools.containsAll(listOf("echo", "add", "fail"))) + val echo = client.callTool("echo", buildJsonObject { put("text", "hi") }) + assertEquals("hi", echo.content.single().text) + assertEquals(false, echo.isError) + val fail = client.callTool("fail") + assertTrue(fail.isError) + val resource = client.readResource("reqlab://docs/welcome") + assertTrue(resource.contents.single().text!!.contains("Welcome")) + client.subscribeResource("reqlab://docs/welcome") + val prompts = client.listPrompts() + assertEquals("greet", prompts.single().name) + val completion = client.complete( + buildJsonObject { put("type", "ref/resource") }, + "name", + "wel", + ) + assertTrue(completion.completion.values.contains("welcome")) + client.disconnect() + } + + @Test + fun subscribe_and_response_headers_captured() = runBlocking { + val client = McpClient(this, http(), callTimeoutMs = 10_000) + client.connect( + McpConnectionConfig(url = "$BASE_URL/mcp", httpMode = McpHttpMode.STREAMABLE_2025_06_18), + ) + client.listTools() + val headers = client.lastResponseHeaders + assertTrue(headers != null && headers.isNotEmpty(), "HTTP response headers should be captured") + // Subscribing against a subscribe-capable mock should not raise a JSON-RPC error. + client.subscribeResource("reqlab://docs/welcome") + client.disconnect() + } + + @Test + fun subscribe_emits_resource_updated_notification() = runBlocking { + val client = McpClient(this, http(), callTimeoutMs = 10_000) + client.connect( + McpConnectionConfig(url = "$BASE_URL/mcp", httpMode = McpHttpMode.STREAMABLE_2025_06_18), + ) + val ready = kotlinx.coroutines.CompletableDeferred() + val updated = async { + withTimeout(10_000) { + client.notifications + .onStart { ready.complete(Unit) } + .first { it.method == "notifications/resources/updated" } + } + } + ready.await() + client.subscribeResource("reqlab://docs/welcome") + val notification = updated.await() + val uri = (notification.params as? JsonObject)?.get("uri")?.jsonPrimitive?.contentOrNull + assertEquals("reqlab://docs/welcome", uri) + val headers = client.lastResponseHeaders + assertTrue(headers != null && headers.isNotEmpty(), "subscribe response headers should be captured") + client.unsubscribeResource("reqlab://docs/welcome") + client.disconnect() + } + + @Test + fun bearer_and_api_key_auth_headers_are_accepted() = runBlocking { + val client = McpClient(this, http(), callTimeoutMs = 10_000) + val init = client.connect( + McpConnectionConfig( + url = "$BASE_URL/mcp/authed", + httpMode = McpHttpMode.STREAMABLE_2025_06_18, + auth = AuthConfig(AuthType.BEARER, mapOf("token" to "reqlab-mcp-token")), + headers = listOf(KeyValueEntry("X-Api-Key", "reqlab-key")), + ), + ) + assertEquals("ReqLab MCP Mock", init.serverInfo.name) + assertTrue(client.sessionId != null) + val headers = client.lastResponseHeaders + assertTrue(headers != null && headers.isNotEmpty()) + val tools = client.listTools().map { it.name } + assertTrue(tools.contains("echo")) + client.disconnect() + } + + @Test + fun missing_auth_on_authed_endpoint_is_unauthorized() = runBlocking { + val client = McpClient(this, http(), callTimeoutMs = 10_000) + val error = runCatching { + client.connect(McpConnectionConfig(url = "$BASE_URL/mcp/authed")) + }.exceptionOrNull() + assertTrue(error is McpUnauthorizedException, "expected unauthorized, got $error") + client.disconnect() + } + + @Test + fun bearer_only_auth_is_accepted() = runBlocking { + val client = McpClient(this, http(), callTimeoutMs = 10_000) + val init = client.connect( + McpConnectionConfig( + url = "$BASE_URL/mcp/auth/bearer", + httpMode = McpHttpMode.STREAMABLE_2025_06_18, + auth = AuthConfig(AuthType.BEARER, mapOf("token" to "reqlab-mcp-token")), + ), + ) + assertEquals("ReqLab MCP Mock", init.serverInfo.name) + client.disconnect() + } + + @Test + fun bearer_only_auth_rejects_missing_token() = runBlocking { + val client = McpClient(this, http(), callTimeoutMs = 10_000) + val error = runCatching { + client.connect(McpConnectionConfig(url = "$BASE_URL/mcp/auth/bearer")) + }.exceptionOrNull() + assertTrue(error is McpUnauthorizedException, "expected unauthorized, got $error") + client.disconnect() + } + + @Test + fun basic_auth_is_accepted() = runBlocking { + val client = McpClient(this, http(), callTimeoutMs = 10_000) + val init = client.connect( + McpConnectionConfig( + url = "$BASE_URL/mcp/auth/basic", + httpMode = McpHttpMode.STREAMABLE_2025_06_18, + auth = AuthConfig(AuthType.BASIC, mapOf("username" to "admin", "password" to "password")), + ), + ) + assertEquals("ReqLab MCP Mock", init.serverInfo.name) + client.disconnect() + } + + @Test + fun basic_auth_rejects_wrong_password() = runBlocking { + val client = McpClient(this, http(), callTimeoutMs = 10_000) + val error = runCatching { + client.connect( + McpConnectionConfig( + url = "$BASE_URL/mcp/auth/basic", + auth = AuthConfig(AuthType.BASIC, mapOf("username" to "admin", "password" to "nope")), + ), + ) + }.exceptionOrNull() + assertTrue(error is McpUnauthorizedException, "expected unauthorized, got $error") + client.disconnect() + } + + @Test + fun api_key_auth_is_accepted() = runBlocking { + val client = McpClient(this, http(), callTimeoutMs = 10_000) + val init = client.connect( + McpConnectionConfig( + url = "$BASE_URL/mcp/auth/apikey", + httpMode = McpHttpMode.STREAMABLE_2025_06_18, + auth = AuthConfig(AuthType.API_KEY, mapOf("key" to "X-Api-Key", "value" to "reqlab-key")), + ), + ) + assertEquals("ReqLab MCP Mock", init.serverInfo.name) + client.disconnect() + } + + @Test + fun api_key_auth_rejects_missing_header() = runBlocking { + val client = McpClient(this, http(), callTimeoutMs = 10_000) + val error = runCatching { + client.connect(McpConnectionConfig(url = "$BASE_URL/mcp/auth/apikey")) + }.exceptionOrNull() + assertTrue(error is McpUnauthorizedException, "expected unauthorized, got $error") + client.disconnect() + } + + @Test + fun jwt_auth_is_accepted() = runBlocking { + val client = McpClient(this, http(), callTimeoutMs = 10_000) + val init = client.connect( + McpConnectionConfig( + url = "$BASE_URL/mcp/auth/jwt", + httpMode = McpHttpMode.STREAMABLE_2025_06_18, + auth = AuthConfig(AuthType.JWT, mapOf("token" to "reqlab-mcp-jwt")), + ), + ) + assertEquals("ReqLab MCP Mock", init.serverInfo.name) + client.disconnect() + } + + @Test + fun jwt_auth_rejects_wrong_token() = runBlocking { + val client = McpClient(this, http(), callTimeoutMs = 10_000) + val error = runCatching { + client.connect( + McpConnectionConfig( + url = "$BASE_URL/mcp/auth/jwt", + auth = AuthConfig(AuthType.JWT, mapOf("token" to "reqlab-mcp-token")), + ), + ) + }.exceptionOrNull() + assertTrue(error is McpUnauthorizedException, "expected unauthorized, got $error") + client.disconnect() + } + + @Test + fun url_query_params_are_sent_and_required_tenant_is_accepted() = runBlocking { + val client = McpClient(this, http(), callTimeoutMs = 10_000) + val init = client.connect( + McpConnectionConfig( + url = "{{base}}/mcp?requireTenant=true&tenant={{tenant}}", + httpMode = McpHttpMode.STREAMABLE_2025_06_18, + ), + variableLayers = listOf(mapOf("base" to BASE_URL, "tenant" to "acme")), + ) + assertEquals("ReqLab MCP Mock", init.serverInfo.name) + client.disconnect() + } + + @Test + fun url_query_params_reject_missing_required_tenant() = runBlocking { + val client = McpClient(this, http(), callTimeoutMs = 10_000) + val error = runCatching { + client.connect( + McpConnectionConfig( + url = "$BASE_URL/mcp?requireTenant=true", + httpMode = McpHttpMode.STREAMABLE_2025_06_18, + ), + ) + }.exceptionOrNull() + assertTrue(error != null, "expected connect to fail without tenant") + assertTrue(error!!.message!!.contains("missing_tenant") || error.message!!.contains("400"), error.message) + client.disconnect() + } + + @Test + fun stateless_query_param_still_initializes() = runBlocking { + val client = McpClient(this, http(), callTimeoutMs = 10_000) + val init = client.connect( + McpConnectionConfig( + url = "$BASE_URL/mcp?stateless=true", + httpMode = McpHttpMode.STREAMABLE_2025_06_18, + ), + ) + assertEquals("ReqLab MCP Mock", init.serverInfo.name) + client.disconnect() + } + + @Test + fun url_variables_are_interpolated_on_connect() = runBlocking { + val client = McpClient(this, http(), callTimeoutMs = 10_000) + val init = client.connect( + McpConnectionConfig(url = "{{base}}{{path}}", httpMode = McpHttpMode.STREAMABLE_2025_06_18), + variableLayers = listOf(mapOf("base" to BASE_URL, "path" to "/mcp")), + ) + assertEquals("ReqLab MCP Mock", init.serverInfo.name) + client.disconnect() + } + + @Test + fun unknown_method_is_jsonrpc_error() = runBlocking { + val client = McpClient(this, http(), callTimeoutMs = 10_000) + client.connect(McpConnectionConfig(url = "$BASE_URL/mcp")) + val error = runCatching { client.callTool("nope") }.exceptionOrNull() + assertTrue(error!!.message!!.contains("-32602") || error.message!!.contains("Unknown")) + client.disconnect() + } + + @Test + fun bidirectional_sampling_via_stdio_framing() = runBlocking { + val inbound = Channel(Channel.UNLIMITED) + val written = Channel(Channel.UNLIMITED) + val transport = NdjsonStdioTransport(this, inbound, { written.send(it) }) + val client = McpClient(this, stdioFactory = { transport }, callTimeoutMs = 10_000) + val job = async { + client.connect( + com.reqlab.core.model.McpConnectionConfig( + transport = com.reqlab.core.model.McpTransportType.STDIO, + command = "unused", + ), + ) + } + written.receive() + inbound.send("""{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{"tools":{}},"serverInfo":{"name":"pipe","version":"1"}}}""") + written.receive() // notifications/initialized + job.await() + inbound.send("""{"jsonrpc":"2.0","id":"srv-1","method":"sampling/createMessage","params":{"messages":[],"maxTokens":8}}""") + val reply = written.receive() + assertTrue(reply.contains("mock reply")) + client.disconnect() + } + + @Test + fun oauth_client_credentials_against_sample_server() = runBlocking { + val http = http() + val oauth = McpOAuthClient(http) + val tokens = oauth.authorize( + "$BASE_URL/mcp/secure", + McpOAuthConfig( + authServerUrl = BASE_URL, + grantType = McpOAuthGrantType.CLIENT_CREDENTIALS, + useDcr = true, + ), + ) + assertEquals("mcp-oauth-token", tokens.accessToken) + val client = McpClient(this, http, oauthClient = oauth, callTimeoutMs = 10_000) + val init = client.connect( + McpConnectionConfig( + url = "$BASE_URL/mcp/secure", + oauth = tokens, + ), + ) + assertEquals("ReqLab MCP Mock", init.serverInfo.name) + client.disconnect() + } + + @Test + fun desktop_stdio_is_supported() { + assertTrue(mcpStdioSupported) + } + + companion object { + private var server: EmbeddedServer? = null + private var port: Int = 0 + var BASE_URL: String = "" + + @JvmStatic + @BeforeClass + fun startServer() { + port = ServerSocket(0).use { it.localPort } + BASE_URL = "http://127.0.0.1:$port" + server = embeddedServer(Netty, port = port, module = { module() }) + server!!.start(wait = false) + repeat(50) { + runCatching { java.net.Socket("127.0.0.1", port).close(); return } + Thread.sleep(100) + } + } + + @JvmStatic + @AfterClass + fun stopServer() { + server?.stop(1000, 2000) + } + } +} diff --git a/sample-server/src/main/kotlin/com/reqlab/server/Application.kt b/sample-server/src/main/kotlin/com/reqlab/server/Application.kt index 331e7b3..fe12824 100644 --- a/sample-server/src/main/kotlin/com/reqlab/server/Application.kt +++ b/sample-server/src/main/kotlin/com/reqlab/server/Application.kt @@ -53,12 +53,19 @@ import java.io.File import java.time.Instant import java.util.Base64 -fun main() { +fun main(args: Array = emptyArray()) { + if (args.contains("--stdio")) { + System.err.println("ReqLab MCP stdio mock ready") + runMcpStdio() + return + } println("==========================================================") println(" ReqLab Sample API Server") println(" Listening on http://localhost:8080") println(" WebSocket ws://localhost:8080/ws") println(" LLM mock POST /v1/chat/completions (?demo=true for a visible token stream)") + println(" MCP POST /mcp (OAuth-protected: POST /mcp/secure)") + println(" MCP legacy GET /mcp/sse") println(" Press Ctrl+C to stop") println("==========================================================") embeddedServer(Netty, port = 8080, module = Application::module).start(wait = true) @@ -1188,6 +1195,8 @@ module.exports = api;""", } } } + + mcpAndOAuthRoutes() } } diff --git a/sample-server/src/main/kotlin/com/reqlab/server/McpMock.kt b/sample-server/src/main/kotlin/com/reqlab/server/McpMock.kt new file mode 100644 index 0000000..7c55272 --- /dev/null +++ b/sample-server/src/main/kotlin/com/reqlab/server/McpMock.kt @@ -0,0 +1,355 @@ +package com.reqlab.server + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.add +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import java.security.MessageDigest +import java.util.Base64 +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +internal val mcpMockJson = Json { ignoreUnknownKeys = true; isLenient = true } + +data class McpMockSession( + val id: String = UUID.randomUUID().toString(), + val subscribed: MutableSet = ConcurrentHashMap.newKeySet(), + var logLevel: String = "info", +) + +data class McpOutbound( + val envelope: JsonObject, + val sseEventId: String? = null, + val eventType: String = "message", +) + +object McpMockProtocol { + val sessions = ConcurrentHashMap() + val oauthClients = ConcurrentHashMap() + val oauthCodes = ConcurrentHashMap() + val oauthTokens = ConcurrentHashMap() + const val ISSUED_ACCESS_TOKEN = "mcp-oauth-token" + const val ISSUED_REFRESH_TOKEN = "mcp-refresh-token" + + fun session(id: String?): McpMockSession? = id?.let { sessions[it] } + + fun requireOrCreate(id: String?): McpMockSession { + if (id != null) sessions[id]?.let { return it } + val created = McpMockSession(id ?: UUID.randomUUID().toString()) + sessions[created.id] = created + return created + } + + fun handle( + raw: String, + session: McpMockSession, + extraOut: MutableList = mutableListOf(), + ): JsonObject? { + val obj = runCatching { mcpMockJson.parseToJsonElement(raw).jsonObject }.getOrNull() + ?: return rpcError(null, -32700, "Parse error") + val method = obj["method"]?.jsonPrimitive?.contentOrNull + val id = obj["id"] + val params = obj["params"] as? JsonObject + if (method == null) { + return if (id != null && id !is JsonNull) rpcError(id, -32600, "Invalid request") else null + } + if (id == null || id is JsonNull) { + // notification + return null + } + return when (method) { + "initialize" -> rpcResult(id, initializeResult()) + "ping" -> rpcResult(id, buildJsonObject {}) + "tools/list" -> rpcResult(id, toolsList(params)) + "tools/call" -> callTool(id, params, session, extraOut) + "resources/list" -> rpcResult(id, resourcesList()) + "resources/templates/list" -> rpcResult(id, resourceTemplates()) + "resources/read" -> rpcResult(id, resourceRead(params)) + "resources/subscribe" -> { + val uri = params?.string("uri").orEmpty() + session.subscribed += uri + extraOut += McpOutbound(rpcNotification("notifications/resources/updated", buildJsonObject { put("uri", uri) })) + rpcResult(id, buildJsonObject {}) + } + "resources/unsubscribe" -> { + session.subscribed -= params?.string("uri").orEmpty() + rpcResult(id, buildJsonObject {}) + } + "prompts/list" -> rpcResult(id, promptsList()) + "prompts/get" -> rpcResult(id, promptGet(params)) + "completion/complete" -> rpcResult(id, complete(params)) + "logging/setLevel" -> { + session.logLevel = params?.string("level") ?: "info" + rpcResult(id, buildJsonObject {}) + } + else -> rpcError(id, -32601, "Method not found: $method") + } + } + + private fun initializeResult() = buildJsonObject { + put("protocolVersion", "2025-06-18") + put("capabilities", buildJsonObject { + put("tools", buildJsonObject { put("listChanged", true) }) + put("resources", buildJsonObject { + put("subscribe", true) + put("listChanged", true) + }) + put("prompts", buildJsonObject { put("listChanged", true) }) + put("logging", buildJsonObject {}) + put("completions", buildJsonObject {}) + }) + put("serverInfo", buildJsonObject { + put("name", "ReqLab MCP Mock") + put("version", "1.18.0") + }) + put("instructions", "Deterministic ReqLab sample MCP server") + } + + private fun toolsList(params: JsonObject?): JsonObject { + val cursor = params?.string("cursor") + val tools = listOf( + tool("echo", "Echo the text argument", "text"), + tool("add", "Add two numbers", "a", "b"), + tool("fail", "Return a tool-level isError result"), + tool("slow", "Emit progress notifications then finish"), + tool("trigger_sampling", "Ask the client to sample a message"), + tool("trigger_elicitation", "Ask the client to fill a form"), + tool("trigger_roots", "Ask the client for roots/list"), + ) + return if (cursor == "page2") { + buildJsonObject { put("tools", buildJsonArray {}) } + } else { + buildJsonObject { + put("tools", buildJsonArray { tools.forEach { add(it) } }) + } + } + } + + private fun tool(name: String, description: String, vararg props: String) = buildJsonObject { + put("name", name) + put("description", description) + put("inputSchema", buildJsonObject { + put("type", "object") + put("properties", buildJsonObject { + props.forEach { put(it, buildJsonObject { put("type", "string") }) } + }) + }) + } + + private fun callTool( + id: JsonElement, + params: JsonObject?, + session: McpMockSession, + extraOut: MutableList, + ): JsonObject { + val name = params?.string("name") ?: return rpcError(id, -32602, "Missing tool name") + val args = params["arguments"] as? JsonObject + val token = ((params["_meta"] as? JsonObject)?.get("progressToken")) + return when (name) { + "echo" -> rpcResult(id, toolText(args?.string("text") ?: "")) + "add" -> { + val a = args?.string("a")?.toIntOrNull() ?: args?.get("a")?.jsonPrimitive?.intOrNull ?: 0 + val b = args?.string("b")?.toIntOrNull() ?: args?.get("b")?.jsonPrimitive?.intOrNull ?: 0 + rpcResult(id, toolText((a + b).toString())) + } + "fail" -> rpcResult(id, toolText("tool failed", isError = true)) + "slow" -> { + if (token != null) { + extraOut += McpOutbound(rpcNotification("notifications/progress", buildJsonObject { + put("progressToken", token) + put("progress", 1) + put("total", 2) + put("message", "halfway") + })) + } + rpcResult(id, toolText("done")) + } + "trigger_sampling" -> { + extraOut += McpOutbound( + buildJsonObject { + put("jsonrpc", "2.0") + put("id", "srv-sample") + put("method", "sampling/createMessage") + put("params", buildJsonObject { + put("messages", buildJsonArray { + add(buildJsonObject { + put("role", "user") + put("content", buildJsonObject { + put("type", "text") + put("text", "Say hi") + }) + }) + }) + put("maxTokens", 32) + }) + }, + ) + rpcResult(id, toolText("sampling requested")) + } + "trigger_elicitation" -> { + extraOut += McpOutbound( + buildJsonObject { + put("jsonrpc", "2.0") + put("id", "srv-elicit") + put("method", "elicitation/create") + put("params", buildJsonObject { + put("message", "What is your name?") + put("requestedSchema", buildJsonObject { + put("type", "object") + put("properties", buildJsonObject { + put("name", buildJsonObject { put("type", "string") }) + }) + }) + }) + }, + ) + rpcResult(id, toolText("elicitation requested")) + } + "trigger_roots" -> { + extraOut += McpOutbound( + buildJsonObject { + put("jsonrpc", "2.0") + put("id", "srv-roots") + put("method", "roots/list") + put("params", buildJsonObject {}) + }, + ) + rpcResult(id, toolText("roots requested")) + } + else -> rpcError(id, -32602, "Unknown tool $name") + } + } + + private fun resourcesList() = buildJsonObject { + put("resources", buildJsonArray { + add(buildJsonObject { + put("uri", "reqlab://docs/welcome") + put("name", "welcome") + put("mimeType", "text/plain") + }) + }) + } + + private fun resourceTemplates() = buildJsonObject { + put("resourceTemplates", buildJsonArray { + add(buildJsonObject { + put("uriTemplate", "reqlab://docs/{name}") + put("name", "docs") + }) + }) + } + + private fun resourceRead(params: JsonObject?): JsonObject { + val uri = params?.string("uri") ?: return buildJsonObject { put("contents", buildJsonArray {}) } + return buildJsonObject { + put("contents", buildJsonArray { + add(buildJsonObject { + put("uri", uri) + put("mimeType", "text/plain") + put("text", "Welcome to ReqLab MCP ($uri)") + }) + }) + } + } + + private fun promptsList() = buildJsonObject { + put("prompts", buildJsonArray { + add(buildJsonObject { + put("name", "greet") + put("description", "Greet someone") + put("arguments", buildJsonArray { + add(buildJsonObject { + put("name", "name") + put("required", true) + }) + }) + }) + }) + } + + private fun promptGet(params: JsonObject?): JsonObject { + val name = (params?.get("arguments") as? JsonObject)?.string("name") ?: "world" + return buildJsonObject { + put("description", "greeting") + put("messages", buildJsonArray { + add(buildJsonObject { + put("role", "user") + put("content", buildJsonObject { + put("type", "text") + put("text", "Hello $name") + }) + }) + }) + } + } + + private fun complete(params: JsonObject?): JsonObject { + val argument = (params?.get("argument") as? JsonObject)?.string("value").orEmpty() + val values = listOf("welcome", "secret", "guide").filter { it.startsWith(argument) } + return buildJsonObject { + put("completion", buildJsonObject { + put("values", buildJsonArray { values.forEach { add(JsonPrimitive(it)) } }) + put("hasMore", false) + }) + } + } + + fun sseFrame(outbound: McpOutbound, eventId: Int): String = buildString { + append("id: ").append(eventId).append('\n') + if (outbound.eventType != "message") append("event: ").append(outbound.eventType).append('\n') + append("data: ").append(outbound.envelope.toString()).append("\n\n") + } + + fun sseEndpointFrame(path: String): String = "event: endpoint\ndata: $path\n\n" + + private fun toolText(text: String, isError: Boolean = false) = buildJsonObject { + put("content", buildJsonArray { + add(buildJsonObject { + put("type", "text") + put("text", text) + }) + }) + put("isError", isError) + } + + fun rpcResult(id: JsonElement, result: JsonObject) = buildJsonObject { + put("jsonrpc", "2.0") + put("id", id) + put("result", result) + } + + fun rpcError(id: JsonElement?, code: Int, message: String) = buildJsonObject { + put("jsonrpc", "2.0") + if (id != null) put("id", id) else put("id", JsonNull) + put("error", buildJsonObject { + put("code", code) + put("message", message) + }) + } + + fun rpcNotification(method: String, params: JsonObject) = buildJsonObject { + put("jsonrpc", "2.0") + put("method", method) + put("params", params) + } + + private fun JsonObject.string(key: String): String? = + (this[key] as? JsonPrimitive)?.contentOrNull +} + +data class OAuthClientRecord(val clientId: String, val redirectUris: List) +data class OAuthCodeRecord(val code: String, val clientId: String, val codeChallenge: String, val redirectUri: String) + +internal fun sha256Base64Url(value: String): String { + val digest = MessageDigest.getInstance("SHA-256").digest(value.toByteArray()) + return Base64.getUrlEncoder().withoutPadding().encodeToString(digest) +} diff --git a/sample-server/src/main/kotlin/com/reqlab/server/McpRoutes.kt b/sample-server/src/main/kotlin/com/reqlab/server/McpRoutes.kt new file mode 100644 index 0000000..9e69ef0 --- /dev/null +++ b/sample-server/src/main/kotlin/com/reqlab/server/McpRoutes.kt @@ -0,0 +1,325 @@ +package com.reqlab.server + +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.server.application.ApplicationCall +import io.ktor.server.request.header +import io.ktor.server.request.receiveText +import io.ktor.server.response.header +import io.ktor.server.response.respond +import io.ktor.server.response.respondRedirect +import io.ktor.server.response.respondText +import io.ktor.server.response.respondTextWriter +import io.ktor.server.routing.Route +import io.ktor.server.routing.delete +import io.ktor.server.routing.get +import io.ktor.server.routing.options +import io.ktor.server.routing.post +import kotlinx.coroutines.channels.Channel +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.add +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicInteger + +private val eventIds = AtomicInteger(1) +private val legacyStreams = ConcurrentHashMap>() + +fun Route.mcpAndOAuthRoutes() { + options("/mcp") { call.applyMcpCors(); call.respond(HttpStatusCode.NoContent) } + options("/mcp/authed") { call.applyMcpCors(); call.respond(HttpStatusCode.NoContent) } + options("/mcp/auth/bearer") { call.applyMcpCors(); call.respond(HttpStatusCode.NoContent) } + options("/mcp/auth/basic") { call.applyMcpCors(); call.respond(HttpStatusCode.NoContent) } + options("/mcp/auth/apikey") { call.applyMcpCors(); call.respond(HttpStatusCode.NoContent) } + options("/mcp/auth/jwt") { call.applyMcpCors(); call.respond(HttpStatusCode.NoContent) } + options("/mcp/secure") { call.applyMcpCors(); call.respond(HttpStatusCode.NoContent) } + options("/mcp/sse") { call.applyMcpCors(); call.respond(HttpStatusCode.NoContent) } + options("/mcp/messages") { call.applyMcpCors(); call.respond(HttpStatusCode.NoContent) } + + post("/mcp") { call.handleMcpPost(McpAuthGate.NONE) } + post("/mcp/authed") { call.handleMcpPost(McpAuthGate.BEARER_AND_API_KEY) } + post("/mcp/auth/bearer") { call.handleMcpPost(McpAuthGate.BEARER) } + post("/mcp/auth/basic") { call.handleMcpPost(McpAuthGate.BASIC) } + post("/mcp/auth/apikey") { call.handleMcpPost(McpAuthGate.API_KEY) } + post("/mcp/auth/jwt") { call.handleMcpPost(McpAuthGate.JWT) } + get("/mcp") { call.handleMcpGet() } + delete("/mcp") { + call.applyMcpCors() + val sid = call.request.header("Mcp-Session-Id") + if (sid != null) McpMockProtocol.sessions.remove(sid) + call.respond(HttpStatusCode.NoContent) + } + + post("/mcp/secure") { call.handleMcpPost(McpAuthGate.OAUTH) } + + get("/mcp/sse") { call.handleLegacySse() } + post("/mcp/messages") { call.handleLegacyMessage() } + + get("/.well-known/oauth-protected-resource") { + call.applyMcpCors() + val origin = call.originBase() + call.respondText( + buildJsonObject { + put("resource", "$origin/mcp/secure") + put("authorization_servers", buildJsonArray { add(JsonPrimitive(origin)) }) + }.toString(), + ContentType.Application.Json, + ) + } + get("/.well-known/oauth-authorization-server") { + call.applyMcpCors() + val origin = call.originBase() + call.respondText( + buildJsonObject { + put("issuer", origin) + put("authorization_endpoint", "$origin/oauth/authorize") + put("token_endpoint", "$origin/oauth/token") + put("registration_endpoint", "$origin/oauth/register") + put("code_challenge_methods_supported", buildJsonArray { add(JsonPrimitive("S256")) }) + put("grant_types_supported", buildJsonArray { + add(JsonPrimitive("authorization_code")) + add(JsonPrimitive("refresh_token")) + add(JsonPrimitive("client_credentials")) + }) + }.toString(), + ContentType.Application.Json, + ) + } + post("/oauth/register") { + call.applyMcpCors() + val body = call.receiveText() + val obj = runCatching { mcpMockJson.parseToJsonElement(body) as? JsonObject }.getOrNull() + val redirect = (obj?.get("redirect_uris") as? kotlinx.serialization.json.JsonArray) + ?.mapNotNull { (it as? JsonPrimitive)?.content } + ?: listOf("http://127.0.0.1:8099/callback") + val clientId = "dcr-" + UUID.randomUUID().toString().take(8) + McpMockProtocol.oauthClients[clientId] = OAuthClientRecord(clientId, redirect) + call.respondText( + buildJsonObject { put("client_id", clientId) }.toString(), + ContentType.Application.Json, + HttpStatusCode.Created, + ) + } + get("/oauth/authorize") { + call.applyMcpCors() + val clientId = call.request.queryParameters["client_id"].orEmpty() + val redirect = call.request.queryParameters["redirect_uri"].orEmpty() + val challenge = call.request.queryParameters["code_challenge"].orEmpty() + val state = call.request.queryParameters["state"] + val code = "code-" + UUID.randomUUID().toString().take(8) + McpMockProtocol.oauthCodes[code] = OAuthCodeRecord(code, clientId, challenge, redirect) + val sep = if (redirect.contains('?')) "&" else "?" + val location = buildString { + append(redirect).append(sep).append("code=").append(code) + if (!state.isNullOrBlank()) append("&state=").append(state) + } + call.respondRedirect(location) + } + post("/oauth/token") { + call.applyMcpCors() + val params = call.receiveText() + val form = params.split("&").mapNotNull { + val parts = it.split("=", limit = 2) + if (parts.size == 2) parts[0] to java.net.URLDecoder.decode(parts[1], Charsets.UTF_8) else null + }.toMap() + val grant = form["grant_type"] + when (grant) { + "authorization_code" -> { + val code = form["code"].orEmpty() + val verifier = form["code_verifier"].orEmpty() + val record = McpMockProtocol.oauthCodes.remove(code) + if (record == null || sha256Base64Url(verifier) != record.codeChallenge) { + call.respond(HttpStatusCode.BadRequest, buildJsonObject { put("error", "invalid_grant") }) + return@post + } + McpMockProtocol.oauthTokens[McpMockProtocol.ISSUED_ACCESS_TOKEN] = record.clientId + call.respondText(tokenJson(), ContentType.Application.Json) + } + "refresh_token" -> { + if (form["refresh_token"] != McpMockProtocol.ISSUED_REFRESH_TOKEN) { + call.respond(HttpStatusCode.BadRequest, buildJsonObject { put("error", "invalid_grant") }) + return@post + } + call.respondText(tokenJson(), ContentType.Application.Json) + } + "client_credentials" -> { + McpMockProtocol.oauthTokens[McpMockProtocol.ISSUED_ACCESS_TOKEN] = form["client_id"].orEmpty() + call.respondText(tokenJson(), ContentType.Application.Json) + } + else -> call.respond(HttpStatusCode.BadRequest, buildJsonObject { put("error", "unsupported_grant_type") }) + } + } +} + +internal const val MCP_TEST_BEARER_TOKEN = "reqlab-mcp-token" +internal const val MCP_TEST_API_KEY = "reqlab-key" +internal const val MCP_TEST_JWT = "reqlab-mcp-jwt" +internal const val MCP_TEST_BASIC_USER = "admin" +internal const val MCP_TEST_BASIC_PASSWORD = "password" + +private enum class McpAuthGate { NONE, BEARER, BASIC, API_KEY, JWT, BEARER_AND_API_KEY, OAUTH } + +private suspend fun ApplicationCall.handleMcpPost(gate: McpAuthGate) { + applyMcpCors() + if (!enforceMcpAuth(gate)) return + if (request.queryParameters["requireTenant"] == "true" && request.queryParameters["tenant"].isNullOrBlank()) { + respond(HttpStatusCode.BadRequest, buildJsonObject { put("error", "missing_tenant") }) + return + } + val stateless = request.queryParameters["stateless"] == "true" + val sessionHeader = request.header("Mcp-Session-Id") + if (!sessionHeader.isNullOrBlank() && McpMockProtocol.session(sessionHeader) == null) { + respond(HttpStatusCode.NotFound, "Unknown MCP session") + return + } + val body = receiveText() + val extra = mutableListOf() + val session = if (stateless) McpMockSession() else McpMockProtocol.requireOrCreate(sessionHeader) + if (!stateless) McpMockProtocol.sessions[session.id] = session + val result = McpMockProtocol.handle(body, session, extra) + if (result != null && result["result"] != null) { + val method = runCatching { + mcpMockJson.parseToJsonElement(body) as JsonObject + }.getOrNull()?.get("method")?.let { (it as? JsonPrimitive)?.content } + if (method == "initialize" && !stateless) { + response.header("Mcp-Session-Id", session.id) + } + } + val accept = request.header(HttpHeaders.Accept).orEmpty() + val wantsSse = accept.contains("text/event-stream") + if (wantsSse && (extra.isNotEmpty() || (result != null && accept.contains("text/event-stream") && extra.isNotEmpty()))) { + respondTextWriter(contentType = ContentType.parse("text/event-stream")) { + extra.forEach { outbound -> + write(McpMockProtocol.sseFrame(outbound, eventIds.getAndIncrement())) + flush() + } + if (result != null) { + write(McpMockProtocol.sseFrame(McpOutbound(result), eventIds.getAndIncrement())) + flush() + } + } + return + } + if (wantsSse && extra.isNotEmpty()) { + respondTextWriter(contentType = ContentType.parse("text/event-stream")) { + extra.forEach { write(McpMockProtocol.sseFrame(it, eventIds.getAndIncrement())); flush() } + if (result != null) { + write(McpMockProtocol.sseFrame(McpOutbound(result), eventIds.getAndIncrement())) + flush() + } + } + return + } + if (result == null) { + respond(HttpStatusCode.Accepted) + return + } + respondText(result.toString(), ContentType.Application.Json) +} + +private suspend fun ApplicationCall.handleMcpGet() { + applyMcpCors() + respond(HttpStatusCode.MethodNotAllowed, "GET SSE optional; not used by this mock") +} + +private suspend fun ApplicationCall.handleLegacySse() { + applyMcpCors() + val session = McpMockProtocol.requireOrCreate(null) + val channel = Channel(Channel.UNLIMITED) + legacyStreams[session.id] = channel + respondTextWriter(contentType = ContentType.parse("text/event-stream")) { + write(McpMockProtocol.sseEndpointFrame("/mcp/messages?sessionId=${session.id}")) + flush() + for (frame in channel) { + write(frame) + flush() + } + } +} + +private suspend fun ApplicationCall.handleLegacyMessage() { + applyMcpCors() + val sid = request.queryParameters["sessionId"] + val session = McpMockProtocol.session(sid) ?: McpMockProtocol.requireOrCreate(sid) + val channel = legacyStreams[session.id] + val extra = mutableListOf() + val result = McpMockProtocol.handle(receiveText(), session, extra) + extra.forEach { channel?.send(McpMockProtocol.sseFrame(it, eventIds.getAndIncrement())) } + if (result != null) channel?.send(McpMockProtocol.sseFrame(McpOutbound(result), eventIds.getAndIncrement())) + respond(HttpStatusCode.Accepted) +} + +private fun ApplicationCall.applyMcpCors() { + response.header("Access-Control-Allow-Origin", "*") + response.header("Access-Control-Allow-Headers", "*") + response.header("Access-Control-Allow-Methods", "GET,POST,DELETE,OPTIONS") + response.header("Access-Control-Expose-Headers", "Mcp-Session-Id, WWW-Authenticate") +} + +private fun ApplicationCall.originBase(): String { + val host = request.header("Host") ?: "localhost:8080" + return "http://$host" +} + +private suspend fun ApplicationCall.enforceMcpAuth(gate: McpAuthGate): Boolean { + val authorization = request.header(HttpHeaders.Authorization).orEmpty() + val ok = when (gate) { + McpAuthGate.NONE -> true + McpAuthGate.OAUTH -> hasMcpOAuth() + McpAuthGate.BEARER -> authorization == "Bearer $MCP_TEST_BEARER_TOKEN" + McpAuthGate.JWT -> authorization == "Bearer $MCP_TEST_JWT" + McpAuthGate.API_KEY -> request.header("X-Api-Key") == MCP_TEST_API_KEY + McpAuthGate.BASIC -> { + val expected = "Basic " + java.util.Base64.getEncoder() + .encodeToString("$MCP_TEST_BASIC_USER:$MCP_TEST_BASIC_PASSWORD".toByteArray()) + authorization == expected + } + McpAuthGate.BEARER_AND_API_KEY -> + authorization == "Bearer $MCP_TEST_BEARER_TOKEN" && request.header("X-Api-Key") == MCP_TEST_API_KEY + } + if (!ok) { + if (gate == McpAuthGate.OAUTH) respondUnauthorized() + else respond(HttpStatusCode.Unauthorized, buildJsonObject { put("error", "invalid_token") }) + } + return ok +} + +private fun ApplicationCall.hasMcpOAuth(): Boolean { + val header = request.header(HttpHeaders.Authorization).orEmpty() + val token = if (header.startsWith("Bearer ")) header.removePrefix("Bearer ").trim() else "" + return token == McpMockProtocol.ISSUED_ACCESS_TOKEN || McpMockProtocol.oauthTokens.containsKey(token) +} + +private suspend fun ApplicationCall.respondUnauthorized() { + response.header( + HttpHeaders.WWWAuthenticate, + """Bearer realm="mcp", resource_metadata="${originBase()}/.well-known/oauth-protected-resource"""", + ) + respond(HttpStatusCode.Unauthorized, buildJsonObject { put("error", "invalid_token") }) +} + +private fun tokenJson(): String = buildJsonObject { + put("access_token", McpMockProtocol.ISSUED_ACCESS_TOKEN) + put("token_type", "Bearer") + put("expires_in", 3600) + put("refresh_token", McpMockProtocol.ISSUED_REFRESH_TOKEN) +}.toString() + +fun runMcpStdio() { + val session = McpMockProtocol.requireOrCreate(null) + val reader = System.`in`.bufferedReader() + while (true) { + val line = reader.readLine() ?: break + if (line.isBlank()) continue + val extra = mutableListOf() + val result = McpMockProtocol.handle(line, session, extra) + extra.forEach { System.out.println(it.envelope.toString()) } + if (result != null) System.out.println(result.toString()) + System.out.flush() + } +} diff --git a/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/persistence/ImportExportFixturesIntegrationTest.kt b/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/persistence/ImportExportFixturesIntegrationTest.kt index ee762db..442de90 100644 --- a/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/persistence/ImportExportFixturesIntegrationTest.kt +++ b/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/persistence/ImportExportFixturesIntegrationTest.kt @@ -46,12 +46,17 @@ class ImportExportFixturesIntegrationTest { assertTrue(root != null) assertTrue(root.children.any { it.isFolder && it.name == "HTTP Methods" }) assertTrue(root.children.any { it.isFolder && it.name == "LLM (OpenAI-compatible)" }) + assertTrue(root.children.any { it.isFolder && it.name == "MCP (Model Context Protocol)" }) val env = restored.environments.firstOrNull { it.name == "Local Dev – Sample Server" } assertTrue(env != null) assertTrue(env.variables.any { it.key == "graphqlUserId" && it.value == "1" }) assertTrue(env.variables.any { it.key == "llmBaseUrl" && it.value == "http://localhost:8080" }) assertTrue(env.variables.any { it.key == "llmApiKey" && it.value == "llm-test-key" }) + assertTrue(env.variables.any { it.key == "mcpBaseUrl" && it.value == "http://localhost:8080/mcp" }) + assertTrue(env.variables.any { it.key == "mcpAuthedUrl" && it.value == "http://localhost:8080/mcp/authed" }) + assertTrue(env.variables.any { it.key == "mcpBearerUrl" && it.value == "http://localhost:8080/mcp/auth/bearer" }) + assertTrue(env.variables.any { it.key == "mcpTenantUrl" && it.value.contains("requireTenant=true") }) } private fun resolveFixture(name: String): File { diff --git a/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/MainScreen.kt b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/MainScreen.kt index 0abb295..5794666 100644 --- a/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/MainScreen.kt +++ b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/MainScreen.kt @@ -53,6 +53,7 @@ import com.reqlab.ui.shared.components.HorizontalSplitPane import com.reqlab.ui.shared.components.OperationProgressDialog import com.reqlab.ui.shared.components.RealtimePanel import com.reqlab.ui.shared.components.RequestEditor +import com.reqlab.ui.shared.components.McpPanel import com.reqlab.ui.shared.components.RequestTabsBar import com.reqlab.ui.shared.components.ResponseViewer import com.reqlab.ui.shared.components.SettingsDialog @@ -201,7 +202,10 @@ fun MainScreen(state: AppState = remember { AppState() }) { isMeta && event.key == Key.Enter -> { val activeTab = state.activeTab if (activeTab != null) { - if (activeTab.isLoading) { + if (activeTab.kind == com.reqlab.core.model.RequestKind.MCP) { + val session = state.getOrCreateMcpSession(activeTab.id) + if (activeTab.isLoading) session.cancelCall() else session.pendingShortcut?.invoke() + } else if (activeTab.isLoading) { activeTab.currentJob?.cancel() activeTab.isLoading = false } else { @@ -372,7 +376,25 @@ private fun ColumnScope.HttpWorkspaceContent( val tab = state.activeTab if (tab != null) { - if (state.settings.responseLayout == ResponseLayout.RIGHT) { + if (tab.kind == com.reqlab.core.model.RequestKind.MCP) { + if (state.settings.responseLayout == ResponseLayout.RIGHT) { + HorizontalSplitPane( + modifier = Modifier.weight(1f).testTag("mcp-workspace"), + splitFraction = state.requestResponseSplit, + onSplitChanged = { state.requestResponseSplit = it }, + first = { McpPanel(state, tab) }, + second = { ResponseViewer(tab) }, + ) + } else { + VerticalSplitPane( + modifier = Modifier.weight(1f).testTag("mcp-workspace"), + splitFraction = state.mainVerticalSplit, + onSplitChanged = { state.mainVerticalSplit = it }, + first = { McpPanel(state, tab) }, + second = { ResponseViewer(tab) }, + ) + } + } else if (state.settings.responseLayout == ResponseLayout.RIGHT) { HorizontalSplitPane( modifier = Modifier.weight(1f).testTag("response-layout-right"), splitFraction = state.requestResponseSplit, diff --git a/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/HelpAboutDialog.kt b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/HelpAboutDialog.kt index db0f2f6..6a4d6bb 100644 --- a/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/HelpAboutDialog.kt +++ b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/HelpAboutDialog.kt @@ -109,7 +109,7 @@ fun HelpAboutDialog(state: AppState) { } HelpSection("Shortcuts") { - ShortcutRow("⌘ + Enter / Ctrl + Enter", "Send request (or cancel if in progress)") + ShortcutRow("⌘ + Enter / Ctrl + Enter", "Send request or run MCP tool (or cancel if in progress)") ShortcutRow("⌘ + Shift + [ / Ctrl + Shift + [", "Move active tab left") ShortcutRow("⌘ + Shift + ] / Ctrl + Shift + ]", "Move active tab right") ShortcutRow("⌘ + S / Ctrl + S", "Save active request") diff --git a/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/KeyValueEditor.kt b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/KeyValueEditor.kt index 5ac7fef..d5a4522 100644 --- a/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/KeyValueEditor.kt +++ b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/KeyValueEditor.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.icons.Icons @@ -39,6 +40,8 @@ import com.reqlab.ui.shared.state.AppState import com.reqlab.ui.shared.state.HeaderKind import com.reqlab.ui.shared.state.MutableKeyValue import com.reqlab.ui.shared.state.SystemHeaderRules +import com.reqlab.ui.shared.platform.PlatformLazyVerticalScrollbar +import com.reqlab.ui.shared.platform.insetScrollbar import com.reqlab.ui.shared.theme.CodeFontFamily import com.reqlab.ui.shared.theme.ReqLabColors @@ -72,22 +75,33 @@ fun KeyValueEditor( androidx.compose.foundation.layout.Spacer(Modifier.size(32.dp)) } - LazyColumn(modifier = Modifier.weight(1f)) { - itemsIndexed(entries, key = { idx, _ -> idx }) { idx, kv -> - KeyValueRow( - kv = kv, - onDelete = { - if (!(isHeaderEditor && kv.kind == HeaderKind.SYSTEM)) { - entries.removeAt(idx) - onDirty() - } - }, - onDirty = onDirty, - isHeaderEditor = isHeaderEditor, - state = state, - testTag = "$tag-row-$idx", - ) + val listState = rememberLazyListState() + Box(Modifier.weight(1f).fillMaxSize()) { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize().padding(end = 10.dp), + ) { + itemsIndexed(entries, key = { idx, _ -> idx }) { idx, kv -> + KeyValueRow( + kv = kv, + onDelete = { + if (!(isHeaderEditor && kv.kind == HeaderKind.SYSTEM)) { + entries.removeAt(idx) + onDirty() + } + }, + onDirty = onDirty, + isHeaderEditor = isHeaderEditor, + state = state, + testTag = "$tag-row-$idx", + ) + } } + PlatformLazyVerticalScrollbar( + listState = listState, + modifier = Modifier.align(Alignment.CenterEnd).insetScrollbar(), + testTag = "$tag-list-vscrollbar", + ) } // "Add …" button diff --git a/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/McpPanel.kt b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/McpPanel.kt new file mode 100644 index 0000000..203356a --- /dev/null +++ b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/McpPanel.kt @@ -0,0 +1,1218 @@ +package com.reqlab.ui.shared.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.reqlab.core.model.KeyValueEntry +import com.reqlab.core.model.McpConnectionState +import com.reqlab.core.model.McpHttpMode +import com.reqlab.core.model.McpLogEntry +import com.reqlab.core.model.McpLogEntryKind +import com.reqlab.core.model.McpPrompt +import com.reqlab.core.model.McpResource +import com.reqlab.core.model.McpTool +import com.reqlab.core.model.McpTransportType +import com.reqlab.ui.shared.i18n.Strings +import com.reqlab.ui.shared.mcp.mcpArgsGet +import com.reqlab.ui.shared.mcp.mcpArgsPut +import com.reqlab.ui.shared.mcp.mcpDefaultArgsJson +import com.reqlab.ui.shared.mcp.mcpMissingRequiredArgs +import com.reqlab.ui.shared.mcp.mcpParseScalar +import com.reqlab.ui.shared.mcp.mcpPrettyJson +import com.reqlab.ui.shared.mcp.mcpPrettyWireJson +import com.reqlab.ui.shared.mcp.mcpPromptSchema +import com.reqlab.ui.shared.mcp.mcpSchemaFields +import com.reqlab.ui.shared.mcp.mcpSchemaFormSupported +import com.reqlab.ui.shared.mcp.mcpToolHintChips +import com.reqlab.ui.shared.platform.PlatformColumnVerticalScrollbar +import com.reqlab.ui.shared.platform.PlatformLazyVerticalScrollbar +import com.reqlab.ui.shared.platform.copyToClipboard +import com.reqlab.ui.shared.platform.insetScrollbar +import com.reqlab.ui.shared.platform.formatTimestamp +import com.reqlab.ui.shared.state.AppState +import com.reqlab.ui.shared.state.MutableKeyValue +import com.reqlab.ui.shared.state.RequestTabState +import com.reqlab.ui.shared.state.ResponseTab +import com.reqlab.ui.shared.theme.CodeFontFamily +import com.reqlab.ui.shared.theme.ReqLabColors +import kotlinx.coroutines.launch +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull + +private const val SECTION_TOOLS = 0 +private const val SECTION_RESOURCES = 1 +private const val SECTION_PROMPTS = 2 +private const val SECTION_AUTH = 3 +private const val SECTION_HEADERS = 4 +private const val SECTION_PARAMS = 5 +private const val SECTION_ACTIVITY = 6 +private const val SECTION_CLIENT = 7 + +@Composable +fun McpPanel(state: AppState, tab: RequestTabState) { + val session = remember(tab.id) { state.getOrCreateMcpSession(tab.id) } + val connection by session.connectionState.collectAsState() + val tools by session.tools.collectAsState() + val resources by session.resources.collectAsState() + val prompts by session.prompts.collectAsState() + val logs by session.logs.collectAsState() + val subscribed by session.subscribedUris.collectAsState() + val lastOperation by session.lastOperation.collectAsState() + val busy by session.busy.collectAsState() + val error by session.error.collectAsState() + + var section by remember { mutableIntStateOf(0) } + var toolArgs by remember { mutableStateOf("{}") } + var selectedTool by remember { mutableStateOf(null) } + var selectedResource by remember { mutableStateOf(null) } + var selectedPrompt by remember { mutableStateOf(null) } + var promptArgs by remember { mutableStateOf("{}") } + var showStdioConfirm by remember { mutableStateOf(false) } + val lastToolArgs = remember(tab.id) { mutableStateMapOf() } + val lastPromptArgs = remember(tab.id) { mutableStateMapOf() } + var listSplit by remember { mutableFloatStateOf(0.32f) } + + val headerRows = remember(tab.id) { + mutableStateListOf().apply { + addAll(tab.mcpConfig.headers.map { MutableKeyValue(it.key, it.value, it.enabled, it.secret) }) + } + } + fun persistHeaders() { + tab.mcpConfig = tab.mcpConfig.copy( + headers = headerRows.map { KeyValueEntry(it.key, it.value, it.enabled, it.secret) }, + ) + tab.markDirty() + } + + fun liveConfig() = tab.mcpConfig.copy( + url = tab.url.ifBlank { tab.mcpConfig.url }, + auth = buildAuthConfig(tab), + headers = headerRows.map { KeyValueEntry(it.key, it.value, it.enabled, it.secret) }, + ) + + val errorLabel = Strings.t("mcp_tool_error") + val okLabel = Strings.t("mcp_tool_ok") + LaunchedEffect(lastOperation) { + val op = lastOperation ?: return@LaunchedEffect + tab.response = op.toResponseDefinition(tab.id, okStatusText = okLabel, errorStatusText = errorLabel) + tab.responseTab = ResponseTab.BODY + tab.lastError = if (op.isError) error else null + } + LaunchedEffect(busy) { tab.isLoading = busy } + LaunchedEffect(error) { + if (lastOperation?.isError != true) tab.lastError = error + } + LaunchedEffect(connection) { + if (connection == McpConnectionState.DISCONNECTED) { + tab.response = null + tab.lastError = null + tab.isLoading = false + } + } + + LaunchedEffect(connection, tools) { + if (connection != McpConnectionState.CONNECTED || tools.isEmpty()) return@LaunchedEffect + val preferred = selectedTool?.takeIf { name -> tools.any { it.name == name } } ?: tools.first().name + if (selectedTool != preferred) { + selectedTool = preferred + toolArgs = lastToolArgs[preferred] ?: mcpDefaultArgsJson(tools.first { it.name == preferred }.inputSchema) + } + } + LaunchedEffect(connection, prompts) { + if (connection != McpConnectionState.CONNECTED || prompts.isEmpty()) return@LaunchedEffect + val preferred = selectedPrompt?.takeIf { name -> prompts.any { it.name == name } } ?: prompts.first().name + if (selectedPrompt != preferred) { + selectedPrompt = preferred + promptArgs = lastPromptArgs[preferred] ?: defaultPromptArgsJson(prompts.first { it.name == preferred }) + } + } + + fun connectNow() { + syncUrlFromParams(tab) + val url = tab.url.ifBlank { tab.mcpConfig.url } + tab.url = url + persistHeaders() + tab.mcpConfig = tab.mcpConfig.copy(url = url, auth = buildAuthConfig(tab)) + val cfg = tab.mcpConfig + state.appScope.launch { runCatching { session.connect(cfg, state.activeVariableLayers()) } } + } + + fun requestConnect() { + if (tab.mcpConfig.transport == McpTransportType.STDIO && !session.confirmStdio) { + showStdioConfirm = true + } else { + connectNow() + } + } + + val selectedToolSchema = tools.firstOrNull { it.name == selectedTool }?.inputSchema + val selectedPromptModel = prompts.firstOrNull { it.name == selectedPrompt } + LaunchedEffect(section, selectedTool, selectedPrompt, selectedResource, toolArgs, promptArgs, busy, connection) { + session.pendingShortcut = { + when { + busy -> session.cancelCall() + connection != McpConnectionState.CONNECTED -> Unit + section == SECTION_TOOLS && selectedTool != null && + mcpMissingRequiredArgs(selectedToolSchema ?: JsonObject(emptyMap()), toolArgs).isEmpty() -> { + val args = runCatching { mcpPrettyJson.parseToJsonElement(toolArgs) }.getOrNull() as? JsonObject + session.launchCall { session.callSelectedTool(selectedTool!!, args) } + } + section == SECTION_RESOURCES && selectedResource != null -> { + session.launchCall { session.readSelectedResource(selectedResource!!) } + } + section == SECTION_PROMPTS && selectedPrompt != null && selectedPromptModel != null && + mcpMissingRequiredArgs(mcpPromptSchema(selectedPromptModel), promptArgs).isEmpty() -> { + val parsed = runCatching { mcpPrettyJson.parseToJsonElement(promptArgs) }.getOrNull() as? JsonObject + val map = parsed?.mapNotNull { (k, v) -> + (v as? JsonPrimitive)?.contentOrNull?.let { k to it } + }?.toMap().orEmpty() + session.launchCall { session.getSelectedPrompt(selectedPrompt!!, map) } + } + } + } + } + + val reconnectNeeded = session.isReconnectNeeded(liveConfig()) + Column( + modifier = Modifier + .fillMaxSize() + .background(ReqLabColors.Background) + .padding(12.dp) + .testTag("mcp-panel"), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + ConnectionBar( + tab = tab, + state = state, + session = session, + connection = connection, + reconnectNeeded = reconnectNeeded, + showStdioConfirm = showStdioConfirm, + onShowStdioConfirm = { showStdioConfirm = it }, + onConnect = { requestConnect() }, + onDisconnect = { state.appScope.launch { session.disconnect() } }, + onReconnect = { + state.appScope.launch { + session.disconnect() + requestConnect() + } + }, + onConfirmStdio = { + session.confirmStdio = true + showStdioConfirm = false + connectNow() + }, + ) + error?.let { Text(it, color = ReqLabColors.Error, fontSize = 12.sp, modifier = Modifier.testTag("mcp-error")) } + + val tabLabels = listOf( + "${Strings.t("mcp_tools")}${countSuffix(tools.size)}", + "${Strings.t("mcp_resources")}${countSuffix(resources.size)}", + "${Strings.t("mcp_prompts")}${countSuffix(prompts.size)}", + Strings.t("auth"), + "${Strings.t("headers")}${countSuffix(headerRows.size)}", + "${Strings.t("params")}${countSuffix(tab.params.size)}", + Strings.t("mcp_activity"), + Strings.t("mcp_client"), + ) + TabRow( + selectedTabIndex = section, + modifier = Modifier.fillMaxWidth().testTag("mcp-tabs"), + containerColor = ReqLabColors.Background, + ) { + tabLabels.forEachIndexed { index, title -> + Tab( + selected = section == index, + onClick = { section = index }, + text = { + Text(title, fontSize = 11.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) + }, + ) + } + } + + Box(Modifier.weight(1f).fillMaxWidth()) { + val connected = connection == McpConnectionState.CONNECTED + when (section) { + SECTION_TOOLS -> ToolsSection( + state = state, + connected = connected, + tools = tools, + selected = selectedTool, + args = toolArgs, + busy = busy, + listSplit = listSplit, + onListSplitChanged = { listSplit = it }, + onSelect = { tool -> + selectedTool?.let { lastToolArgs[it] = toolArgs } + selectedTool = tool.name + toolArgs = lastToolArgs[tool.name] ?: mcpDefaultArgsJson(tool.inputSchema) + }, + onArgsChange = { next -> + toolArgs = next + selectedTool?.let { lastToolArgs[it] = next } + }, + onRun = { name -> + val args = runCatching { mcpPrettyJson.parseToJsonElement(toolArgs) }.getOrNull() as? JsonObject + session.launchCall { session.callSelectedTool(name, args) } + }, + onStop = { session.cancelCall() }, + ) + SECTION_RESOURCES -> ResourcesSection( + connected = connected, + resources = resources, + selected = selectedResource, + subscribed = subscribed, + supportsSubscribe = session.supportsSubscribe(), + busy = busy, + listSplit = listSplit, + onListSplitChanged = { listSplit = it }, + onSelect = { selectedResource = it.uri }, + onRead = { uri -> session.launchCall { session.readSelectedResource(uri) } }, + onSubscribe = { uri -> session.launchCall { session.subscribeResource(uri) } }, + onUnsubscribe = { uri -> session.launchCall { session.unsubscribeResource(uri) } }, + onStop = { session.cancelCall() }, + ) + SECTION_PROMPTS -> PromptsSection( + state = state, + connected = connected, + prompts = prompts, + selected = selectedPrompt, + args = promptArgs, + busy = busy, + listSplit = listSplit, + onListSplitChanged = { listSplit = it }, + onSelect = { prompt -> + selectedPrompt?.let { lastPromptArgs[it] = promptArgs } + selectedPrompt = prompt.name + promptArgs = lastPromptArgs[prompt.name] ?: defaultPromptArgsJson(prompt) + }, + onArgsChange = { next -> + promptArgs = next + selectedPrompt?.let { lastPromptArgs[it] = next } + }, + onGet = { name -> + val parsed = runCatching { mcpPrettyJson.parseToJsonElement(promptArgs) }.getOrNull() as? JsonObject + val map = parsed?.mapNotNull { (k, v) -> + (v as? JsonPrimitive)?.contentOrNull?.let { k to it } + }?.toMap().orEmpty() + session.launchCall { session.getSelectedPrompt(name, map) } + }, + onStop = { session.cancelCall() }, + ) + SECTION_AUTH -> AuthEditor(tab, state) { + tab.mcpConfig = tab.mcpConfig.copy(auth = buildAuthConfig(tab)) + tab.markDirty() + } + SECTION_HEADERS -> KeyValueEditor(headerRows, "header", state) { persistHeaders() } + SECTION_PARAMS -> KeyValueEditor(tab.params, "param", state) { + syncUrlFromParams(tab) + tab.mcpConfig = tab.mcpConfig.copy(url = tab.url) + tab.markDirty() + } + SECTION_ACTIVITY -> ActivitySection(logs) + else -> ClientSection(tab, session) + } + } + } +} + +@Composable +private fun ConnectionBar( + tab: RequestTabState, + state: AppState, + session: com.reqlab.ui.shared.mcp.McpSessionState, + connection: McpConnectionState, + reconnectNeeded: Boolean, + showStdioConfirm: Boolean, + onShowStdioConfirm: (Boolean) -> Unit, + onConnect: () -> Unit, + onDisconnect: () -> Unit, + onReconnect: () -> Unit, + onConfirmStdio: () -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + StatusDot(connection) + Text( + when (connection) { + McpConnectionState.CONNECTED -> Strings.t("mcp_connected") + McpConnectionState.CONNECTING -> Strings.t("mcp_connecting") + McpConnectionState.ERROR -> Strings.t("mcp_status_error") + McpConnectionState.DISCONNECTED -> Strings.t("mcp_disconnected") + }, + color = ReqLabColors.OnSurface, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + ) + val negotiated = session.negotiatedLabel() + if (negotiated.isNotBlank()) { + Text(negotiated, color = ReqLabColors.OnSurfaceDim, fontSize = 11.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) + } else { + Spacer(Modifier.weight(1f)) + } + session.sessionId()?.let { sid -> + val short = if (sid.length > 16) sid.take(10) + "…" else sid + SelectionContainer { + Text( + "${Strings.t("mcp_session_id")} $short", + color = ReqLabColors.OnSurfaceDim, + fontSize = 11.sp, + fontFamily = CodeFontFamily, + maxLines = 1, + modifier = Modifier.testTag("mcp-session-id"), + ) + } + } + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + if (tab.mcpConfig.transport == McpTransportType.STDIO) { + Box( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(8.dp)) + .background(ReqLabColors.Surface) + .border(1.dp, ReqLabColors.Border, RoundedCornerShape(8.dp)) + .padding(horizontal = 10.dp, vertical = 10.dp), + ) { + VariableAwareTextField( + value = tab.mcpConfig.command, + onValueChange = { tab.mcpConfig = tab.mcpConfig.copy(command = it); tab.markDirty() }, + placeholder = Strings.t("mcp_command"), + state = state, + modifier = Modifier.fillMaxWidth().testTag("mcp-command"), + ) + } + } else { + Box( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(8.dp)) + .background(ReqLabColors.Surface) + .border(1.dp, ReqLabColors.Border, RoundedCornerShape(8.dp)) + .padding(horizontal = 10.dp, vertical = 10.dp), + ) { + VariableAwareTextField( + value = tab.mcpConfig.url.ifBlank { tab.url }, + onValueChange = { + tab.mcpConfig = tab.mcpConfig.copy(url = it) + tab.url = it + syncParamsFromUrl(tab, it) + tab.markDirty() + }, + placeholder = Strings.t("mcp_url"), + state = state, + undoStack = tab.urlUndoStack, + modifier = Modifier.fillMaxWidth().testTag("mcp-url"), + ) + } + } + ConnectionActionButton( + connection = connection, + reconnectNeeded = reconnectNeeded, + onConnect = onConnect, + onDisconnect = onDisconnect, + onReconnect = onReconnect, + ) + } + if (showStdioConfirm) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(Strings.t("mcp_stdio_confirm"), color = ReqLabColors.OnSurface, modifier = Modifier.weight(1f)) + TextButton(onClick = onConfirmStdio) { Text(Strings.confirm) } + TextButton(onClick = { onShowStdioConfirm(false) }) { Text(Strings.cancel) } + } + } + } +} + +@Composable +private fun ConnectionActionButton( + connection: McpConnectionState, + reconnectNeeded: Boolean, + onConnect: () -> Unit, + onDisconnect: () -> Unit, + onReconnect: () -> Unit, +) { + when { + connection == McpConnectionState.CONNECTING -> { + Button(onClick = {}, enabled = false, modifier = Modifier.testTag("mcp-connect")) { + Text(Strings.t("mcp_connecting")) + } + } + connection == McpConnectionState.CONNECTED && !reconnectNeeded -> { + OutlinedButton(onClick = onDisconnect, modifier = Modifier.testTag("mcp-disconnect")) { + Text(Strings.disconnect) + } + } + connection == McpConnectionState.ERROR || reconnectNeeded -> { + Button(onClick = onReconnect, modifier = Modifier.testTag("mcp-reconnect")) { + Text(Strings.t("mcp_reconnect")) + } + } + else -> { + Button(onClick = onConnect, modifier = Modifier.testTag("mcp-connect")) { + Text(Strings.connect) + } + } + } +} + +@Composable +private fun ToolsSection( + state: AppState, + connected: Boolean, + tools: List, + selected: String?, + args: String, + busy: Boolean, + listSplit: Float, + onListSplitChanged: (Float) -> Unit, + onSelect: (McpTool) -> Unit, + onArgsChange: (String) -> Unit, + onRun: (String) -> Unit, + onStop: () -> Unit, +) { + Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (!connected) { + EmptyState(Strings.t("mcp_connect_hint")) + return@Column + } + if (tools.isEmpty()) { + EmptyState(Strings.t("mcp_no_tools")) + return@Column + } + var query by remember { mutableStateOf("") } + val filtered = remember(tools, query) { + val q = query.trim() + if (q.isEmpty()) tools + else tools.filter { it.name.contains(q, ignoreCase = true) || it.description.orEmpty().contains(q, ignoreCase = true) } + } + SplitCard(Modifier.weight(1f).fillMaxWidth()) { + HorizontalSplitPane( + modifier = Modifier.fillMaxSize(), + splitFraction = listSplit, + onSplitChanged = onListSplitChanged, + minFraction = 0.18f, + maxFraction = 0.55f, + dividerTag = "mcp-tools-split", + hairline = true, + first = { + SplitColumn { + SearchField(query, { query = it }, Strings.t("mcp_search_tools"), "mcp-search-tools") + ScrollableLazyColumn( + modifier = Modifier.weight(1f).fillMaxHeight(), + listTestTag = "mcp-tool-list", + scrollbarTag = "mcp-tool-list-vscrollbar", + ) { + items(filtered, key = { it.name }) { tool -> + ListRow( + title = tool.name, + subtitle = tool.description, + active = tool.name == selected, + onClick = { onSelect(tool) }, + ) + } + } + } + }, + second = { + SplitColumn { + val tool = tools.firstOrNull { it.name == selected } + if (tool == null) { + EmptyState(Strings.t("mcp_select_tool")) + return@SplitColumn + } + val missing = mcpMissingRequiredArgs(tool.inputSchema, args) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Text(tool.name, color = ReqLabColors.OnSurface, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis) + mcpToolHintChips(tool.annotations).forEach { chip -> + val label = if (chip == "readOnly") Strings.t("mcp_readonly") else Strings.t("mcp_destructive") + AnnotationChip(label, destructive = chip == "destructive") + } + RunButton( + busy = busy, + enabled = missing.isEmpty(), + label = Strings.t("mcp_run"), + testTag = "mcp-run", + onClick = { onRun(tool.name) }, + onStop = onStop, + ) + } + SchemaArgsEditor( + state = state, + schema = tool.inputSchema, + args = args, + onArgsChange = onArgsChange, + testTagPrefix = "mcp-tool-args", + ) + } + }, + ) + } + } +} + +@Composable +private fun ResourcesSection( + connected: Boolean, + resources: List, + selected: String?, + subscribed: Set, + supportsSubscribe: Boolean, + busy: Boolean, + listSplit: Float, + onListSplitChanged: (Float) -> Unit, + onSelect: (McpResource) -> Unit, + onRead: (String) -> Unit, + onSubscribe: (String) -> Unit, + onUnsubscribe: (String) -> Unit, + onStop: () -> Unit, +) { + Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (!connected) { + EmptyState(Strings.t("mcp_connect_hint")) + return@Column + } + if (resources.isEmpty()) { + EmptyState(Strings.t("mcp_no_resources")) + return@Column + } + var query by remember { mutableStateOf("") } + val filtered = remember(resources, query) { + val q = query.trim() + if (q.isEmpty()) resources + else resources.filter { it.name.contains(q, ignoreCase = true) || it.uri.contains(q, ignoreCase = true) } + } + SplitCard(Modifier.weight(1f).fillMaxWidth()) { + HorizontalSplitPane( + modifier = Modifier.fillMaxSize(), + splitFraction = listSplit, + onSplitChanged = onListSplitChanged, + minFraction = 0.18f, + maxFraction = 0.55f, + dividerTag = "mcp-resources-split", + hairline = true, + first = { + SplitColumn { + SearchField(query, { query = it }, Strings.t("mcp_search_resources"), "mcp-search-resources") + ScrollableLazyColumn( + modifier = Modifier.weight(1f).fillMaxHeight(), + listTestTag = "mcp-resource-list", + scrollbarTag = "mcp-resource-list-vscrollbar", + ) { + items(filtered, key = { it.uri }) { res -> + ListRow( + title = res.name, + subtitle = res.uri, + active = res.uri == selected, + trailing = if (res.uri in subscribed) Strings.t("mcp_subscribed") else null, + onClick = { onSelect(res) }, + ) + } + } + } + }, + second = { + SplitColumn { + val res = resources.firstOrNull { it.uri == selected } + if (res == null) { + EmptyState(Strings.t("mcp_resource_empty")) + return@SplitColumn + } + Text(res.name, color = ReqLabColors.OnSurface, fontWeight = FontWeight.SemiBold) + Text(res.uri, color = ReqLabColors.OnSurfaceDim, fontSize = 11.sp, fontFamily = CodeFontFamily, maxLines = 2, overflow = TextOverflow.Ellipsis) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + RunButton( + busy = busy, + enabled = true, + label = Strings.t("mcp_read_resource"), + testTag = "mcp-read", + onClick = { onRead(res.uri) }, + onStop = onStop, + ) + if (supportsSubscribe) { + if (res.uri in subscribed) { + OutlinedButton(onClick = { onUnsubscribe(res.uri) }, enabled = !busy, modifier = Modifier.testTag("mcp-unsubscribe")) { + Text(Strings.t("mcp_unsubscribe")) + } + } else { + OutlinedButton(onClick = { onSubscribe(res.uri) }, enabled = !busy, modifier = Modifier.testTag("mcp-subscribe")) { + Text(Strings.t("mcp_subscribe")) + } + } + } + } + } + }, + ) + } + } +} + +@Composable +private fun PromptsSection( + state: AppState, + connected: Boolean, + prompts: List, + selected: String?, + args: String, + busy: Boolean, + listSplit: Float, + onListSplitChanged: (Float) -> Unit, + onSelect: (McpPrompt) -> Unit, + onArgsChange: (String) -> Unit, + onGet: (String) -> Unit, + onStop: () -> Unit, +) { + Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (!connected) { + EmptyState(Strings.t("mcp_connect_hint")) + return@Column + } + if (prompts.isEmpty()) { + EmptyState(Strings.t("mcp_no_prompts")) + return@Column + } + var query by remember { mutableStateOf("") } + val filtered = remember(prompts, query) { + val q = query.trim() + if (q.isEmpty()) prompts + else prompts.filter { it.name.contains(q, ignoreCase = true) || it.description.orEmpty().contains(q, ignoreCase = true) } + } + SplitCard(Modifier.weight(1f).fillMaxWidth()) { + HorizontalSplitPane( + modifier = Modifier.fillMaxSize(), + splitFraction = listSplit, + onSplitChanged = onListSplitChanged, + minFraction = 0.18f, + maxFraction = 0.55f, + dividerTag = "mcp-prompts-split", + hairline = true, + first = { + SplitColumn { + SearchField(query, { query = it }, Strings.t("mcp_search_prompts"), "mcp-search-prompts") + ScrollableLazyColumn( + modifier = Modifier.weight(1f).fillMaxHeight(), + listTestTag = "mcp-prompt-list", + scrollbarTag = "mcp-prompt-list-vscrollbar", + ) { + items(filtered, key = { it.name }) { prompt -> + ListRow( + title = prompt.name, + subtitle = prompt.description, + active = prompt.name == selected, + onClick = { onSelect(prompt) }, + ) + } + } + } + }, + second = { + SplitColumn { + val prompt = prompts.firstOrNull { it.name == selected } + if (prompt == null) { + EmptyState(Strings.t("mcp_prompt_empty")) + return@SplitColumn + } + val schema = mcpPromptSchema(prompt) + val missing = mcpMissingRequiredArgs(schema, args) + Row(verticalAlignment = Alignment.CenterVertically) { + Text(prompt.name, color = ReqLabColors.OnSurface, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis) + RunButton( + busy = busy, + enabled = missing.isEmpty(), + label = Strings.t("mcp_get_prompt"), + testTag = "mcp-get-prompt", + onClick = { onGet(prompt.name) }, + onStop = onStop, + ) + } + SchemaArgsEditor( + state = state, + schema = schema, + args = args, + onArgsChange = onArgsChange, + testTagPrefix = "mcp-prompt-args", + ) + } + }, + ) + } + } +} + +@Composable +private fun SchemaArgsEditor( + state: AppState, + schema: JsonObject, + args: String, + onArgsChange: (String) -> Unit, + testTagPrefix: String, +) { + val formSupported = mcpSchemaFormSupported(schema) + var preferForm by remember { mutableStateOf(true) } + val showForm = formSupported && preferForm + Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (formSupported) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + TransportChip(Strings.t("mcp_form"), showForm) { preferForm = true } + TransportChip(Strings.t("mcp_json"), !showForm) { preferForm = false } + } + } + if (showForm) { + Column( + Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + mcpSchemaFields(schema).forEach { field -> + val label = buildString { + append(field.title ?: field.name) + if (field.required) append(" *") + } + Text(label, color = ReqLabColors.OnSurfaceDim, fontSize = 11.sp) + when { + field.enumValues.isNotEmpty() -> { + val current = (mcpArgsGet(args, field.name) as? JsonPrimitive)?.content.orEmpty() + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + field.enumValues.forEach { opt -> + TransportChip(opt, current == opt) { + onArgsChange(mcpArgsPut(args, field.name, JsonPrimitive(opt))) + } + } + } + } + field.type == "boolean" -> { + val checked = (mcpArgsGet(args, field.name) as? JsonPrimitive)?.content == "true" + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = checked, + onCheckedChange = { onArgsChange(mcpArgsPut(args, field.name, JsonPrimitive(it))) }, + ) + Text(field.name, color = ReqLabColors.OnSurface, fontSize = 13.sp) + } + } + else -> { + val current = (mcpArgsGet(args, field.name) as? JsonPrimitive)?.content.orEmpty() + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(ReqLabColors.Surface) + .border(1.dp, ReqLabColors.Border, RoundedCornerShape(8.dp)) + .padding(horizontal = 10.dp, vertical = 8.dp), + ) { + VariableAwareTextField( + value = current, + onValueChange = { + onArgsChange(mcpArgsPut(args, field.name, mcpParseScalar(it, field.type))) + }, + placeholder = field.name, + state = state, + modifier = Modifier.fillMaxWidth().testTag("$testTagPrefix-${field.name}"), + ) + } + } + } + } + } + } else { + Box(Modifier.weight(1f).fillMaxWidth().clip(RoundedCornerShape(8.dp)).border(1.dp, ReqLabColors.Border, RoundedCornerShape(8.dp))) { + CodeEditor( + text = args, + onTextChange = onArgsChange, + language = SyntaxLanguage.JSON, + modifier = Modifier.fillMaxSize(), + enableDownload = false, + testTagPrefix = testTagPrefix, + ) + } + } + } +} + +@Composable +private fun ActivitySection(logs: List) { + Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (logs.isEmpty()) { + EmptyState(Strings.t("mcp_activity_empty")) + return@Column + } + ScrollableLazyColumn( + modifier = Modifier.weight(1f), + listTestTag = "mcp-log", + scrollbarTag = "mcp-activity-vscrollbar", + ) { + items(logs.size) { i -> + ActivityRow(logs[logs.size - 1 - i]) + HorizontalDivider(color = ReqLabColors.BorderLight) + } + } + } +} + +@Composable +private fun ActivityRow(entry: McpLogEntry) { + var expanded by remember(entry) { mutableStateOf(false) } + val hasPayload = !entry.payload.isNullOrBlank() + val pretty = if (hasPayload) prettyPayload(entry.payload.orEmpty()) else "" + Column( + Modifier.fillMaxWidth().padding(vertical = 6.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = hasPayload) { expanded = !expanded }, + ) { + Text(formatTimestamp(entry.timestampEpochMillis), color = ReqLabColors.OnSurfaceDim, fontSize = 11.sp, fontFamily = CodeFontFamily) + Text( + entry.kind.name, + color = activityColor(entry.kind), + fontSize = 10.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier + .clip(RoundedCornerShape(6.dp)) + .border(1.dp, activityColor(entry.kind), RoundedCornerShape(6.dp)) + .padding(horizontal = 6.dp, vertical = 1.dp), + ) + Text(entry.summary, color = ReqLabColors.OnSurface, fontSize = 12.sp, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis) + entry.id?.let { Text("#$it", color = ReqLabColors.OnSurfaceDim, fontSize = 10.sp, fontFamily = CodeFontFamily) } + if (hasPayload) { + IconButton( + onClick = { copyToClipboard(pretty) }, + modifier = Modifier.size(28.dp).testTag("mcp-activity-copy"), + ) { + Icon( + Icons.Default.ContentCopy, + contentDescription = Strings.copy, + tint = ReqLabColors.OnSurfaceDim, + modifier = Modifier.size(14.dp), + ) + } + } + } + if (expanded && hasPayload) { + SelectionContainer { + Text( + pretty, + color = ReqLabColors.OnSurfaceDim, + fontSize = 11.sp, + fontFamily = CodeFontFamily, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(6.dp)) + .background(ReqLabColors.SurfaceContainer) + .padding(8.dp) + .testTag("mcp-activity-payload"), + ) + } + } else if (hasPayload) { + Text(Strings.t("mcp_activity_expand"), color = ReqLabColors.OnSurfaceDim, fontSize = 10.sp) + } + } +} + +@Composable +private fun ClientSection(tab: RequestTabState, session: com.reqlab.ui.shared.mcp.McpSessionState) { + val scroll = rememberScrollState() + Box(Modifier.fillMaxSize()) { + Column( + Modifier.fillMaxSize().verticalScroll(scroll).padding(end = 12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text(Strings.t("mcp_transport"), color = ReqLabColors.OnSurface, fontWeight = FontWeight.SemiBold) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TransportChip("HTTP", tab.mcpConfig.transport == McpTransportType.STREAMABLE_HTTP) { + tab.mcpConfig = tab.mcpConfig.copy(transport = McpTransportType.STREAMABLE_HTTP) + tab.markDirty() + } + if (session.stdioAvailable()) { + TransportChip("stdio", tab.mcpConfig.transport == McpTransportType.STDIO) { + tab.mcpConfig = tab.mcpConfig.copy(transport = McpTransportType.STDIO) + tab.markDirty() + } + } + } + Text(Strings.t("mcp_http_mode"), color = ReqLabColors.OnSurface, fontWeight = FontWeight.SemiBold) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TransportChip("Auto", tab.mcpConfig.httpMode == McpHttpMode.AUTO) { + tab.mcpConfig = tab.mcpConfig.copy(httpMode = McpHttpMode.AUTO); tab.markDirty() + } + TransportChip("2025-06-18", tab.mcpConfig.httpMode == McpHttpMode.STREAMABLE_2025_06_18) { + tab.mcpConfig = tab.mcpConfig.copy(httpMode = McpHttpMode.STREAMABLE_2025_06_18); tab.markDirty() + } + TransportChip("Legacy", tab.mcpConfig.httpMode == McpHttpMode.LEGACY_2024_11_05) { + tab.mcpConfig = tab.mcpConfig.copy(httpMode = McpHttpMode.LEGACY_2024_11_05); tab.markDirty() + } + } + Text(Strings.t("mcp_sampling_mode"), color = ReqLabColors.OnSurface, fontWeight = FontWeight.SemiBold) + Text("${tab.mcpConfig.samplingMode}", color = ReqLabColors.OnSurface, fontFamily = CodeFontFamily, fontSize = 13.sp) + Text(Strings.t("mcp_roots"), color = ReqLabColors.OnSurface, fontWeight = FontWeight.SemiBold) + if (tab.mcpConfig.roots.isEmpty()) { + Text("\u2014", color = ReqLabColors.OnSurfaceDim) + } else { + tab.mcpConfig.roots.forEach { root -> + Text(root.uri, color = ReqLabColors.OnSurfaceDim, fontFamily = CodeFontFamily, fontSize = 12.sp) + } + } + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = tab.mcpConfig.autoRespondElicitation, onCheckedChange = { + tab.mcpConfig = tab.mcpConfig.copy(autoRespondElicitation = it) + tab.markDirty() + }) + Text(Strings.t("mcp_auto_elicit"), color = ReqLabColors.OnSurface) + } + } + PlatformColumnVerticalScrollbar( + scrollState = scroll, + modifier = Modifier.align(Alignment.CenterEnd).insetScrollbar(), + testTag = "mcp-client-vscrollbar", + ) + } +} + +@Composable +private fun ScrollableLazyColumn( + modifier: Modifier, + listTestTag: String, + scrollbarTag: String, + content: LazyListScope.() -> Unit, +) { + val listState = rememberLazyListState() + Box(modifier) { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize().padding(end = 10.dp).testTag(listTestTag), + content = content, + ) + PlatformLazyVerticalScrollbar( + listState = listState, + modifier = Modifier.align(Alignment.CenterEnd).insetScrollbar(), + testTag = scrollbarTag, + ) + } +} + +@Composable +private fun SearchField(value: String, onValueChange: (String) -> Unit, placeholder: String, testTag: String) { + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(6.dp)) + .background(ReqLabColors.Background) + .border(1.dp, ReqLabColors.Border, RoundedCornerShape(6.dp)) + .padding(horizontal = 8.dp, vertical = 6.dp), + ) { + VariableAwareTextField( + value = value, + onValueChange = onValueChange, + placeholder = placeholder, + modifier = Modifier.fillMaxWidth().testTag(testTag), + ) + } +} + +@Composable +private fun ListRow( + title: String, + subtitle: String?, + active: Boolean, + trailing: String? = null, + onClick: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(if (active) ReqLabColors.Primary.copy(alpha = 0.16f) else Color.Transparent) + .clickable(onClick = onClick) + .padding(8.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + title, + color = ReqLabColors.OnSurface, + fontWeight = FontWeight.SemiBold, + fontSize = 13.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + trailing?.let { + Text(it, color = Color(0xFF22C55E), fontSize = 10.sp, fontWeight = FontWeight.SemiBold) + } + } + if (!subtitle.isNullOrBlank()) { + Text( + subtitle, + color = ReqLabColors.OnSurfaceDim, + fontSize = 11.sp, + fontFamily = CodeFontFamily, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +private fun RunButton( + busy: Boolean, + enabled: Boolean, + label: String, + testTag: String, + onClick: () -> Unit, + onStop: () -> Unit, +) { + if (busy) { + OutlinedButton(onClick = onStop, modifier = Modifier.testTag("$testTag-stop")) { + Text(Strings.t("stop")) + } + } else { + Button(onClick = onClick, enabled = enabled, modifier = Modifier.testTag(testTag)) { + Text(label) + } + } +} + +@Composable +private fun AnnotationChip(label: String, destructive: Boolean) { + Text( + label, + color = if (destructive) ReqLabColors.Error else ReqLabColors.OnSurfaceDim, + fontSize = 10.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier + .clip(RoundedCornerShape(6.dp)) + .border(1.dp, if (destructive) ReqLabColors.Error else ReqLabColors.Border, RoundedCornerShape(6.dp)) + .padding(horizontal = 6.dp, vertical = 1.dp), + ) +} + +@Composable +private fun EmptyState(text: String) { + Box( + Modifier.fillMaxSize().clip(RoundedCornerShape(8.dp)).border(1.dp, ReqLabColors.Border, RoundedCornerShape(8.dp)).padding(16.dp), + contentAlignment = Alignment.Center, + ) { + Text(text, color = ReqLabColors.OnSurfaceDim, fontSize = 13.sp) + } +} + +@Composable +private fun SplitCard(modifier: Modifier = Modifier, content: @Composable () -> Unit) { + Box( + modifier + .clip(RoundedCornerShape(8.dp)) + .background(ReqLabColors.Surface) + .border(1.dp, ReqLabColors.Border, RoundedCornerShape(8.dp)), + ) { + content() + } +} + +@Composable +private fun SplitColumn(content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit) { + Column( + Modifier.fillMaxSize().padding(10.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + content = content, + ) +} + +@Composable +private fun StatusDot(state: McpConnectionState) { + val color = when (state) { + McpConnectionState.CONNECTED -> Color(0xFF22C55E) + McpConnectionState.CONNECTING -> Color(0xFFEAB308) + McpConnectionState.ERROR -> ReqLabColors.Error + McpConnectionState.DISCONNECTED -> ReqLabColors.OnSurfaceDim + } + Box(Modifier.size(10.dp).clip(CircleShape).background(color).testTag("mcp-status-${state.name}")) +} + +@Composable +private fun TransportChip(label: String, selected: Boolean, onClick: () -> Unit) { + Text( + label, + color = if (selected) ReqLabColors.Primary else ReqLabColors.OnSurface, + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .border(1.dp, if (selected) ReqLabColors.Primary else ReqLabColors.OnSurfaceDim, RoundedCornerShape(12.dp)) + .clickable(onClick = onClick) + .padding(horizontal = 10.dp, vertical = 4.dp), + fontSize = 12.sp, + ) +} + +@Composable +private fun activityColor(kind: McpLogEntryKind): Color = when (kind) { + McpLogEntryKind.SENT -> ReqLabColors.Primary + McpLogEntryKind.RECEIVED -> Color(0xFF22C55E) + McpLogEntryKind.NOTIFICATION -> ReqLabColors.Tertiary + McpLogEntryKind.ERROR -> ReqLabColors.Error + McpLogEntryKind.STATE, McpLogEntryKind.OAUTH -> ReqLabColors.OnSurfaceDim +} + +private fun prettyPayload(payload: String): String = mcpPrettyWireJson(payload) + +private fun countSuffix(n: Int): String = if (n > 0) " ($n)" else "" + +private fun defaultPromptArgsJson(prompt: McpPrompt): String { + if (prompt.arguments.isEmpty()) return "{}" + return mcpDefaultArgsJson(mcpPromptSchema(prompt)) +} diff --git a/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/RequestTabsBar.kt b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/RequestTabsBar.kt index 09b27c0..4784f0b 100644 --- a/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/RequestTabsBar.kt +++ b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/RequestTabsBar.kt @@ -25,6 +25,7 @@ import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Hub import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon @@ -210,7 +211,16 @@ private fun RequestTabChip( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), ) { - MethodBadge(tab.method, compact = true) + if (tab.kind == com.reqlab.core.model.RequestKind.MCP) { + Icon( + Icons.Default.Hub, + contentDescription = "MCP", + tint = ReqLabColors.Primary, + modifier = Modifier.size(12.dp).testTag("tab-mcp-badge-${tab.id}"), + ) + } else { + MethodBadge(tab.method, compact = true) + } if (renameMode) { BasicTextField( value = renameText, diff --git a/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/ResponseViewer.kt b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/ResponseViewer.kt index 2e42087..7f02f4f 100644 --- a/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/ResponseViewer.kt +++ b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/ResponseViewer.kt @@ -63,11 +63,13 @@ fun ResponseViewer(tab: RequestTabState) { ResponseStatusBar(response) // ── Tabs ──────────────────────────────────────────── - ResponseTabBar(tab.responseTab, onTabSelected = { tab.responseTab = it }) + val visibleTabs = responseTabsFor(tab) + val selectedTab = if (tab.responseTab in visibleTabs) tab.responseTab else ResponseTab.BODY + ResponseTabBar(visibleTabs, selectedTab, onTabSelected = { tab.responseTab = it }) // ── Content ───────────────────────────────────────── Box(modifier = Modifier.weight(1f).fillMaxWidth()) { - when (tab.responseTab) { + when (selectedTab) { ResponseTab.BODY -> ResponseBodyView(response) ResponseTab.HEADERS -> ResponseHeadersView(response) ResponseTab.COOKIES -> ResponseCookiesView(response) @@ -215,22 +217,33 @@ private fun MetricChip(text: String, color: Color) { // ── Tab Bar ───────────────────────────────────────────────────── +private fun responseTabsFor(tab: RequestTabState): List = + if (tab.kind == com.reqlab.core.model.RequestKind.MCP) { + ResponseTab.entries.filter { it != ResponseTab.COOKIES } + } else { + ResponseTab.entries + } + @Composable -private fun ResponseTabBar(selectedTab: ResponseTab, onTabSelected: (ResponseTab) -> Unit) { +private fun ResponseTabBar( + tabs: List, + selectedTab: ResponseTab, + onTabSelected: (ResponseTab) -> Unit, +) { Box( modifier = Modifier .fillMaxWidth() .background(ReqLabColors.Surface) ) { ScrollableTabRow( - selectedTabIndex = selectedTab.ordinal, + selectedTabIndex = tabs.indexOf(selectedTab).coerceAtLeast(0), containerColor = Color.Transparent, contentColor = ReqLabColors.OnSurface, edgePadding = 0.dp, divider = {}, indicator = {}, ) { - ResponseTab.entries.forEach { tab -> + tabs.forEach { tab -> val selected = tab == selectedTab Tab( selected = selected, diff --git a/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/Sidebar.kt b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/Sidebar.kt index a950b4a..ce664a2 100644 --- a/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/Sidebar.kt +++ b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/Sidebar.kt @@ -47,6 +47,7 @@ import androidx.compose.material.icons.automirrored.filled.Input import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.FolderOpen import androidx.compose.material.icons.filled.History +import androidx.compose.material.icons.filled.Hub import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material.icons.filled.MoreVert @@ -1409,6 +1410,19 @@ private fun CollectionTreeNode( .size(14.dp) .testTag(if (depth == 0) "collection-root-icon-${node.id}" else "collection-subfolder-icon-${node.id}"), ) + } else if (node.kind == com.reqlab.core.model.RequestKind.MCP) { + Icon( + Icons.Default.DragIndicator, + contentDescription = Strings.t("drag_to_reorder"), + tint = ReqLabColors.OnSurfaceDim.copy(alpha = 0.4f), + modifier = Modifier.size(12.dp), + ) + Icon( + Icons.Default.Hub, + contentDescription = "MCP", + tint = ReqLabColors.Primary, + modifier = Modifier.size(14.dp).testTag("mcp-badge-${node.id}"), + ) } else if (node.method != null) { Icon( Icons.Default.DragIndicator, @@ -1506,6 +1520,14 @@ private fun CollectionTreeNode( leadingIcon = { Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(16.dp)) }, onClick = { showMenu = false; onAddRequest(node) }, ) + DropdownMenuItem( + text = { Text(Strings.t("new_mcp_connection")) }, + leadingIcon = { Icon(Icons.Default.Hub, contentDescription = null, modifier = Modifier.size(16.dp)) }, + onClick = { + showMenu = false + state.addMcpConnectionToCollection(node.id) + }, + ) if (isCollectionRoot) { DropdownMenuItem( text = { Text(Strings.exportCollection) }, diff --git a/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/SplitPane.kt b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/SplitPane.kt index 3ad32b8..47cfa14 100644 --- a/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/SplitPane.kt +++ b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/SplitPane.kt @@ -20,6 +20,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.pointerHoverIcon @@ -45,6 +46,8 @@ fun HorizontalSplitPane( minFraction: Float = 0.20f, maxFraction: Float = 0.80f, dividerTag: String = "h-split-divider", + /** 1.dp internal line inside a 6.dp drag hit; does not grow on hover. */ + hairline: Boolean = false, first: @Composable () -> Unit, second: @Composable () -> Unit, ) { @@ -66,8 +69,11 @@ fun HorizontalSplitPane( Box( modifier = Modifier .fillMaxHeight() - .width(if (isHovered) 6.dp else 4.dp) - .background(if (isHovered) ReqLabColors.Primary.copy(alpha = 0.6f) else ReqLabColors.Border) + .width(if (hairline) 6.dp else if (isHovered) 6.dp else 4.dp) + .then( + if (hairline) Modifier + else Modifier.background(if (isHovered) ReqLabColors.Primary.copy(alpha = 0.6f) else ReqLabColors.Border), + ) .hoverable(dividerInteraction) .pointerHoverIcon(horizontalResizeCursor) .platformResizeCursorStyle(isHorizontal = true) @@ -85,7 +91,17 @@ fun HorizontalSplitPane( }, ) .testTag(dividerTag), - ) + contentAlignment = Alignment.Center, + ) { + if (hairline) { + Box( + Modifier + .fillMaxHeight() + .width(1.dp) + .background(if (isHovered) ReqLabColors.Primary.copy(alpha = 0.45f) else ReqLabColors.Border), + ) + } + } Box(modifier = Modifier.fillMaxHeight().weight(1f - currentSplit.coerceIn(minFraction, maxFraction))) { second() diff --git a/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/mcp/McpSessionState.kt b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/mcp/McpSessionState.kt new file mode 100644 index 0000000..94d9d05 --- /dev/null +++ b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/mcp/McpSessionState.kt @@ -0,0 +1,510 @@ +package com.reqlab.ui.shared.mcp + +import com.reqlab.core.model.KeyValueEntry +import com.reqlab.core.model.McpConnectionConfig +import com.reqlab.core.model.McpConnectionState +import com.reqlab.core.model.McpGetPromptResult +import com.reqlab.core.model.McpHttpMode +import com.reqlab.core.model.McpInitializeResult +import com.reqlab.core.model.McpLogEntry +import com.reqlab.core.model.McpLogEntryKind +import com.reqlab.core.model.McpPrompt +import com.reqlab.core.model.McpReadResourceResult +import com.reqlab.core.model.McpResource +import com.reqlab.core.model.McpTool +import com.reqlab.core.model.McpToolResult +import com.reqlab.core.model.McpTransportType +import com.reqlab.core.model.ResponseDefinition +import com.reqlab.core.model.ResponseMetrics +import com.reqlab.core.network.mcp.McpClient +import com.reqlab.core.network.mcp.mcpStdioSupported +import com.reqlab.ui.shared.state.LogLevel +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.yield +import kotlinx.datetime.Clock +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.add +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray + +/** + * Result of the most recent MCP operation (tool call / resource read / prompt get), + * shaped so the UI can render it through the shared REST [ResponseViewer]. + */ +data class McpOperationResult( + val kind: String, + val label: String, + val bodyJson: String, + val isError: Boolean, + val headers: List, + val elapsedMs: Long, + val sizeBytes: Long, + val timestampMs: Long, +) { + fun toResponseDefinition( + requestId: String, + okStatusText: String = "Success", + errorStatusText: String = "Error", + ): ResponseDefinition { + val code = if (isError) 500 else 200 + return ResponseDefinition( + requestId = requestId, + statusCode = code, + statusText = if (isError) errorStatusText else okStatusText, + headers = headers, + cookies = emptyList(), + bodyText = bodyJson, + contentType = "application/json", + executedAtEpochMillis = timestampMs, + metrics = ResponseMetrics( + statusCode = code, + responseTimeMs = elapsedMs, + responseSizeBytes = sizeBytes, + ), + ) + } +} + +class McpSessionState( + private val scope: CoroutineScope, + /** Optional bridge that forwards MCP activity to the global console. */ + private val onConsole: ((String, LogLevel) -> Unit)? = null, + private val clientFactory: (CoroutineScope) -> McpClient = { McpClient(it) }, +) { + var client: McpClient? = null + private set + private val _connectionState = MutableStateFlow(McpConnectionState.DISCONNECTED) + val connectionState: StateFlow = _connectionState + private val _tools = MutableStateFlow>(emptyList()) + val tools: StateFlow> = _tools + private val _resources = MutableStateFlow>(emptyList()) + val resources: StateFlow> = _resources + private val _prompts = MutableStateFlow>(emptyList()) + val prompts: StateFlow> = _prompts + private val _logs = MutableStateFlow>(emptyList()) + val logs: StateFlow> = _logs + private val _lastToolResult = MutableStateFlow(null) + val lastToolResult: StateFlow = _lastToolResult + private val _lastToolName = MutableStateFlow(null) + val lastToolName: StateFlow = _lastToolName + private val _lastResourceResult = MutableStateFlow(null) + val lastResourceResult: StateFlow = _lastResourceResult + private val _lastPromptResult = MutableStateFlow(null) + val lastPromptResult: StateFlow = _lastPromptResult + private val _lastOperation = MutableStateFlow(null) + val lastOperation: StateFlow = _lastOperation + private val _subscribedUris = MutableStateFlow>(emptySet()) + val subscribedUris: StateFlow> = _subscribedUris + private val _busy = MutableStateFlow(false) + val busy: StateFlow = _busy + private val _error = MutableStateFlow(null) + val error: StateFlow = _error + private val _initializeResult = MutableStateFlow(null) + val initializeResult: StateFlow = _initializeResult + var confirmStdio: Boolean = false + /** Cmd/Ctrl+Enter target set by the MCP panel for the active section. */ + var pendingShortcut: (() -> Unit)? = null + private var logJob: Job? = null + private var notifJob: Job? = null + private var callJob: Job? = null + private var connectedFingerprint: String? = null + + fun stdioAvailable(): Boolean = mcpStdioSupported + + /** True when the connected server advertises the resources/subscribe capability. */ + fun supportsSubscribe(): Boolean = + _initializeResult.value?.capabilities?.resources?.subscribe == true + + suspend fun connect(config: McpConnectionConfig, variableLayers: List> = emptyList()) { + if (config.transport == McpTransportType.STDIO && !confirmStdio) { + _error.value = "Confirm stdio before connecting (local process execution)" + return + } + disconnect() + val created = clientFactory(scope) + client = created + logJob = scope.launch { + created.logs.collect { entry -> + _logs.value = (_logs.value + entry).takeLast(200) + onConsole?.invoke(consoleMessage(entry), consoleLevel(entry.kind)) + } + } + notifJob = scope.launch { + created.notifications.collect { n -> handleNotification(created, n) } + } + yield() + _connectionState.value = McpConnectionState.CONNECTING + try { + val init = created.connect(config, variableLayers) + _initializeResult.value = init + connectedFingerprint = connectionFingerprint(config) + _connectionState.value = McpConnectionState.CONNECTED + if (init.capabilities.tools != null) _tools.value = created.listTools() + if (init.capabilities.resources != null) _resources.value = created.listResources() + if (init.capabilities.prompts != null) _prompts.value = created.listPrompts() + _error.value = null + } catch (e: Exception) { + _connectionState.value = McpConnectionState.ERROR + _error.value = e.message + throw e + } + } + + suspend fun callSelectedTool(name: String, arguments: JsonElement?) { + runCall("tool", name) { + val result = client?.callTool(name, arguments) ?: return@runCall null + _lastToolResult.value = result + _lastToolName.value = name + OpBody(latestWireJson() ?: mcpPrettyJson.encodeToString(McpToolResult.serializer(), result), result.isError) + } + } + + suspend fun readSelectedResource(uri: String) { + runCall("resource", uri) { + val result = client?.readResource(uri) ?: return@runCall null + _lastResourceResult.value = result + OpBody(latestWireJson() ?: mcpPrettyJson.encodeToString(McpReadResourceResult.serializer(), result), false) + } + } + + suspend fun getSelectedPrompt(name: String, arguments: Map = emptyMap()) { + runCall("prompt", name) { + val result = client?.getPrompt(name, arguments) ?: return@runCall null + _lastPromptResult.value = result + OpBody(latestWireJson() ?: mcpPrettyJson.encodeToString(McpGetPromptResult.serializer(), result), false) + } + } + + suspend fun subscribeResource(uri: String) { + runCall("subscribe", uri) { + client?.subscribeResource(uri) ?: return@runCall null + _subscribedUris.value = _subscribedUris.value + uri + onConsole?.invoke("MCP subscribed to $uri", LogLevel.INFO) + null + } + } + + suspend fun unsubscribeResource(uri: String) { + runCall("unsubscribe", uri) { + client?.unsubscribeResource(uri) ?: return@runCall null + _subscribedUris.value = _subscribedUris.value - uri + onConsole?.invoke("MCP unsubscribed from $uri", LogLevel.INFO) + null + } + } + + private class OpBody(val bodyJson: String, val isError: Boolean) + + private suspend fun runCall(kind: String, label: String, block: suspend () -> OpBody?) { + _busy.value = true + _error.value = null + val start = Clock.System.now().toEpochMilliseconds() + try { + val body = block() ?: return + val elapsed = Clock.System.now().toEpochMilliseconds() - start + _lastOperation.value = McpOperationResult( + kind = kind, + label = label, + bodyJson = body.bodyJson, + isError = body.isError, + headers = client?.lastResponseHeaders?.toKeyValueEntries().orEmpty(), + elapsedMs = elapsed, + sizeBytes = body.bodyJson.length.toLong(), + timestampMs = Clock.System.now().toEpochMilliseconds(), + ) + } catch (e: CancellationException) { + _busy.value = false + throw e + } catch (e: Exception) { + val elapsed = Clock.System.now().toEpochMilliseconds() - start + val msg = e.message ?: e.toString() + _error.value = msg + onConsole?.invoke("MCP \u2717 $msg", LogLevel.ERROR) + _lastOperation.value = McpOperationResult( + kind = kind, + label = label, + bodyJson = latestWireJson() ?: mcpPrettyJson.encodeToString( + JsonObject.serializer(), + buildJsonObject { put("error", msg) }, + ), + isError = true, + headers = client?.lastResponseHeaders?.toKeyValueEntries().orEmpty(), + elapsedMs = elapsed, + sizeBytes = 0, + timestampMs = Clock.System.now().toEpochMilliseconds(), + ) + } finally { + _busy.value = false + } + } + + private suspend fun handleNotification(activeClient: McpClient, notification: com.reqlab.core.model.JsonRpcEnvelope) { + if (notification.method != "notifications/resources/updated") return + val uri = (notification.params as? JsonObject)?.get("uri")?.jsonPrimitive?.contentOrNull ?: return + onConsole?.invoke("MCP resource updated: $uri", LogLevel.INFO) + if (uri !in _subscribedUris.value) return + runCatching { + val result = activeClient.readResource(uri) + _lastResourceResult.value = result + _lastOperation.value = McpOperationResult( + kind = "resource", + label = uri, + bodyJson = mcpPrettyWireJson(activeClient.lastReceivedPayload.orEmpty()) + .ifBlank { mcpPrettyJson.encodeToString(McpReadResourceResult.serializer(), result) }, + isError = false, + headers = activeClient.lastResponseHeaders?.toKeyValueEntries().orEmpty(), + elapsedMs = 0, + sizeBytes = 0, + timestampMs = Clock.System.now().toEpochMilliseconds(), + ) + } + } + + fun launchCall(block: suspend () -> Unit) { + callJob?.cancel() + callJob = scope.launch { + try { + block() + } catch (_: CancellationException) { + _busy.value = false + } + } + } + + fun cancelCall() { + callJob?.cancel() + callJob = null + _busy.value = false + } + + fun isReconnectNeeded(config: McpConnectionConfig): Boolean = + when (_connectionState.value) { + McpConnectionState.ERROR -> true + McpConnectionState.CONNECTED -> + connectedFingerprint != null && connectedFingerprint != connectionFingerprint(config) + else -> false + } + + suspend fun disconnect() { + callJob?.cancel() + callJob = null + logJob?.cancel() + logJob = null + notifJob?.cancel() + notifJob = null + runCatching { client?.disconnect() } + client = null + connectedFingerprint = null + _connectionState.value = McpConnectionState.DISCONNECTED + _tools.value = emptyList() + _resources.value = emptyList() + _prompts.value = emptyList() + _initializeResult.value = null + _lastToolResult.value = null + _lastToolName.value = null + _lastResourceResult.value = null + _lastPromptResult.value = null + _lastOperation.value = null + _subscribedUris.value = emptySet() + _busy.value = false + } + + fun negotiatedLabel(): String { + val init = _initializeResult.value ?: return "" + val mode = client?.negotiatedHttpMode ?: McpHttpMode.AUTO + return "${init.protocolVersion} \u00B7 $mode \u00B7 ${init.serverInfo.name}" + } + + fun sessionId(): String? = client?.sessionId + + private fun latestWireJson(): String? = + client?.lastReceivedPayload?.takeIf { it.isNotBlank() }?.let(::mcpPrettyWireJson) + + private fun consoleLevel(kind: McpLogEntryKind): LogLevel = when (kind) { + McpLogEntryKind.ERROR -> LogLevel.ERROR + McpLogEntryKind.RECEIVED -> LogLevel.SUCCESS + else -> LogLevel.INFO + } + + private fun consoleMessage(entry: McpLogEntry): String { + val marker = when (entry.kind) { + McpLogEntryKind.SENT -> "\u2192" + McpLogEntryKind.RECEIVED -> "\u2190" + McpLogEntryKind.NOTIFICATION -> "\u25C8" + McpLogEntryKind.ERROR -> "\u2717" + McpLogEntryKind.STATE -> "\u2022" + McpLogEntryKind.OAUTH -> "\u26BF" + } + return "MCP $marker ${entry.summary}" + } +} + +private fun Map>.toKeyValueEntries(): List = + entries.flatMap { (key, values) -> values.map { KeyValueEntry(key = key, value = it) } } + +internal val mcpPrettyJson = Json { prettyPrint = true; encodeDefaults = true; ignoreUnknownKeys = true } + +/** Pretty-print a JSON-RPC wire frame without re-encoding through MCP data classes. */ +internal val mcpWirePretty = Json { + prettyPrint = true + encodeDefaults = false + explicitNulls = false + ignoreUnknownKeys = true +} + +internal fun mcpPrettyWireJson(raw: String): String = + runCatching { + mcpWirePretty.encodeToString(JsonElement.serializer(), mcpWirePretty.parseToJsonElement(raw)) + }.getOrDefault(raw) + +internal fun mcpDefaultArgsJson(schema: JsonObject): String { + val properties = schema["properties"] as? JsonObject + if (properties.isNullOrEmpty()) return "{}" + val obj = buildJsonObject { + properties.forEach { (key, spec) -> + val type = ((spec as? JsonObject)?.get("type") as? JsonPrimitive)?.content + when (type) { + "number", "integer" -> put(key, 0) + "boolean" -> put(key, false) + "object" -> put(key, buildJsonObject {}) + "array" -> put(key, buildJsonArray {}) + else -> put(key, "") + } + } + } + return mcpPrettyJson.encodeToString(JsonObject.serializer(), obj) +} + +internal data class McpSchemaField( + val name: String, + val type: String, + val required: Boolean, + val enumValues: List = emptyList(), + val title: String? = null, +) + +internal fun mcpSchemaFields(schema: JsonObject): List { + val properties = schema["properties"] as? JsonObject ?: return emptyList() + val required = (schema["required"] as? JsonArray) + ?.mapNotNull { (it as? JsonPrimitive)?.content } + ?.toSet() + .orEmpty() + return properties.entries.map { (key, spec) -> + val obj = spec as? JsonObject + val type = (obj?.get("type") as? JsonPrimitive)?.content ?: "string" + val enums = (obj?.get("enum") as? JsonArray) + ?.mapNotNull { (it as? JsonPrimitive)?.content } + .orEmpty() + val title = (obj?.get("title") as? JsonPrimitive)?.content + McpSchemaField( + name = key, + type = type, + required = key in required, + enumValues = enums, + title = title, + ) + } +} + +internal fun mcpSchemaFormSupported(schema: JsonObject): Boolean { + val fields = mcpSchemaFields(schema) + if (fields.isEmpty()) return false + return fields.all { field -> + field.enumValues.isNotEmpty() || field.type in setOf("string", "number", "integer", "boolean") + } +} + +internal fun mcpMissingRequiredArgs(schema: JsonObject, argsJson: String): List { + val obj = runCatching { mcpPrettyJson.parseToJsonElement(argsJson) }.getOrNull() as? JsonObject + ?: JsonObject(emptyMap()) + return mcpSchemaFields(schema).filter { it.required }.mapNotNull { field -> + val value = obj[field.name] + val missing = when { + value == null -> true + value is JsonPrimitive && value.isString && value.content.isBlank() -> true + else -> false + } + field.name.takeIf { missing } + } +} + +internal fun mcpArgsGet(argsJson: String, key: String): JsonElement? { + val obj = runCatching { mcpPrettyJson.parseToJsonElement(argsJson) }.getOrNull() as? JsonObject + return obj?.get(key) +} + +internal fun mcpArgsPut(argsJson: String, key: String, value: JsonElement): String { + val obj = runCatching { mcpPrettyJson.parseToJsonElement(argsJson) }.getOrNull() as? JsonObject + ?: JsonObject(emptyMap()) + val next = buildJsonObject { + obj.forEach { (k, v) -> if (k != key) put(k, v) } + put(key, value) + } + return mcpPrettyJson.encodeToString(JsonObject.serializer(), next) +} + +internal fun mcpToolHintChips(annotations: JsonObject?): List { + if (annotations == null) return emptyList() + fun flag(name: String): Boolean { + val prim = annotations[name] as? JsonPrimitive ?: return false + return prim.booleanOrNull == true || prim.content.equals("true", ignoreCase = true) + } + return buildList { + if (flag("readOnlyHint")) add("readOnly") + if (flag("destructiveHint")) add("destructive") + } +} + +internal fun mcpPromptSchema(prompt: McpPrompt): JsonObject = buildJsonObject { + put("type", "object") + putJsonArray("required") { + prompt.arguments.filter { it.required == true }.forEach { add(JsonPrimitive(it.name)) } + } + put("properties", buildJsonObject { + prompt.arguments.forEach { arg -> + put(arg.name, buildJsonObject { + put("type", "string") + arg.description?.let { put("description", it) } + }) + } + }) +} + +internal fun connectionFingerprint(config: McpConnectionConfig): String = + listOf( + config.transport.name, + config.httpMode.name, + config.url, + config.command, + config.auth.type.name, + config.auth.params.entries.sortedBy { it.key }.joinToString { "${it.key}=${it.value}" }, + config.headers.filter { it.enabled && it.key.isNotBlank() }.joinToString { "${it.key}=${it.value}" }, + ).joinToString("|") + +internal fun mcpParseScalar(raw: String, type: String): JsonElement { + if (raw.contains("{{")) return JsonPrimitive(raw) + return when (type) { + "integer" -> raw.toLongOrNull()?.let { JsonPrimitive(it) } ?: JsonPrimitive(raw) + "number" -> raw.toDoubleOrNull()?.let { JsonPrimitive(it) } ?: JsonPrimitive(raw) + "boolean" -> when (raw.lowercase()) { + "true" -> JsonPrimitive(true) + "false" -> JsonPrimitive(false) + else -> JsonPrimitive(raw) + } + else -> JsonPrimitive(raw) + } +} diff --git a/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/persistence/ImportExportRepository.kt b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/persistence/ImportExportRepository.kt index c84f911..abb7b94 100644 --- a/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/persistence/ImportExportRepository.kt +++ b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/persistence/ImportExportRepository.kt @@ -1,9 +1,11 @@ package com.reqlab.ui.shared.persistence +import com.reqlab.core.model.AuthConfig import com.reqlab.core.model.AuthType import com.reqlab.core.model.BodyType import com.reqlab.core.model.FormEntryType import com.reqlab.core.model.HttpMethodType +import com.reqlab.core.model.KeyValueEntry import com.reqlab.ui.shared.state.FormDataEntryState import com.reqlab.ui.shared.state.AppState import com.reqlab.ui.shared.state.CollectionNode @@ -78,6 +80,12 @@ data class RequestDto( val authToken: String? = null, val authApiKey: String? = null, val authApiValue: String? = null, + val kind: String? = null, + val mcpTransport: String? = null, + val mcpHttpMode: String? = null, + val mcpCommand: String? = null, + val mcpArgs: List = emptyList(), + val mcpEnv: Map = emptyMap(), ) data class ReqLabEnvironmentDto( @@ -406,6 +414,17 @@ object ImportExportRepository { node.authApiValue?.takeIf { it.isNotBlank() }?.let { put("apiValue", it) } }) } + if (node.kind == com.reqlab.core.model.RequestKind.MCP) { + put("kind", "MCP") + val mcp = node.mcpConfig + if (mcp != null) { + put("mcpTransport", mcp.transport.name) + put("mcpHttpMode", mcp.httpMode.name) + if (mcp.command.isNotBlank()) put("mcpCommand", mcp.command) + if (mcp.args.isNotEmpty()) put("mcpArgs", buildJsonArray { mcp.args.forEach { add(JsonPrimitive(it)) } }) + if (mcp.env.isNotEmpty()) put("mcpEnv", buildJsonObject { mcp.env.forEach { (k, v) -> put(k, v) } }) + } + } } } @@ -527,6 +546,12 @@ object ImportExportRepository { dto.authApiValue?.takeIf { it.isNotBlank() }?.let { put("apiValue", it) } }) } + dto.kind?.takeIf { it.equals("MCP", ignoreCase = true) }?.let { put("kind", it) } + dto.mcpTransport?.let { put("mcpTransport", it) } + dto.mcpHttpMode?.let { put("mcpHttpMode", it) } + dto.mcpCommand?.takeIf { it.isNotBlank() }?.let { put("mcpCommand", it) } + if (dto.mcpArgs.isNotEmpty()) put("mcpArgs", buildJsonArray { dto.mcpArgs.forEach { add(JsonPrimitive(it)) } }) + if (dto.mcpEnv.isNotEmpty()) put("mcpEnv", buildJsonObject { dto.mcpEnv.forEach { (k, v) -> put(k, v) } }) } private fun environmentDtoToJson(dto: ReqLabEnvironmentDto): JsonObject = @@ -635,6 +660,12 @@ object ImportExportRepository { authType = authType, authUsername = authUsername, authPassword = authPassword, authToken = authToken, authApiKey = authApiKey, authApiValue = authApiValue, + kind = root["kind"]?.jsonPrimitive?.contentOrNull, + mcpTransport = root["mcpTransport"]?.jsonPrimitive?.contentOrNull, + mcpHttpMode = root["mcpHttpMode"]?.jsonPrimitive?.contentOrNull, + mcpCommand = root["mcpCommand"]?.jsonPrimitive?.contentOrNull, + mcpArgs = root["mcpArgs"]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList(), + mcpEnv = root["mcpEnv"]?.jsonObject?.mapValues { it.value.jsonPrimitive.content } ?: emptyMap(), ) } @@ -763,6 +794,30 @@ object ImportExportRepository { val method = runCatching { HttpMethodType.valueOf(dto.method.uppercase()) }.getOrDefault(HttpMethodType.GET) val bodyType = dto.bodyType?.let { runCatching { BodyType.valueOf(it.uppercase()) }.getOrNull() } val authType = dto.authType?.let { runCatching { AuthType.valueOf(it.uppercase()) }.getOrNull() } + val kind = dto.kind?.let { runCatching { com.reqlab.core.model.RequestKind.valueOf(it.uppercase()) }.getOrNull() } + ?: com.reqlab.core.model.RequestKind.HTTP + val mcp = if (kind == com.reqlab.core.model.RequestKind.MCP) { + val authParams = when (authType) { + AuthType.BASIC -> mapOf("username" to dto.authUsername.orEmpty(), "password" to dto.authPassword.orEmpty()) + AuthType.BEARER, AuthType.JWT -> mapOf("token" to dto.authToken.orEmpty()) + AuthType.API_KEY -> mapOf("key" to dto.authApiKey.orEmpty(), "value" to dto.authApiValue.orEmpty()) + else -> emptyMap() + } + com.reqlab.core.model.McpConnectionConfig( + transport = runCatching { + com.reqlab.core.model.McpTransportType.valueOf(dto.mcpTransport ?: "STREAMABLE_HTTP") + }.getOrDefault(com.reqlab.core.model.McpTransportType.STREAMABLE_HTTP), + httpMode = runCatching { + com.reqlab.core.model.McpHttpMode.valueOf(dto.mcpHttpMode ?: "AUTO") + }.getOrDefault(com.reqlab.core.model.McpHttpMode.AUTO), + url = dto.url, + headers = dto.userHeaders.map { KeyValueEntry(it.first, it.second) }, + auth = AuthConfig(type = authType ?: AuthType.NONE, params = authParams), + command = dto.mcpCommand.orEmpty(), + args = dto.mcpArgs, + env = dto.mcpEnv, + ) + } else null return CollectionNode( id = generateUuid(), requestRef = dto.requestRef ?: generateUuid(), @@ -784,6 +839,8 @@ object ImportExportRepository { authToken = dto.authToken, authApiKey = dto.authApiKey, authApiValue = dto.authApiValue, + kind = kind, + mcpConfig = mcp, ) } diff --git a/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/persistence/TabsRepository.kt b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/persistence/TabsRepository.kt index 4667584..cfd3e03 100644 --- a/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/persistence/TabsRepository.kt +++ b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/persistence/TabsRepository.kt @@ -14,8 +14,8 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.boolean import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.int @@ -77,6 +77,10 @@ object TabsRepository { put("headers", kvListJson(tab.headers)) put("formRows", formRowListJson(tab.formRows)) put("urlencodedRows", formRowListJson(tab.urlencodedRows)) + put("kind", tab.kind.name) + put("mcp", json.parseToJsonElement( + json.encodeToString(com.reqlab.core.model.McpConnectionConfig.serializer(), tab.mcpConfig), + )) }) } } @@ -212,6 +216,15 @@ object TabsRepository { tab.retryCount = obj["retryCount"]?.jsonPrimitive?.intOrNull ?: 1 tab.retryDelayMs = obj["retryDelayMs"]?.jsonPrimitive?.content?.toLongOrNull() ?: 250L tab.lastSavedTimestamp = obj["lastSavedTimestamp"]?.jsonPrimitive?.content?.toLongOrNull() + tab.kind = runCatching { + com.reqlab.core.model.RequestKind.valueOf(obj["kind"]?.jsonPrimitive?.content ?: "HTTP") + }.getOrDefault(com.reqlab.core.model.RequestKind.HTTP) + obj["mcp"]?.jsonObject?.let { mcpObj -> + tab.mcpConfig = json.decodeFromJsonElement( + com.reqlab.core.model.McpConnectionConfig.serializer(), + mcpObj, + ) + } obj["params"]?.jsonArray?.forEach { kv -> tab.params.add(kvFromJson(kv.jsonObject)) diff --git a/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/platform/PlatformScrollbar.kt b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/platform/PlatformScrollbar.kt new file mode 100644 index 0000000..616fdde --- /dev/null +++ b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/platform/PlatformScrollbar.kt @@ -0,0 +1,28 @@ +package com.reqlab.ui.shared.platform + +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +/** Desktop draws a vertical bar; other targets are a no-op (wheel/trackpad still scroll). */ +@Composable +expect fun PlatformLazyVerticalScrollbar( + listState: LazyListState, + modifier: Modifier = Modifier, + testTag: String = "", +) + +@Composable +expect fun PlatformColumnVerticalScrollbar( + scrollState: ScrollState, + modifier: Modifier = Modifier, + testTag: String = "", +) + +/** Right-side inset only so the thumb can travel the full list height. */ +fun Modifier.insetScrollbar(): Modifier = + fillMaxHeight().padding(end = 4.dp) diff --git a/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/state/AppState.kt b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/state/AppState.kt index 26a17ec..ec0e4f2 100644 --- a/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/state/AppState.kt +++ b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/state/AppState.kt @@ -10,11 +10,18 @@ import com.reqlab.core.model.AuthType import com.reqlab.core.model.BodyType import com.reqlab.core.model.FormEntryType import com.reqlab.core.model.HttpMethodType +import com.reqlab.core.model.McpConnectionConfig +import com.reqlab.core.model.RequestKind import com.reqlab.editor.core.LanguageMode import com.reqlab.editor.ui.EditorViewModel import com.reqlab.editor.ui.SyntaxHighlighterRegistry import com.reqlab.core.model.ResponseDefinition +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import com.reqlab.ui.shared.mcp.McpSessionState import com.reqlab.ui.shared.platform.generateUuid import com.reqlab.ui.shared.platform.currentTimeMillis import com.reqlab.ui.shared.components.syncParamsFromUrl @@ -96,6 +103,8 @@ data class CollectionNode( val authApiKey: String? = null, val authApiValue: String? = null, val requestRef: String? = null, + val kind: RequestKind = RequestKind.HTTP, + val mcpConfig: McpConnectionConfig? = null, ) /** @@ -290,6 +299,8 @@ class RequestTabState( var preRequestScript by mutableStateOf("") var testScript by mutableStateOf("") + var kind by mutableStateOf(RequestKind.HTTP) + var mcpConfig by mutableStateOf(McpConnectionConfig()) var retryEnabled by mutableStateOf(false) var retryCount by mutableStateOf(1) @@ -379,6 +390,10 @@ class RequestTabState( headersSnapshot, formRowsSnapshot, urlencodedRowsSnapshot, + kind.name, + mcpConfig.url, + mcpConfig.transport.name, + mcpConfig.httpMode.name, ).joinToString("#") } @@ -550,6 +565,24 @@ class AppState(openDefaultTab: Boolean = true, withDemoData: Boolean = false) { var activeTabIndex by mutableStateOf(if (openDefaultTab) 0 else -1) val activeTab: RequestTabState? get() = openTabs.getOrNull(activeTabIndex) + // ── MCP sessions ── + // Long-lived, keyed by tab id so an MCP connection (and its loaded tools/ + // resources) survives tab switches. Disposed only when the tab is closed. + /** Application-lifetime scope for background work that must outlive individual composables. */ + val appScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val mcpSessions = mutableMapOf() + + /** Returns the persistent MCP session for [tabId], creating it on first use. */ + fun getOrCreateMcpSession(tabId: String): McpSessionState = + mcpSessions.getOrPut(tabId) { + McpSessionState(appScope, onConsole = { message, level -> logNetworkEvent(message, level) }) + } + + /** Disconnects and forgets the MCP session for [tabId] (called on tab close). */ + fun disposeMcpSession(tabId: String) { + mcpSessions.remove(tabId)?.let { session -> appScope.launch { session.disconnect() } } + } + // ── bottom panel ── var selectedBottomTab by mutableStateOf(BottomTab.CONSOLE) var bottomPanelExpanded by mutableStateOf(true) @@ -792,6 +825,11 @@ class AppState(openDefaultTab: Boolean = true, withDemoData: Boolean = false) { if (existing != null) existing.value = v else tab.headers.add(MutableKeyValue(k, v, kind = HeaderKind.USER)) } + tab.kind = node?.kind ?: RequestKind.HTTP + node?.mcpConfig?.let { tab.mcpConfig = it } + if (tab.kind == RequestKind.MCP && tab.url.isBlank()) { + tab.url = tab.mcpConfig.url + } // Re-anchor the saved snapshot after all fields (including system headers // injected by syncSystemHeaders above) have been populated. Without this, // the snapshot captured in RequestTabState.init{} pre-dates the system @@ -978,6 +1016,7 @@ class AppState(openDefaultTab: Boolean = true, withDemoData: Boolean = false) { fun closeTab(index: Int) { if (index !in openTabs.indices) return openTabs[index].disposeBodyViewModels() + disposeMcpSession(openTabs[index].id) openTabs.removeAt(index) if (openTabs.isEmpty()) { activeTabIndex = -1 @@ -1000,6 +1039,7 @@ class AppState(openDefaultTab: Boolean = true, withDemoData: Boolean = false) { .filter { openTabs[it].id in idSet } .forEach { idx -> openTabs[idx].disposeBodyViewModels() + disposeMcpSession(openTabs[idx].id) openTabs.removeAt(idx) } if (openTabs.isEmpty()) { @@ -1088,6 +1128,29 @@ class AppState(openDefaultTab: Boolean = true, withDemoData: Boolean = false) { addTab(requestId = requestId, name = name, method = HttpMethodType.GET, url = "") } + fun addMcpConnectionToCollection(collectionId: String) { + val folder = collections.firstOrNull { it.id == collectionId && it.isFolder } ?: return + val siblingNames = folder.children.map { it.name }.toSet() + val name = generateUniqueName("New MCP Connection", siblingNames) + val requestId = generateUuid() + val mcp = McpConnectionConfig(url = "{{mcpBaseUrl}}") + val node = CollectionNode( + id = requestId, + requestRef = generateUuid(), + name = name, + isFolder = false, + method = HttpMethodType.POST, + url = mcp.url, + kind = RequestKind.MCP, + mcpConfig = mcp, + ) + folder.children.add(node) + notifyCollectionsChanged() + selectedCollectionId = collectionId + selectedRequestId = requestId + addTab(requestId = requestId, name = name, method = HttpMethodType.POST, url = mcp.url) + } + /** Create a request in the selected collection, ensuring a default collection exists when needed. */ fun addTabInSelectedCollection() { val collId = selectedCollectionId diff --git a/ui-shared/src/commonMain/resources/i18n/de.json b/ui-shared/src/commonMain/resources/i18n/de.json index 1d1288d..5d87e4d 100644 --- a/ui-shared/src/commonMain/resources/i18n/de.json +++ b/ui-shared/src/commonMain/resources/i18n/de.json @@ -178,5 +178,69 @@ "variable_name": "Variablenname", "variables": "Variablen", "word_wrap": "Zeilenumbruch", - "retry_enable": "Wiederholungen aktivieren" + "retry_enable": "Wiederholungen aktivieren", + "mcp_connection": "MCP-Verbindung", + "mcp_url": "MCP-URL", + "mcp_command": "Befehl", + "mcp_tools": "Tools", + "mcp_resources": "Ressourcen", + "mcp_prompts": "Prompts", + "mcp_notifications": "Benachrichtigungen", + "mcp_timeline": "Zeitachse", + "mcp_client": "Client", + "mcp_call_tool": "Tool ausführen", + "mcp_run": "Ausführen", + "mcp_arguments": "Argumente", + "mcp_arguments_hint": "JSON, das an das Tool gesendet wird — wie ein HTTP-Request-Body. Werte anpassen, dann Ausführen. Das Ergebnis erscheint unter Antwort.", + "mcp_response": "Antwort", + "mcp_response_empty": "Klicken Sie auf Ausführen, um das gewählte Tool zu starten. Das Ergebnis erscheint hier.", + "mcp_running": "Läuft…", + "mcp_select_tool": "Wählen Sie links ein Tool, füllen Sie die Argumente und klicken Sie auf Ausführen.", + "mcp_no_tools": "Verbunden, aber dieser Server hat keine Tools.", + "mcp_connect_hint": "Klicken Sie auf Verbinden, um die Sitzung zu starten und Tools, Ressourcen und Prompts zu laden.", + "mcp_tools_hint": "Tools sind Funktionen des Servers. Auswählen → Argumente füllen → Ausführen. Das Ergebnis steht rechts unter Antwort.", + "mcp_resources_hint": "Ressourcen sind Dateien oder Daten, die der Server teilt. Eine auswählen und Lesen klicken.", + "mcp_read_resource": "Lesen", + "mcp_resource_empty": "Wählen Sie eine Ressource und klicken Sie auf Lesen.", + "mcp_no_resources": "Dieser Server hat keine Ressourcen.", + "mcp_prompts_hint": "Prompts sind wiederverwendbare Vorlagen. Einen wählen, Argumente füllen, Prompt holen.", + "mcp_get_prompt": "Prompt holen", + "mcp_prompt_empty": "Wählen Sie einen Prompt und klicken Sie auf Prompt holen.", + "mcp_no_prompts": "Dieser Server hat keine Prompts.", + "mcp_activity": "Aktivität", + "mcp_activity_hint": "Live-Ereignisse vom Server (Fortschritt, Logs, Updates) plus jede gesendete oder empfangene JSON-RPC-Nachricht.", + "mcp_activity_empty": "Noch kein Verkehr. Verbinden und ein Tool ausführen.", + "mcp_client_hint": "Wie dieser Client antwortet, wenn der Server zurückruft. Sampling = der Server will eine Modellantwort. Roots = erlaubte Ordner. Elicitation = der Server stellt ein Formular.", + "mcp_sampling_explain": "Wenn der Server eine Completion anfordert, liefert ReqLab derzeit eine Mock-Antwort.", + "mcp_roots_explain": "Ordner, die dieser Client meldet, wenn der Server roots/list fragt.", + "mcp_elicit_explain": "Wenn der Server eine Frage stellt, antwortet Auto-Accept ohne Formular.", + "mcp_tool_error": "Tool-Fehler", + "mcp_tool_ok": "Erfolg", + "mcp_disconnected": "Getrennt", + "mcp_connected": "Verbunden", + "mcp_connecting": "Verbinden…", + "mcp_status_error": "Fehler", + "mcp_fill_args": "Erwartete Felder", + "mcp_stdio_confirm": "Dadurch wird ein lokaler Prozess gestartet. Fortfahren?", + "mcp_roots": "Roots", + "mcp_sampling_mode": "Sampling", + "mcp_auto_elicit": "Elicitation automatisch akzeptieren", + "mcp_session_id": "Sitzungs-ID", + "mcp_subscribe": "Abonnieren", + "mcp_unsubscribe": "Abbestellen", + "mcp_subscribed": "Abonniert", + "mcp_subscribe_explain": "Beim Abonnieren benachrichtigt der Server bei Änderungen dieser Ressource. Aktualisierungen werden automatisch neu gelesen und in der Antwort angezeigt.", + "mcp_response_in_viewer": "Ergebnisse werden im Antwortbereich mit Formatierung, Headern und Timing angezeigt.", + "mcp_activity_expand": "Klicken, um die rohe JSON-RPC-Nutzlast anzuzeigen.", + "mcp_form": "Formular", + "mcp_json": "JSON", + "mcp_search_tools": "Tools suchen", + "mcp_search_resources": "Ressourcen suchen", + "mcp_search_prompts": "Prompts suchen", + "mcp_reconnect": "Neu verbinden", + "mcp_readonly": "Nur lesen", + "mcp_destructive": "Zerstörend", + "mcp_transport": "Transport", + "mcp_http_mode": "HTTP-Modus", + "new_mcp_connection": "Neue MCP-Verbindung" } diff --git a/ui-shared/src/commonMain/resources/i18n/en.json b/ui-shared/src/commonMain/resources/i18n/en.json index c67a13d..e01e21a 100644 --- a/ui-shared/src/commonMain/resources/i18n/en.json +++ b/ui-shared/src/commonMain/resources/i18n/en.json @@ -232,5 +232,69 @@ "variable_name": "Variable name", "variables": "Variables", "word_wrap": "Word wrap", - "retry_enable": "Enable retry" + "retry_enable": "Enable retry", + "mcp_connection": "MCP Connection", + "mcp_url": "MCP URL", + "mcp_command": "Command", + "mcp_tools": "Tools", + "mcp_resources": "Resources", + "mcp_prompts": "Prompts", + "mcp_notifications": "Notifications", + "mcp_timeline": "Timeline", + "mcp_client": "Client", + "mcp_call_tool": "Run tool", + "mcp_run": "Run", + "mcp_arguments": "Arguments", + "mcp_arguments_hint": "JSON inputs sent to the tool — same idea as an HTTP request body. Edit the values, then click Run. The result appears in Response below.", + "mcp_response": "Response", + "mcp_response_empty": "Click Run to execute the selected tool. The result shows up here.", + "mcp_running": "Running…", + "mcp_select_tool": "Select a tool on the left, fill its arguments, then click Run.", + "mcp_no_tools": "Connected, but this server has no tools.", + "mcp_connect_hint": "Click Connect to handshake with the server and load tools, resources, and prompts.", + "mcp_tools_hint": "Tools are functions the server exposes. Select one → fill arguments → Run. The result is the Response pane on the right.", + "mcp_resources_hint": "Resources are files or data the server shares (docs, configs, blobs). Select one and click Read — contents appear on the right.", + "mcp_read_resource": "Read", + "mcp_resource_empty": "Select a resource and click Read to see its contents here.", + "mcp_no_resources": "This server has no resources.", + "mcp_prompts_hint": "Prompts are reusable message templates. Select one, fill arguments, click Get prompt. The rendered messages appear on the right.", + "mcp_get_prompt": "Get prompt", + "mcp_prompt_empty": "Select a prompt and click Get prompt to see the rendered messages.", + "mcp_no_prompts": "This server has no prompts.", + "mcp_activity": "Activity", + "mcp_activity_hint": "Live events the server pushes (progress, logs, resource updates) plus every JSON-RPC message sent or received. Use this to debug the connection.", + "mcp_activity_empty": "Nothing yet. Connect and run a tool to see traffic here.", + "mcp_client_hint": "How this client answers if the server calls back. Sampling = the server asks ReqLab for a model reply. Roots = folders you allow. Elicitation = the server asking you to fill a form.", + "mcp_sampling_explain": "When the server asks for a model completion, ReqLab currently returns a mock reply (this is a test harness).", + "mcp_roots_explain": "Workspace folders this client will report if the server asks for roots/list.", + "mcp_elicit_explain": "If the server asks a question, auto-accept replies without showing a form.", + "mcp_tool_error": "Tool error", + "mcp_tool_ok": "Success", + "mcp_disconnected": "Disconnected", + "mcp_connected": "Connected", + "mcp_connecting": "Connecting…", + "mcp_status_error": "Error", + "mcp_fill_args": "Expected fields", + "mcp_stdio_confirm": "This will start a local process. Continue?", + "mcp_roots": "Roots", + "mcp_sampling_mode": "Sampling", + "mcp_auto_elicit": "Auto-accept elicitation", + "mcp_session_id": "Session ID", + "mcp_subscribe": "Subscribe", + "mcp_unsubscribe": "Unsubscribe", + "mcp_subscribed": "Subscribed", + "mcp_subscribe_explain": "Subscribe asks the server to notify you whenever this resource changes. Updates are re-read automatically and shown in Response.", + "mcp_response_in_viewer": "Results open in the Response panel with formatting, headers, and timing.", + "mcp_activity_expand": "Click to show the raw JSON-RPC payload.", + "mcp_form": "Form", + "mcp_json": "JSON", + "mcp_search_tools": "Search tools", + "mcp_search_resources": "Search resources", + "mcp_search_prompts": "Search prompts", + "mcp_reconnect": "Reconnect", + "mcp_readonly": "Read-only", + "mcp_destructive": "Destructive", + "mcp_transport": "Transport", + "mcp_http_mode": "HTTP mode", + "new_mcp_connection": "New MCP Connection" } diff --git a/ui-shared/src/commonMain/resources/i18n/es.json b/ui-shared/src/commonMain/resources/i18n/es.json index 2504c11..a821ffc 100644 --- a/ui-shared/src/commonMain/resources/i18n/es.json +++ b/ui-shared/src/commonMain/resources/i18n/es.json @@ -178,5 +178,69 @@ "variable_name": "Nombre de variable", "variables": "Variables", "word_wrap": "Ajuste de línea", - "retry_enable": "Habilitar reintentos" + "retry_enable": "Habilitar reintentos", + "mcp_connection": "Conexión MCP", + "mcp_url": "URL MCP", + "mcp_command": "Comando", + "mcp_tools": "Herramientas", + "mcp_resources": "Recursos", + "mcp_prompts": "Prompts", + "mcp_notifications": "Notificaciones", + "mcp_timeline": "Línea de tiempo", + "mcp_client": "Cliente", + "mcp_call_tool": "Ejecutar herramienta", + "mcp_run": "Ejecutar", + "mcp_arguments": "Argumentos", + "mcp_arguments_hint": "JSON que se envía a la herramienta — como el cuerpo de una petición HTTP. Edita los valores y pulsa Ejecutar. El resultado aparece en Respuesta.", + "mcp_response": "Respuesta", + "mcp_response_empty": "Pulsa Ejecutar para lanzar la herramienta seleccionada. El resultado aparece aquí.", + "mcp_running": "Ejecutando…", + "mcp_select_tool": "Elige una herramienta a la izquierda, rellena los argumentos y pulsa Ejecutar.", + "mcp_no_tools": "Conectado, pero este servidor no tiene herramientas.", + "mcp_connect_hint": "Pulsa Conectar para iniciar la sesión y cargar herramientas, recursos y prompts.", + "mcp_tools_hint": "Las herramientas son funciones del servidor. Elige una → rellena argumentos → Ejecutar. El resultado está a la derecha, en Respuesta.", + "mcp_resources_hint": "Los recursos son archivos o datos que comparte el servidor. Elige uno y pulsa Leer.", + "mcp_read_resource": "Leer", + "mcp_resource_empty": "Elige un recurso y pulsa Leer para ver su contenido.", + "mcp_no_resources": "Este servidor no tiene recursos.", + "mcp_prompts_hint": "Los prompts son plantillas reutilizables. Elige uno, rellena argumentos y pulsa Obtener prompt.", + "mcp_get_prompt": "Obtener prompt", + "mcp_prompt_empty": "Elige un prompt y pulsa Obtener prompt para ver los mensajes.", + "mcp_no_prompts": "Este servidor no tiene prompts.", + "mcp_activity": "Actividad", + "mcp_activity_hint": "Eventos en vivo del servidor (progreso, logs, actualizaciones) y cada mensaje JSON-RPC enviado o recibido.", + "mcp_activity_empty": "Aún no hay tráfico. Conecta y ejecuta una herramienta.", + "mcp_client_hint": "Cómo responde este cliente si el servidor llama hacia atrás. Sampling = el servidor pide una respuesta de modelo. Roots = carpetas permitidas. Elicitation = el servidor pide un formulario.", + "mcp_sampling_explain": "Si el servidor pide una completion, ReqLab devuelve ahora una respuesta simulada.", + "mcp_roots_explain": "Carpetas que este cliente reportará si el servidor pide roots/list.", + "mcp_elicit_explain": "Si el servidor hace una pregunta, auto-aceptar responde sin mostrar un formulario.", + "mcp_tool_error": "Error de herramienta", + "mcp_tool_ok": "Correcto", + "mcp_disconnected": "Desconectado", + "mcp_connected": "Conectado", + "mcp_connecting": "Conectando…", + "mcp_status_error": "Error", + "mcp_fill_args": "Campos esperados", + "mcp_stdio_confirm": "Esto iniciará un proceso local. ¿Continuar?", + "mcp_roots": "Roots", + "mcp_sampling_mode": "Muestreo", + "mcp_auto_elicit": "Aceptar elicitación automáticamente", + "mcp_session_id": "ID de sesión", + "mcp_subscribe": "Suscribirse", + "mcp_unsubscribe": "Cancelar suscripción", + "mcp_subscribed": "Suscrito", + "mcp_subscribe_explain": "Suscribirse le pide al servidor que le notifique cuando este recurso cambie. Las actualizaciones se releen automáticamente y se muestran en la Respuesta.", + "mcp_response_in_viewer": "Los resultados se abren en el panel de Respuesta con formato, encabezados y tiempos.", + "mcp_activity_expand": "Haz clic para mostrar la carga JSON-RPC sin procesar.", + "mcp_form": "Formulario", + "mcp_json": "JSON", + "mcp_search_tools": "Buscar tools", + "mcp_search_resources": "Buscar recursos", + "mcp_search_prompts": "Buscar prompts", + "mcp_reconnect": "Reconectar", + "mcp_readonly": "Solo lectura", + "mcp_destructive": "Destructivo", + "mcp_transport": "Transporte", + "mcp_http_mode": "Modo HTTP", + "new_mcp_connection": "Nueva conexión MCP" } diff --git a/ui-shared/src/commonMain/resources/i18n/fr.json b/ui-shared/src/commonMain/resources/i18n/fr.json index d9a1aec..0ef5cfb 100644 --- a/ui-shared/src/commonMain/resources/i18n/fr.json +++ b/ui-shared/src/commonMain/resources/i18n/fr.json @@ -178,5 +178,69 @@ "variable_name": "Nom de variable", "variables": "Variables", "word_wrap": "Retour à la ligne", - "retry_enable": "Activer les tentatives" + "retry_enable": "Activer les tentatives", + "mcp_connection": "Connexion MCP", + "mcp_url": "URL MCP", + "mcp_command": "Commande", + "mcp_tools": "Outils", + "mcp_resources": "Ressources", + "mcp_prompts": "Prompts", + "mcp_notifications": "Notifications", + "mcp_timeline": "Chronologie", + "mcp_client": "Client", + "mcp_call_tool": "Exécuter l'outil", + "mcp_run": "Exécuter", + "mcp_arguments": "Arguments", + "mcp_arguments_hint": "JSON envoyé à l'outil — comme le corps d'une requête HTTP. Modifiez les valeurs puis cliquez sur Exécuter. Le résultat apparaît dans Réponse.", + "mcp_response": "Réponse", + "mcp_response_empty": "Cliquez sur Exécuter pour lancer l'outil sélectionné. Le résultat s'affiche ici.", + "mcp_running": "Exécution…", + "mcp_select_tool": "Choisissez un outil à gauche, remplissez les arguments, puis cliquez sur Exécuter.", + "mcp_no_tools": "Connecté, mais ce serveur n'a pas d'outils.", + "mcp_connect_hint": "Cliquez sur Connecter pour initialiser la session et charger outils, ressources et prompts.", + "mcp_tools_hint": "Les outils sont des fonctions exposées par le serveur. Sélectionnez → arguments → Exécuter. Le résultat est à droite dans Réponse.", + "mcp_resources_hint": "Les ressources sont des fichiers ou données partagés par le serveur. Sélectionnez-en une et cliquez sur Lire.", + "mcp_read_resource": "Lire", + "mcp_resource_empty": "Sélectionnez une ressource et cliquez sur Lire pour voir son contenu.", + "mcp_no_resources": "Ce serveur n'a pas de ressources.", + "mcp_prompts_hint": "Les prompts sont des modèles réutilisables. Sélectionnez-en un, remplissez les arguments, cliquez sur Obtenir le prompt.", + "mcp_get_prompt": "Obtenir le prompt", + "mcp_prompt_empty": "Sélectionnez un prompt et cliquez sur Obtenir le prompt.", + "mcp_no_prompts": "Ce serveur n'a pas de prompts.", + "mcp_activity": "Activité", + "mcp_activity_hint": "Événements envoyés par le serveur (progression, journaux, mises à jour) et chaque message JSON-RPC émis ou reçu.", + "mcp_activity_empty": "Rien pour l'instant. Connectez-vous et exécutez un outil.", + "mcp_client_hint": "Comment ce client répond si le serveur rappelle. Sampling = le serveur demande une réponse de modèle. Roots = dossiers autorisés. Elicitation = le serveur pose un formulaire.", + "mcp_sampling_explain": "Si le serveur demande une completion, ReqLab renvoie actuellement une réponse simulée.", + "mcp_roots_explain": "Dossiers que ce client déclarera si le serveur demande roots/list.", + "mcp_elicit_explain": "Si le serveur pose une question, l'acceptation auto répond sans afficher de formulaire.", + "mcp_tool_error": "Erreur d'outil", + "mcp_tool_ok": "Succès", + "mcp_disconnected": "Déconnecté", + "mcp_connected": "Connecté", + "mcp_connecting": "Connexion…", + "mcp_status_error": "Erreur", + "mcp_fill_args": "Champs attendus", + "mcp_stdio_confirm": "Cela démarrera un processus local. Continuer ?", + "mcp_roots": "Roots", + "mcp_sampling_mode": "Échantillonnage", + "mcp_auto_elicit": "Accepter l'élicitation automatiquement", + "mcp_session_id": "ID de session", + "mcp_subscribe": "S'abonner", + "mcp_unsubscribe": "Se désabonner", + "mcp_subscribed": "Abonné", + "mcp_subscribe_explain": "S'abonner demande au serveur de vous notifier lorsque cette ressource change. Les mises à jour sont relues automatiquement et affichées dans la Réponse.", + "mcp_response_in_viewer": "Les résultats s'ouvrent dans le panneau Réponse avec mise en forme, en-têtes et minutage.", + "mcp_activity_expand": "Cliquez pour afficher la charge utile JSON-RPC brute.", + "mcp_form": "Formulaire", + "mcp_json": "JSON", + "mcp_search_tools": "Rechercher des outils", + "mcp_search_resources": "Rechercher des ressources", + "mcp_search_prompts": "Rechercher des prompts", + "mcp_reconnect": "Reconnecter", + "mcp_readonly": "Lecture seule", + "mcp_destructive": "Destructif", + "mcp_transport": "Transport", + "mcp_http_mode": "Mode HTTP", + "new_mcp_connection": "Nouvelle connexion MCP" } diff --git a/ui-shared/src/commonTest/kotlin/com/reqlab/ui/shared/i18n/I18nCompletenessTest.kt b/ui-shared/src/commonTest/kotlin/com/reqlab/ui/shared/i18n/I18nCompletenessTest.kt index 5262679..b4cc9b6 100644 --- a/ui-shared/src/commonTest/kotlin/com/reqlab/ui/shared/i18n/I18nCompletenessTest.kt +++ b/ui-shared/src/commonTest/kotlin/com/reqlab/ui/shared/i18n/I18nCompletenessTest.kt @@ -61,6 +61,16 @@ class I18nCompletenessTest { // Import/Export "import_collection", "export_collection", "import_success", "export_success", "operation_failed", + // MCP + "mcp_connection", "new_mcp_connection", "mcp_tools", "mcp_resources", "mcp_prompts", + "mcp_url", "mcp_command", "mcp_notifications", "mcp_timeline", "mcp_client", + "mcp_call_tool", "mcp_stdio_confirm", "mcp_roots", "mcp_sampling_mode", "mcp_auto_elicit", + "mcp_run", "mcp_arguments", "mcp_response", "mcp_activity", "mcp_connect_hint", + "mcp_tools_hint", "mcp_resources_hint", "mcp_prompts_hint", "mcp_activity_hint", + "mcp_client_hint", "mcp_tool_ok", "mcp_tool_error", "mcp_connected", "mcp_disconnected", + "mcp_session_id", "mcp_subscribe", "mcp_unsubscribe", "mcp_subscribed", + "mcp_subscribe_explain", "mcp_response_in_viewer", "mcp_activity_expand", + "mcp_form", "mcp_json", "mcp_search_tools", "mcp_reconnect", ) // ── Completeness ── diff --git a/ui-shared/src/commonTest/kotlin/com/reqlab/ui/shared/mcp/McpSessionStateTest.kt b/ui-shared/src/commonTest/kotlin/com/reqlab/ui/shared/mcp/McpSessionStateTest.kt new file mode 100644 index 0000000..ff039da --- /dev/null +++ b/ui-shared/src/commonTest/kotlin/com/reqlab/ui/shared/mcp/McpSessionStateTest.kt @@ -0,0 +1,310 @@ +package com.reqlab.ui.shared.mcp + +import com.reqlab.core.model.McpConnectionConfig +import com.reqlab.core.model.McpConnectionState +import com.reqlab.core.model.McpTransportType +import com.reqlab.core.network.mcp.McpClient +import com.reqlab.core.network.mcp.NdjsonStdioTransport +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class McpSessionStateTest { + @Test + fun connect_lists_tools_from_injected_client() = runTest { + val inbound = Channel(Channel.UNLIMITED) + val written = Channel(Channel.UNLIMITED) + val transport = NdjsonStdioTransport(this, inbound, { written.send(it) }) + val session = McpSessionState(this) { scope -> + McpClient(scope, stdioFactory = { transport }, callTimeoutMs = 5_000) + } + session.confirmStdio = true + val job = async { + session.connect(McpConnectionConfig(transport = McpTransportType.STDIO, command = "x")) + } + written.receive() // initialize + inbound.send("""{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{"tools":{}},"serverInfo":{"name":"s","version":"1"}}}""") + written.receive() // notifications/initialized + written.receive() // tools/list + inbound.send("""{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"echo","inputSchema":{"type":"object"}}]}}""") + job.await() + assertEquals(McpConnectionState.CONNECTED, session.connectionState.value) + assertEquals(listOf("echo"), session.tools.value.map { it.name }) + session.disconnect() + } + + @Test + fun subscribe_and_resource_updated_triggers_reread() = runTest { + val inbound = Channel(Channel.UNLIMITED) + val written = Channel(Channel.UNLIMITED) + val transport = NdjsonStdioTransport(this, inbound, { written.send(it) }) + val session = McpSessionState(this) { scope -> + McpClient(scope, stdioFactory = { transport }, callTimeoutMs = 5_000) + } + session.confirmStdio = true + val job = async { + session.connect(McpConnectionConfig(transport = McpTransportType.STDIO, command = "x")) + } + written.receive() // initialize + inbound.send("""{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{"resources":{"subscribe":true}},"serverInfo":{"name":"s","version":"1"}}}""") + written.receive() // notifications/initialized + written.receive() // resources/list + inbound.send("""{"jsonrpc":"2.0","id":2,"result":{"resources":[{"uri":"reqlab://doc","name":"Doc"}]}}""") + job.await() + assertTrue(session.supportsSubscribe()) + + // Subscribe to the resource. + val sub = async { session.subscribeResource("reqlab://doc") } + written.receive() // resources/subscribe + inbound.send("""{"jsonrpc":"2.0","id":3,"result":{}}""") + sub.await() + assertTrue(session.subscribedUris.value.contains("reqlab://doc")) + + // Server pushes an update notification -> session re-reads the resource. + inbound.send("""{"jsonrpc":"2.0","method":"notifications/resources/updated","params":{"uri":"reqlab://doc"}}""") + written.receive() // resources/read triggered by the re-read + inbound.send("""{"jsonrpc":"2.0","id":4,"result":{"contents":[{"uri":"reqlab://doc","text":"updated"}]}}""") + + withTimeout(5_000) { + session.lastResourceResult.first { it?.contents?.firstOrNull()?.text == "updated" } + } + assertEquals("updated", session.lastResourceResult.value?.contents?.firstOrNull()?.text) + assertEquals("resource", session.lastOperation.value?.kind) + session.disconnect() + } + + @Test + fun unsubscribe_removes_uri_from_subscribed_set() = runTest { + val inbound = Channel(Channel.UNLIMITED) + val written = Channel(Channel.UNLIMITED) + val transport = NdjsonStdioTransport(this, inbound, { written.send(it) }) + val session = McpSessionState(this) { scope -> + McpClient(scope, stdioFactory = { transport }, callTimeoutMs = 5_000) + } + session.confirmStdio = true + val job = async { + session.connect(McpConnectionConfig(transport = McpTransportType.STDIO, command = "x")) + } + written.receive() + inbound.send("""{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{"resources":{"subscribe":true}},"serverInfo":{"name":"s","version":"1"}}}""") + written.receive() + written.receive() + inbound.send("""{"jsonrpc":"2.0","id":2,"result":{"resources":[{"uri":"reqlab://doc","name":"Doc"}]}}""") + job.await() + + val sub = async { session.subscribeResource("reqlab://doc") } + written.receive() + inbound.send("""{"jsonrpc":"2.0","id":3,"result":{}}""") + sub.await() + val unsub = async { session.unsubscribeResource("reqlab://doc") } + written.receive() + inbound.send("""{"jsonrpc":"2.0","id":4,"result":{}}""") + unsub.await() + assertTrue("reqlab://doc" !in session.subscribedUris.value) + session.disconnect() + } + + @Test + fun failed_call_populates_error_operation_for_response_viewer() = runTest { + val inbound = Channel(Channel.UNLIMITED) + val written = Channel(Channel.UNLIMITED) + val transport = NdjsonStdioTransport(this, inbound, { written.send(it) }) + val session = McpSessionState(this) { scope -> + McpClient(scope, stdioFactory = { transport }, callTimeoutMs = 5_000) + } + session.confirmStdio = true + val job = async { + session.connect(McpConnectionConfig(transport = McpTransportType.STDIO, command = "x")) + } + written.receive() + inbound.send("""{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{"tools":{}},"serverInfo":{"name":"s","version":"1"}}}""") + written.receive() + written.receive() + inbound.send("""{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"echo","inputSchema":{"type":"object"}}]}}""") + job.await() + + val call = async { session.callSelectedTool("nope", null) } + written.receive() + inbound.send("""{"jsonrpc":"2.0","id":3,"error":{"code":-32602,"message":"Unknown tool"}}""") + call.await() + val op = session.lastOperation.value + assertTrue(op != null && op.isError) + assertTrue(op!!.bodyJson.contains("Unknown tool")) + val response = op.toResponseDefinition("tab-1") + assertEquals(500, response.statusCode) + assertTrue(response.bodyText.contains("Unknown tool")) + session.disconnect() + } + + @Test + fun console_bridge_forwards_received_as_success() = runTest { + val inbound = Channel(Channel.UNLIMITED) + val written = Channel(Channel.UNLIMITED) + val transport = NdjsonStdioTransport(this, inbound, { written.send(it) }) + val captured = mutableListOf>() + val session = McpSessionState( + this, + clientFactory = { scope -> McpClient(scope, stdioFactory = { transport }, callTimeoutMs = 5_000) }, + onConsole = { message, level -> captured.add(message to level) }, + ) + session.confirmStdio = true + val job = async { + session.connect(McpConnectionConfig(transport = McpTransportType.STDIO, command = "x")) + } + written.receive() + inbound.send("""{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{"tools":{}},"serverInfo":{"name":"s","version":"1"}}}""") + written.receive() + written.receive() + inbound.send("""{"jsonrpc":"2.0","id":2,"result":{"tools":[]}}""") + job.await() + assertTrue(captured.any { it.first.startsWith("MCP") }) + assertTrue(captured.any { it.second == com.reqlab.ui.shared.state.LogLevel.SUCCESS }) + session.disconnect() + } + + @Test + fun operation_to_response_definition_includes_headers_and_timing() { + val op = McpOperationResult( + kind = "tool", + label = "echo", + bodyJson = """{"ok":true}""", + isError = false, + headers = listOf(com.reqlab.core.model.KeyValueEntry("Mcp-Session-Id", "sess-1")), + elapsedMs = 42, + sizeBytes = 11, + timestampMs = 1_700_000_000_000L, + ) + val response = op.toResponseDefinition("req-9", okStatusText = "Success") + assertEquals(200, response.statusCode) + assertEquals("Success", response.statusText) + assertEquals("application/json", response.contentType) + assertEquals("""{"ok":true}""", response.bodyText) + assertEquals("sess-1", response.headers.single { it.key == "Mcp-Session-Id" }.value) + assertEquals(42, response.metrics.responseTimeMs) + assertEquals(11, response.metrics.responseSizeBytes) + } + + @Test + fun tool_response_uses_wire_jsonrpc_not_model_defaults() = runTest { + val inbound = Channel(Channel.UNLIMITED) + val written = Channel(Channel.UNLIMITED) + val transport = NdjsonStdioTransport(this, inbound, { written.send(it) }) + val session = McpSessionState(this) { scope -> + McpClient(scope, stdioFactory = { transport }, callTimeoutMs = 5_000) + } + session.confirmStdio = true + val job = async { + session.connect(McpConnectionConfig(transport = McpTransportType.STDIO, command = "x")) + } + written.receive() + inbound.send("""{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{"tools":{}},"serverInfo":{"name":"s","version":"1"}}}""") + written.receive() + written.receive() + inbound.send("""{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"add","inputSchema":{"type":"object"}}]}}""") + job.await() + + val call = async { session.callSelectedTool("add", null) } + written.receive() + inbound.send("""{"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":6}],"isError":false}}""") + call.await() + val body = session.lastOperation.value?.bodyJson.orEmpty() + assertTrue(body.contains("\"jsonrpc\""), body) + assertTrue(body.contains("\"result\""), body) + assertTrue(body.contains("\"text\": 6") || body.contains("\"text\":6"), body) + assertTrue(!body.contains("structuredContent"), body) + assertTrue(!body.contains("\"mimeType\""), body) + session.disconnect() + } + + @Test + fun pretty_wire_json_preserves_numeric_text() { + val pretty = mcpPrettyWireJson("""{"jsonrpc":"2.0","id":27,"result":{"content":[{"type":"text","text":6}],"isError":false}}""") + assertTrue(pretty.contains("\"jsonrpc\"")) + assertTrue(pretty.contains("\"text\": 6") || pretty.contains("\"text\":6"), pretty) + assertTrue(!pretty.contains("null"), pretty) + } + + @Test + fun default_args_json_from_schema() { + val schema = buildJsonObject { + put("type", "object") + put("properties", buildJsonObject { + put("text", buildJsonObject { put("type", "string") }) + put("count", buildJsonObject { put("type", "integer") }) + }) + } + val json = mcpDefaultArgsJson(schema) + assertTrue(json.contains("\"text\"")) + assertTrue(json.contains("\"count\"")) + assertTrue(json.contains("\"count\": 0") || json.contains("\"count\":0"), json) + assertTrue(!json.contains("\"count\": \"\""), json) + } + + @Test + fun schema_form_fields_and_required_validation() { + val schema = buildJsonObject { + put("type", "object") + put("required", kotlinx.serialization.json.buildJsonArray { add(kotlinx.serialization.json.JsonPrimitive("a")) }) + put("properties", buildJsonObject { + put("a", buildJsonObject { put("type", "integer") }) + put("b", buildJsonObject { put("type", "string") }) + }) + } + val fields = mcpSchemaFields(schema) + assertEquals(listOf("a", "b"), fields.map { it.name }) + assertTrue(fields.first { it.name == "a" }.required) + assertTrue(mcpSchemaFormSupported(schema)) + assertEquals(listOf("a"), mcpMissingRequiredArgs(schema, "{}")) + assertEquals(emptyList(), mcpMissingRequiredArgs(schema, """{"a": 1}""")) + val updated = mcpArgsPut("""{"a": 0}""", "a", kotlinx.serialization.json.JsonPrimitive(3)) + assertTrue(updated.contains("3"), updated) + } + + @Test + fun tool_annotation_chips_and_prompt_schema() { + val chips = mcpToolHintChips( + buildJsonObject { + put("readOnlyHint", true) + put("destructiveHint", false) + }, + ) + assertEquals(listOf("readOnly"), chips) + val prompt = com.reqlab.core.model.McpPrompt( + name = "greet", + arguments = listOf( + com.reqlab.core.model.McpPromptArgument(name = "who", required = true), + ), + ) + val schema = mcpPromptSchema(prompt) + assertEquals(listOf("who"), mcpMissingRequiredArgs(schema, "{}")) + } + + @Test + fun reconnect_needed_when_url_changes_after_connect() = runTest { + val inbound = Channel(Channel.UNLIMITED) + val written = Channel(Channel.UNLIMITED) + val transport = NdjsonStdioTransport(this, inbound, { written.send(it) }) + val session = McpSessionState(this) { scope -> + McpClient(scope, stdioFactory = { transport }, callTimeoutMs = 5_000) + } + session.confirmStdio = true + val cfg = McpConnectionConfig(transport = McpTransportType.STDIO, command = "x") + val job = async { session.connect(cfg) } + written.receive() + inbound.send("""{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{"tools":{}},"serverInfo":{"name":"s","version":"1"}}}""") + written.receive() + written.receive() + inbound.send("""{"jsonrpc":"2.0","id":2,"result":{"tools":[]}}""") + job.await() + assertTrue(!session.isReconnectNeeded(cfg)) + assertTrue(session.isReconnectNeeded(cfg.copy(command = "y"))) + session.disconnect() + } +} diff --git a/ui-shared/src/commonTest/kotlin/com/reqlab/ui/shared/persistence/McpWorkspaceBackwardCompatTest.kt b/ui-shared/src/commonTest/kotlin/com/reqlab/ui/shared/persistence/McpWorkspaceBackwardCompatTest.kt new file mode 100644 index 0000000..a7d100f --- /dev/null +++ b/ui-shared/src/commonTest/kotlin/com/reqlab/ui/shared/persistence/McpWorkspaceBackwardCompatTest.kt @@ -0,0 +1,52 @@ +package com.reqlab.ui.shared.persistence + +import com.reqlab.core.model.RequestKind +import com.reqlab.ui.shared.state.AppState +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class McpWorkspaceBackwardCompatTest { + @Test + fun pre_mcp_workspace_loads_as_http() { + val json = """ + {"type":"reqLabWorkspace","version":"1.0", + "collections":[{"type":"reqLabCollection","version":"1.0","name":"Old", + "folders":[],"requests":[{"name":"Ping","method":"GET","url":"http://localhost/ping"}]}], + "environments":[]} + """.trimIndent() + val state = AppState(openDefaultTab = false, withDemoData = false) + ImportExportRepository.importWorkspaceFromString(state, json) + val req = state.collections.first().children.first { !it.isFolder } + assertEquals(RequestKind.HTTP, req.kind) + assertEquals("Ping", req.name) + } + + @Test + fun mcp_request_imports_auth_and_headers_into_mcp_config() { + val json = """ + {"type":"reqLabWorkspace","version":"1.0", + "collections":[{"type":"reqLabCollection","version":"1.0","name":"Mcp", + "folders":[],"requests":[{ + "name":"Authed MCP", + "kind":"MCP", + "method":"POST", + "url":"http://localhost/mcp/authed", + "mcpTransport":"STREAMABLE_HTTP", + "auth":{"type":"BEARER","token":"reqlab-mcp-token"}, + "headers":[{"key":"X-Api-Key","value":"reqlab-key"}] + }]}], + "environments":[]} + """.trimIndent() + val state = AppState(openDefaultTab = false, withDemoData = false) + ImportExportRepository.importWorkspaceFromString(state, json) + val req = state.collections.first().children.first { !it.isFolder } + assertEquals(RequestKind.MCP, req.kind) + assertEquals("BEARER", req.authType?.name) + assertEquals("reqlab-mcp-token", req.authToken) + val mcp = req.mcpConfig + assertTrue(mcp != null) + assertEquals("reqlab-mcp-token", mcp!!.auth.params["token"]) + assertEquals("reqlab-key", mcp.headers.single { it.key == "X-Api-Key" }.value) + } +} diff --git a/ui-shared/src/commonTest/kotlin/com/reqlab/ui/shared/state/AppStateBehaviorTest.kt b/ui-shared/src/commonTest/kotlin/com/reqlab/ui/shared/state/AppStateBehaviorTest.kt index 4b62642..c3426f2 100644 --- a/ui-shared/src/commonTest/kotlin/com/reqlab/ui/shared/state/AppStateBehaviorTest.kt +++ b/ui-shared/src/commonTest/kotlin/com/reqlab/ui/shared/state/AppStateBehaviorTest.kt @@ -163,4 +163,17 @@ class AppStateBehaviorTest { "Repeated syncSystemHeaders calls must not reset a user-overridden Accept value", ) } + + @Test + fun mcp_session_is_reused_until_tab_is_closed() { + val state = AppState() + val tab = state.openTabs.first() + val first = state.getOrCreateMcpSession(tab.id) + val second = state.getOrCreateMcpSession(tab.id) + assertTrue(first === second, "MCP session must survive tab switches") + val tabId = tab.id + state.closeTab(0) + val afterClose = state.getOrCreateMcpSession(tabId) + assertTrue(first !== afterClose, "Closing the tab must dispose the MCP session") + } } diff --git a/ui-shared/src/desktopMain/kotlin/com/reqlab/ui/shared/platform/PlatformScrollbar.desktop.kt b/ui-shared/src/desktopMain/kotlin/com/reqlab/ui/shared/platform/PlatformScrollbar.desktop.kt new file mode 100644 index 0000000..140ecf6 --- /dev/null +++ b/ui-shared/src/desktopMain/kotlin/com/reqlab/ui/shared/platform/PlatformScrollbar.desktop.kt @@ -0,0 +1,49 @@ +package com.reqlab.ui.shared.platform + +import androidx.compose.foundation.ScrollbarStyle +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.VerticalScrollbar +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.rememberScrollbarAdapter +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import com.reqlab.ui.shared.theme.ReqLabColors + +@Composable +actual fun PlatformLazyVerticalScrollbar( + listState: LazyListState, + modifier: Modifier, + testTag: String, +) { + VerticalScrollbar( + adapter = rememberScrollbarAdapter(listState), + style = reqlabScrollbarStyle(), + modifier = modifier.then(if (testTag.isNotEmpty()) Modifier.testTag(testTag) else Modifier), + ) +} + +@Composable +actual fun PlatformColumnVerticalScrollbar( + scrollState: ScrollState, + modifier: Modifier, + testTag: String, +) { + VerticalScrollbar( + adapter = rememberScrollbarAdapter(scrollState), + style = reqlabScrollbarStyle(), + modifier = modifier.then(if (testTag.isNotEmpty()) Modifier.testTag(testTag) else Modifier), + ) +} + +@Composable +private fun reqlabScrollbarStyle() = ScrollbarStyle( + minimalHeight = 28.dp, + thickness = 4.dp, + shape = RoundedCornerShape(50), + hoverDurationMillis = 300, + unhoverColor = ReqLabColors.OnSurface.copy(alpha = 0.18f), + hoverColor = ReqLabColors.OnSurface.copy(alpha = 0.40f), +) diff --git a/ui-shared/src/wasmJsMain/kotlin/com/reqlab/ui/shared/platform/PlatformScrollbar.wasmJs.kt b/ui-shared/src/wasmJsMain/kotlin/com/reqlab/ui/shared/platform/PlatformScrollbar.wasmJs.kt new file mode 100644 index 0000000..e89cc36 --- /dev/null +++ b/ui-shared/src/wasmJsMain/kotlin/com/reqlab/ui/shared/platform/PlatformScrollbar.wasmJs.kt @@ -0,0 +1,22 @@ +package com.reqlab.ui.shared.platform + +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +actual fun PlatformLazyVerticalScrollbar( + listState: LazyListState, + modifier: Modifier, + testTag: String, +) { +} + +@Composable +actual fun PlatformColumnVerticalScrollbar( + scrollState: ScrollState, + modifier: Modifier, + testTag: String, +) { +} From 9a56990caffcdcfea61becd1e2a370a9e0b258f9 Mon Sep 17 00:00:00 2001 From: Sanjay Sharma Date: Sun, 30 Aug 2026 22:44:30 +0530 Subject: [PATCH 2/5] Add config changes for mcp tool calls --- DEVELOPMENT.md | 51 +- FEATURES.md | 2 +- README.md | 19 +- .../kotlin/com/reqlab/core/model/McpModels.kt | 2 + .../network/mcp/LegacyHttpSseTransport.kt | 38 +- .../com/reqlab/core/network/mcp/McpClient.kt | 56 +- .../com/reqlab/core/network/mcp/McpJson.kt | 1 + .../reqlab/core/network/mcp/McpSamplingLlm.kt | 97 +++ .../com/reqlab/core/network/mcp/McpStdio.kt | 129 ++++ .../network/mcp/StreamableHttpTransport.kt | 41 +- .../network/mcp/McpClientHandshakeTest.kt | 137 ++++ .../core/network/mcp/McpSamplingLlmTest.kt | 57 ++ .../core/network/mcp/McpStdioArgvTest.kt | 103 +++ .../core/network/mcp/McpPlatform.desktop.kt | 65 +- docs/images/mcp-tools.png | Bin 0 -> 511431 bytes docs/mcp.md | 176 +++++ docs/tests.md | 10 +- .../kotlin/com/reqlab/editor/core/JsonMode.kt | 31 +- .../com/reqlab/editor/core/JsonModeTest.kt | 38 + qa-tests/build.gradle.kts | 1 + qa-tests/collection-validator.mjs | 16 + qa-tests/fixtures/reqlab-test-collection.json | 79 ++ .../fixtures/reqlab-test-environment.json | 4 +- .../kotlin/com/reqlab/qa/McpHttpE2ETest.kt | 305 ++++++++ sample-server/build.gradle.kts | 22 + sample-server/mcp-stdio | 11 + .../com/reqlab/server/InstallMcpCommand.kt | 15 + .../com/reqlab/server/McpCommandShim.kt | 40 + .../main/kotlin/com/reqlab/server/McpMock.kt | 185 +++-- .../kotlin/com/reqlab/server/McpRoutes.kt | 87 ++- .../com/reqlab/server/McpCommandShimTest.kt | 55 ++ .../com/reqlab/server/McpMockProtocolTest.kt | 126 ++++ ui-desktop/build.gradle.kts | 2 + .../ui/desktop/McpCallbackPaneUiTest.kt | 94 +++ .../ui/desktop/McpSessionCallbackE2ETest.kt | 171 +++++ .../com/reqlab/ui/desktop/QaFixesStateTest.kt | 10 + .../ImportExportFixturesIntegrationTest.kt | 39 + .../desktop/state/AppStateCollectionTest.kt | 43 ++ .../kotlin/com/reqlab/ui/shared/MainScreen.kt | 8 +- .../ui/shared/components/McpCallbackPane.kt | 406 ++++++++++ .../reqlab/ui/shared/components/McpPanel.kt | 710 ++++++++++++++---- .../ui/shared/components/RequestTabsBar.kt | 8 +- .../reqlab/ui/shared/components/Sidebar.kt | 21 +- .../reqlab/ui/shared/mcp/McpSessionState.kt | 181 ++++- .../persistence/ImportExportRepository.kt | 57 ++ .../ui/shared/platform/FileChooserMemory.kt | 46 ++ .../reqlab/ui/shared/platform/PlatformApi.kt | 4 +- .../com/reqlab/ui/shared/state/AppState.kt | 30 +- .../src/commonMain/resources/i18n/de.json | 32 +- .../src/commonMain/resources/i18n/en.json | 32 +- .../src/commonMain/resources/i18n/es.json | 32 +- .../src/commonMain/resources/i18n/fr.json | 32 +- .../ui/shared/i18n/I18nCompletenessTest.kt | 3 + .../ui/shared/mcp/McpSessionStateTest.kt | 104 +++ .../McpWorkspaceBackwardCompatTest.kt | 40 + .../shared/platform/FileChooserMemoryTest.kt | 69 ++ .../ui/shared/platform/PlatformApi.desktop.kt | 25 +- 57 files changed, 3892 insertions(+), 306 deletions(-) create mode 100644 core-network/src/commonMain/kotlin/com/reqlab/core/network/mcp/McpSamplingLlm.kt create mode 100644 core-network/src/commonMain/kotlin/com/reqlab/core/network/mcp/McpStdio.kt create mode 100644 core-network/src/commonTest/kotlin/com/reqlab/core/network/mcp/McpSamplingLlmTest.kt create mode 100644 core-network/src/commonTest/kotlin/com/reqlab/core/network/mcp/McpStdioArgvTest.kt create mode 100644 docs/images/mcp-tools.png create mode 100644 docs/mcp.md create mode 100755 sample-server/mcp-stdio create mode 100644 sample-server/src/main/kotlin/com/reqlab/server/InstallMcpCommand.kt create mode 100644 sample-server/src/main/kotlin/com/reqlab/server/McpCommandShim.kt create mode 100644 sample-server/src/test/kotlin/com/reqlab/server/McpCommandShimTest.kt create mode 100644 sample-server/src/test/kotlin/com/reqlab/server/McpMockProtocolTest.kt create mode 100644 ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/McpCallbackPaneUiTest.kt create mode 100644 ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/McpSessionCallbackE2ETest.kt create mode 100644 ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/McpCallbackPane.kt create mode 100644 ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/platform/FileChooserMemory.kt create mode 100644 ui-shared/src/commonTest/kotlin/com/reqlab/ui/shared/platform/FileChooserMemoryTest.kt diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 5a89a28..4387cd8 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -169,9 +169,14 @@ Available endpoints (selected): | POST | `/v1/embeddings` | Fixed-length embedding vector | | GET | `/v1/chat/slow` | Delayed non-stream chat completion | | POST | `/mcp` | MCP Streamable HTTP (JSON-RPC) | +| POST | `/mcp/auth/bearer` | MCP Bearer `reqlab-mcp-token` | +| POST | `/mcp/auth/basic` | MCP Basic `admin` / `password` | +| POST | `/mcp/auth/apikey` | MCP header `X-Api-Key: reqlab-key` | +| POST | `/mcp/auth/jwt` | MCP Bearer JWT `reqlab-mcp-jwt` | | POST | `/mcp/authed` | MCP requiring Bearer `reqlab-mcp-token` and `X-Api-Key: reqlab-key` | +| POST | `/mcp?requireTenant=true&tenant=acme` | MCP query params required by the mock | | GET | `/mcp` | MCP GET SSE (server-initiated) | -| DELETE | `/mcp` | MCP session terminate | +| DELETE | `/mcp` | MCP session terminate (`Mcp-Session-Id`) | | GET | `/mcp/sse` | Legacy MCP HTTP+SSE | | POST | `/mcp/messages` | Legacy MCP POST | | POST | `/mcp/secure` | MCP with Bearer `mcp-oauth-token` | @@ -180,12 +185,54 @@ Available endpoints (selected): | GET | `/oauth/authorize` | Authorization (auto-approve for tests) | | WS | `/ws` | WebSocket echo | -MCP stdio mock: +### MCP stdio and PATH shim + +Stdio only (no HTTP port): ```bash ./gradlew :sample-server:run --args='--stdio' ``` +To put `sample-server` on your login PATH: + +```bash +./gradlew :sample-server:installMcpCommand +``` + +That writes `~/.local/bin/sample-server` on macOS/Linux, or `%USERPROFILE%\AppData\Local\ReqLab\bin\sample-server.cmd` on Windows. The shim always starts MCP stdio (the Gradle HTTP start script without `--stdio` would print a banner on stdout and break framing). After install, the command field is `sample-server`. On a new machine or after moving the repo, run `installMcpCommand` again (the shim stores an absolute path to this repo’s `mcp-stdio` script). + +How ReqLab resolves a stdio command: + +1. Parse the command line (tokens and quoting). Quoted paths with spaces work. +2. Resolve PATH from your login shell (`zsh`/`bash -ilc 'echo $PATH'`), merged with the process PATH, then look up the first token. On Windows, `.cmd` / `.exe` / `.bat` are tried. +3. If the first token looks like a path (`/` or `\`), resolve it against the process working directory once if that file exists. +4. Otherwise spawn the token as given. GUI apps often see a short PATH; the login-shell PATH is why `npx` and Homebrew binaries still resolve. + +### MCP mock tools + +| Tool | Role | +|---|---| +| `echo` | Returns the `text` argument | +| `add` | Adds numbers | +| `fail` | Error result | +| `slow` | Delayed result | +| `trigger_sampling` | Server requests `sampling/createMessage`; tool result is the client’s sampling reply | +| `trigger_roots` | Server requests `roots/list`; tool result is the client’s roots JSON | +| `trigger_elicitation` | Server requests `elicitation/create`; tool result is accept/decline | +| `trigger_ping` | Server requests `ping`; tool result is the client’s empty ping result | + +Resources, prompts, and logging are also advertised so you can exercise those tabs. Product guide: [docs/mcp.md](docs/mcp.md). + +### MCP sample-server troubleshooting + +| Symptom | Likely cause | What to do | +|---|---|---| +| `Cannot run program "sample-server"` | Not on login PATH | `./gradlew :sample-server:installMcpCommand`, or an absolute path, or `./gradlew :sample-server:run --args='--stdio'` | +| Handshake is garbage / HTTP banner on stdout | PATH `sample-server` is the Gradle HTTP script without `--stdio` | Use the shim from `installMcpCommand` | +| `Cannot run program "sample-server --stdio"` | Whole string used as the executable (fixed in current builds) | Use current ReqLab; command can be `sample-server` once it is on PATH | +| Timed out waiting for legacy SSE endpoint | Sample HTTP server not running, or GET `/mcp/sse` not streaming | `./gradlew :sample-server:run`; URL is `/mcp/sse` not `/mcp` | +| `Lost pending id` on legacy SSE | Reply arrived on SSE before POST returned (fixed in current builds) | Use current ReqLab; **Legacy** and `http://localhost:8080/mcp/sse` | + LLM mock query parameters: - `?demo=true` — longer assistant reply; streaming uses ~200ms per token (visible typewriter) diff --git a/FEATURES.md b/FEATURES.md index b115765..e3b2c32 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -25,7 +25,7 @@ ReqLab supports end-to-end API testing with: - Authentication modes: None, Basic, Bearer, API Key, JWT (OAuth2 planned) - Retry controls and timeout behavior - HTTP streaming for SSE (`text/event-stream`) and NDJSON (OpenAI-style `"stream": true`) -- **MCP client** for Streamable HTTP, legacy HTTP+SSE, and desktop stdio (tools/resources/prompts, subscriptions, OAuth 2.1). Auth (Bearer, API key, Basic), custom headers, query params, `{{var}}` URL editing, and formatted request/response editors match the REST workspace. +- **MCP client** for Streamable HTTP (2025-06-18), legacy HTTP+SSE, and desktop stdio. Tools (Form/JSON arguments), resources (read + subscribe), prompts, Activity JSON-RPC inspector, sampling/roots/elicitation, and auth (None, Basic, Bearer, API Key, JWT) match the REST workspace. See [docs/mcp.md](docs/mcp.md). - Copy request as `curl` ### Response Validation and Inspection diff --git a/README.md b/README.md index 68c735b..d99b6c4 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ You can import these sample fixtures from the repo: - Collection sample: [qa-tests/fixtures/reqlab-test-collection.json](qa-tests/fixtures/reqlab-test-collection.json) - Environment sample: [qa-tests/fixtures/reqlab-test-environment.json](qa-tests/fixtures/reqlab-test-environment.json) -The collection includes an **LLM (OpenAI-compatible)** folder. Start the sample server, then send **LLM Chat Completions Stream (visible)** to watch a token stream on one POST. +The collection includes an **LLM (OpenAI-compatible)** folder and an **MCP (Model Context Protocol)** folder. Start the sample server, then send **LLM Chat Completions Stream (visible)** to watch a token stream, or open an MCP item and Connect. See [docs/mcp.md](docs/mcp.md). ## Features @@ -69,9 +69,23 @@ The collection includes an **LLM (OpenAI-compatible)** folder. Start the sample - Auth: None, Basic, Bearer Token, API Key, JWT - Retry controls and per-request timeout - HTTP streaming: SSE (`text/event-stream`) and NDJSON on a single request (OpenAI-style `"stream": true`) -- MCP client: Streamable HTTP, legacy HTTP+SSE, and desktop stdio; tools/resources/prompts; OAuth 2.1 - Copy request as `curl` +### MCP + +ReqLab is an [MCP](https://modelcontextprotocol.io/) client in the same workspace as REST: collections, environments, auth, and a shared Response pane. + +![ReqLab MCP tools — connected session, tool list, Form/JSON arguments, JSON-RPC result](docs/images/mcp-tools.png) + +- Transports: Streamable HTTP, Auto (legacy fallback), Legacy HTTP+SSE, desktop stdio +- Tools (Form/JSON), resources (read + subscribe), prompts — results in the shared Response pane +- Activity JSON-RPC inspector; Logs vs Console +- Sampling, roots, elicitation on the Client tab +- Same auth editors as REST (None / Basic / Bearer / API Key / JWT); `{{variables}}` in URL, command, headers, and auth +- Collection save, import, and export of MCP items + +Full guide: [docs/mcp.md](docs/mcp.md) + ### 📬 Response Inspection - Status code, status text, and response headers @@ -192,6 +206,7 @@ Open from the toolbar `Help` icon or via **Settings → Open Help & About**: | [DEVELOPMENT.md](DEVELOPMENT.md) | Build, run, and contribute locally | | [docs/architecture.md](docs/architecture.md) | Module structure and data flow | | [docs/editor-architecture.md](docs/editor-architecture.md) | Code editor internals | +| [docs/mcp.md](docs/mcp.md) | MCP client: tools, resources, prompts, Activity, sampling | | [docs/scripts.md](docs/scripts.md) | Scripting API and variable scopes | | [docs/shortcuts.md](docs/shortcuts.md) | Keyboard shortcut reference | | [docs/testing.md](docs/testing.md) | Test strategy and coverage matrix | diff --git a/core-model/src/commonMain/kotlin/com/reqlab/core/model/McpModels.kt b/core-model/src/commonMain/kotlin/com/reqlab/core/model/McpModels.kt index e052e18..6556a9d 100644 --- a/core-model/src/commonMain/kotlin/com/reqlab/core/model/McpModels.kt +++ b/core-model/src/commonMain/kotlin/com/reqlab/core/model/McpModels.kt @@ -409,5 +409,7 @@ data class McpConnectionConfig( val roots: List = emptyList(), val samplingMode: McpSamplingMode = McpSamplingMode.MOCK, val samplingForwardUrl: String? = null, + val samplingForwardToken: String? = null, + val samplingMaxTokens: Int? = null, val autoRespondElicitation: Boolean = true, ) diff --git a/core-network/src/commonMain/kotlin/com/reqlab/core/network/mcp/LegacyHttpSseTransport.kt b/core-network/src/commonMain/kotlin/com/reqlab/core/network/mcp/LegacyHttpSseTransport.kt index 479951a..7e50f51 100644 --- a/core-network/src/commonMain/kotlin/com/reqlab/core/network/mcp/LegacyHttpSseTransport.kt +++ b/core-network/src/commonMain/kotlin/com/reqlab/core/network/mcp/LegacyHttpSseTransport.kt @@ -7,9 +7,9 @@ import com.reqlab.core.model.McpHttpMode import com.reqlab.core.network.SseParser import io.ktor.client.HttpClient import io.ktor.client.request.accept -import io.ktor.client.request.get import io.ktor.client.request.header import io.ktor.client.request.post +import io.ktor.client.request.prepareGet import io.ktor.client.request.setBody import io.ktor.client.statement.bodyAsChannel import io.ktor.http.ContentType @@ -73,29 +73,31 @@ class LegacyHttpSseTransport( } private suspend fun openSse() { - val response = httpClient.get(config.url) { + httpClient.prepareGet(config.url) { accept(ContentType.Text.EventStream) header(HttpHeaders.CacheControl, "no-cache") applyMcpHeaders(config.headers, config.auth, config.oauth, sessionId, protocolVersion, lastEventId) - } - val channel = response.bodyAsChannel() - val parser = SseParser() - while (!channel.isClosedForRead) { - val line = channel.readUTF8Line() ?: break - val event = parser.feedLine(line) ?: continue - if (!event.id.isNullOrBlank()) lastEventId = event.id - if (event.eventType == "endpoint") { - val relative = event.data.trim() - val resolved = resolveEndpoint(config.url, relative) - postUrl = resolved - if (!endpointReady.isCompleted) endpointReady.complete(resolved) - } else { + }.execute { response -> + lastResponseHeaders = response.headers.entries().associate { it.key to it.value } + val channel = response.bodyAsChannel() + val parser = SseParser() + while (!channel.isClosedForRead) { + val line = channel.readUTF8Line() ?: break + val event = parser.feedLine(line) ?: continue + if (!event.id.isNullOrBlank()) lastEventId = event.id + if (event.eventType == "endpoint") { + val relative = event.data.trim() + val resolved = resolveEndpoint(config.url, relative) + postUrl = resolved + if (!endpointReady.isCompleted) endpointReady.complete(resolved) + } else { + parseJsonRpc(event.data)?.let { _incoming.emit(it) } + } + } + parser.flush()?.let { event -> parseJsonRpc(event.data)?.let { _incoming.emit(it) } } } - parser.flush()?.let { event -> - parseJsonRpc(event.data)?.let { _incoming.emit(it) } - } } companion object { diff --git a/core-network/src/commonMain/kotlin/com/reqlab/core/network/mcp/McpClient.kt b/core-network/src/commonMain/kotlin/com/reqlab/core/network/mcp/McpClient.kt index 5f81d3e..c2a3ee8 100644 --- a/core-network/src/commonMain/kotlin/com/reqlab/core/network/mcp/McpClient.kt +++ b/core-network/src/commonMain/kotlin/com/reqlab/core/network/mcp/McpClient.kt @@ -34,6 +34,7 @@ import com.reqlab.core.model.McpReadResourceResult import com.reqlab.core.model.McpResource import com.reqlab.core.model.McpResourceTemplate import com.reqlab.core.model.McpRoot +import com.reqlab.core.model.McpSamplingMode import com.reqlab.core.model.McpTool import com.reqlab.core.model.McpToolResult import com.reqlab.core.model.McpTransportType @@ -115,7 +116,7 @@ class McpClient( ): McpInitializeResult { disconnect() config = resolveMcpConfig(connection, variableLayers) - handlers.onRoots = { config.roots } + applyConfigHandlers(config) _state.value = McpConnectionState.CONNECTING log(McpLogEntryKind.STATE, "Connecting via ${config.transport} ${config.httpMode}") try { @@ -237,6 +238,38 @@ class McpClient( notify("notifications/roots/list_changed", null) } + suspend fun generateSampling(request: McpCreateMessageRequest): McpCreateMessageResult { + val url = config.samplingForwardUrl?.takeIf { it.isNotBlank() } + ?: throw McpProtocolException("No sampling LLM URL") + return forwardMcpSampling( + httpClient = httpClient, + url = url, + bearerToken = config.samplingForwardToken, + request = request, + maxTokensCap = config.samplingMaxTokens, + ) + } + + private fun applyConfigHandlers(cfg: McpConnectionConfig) { + handlers.onRoots = { cfg.roots } + handlers.onSampling = { + when (cfg.samplingMode) { + McpSamplingMode.MANUAL -> cancelledMcpSamplingResult() + McpSamplingMode.MOCK -> McpCreateMessageResult( + content = com.reqlab.core.model.McpContent(type = "text", text = "mock reply from ReqLab"), + ) + McpSamplingMode.FORWARD_LLM -> generateSampling(it) + } + } + handlers.onElicit = { + if (cfg.autoRespondElicitation) { + McpElicitResult(action = McpElicitAction.ACCEPT, content = JsonObject(emptyMap())) + } else { + McpElicitResult(action = McpElicitAction.DECLINE) + } + } + } + private suspend fun handshake(active: McpTransport): McpInitializeResult { val params = McpInitializeParams( protocolVersion = MCP_PROTOCOL_VERSION, @@ -266,7 +299,13 @@ class McpClient( McpTransportType.STDIO -> stdioFactory(cfg) McpTransportType.STREAMABLE_HTTP -> when (cfg.httpMode) { McpHttpMode.LEGACY_2024_11_05 -> LegacyHttpSseTransport(httpClient, cfg, scope) - else -> StreamableHttpTransport(httpClient, cfg, scope) + else -> StreamableHttpTransport( + httpClient, + cfg, + scope, + replyClient = mcpAuxHttpClient(), + streamClient = mcpAuxHttpClient(), + ) } } } @@ -282,10 +321,10 @@ class McpClient( private suspend fun rpcCall(method: String, params: JsonElement?): JsonRpcEnvelope { val active = transport ?: throw McpProtocolException("Not connected") + val deferred = CompletableDeferred() val idValue = pendingMutex.withLock { val id = nextId++ - val key = id.toString() - pending[key] = CompletableDeferred() + pending[id.toString()] = deferred id } val key = idValue.toString() @@ -298,7 +337,8 @@ class McpClient( handshake(active) active.send(message) } - val deferred = pendingMutex.withLock { pending[key] } ?: throw McpProtocolException("Lost pending id $key") + // Keep the local deferred: legacy HTTP+SSE (and any transport that delivers on + // another coroutine) can complete and remove the map entry during send(). return try { awaitWithWallClockTimeout(deferred, callTimeoutMs) { McpTimeoutException("Timed out waiting for $method") @@ -328,7 +368,7 @@ class McpClient( } message.isRequest() -> { log(McpLogEntryKind.RECEIVED, message.method.orEmpty(), encoded, message.method, message.idKey()) - handleServerRequest(message) + scope.launch { handleServerRequest(message) } } message.isNotification() -> { log(McpLogEntryKind.NOTIFICATION, message.method.orEmpty(), encoded, message.method, null) @@ -410,7 +450,9 @@ class McpClient( } } -internal fun defaultMcpHttpClient(): HttpClient = createPlatformHttpClient { +internal fun defaultMcpHttpClient(): HttpClient = mcpAuxHttpClient() + +internal fun mcpAuxHttpClient(): HttpClient = createPlatformHttpClient { install(HttpTimeout) { requestTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS socketTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS diff --git a/core-network/src/commonMain/kotlin/com/reqlab/core/network/mcp/McpJson.kt b/core-network/src/commonMain/kotlin/com/reqlab/core/network/mcp/McpJson.kt index 00b66a6..423dba0 100644 --- a/core-network/src/commonMain/kotlin/com/reqlab/core/network/mcp/McpJson.kt +++ b/core-network/src/commonMain/kotlin/com/reqlab/core/network/mcp/McpJson.kt @@ -43,6 +43,7 @@ internal fun resolveMcpConfig( env = config.env.mapValues { r(it.value) }, workingDir = config.workingDir?.let(::r), samplingForwardUrl = config.samplingForwardUrl?.let(::r), + samplingForwardToken = config.samplingForwardToken?.let(::r), ) } diff --git a/core-network/src/commonMain/kotlin/com/reqlab/core/network/mcp/McpSamplingLlm.kt b/core-network/src/commonMain/kotlin/com/reqlab/core/network/mcp/McpSamplingLlm.kt new file mode 100644 index 0000000..1cc0a1c --- /dev/null +++ b/core-network/src/commonMain/kotlin/com/reqlab/core/network/mcp/McpSamplingLlm.kt @@ -0,0 +1,97 @@ +package com.reqlab.core.network.mcp + +import com.reqlab.core.model.McpContent +import com.reqlab.core.model.McpCreateMessageRequest +import com.reqlab.core.model.McpCreateMessageResult +import com.reqlab.core.network.LlmTextAssembler +import io.ktor.client.HttpClient +import io.ktor.client.request.header +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.contentType +import kotlinx.serialization.json.addJsonObject +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import kotlin.math.min + +fun cancelledMcpSamplingResult(): McpCreateMessageResult = McpCreateMessageResult( + content = McpContent(type = "text", text = ""), + stopReason = "cancelled", +) + +fun emptyMcpSamplingResult(): McpCreateMessageResult = McpCreateMessageResult( + role = "assistant", + content = McpContent(type = "text", text = ""), + model = "mock", + stopReason = "endTurn", +) + +suspend fun forwardMcpSampling( + httpClient: HttpClient, + url: String, + bearerToken: String?, + request: McpCreateMessageRequest, + maxTokensCap: Int? = null, +): McpCreateMessageResult { + val maxTokens = when { + maxTokensCap != null -> min(request.maxTokens, maxTokensCap) + else -> request.maxTokens + }.coerceAtLeast(1) + val model = request.modelPreferences?.hints?.firstOrNull()?.name?.takeIf { it.isNotBlank() } ?: "mock-gpt" + val messages = buildJsonArray { + request.systemPrompt?.takeIf { it.isNotBlank() }?.let { prompt -> + addJsonObject { + put("role", "system") + put("content", prompt) + } + } + request.messages.forEach { message -> + addJsonObject { + put("role", message.role) + put("content", message.content.text.orEmpty()) + } + } + } + val body = buildJsonObject { + put("model", model) + put("messages", messages) + put("max_tokens", maxTokens) + request.temperature?.let { put("temperature", it) } + } + val response = httpClient.post(url) { + contentType(ContentType.Application.Json) + if (!bearerToken.isNullOrBlank()) header(HttpHeaders.Authorization, "Bearer $bearerToken") + setBody(body.toString()) + } + val text = response.bodyAsText() + if (response.status.value >= 400) { + throw McpProtocolException("LLM HTTP ${response.status.value}: ${text.take(240)}") + } + val parsed = runCatching { mcpJson.parseToJsonElement(text).jsonObject }.getOrNull() + val content = LlmTextAssembler.assembleFromBody(text) + if (content.isBlank()) { + throw McpProtocolException("LLM returned empty content") + } + val responseModel = parsed?.get("model")?.jsonPrimitive?.contentOrNull ?: model + val finish = LlmTextAssembler.extractFinishReason(text, emptyList()) + return McpCreateMessageResult( + role = "assistant", + content = McpContent(type = "text", text = content), + model = responseModel, + stopReason = mapFinishReason(finish), + ) +} + +internal fun mapFinishReason(finish: String?): String = when (finish) { + null, "stop" -> "endTurn" + "length" -> "maxTokens" + "content_filter" -> "contentFilter" + else -> finish +} diff --git a/core-network/src/commonMain/kotlin/com/reqlab/core/network/mcp/McpStdio.kt b/core-network/src/commonMain/kotlin/com/reqlab/core/network/mcp/McpStdio.kt new file mode 100644 index 0000000..c3048ea --- /dev/null +++ b/core-network/src/commonMain/kotlin/com/reqlab/core/network/mcp/McpStdio.kt @@ -0,0 +1,129 @@ +package com.reqlab.core.network.mcp + +/** + * Builds the argv passed to the stdio process. + * + * The command field is a full command line (parsed with quoting); + * the first token is the executable, the rest plus [args] are arguments. + */ +internal fun mcpStdioArgv(command: String, args: List): List { + return tokenizeCommandLine(command) + args +} + +/** + * Resolves the executable: login-shell PATH first, then an explicit relative + * path against [workingDir] / [userDir] if that file exists. + */ +internal fun resolveStdioArgv( + command: String, + args: List, + workingDir: String?, + userDir: String, + pathEnv: String = "", + pathSeparator: String = ":", + extraExtensions: List = emptyList(), + exists: (String) -> Boolean, +): List { + val argv = mcpStdioArgv(command, args) + if (argv.isEmpty()) return argv + val exe = resolveStdioExecutable( + exe = argv.first(), + workingDir = workingDir, + userDir = userDir, + pathEnv = pathEnv, + pathSeparator = pathSeparator, + extraExtensions = extraExtensions, + exists = exists, + ) + return listOf(exe) + argv.drop(1) +} + +internal fun tokenizeCommandLine(command: String): List { + val out = mutableListOf() + val buf = StringBuilder() + var quote: Char? = null + var i = 0 + val line = command.trim() + while (i < line.length) { + val c = line[i] + when { + quote != null -> when { + c == quote -> quote = null + c == '\\' && quote == '"' && i + 1 < line.length -> { + buf.append(line[i + 1]) + i++ + } + else -> buf.append(c) + } + c == '\'' || c == '"' -> quote = c + c.isWhitespace() -> { + if (buf.isNotEmpty()) { + out += buf.toString() + buf.clear() + } + } + else -> buf.append(c) + } + i++ + } + if (buf.isNotEmpty()) out += buf.toString() + return out +} + +internal fun resolveStdioExecutable( + exe: String, + workingDir: String?, + userDir: String, + pathEnv: String = "", + pathSeparator: String = ":", + extraExtensions: List = emptyList(), + exists: (String) -> Boolean, +): String { + if (exe.isEmpty()) return exe + val isPath = exe.contains('/') || exe.contains('\\') + val base = workingDir?.takeIf { it.isNotBlank() } ?: userDir + if (!isPath) { + whichOnPath(exe, pathEnv, pathSeparator, exists, extraExtensions)?.let { return it } + return exe + } + if (exists(exe)) return exe + val againstBase = joinStdioPath(base, exe) + if (exists(againstBase)) return againstBase + return exe +} + +internal fun whichOnPath( + exe: String, + pathEnv: String, + pathSeparator: String, + exists: (String) -> Boolean, + extraExtensions: List = emptyList(), +): String? { + if (exe.isEmpty() || pathEnv.isEmpty()) return null + val names = listOf(exe) + extraExtensions.map { exe + it } + for (dir in pathEnv.split(pathSeparator)) { + if (dir.isBlank()) continue + for (name in names) { + val candidate = joinStdioPath(dir, name) + if (exists(candidate)) return candidate + } + } + return null +} + +internal fun joinStdioPath(base: String, relative: String): String { + val b = base.trimEnd('/', '\\') + val r = relative.trimStart('/', '\\') + if (b.isEmpty()) return r + return "$b/$r" +} + +internal fun mergePath(preferred: String, fallback: String, separator: String): String { + if (preferred.isBlank()) return fallback + if (fallback.isBlank()) return preferred + val seen = linkedSetOf() + (preferred.split(separator) + fallback.split(separator)).forEach { part -> + if (part.isNotBlank()) seen += part + } + return seen.joinToString(separator) +} diff --git a/core-network/src/commonMain/kotlin/com/reqlab/core/network/mcp/StreamableHttpTransport.kt b/core-network/src/commonMain/kotlin/com/reqlab/core/network/mcp/StreamableHttpTransport.kt index a252f95..c3c5c78 100644 --- a/core-network/src/commonMain/kotlin/com/reqlab/core/network/mcp/StreamableHttpTransport.kt +++ b/core-network/src/commonMain/kotlin/com/reqlab/core/network/mcp/StreamableHttpTransport.kt @@ -9,9 +9,9 @@ import com.reqlab.core.network.isSseContentType import io.ktor.client.HttpClient import io.ktor.client.request.accept import io.ktor.client.request.delete -import io.ktor.client.request.get import io.ktor.client.request.header -import io.ktor.client.request.post +import io.ktor.client.request.prepareGet +import io.ktor.client.request.preparePost import io.ktor.client.request.setBody import io.ktor.client.statement.HttpResponse import io.ktor.client.statement.bodyAsChannel @@ -26,7 +26,6 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -35,6 +34,8 @@ class StreamableHttpTransport( private val httpClient: HttpClient, private val config: McpConnectionConfig, private val scope: CoroutineScope, + private val replyClient: HttpClient = httpClient, + private val streamClient: HttpClient = httpClient, ) : McpTransport { private val _incoming = MutableSharedFlow(extraBufferCapacity = 256) override val incoming: SharedFlow = _incoming @@ -60,12 +61,15 @@ class StreamableHttpTransport( override suspend fun send(message: JsonRpcEnvelope) { val body = mcpJson.encodeToString(JsonRpcEnvelope.serializer(), message) - val response = httpClient.post(config.url) { + // Replies must not share the request client's connection while a POST SSE body is open. + val client = if (message.isResponse()) replyClient else httpClient + client.preparePost(config.url) { contentType(ContentType.Application.Json) applyMcpHeaders(config.headers, config.auth, config.oauth, sessionId, protocolVersion, lastEventId) setBody(body) + }.execute { response -> + handleResponse(response, isInitialize = message.method == "initialize") } - handleResponse(response, isInitialize = message.method == "initialize") } override suspend fun close() { @@ -81,6 +85,12 @@ class StreamableHttpTransport( } } sessionId = null + if (replyClient !== httpClient) { + runCatching { replyClient.close() } + } + if (streamClient !== httpClient && streamClient !== replyClient) { + runCatching { streamClient.close() } + } } private suspend fun handleResponse(response: HttpResponse, isInitialize: Boolean) { @@ -136,19 +146,20 @@ class StreamableHttpTransport( getJob?.cancel() getJob = scope.launch { try { - val response = httpClient.get(config.url) { + streamClient.prepareGet(config.url) { accept(ContentType.Text.EventStream) header(HttpHeaders.CacheControl, "no-cache") applyMcpHeaders(config.headers, config.auth, config.oauth, sessionId, protocolVersion, lastEventId) - } - if (response.status == HttpStatusCode.MethodNotAllowed || - response.status == HttpStatusCode.NotFound - ) { - return@launch - } - if (response.status == HttpStatusCode.Unauthorized) return@launch - if (isSseContentType(response.headers[HttpHeaders.ContentType])) { - drainSse(response) + }.execute { response -> + if (response.status == HttpStatusCode.MethodNotAllowed || + response.status == HttpStatusCode.NotFound || + response.status == HttpStatusCode.Unauthorized + ) { + return@execute + } + if (isSseContentType(response.headers[HttpHeaders.ContentType])) { + drainSse(response) + } } } catch (_: CancellationException) { } catch (_: Exception) { diff --git a/core-network/src/commonTest/kotlin/com/reqlab/core/network/mcp/McpClientHandshakeTest.kt b/core-network/src/commonTest/kotlin/com/reqlab/core/network/mcp/McpClientHandshakeTest.kt index 0fdc9f0..c6e4be7 100644 --- a/core-network/src/commonTest/kotlin/com/reqlab/core/network/mcp/McpClientHandshakeTest.kt +++ b/core-network/src/commonTest/kotlin/com/reqlab/core/network/mcp/McpClientHandshakeTest.kt @@ -1,10 +1,15 @@ package com.reqlab.core.network.mcp +import com.reqlab.core.model.JsonRpcEnvelope import com.reqlab.core.model.McpConnectionConfig import com.reqlab.core.model.McpContent import com.reqlab.core.model.McpCreateMessageResult import com.reqlab.core.model.McpElicitAction import com.reqlab.core.model.McpHttpMode +import com.reqlab.core.model.McpImplementation +import com.reqlab.core.model.McpInitializeResult +import com.reqlab.core.model.McpRoot +import com.reqlab.core.model.McpSamplingMode import com.reqlab.core.model.McpTransportType import io.ktor.client.HttpClient import io.ktor.client.engine.mock.MockEngine @@ -14,8 +19,12 @@ import io.ktor.http.HttpHeaders import io.ktor.http.HttpMethod import io.ktor.http.HttpStatusCode import io.ktor.http.headersOf +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put @@ -33,6 +42,11 @@ class McpClientHandshakeTest { } val body = request.body.toByteArray().decodeToString() val envelope = mcpJson.decodeFromString(com.reqlab.core.model.JsonRpcEnvelope.serializer(), body) + if (envelope.method == "initialize") { + assertTrue(body.contains("\"sampling\""), body) + assertTrue(body.contains("\"roots\""), body) + assertTrue(body.contains("\"elicitation\""), body) + } val result = when (envelope.method) { "initialize" -> """{"protocolVersion":"2025-06-18","capabilities":{"tools":{"listChanged":true},"resources":{"subscribe":true},"prompts":{}},"serverInfo":{"name":"mock","version":"1"}}""" "tools/list" -> """{"tools":[{"name":"echo","description":"echo","inputSchema":{"type":"object"}}]}""" @@ -89,6 +103,12 @@ class McpClientHandshakeTest { inbound.send("""{"jsonrpc":"2.0","id":$initId,"result":{"protocolVersion":"2025-06-18","capabilities":{},"serverInfo":{"name":"t","version":"1"}}}""") written.receive() // notifications/initialized connect.await() + client.handlers.onSampling = { + McpCreateMessageResult(content = McpContent(type = "text", text = "hello")) + } + client.handlers.onElicit = { + com.reqlab.core.model.McpElicitResult(action = McpElicitAction.ACCEPT, content = buildJsonObject { put("ok", true) }) + } inbound.send("""{"jsonrpc":"2.0","id":"srv-1","method":"sampling/createMessage","params":{"messages":[],"maxTokens":16}}""") inbound.send("""{"jsonrpc":"2.0","id":"srv-2","method":"elicitation/create","params":{"message":"hi","requestedSchema":{"type":"object"}}}""") val replies = listOf(written.receive(), written.receive()) @@ -96,4 +116,121 @@ class McpClientHandshakeTest { assertTrue(replies.any { it.contains("accept") && it.contains("srv-2") }) client.disconnect() } + + @Test + fun config_mock_sampling_and_auto_elicit_accept() = runTest { + val replies = connectStdioAndCollectServerReplies( + McpConnectionConfig( + transport = McpTransportType.STDIO, + command = "unused", + samplingMode = McpSamplingMode.MOCK, + autoRespondElicitation = true, + ), + """{"jsonrpc":"2.0","id":"srv-1","method":"sampling/createMessage","params":{"messages":[],"maxTokens":8}}""", + """{"jsonrpc":"2.0","id":"srv-2","method":"elicitation/create","params":{"message":"hi","requestedSchema":{"type":"object"}}}""", + ) + assertTrue(replies.any { it.contains("mock reply from ReqLab") && it.contains("srv-1") }) + assertTrue(replies.any { it.contains("accept") && it.contains("srv-2") }) + } + + @Test + fun config_manual_sampling_and_elicit_decline() = runTest { + val replies = connectStdioAndCollectServerReplies( + McpConnectionConfig( + transport = McpTransportType.STDIO, + command = "unused", + samplingMode = McpSamplingMode.MANUAL, + autoRespondElicitation = false, + ), + """{"jsonrpc":"2.0","id":"srv-1","method":"sampling/createMessage","params":{"messages":[],"maxTokens":8}}""", + """{"jsonrpc":"2.0","id":"srv-2","method":"elicitation/create","params":{"message":"hi","requestedSchema":{"type":"object"}}}""", + ) + assertTrue(replies.any { it.contains("cancelled") && it.contains("srv-1") }) + assertTrue(replies.none { it.contains("mock reply from ReqLab") }) + assertTrue(replies.any { it.contains("decline") && it.contains("srv-2") }) + } + + @Test + fun config_roots_are_returned_on_roots_list() = runTest { + val replies = connectStdioAndCollectServerReplies( + McpConnectionConfig( + transport = McpTransportType.STDIO, + command = "unused", + roots = listOf(McpRoot("file:///tmp/reqlab", "tmp")), + ), + """{"jsonrpc":"2.0","id":"srv-roots","method":"roots/list","params":{}}""", + ) + assertTrue(replies.single().contains("file:///tmp/reqlab")) + assertTrue(replies.single().contains("tmp")) + } + + @Test + fun initialize_survives_response_arriving_during_send() = runTest { + val transport = ReplyDuringSendTransport() + val client = McpClient( + scope = CoroutineScope(Dispatchers.Unconfined), + stdioFactory = { transport }, + callTimeoutMs = 5_000, + ) + val init = client.connect( + McpConnectionConfig(transport = McpTransportType.STDIO, command = "unused"), + ) + assertEquals("race", init.serverInfo.name) + client.disconnect() + } + + private suspend fun CoroutineScope.connectStdioAndCollectServerReplies( + config: McpConnectionConfig, + vararg inboundMessages: String, + ): List { + val inbound = Channel(Channel.UNLIMITED) + val written = Channel(Channel.UNLIMITED) + val transport = NdjsonStdioTransport(this, inbound, { written.send(it) }) + val client = McpClient(scope = this, stdioFactory = { transport }, callTimeoutMs = 5_000) + val connect = async { client.connect(config) } + val initLine = written.receive() + val initId = mcpJson.decodeFromString( + com.reqlab.core.model.JsonRpcEnvelope.serializer(), + initLine, + ).id + inbound.send("""{"jsonrpc":"2.0","id":$initId,"result":{"protocolVersion":"2025-06-18","capabilities":{},"serverInfo":{"name":"t","version":"1"}}}""") + written.receive() + connect.await() + inboundMessages.forEach { inbound.send(it) } + val replies = inboundMessages.map { written.receive() } + client.disconnect() + return replies + } +} + +/** + * Replies to initialize during send(), matching legacy HTTP+SSE where the SSE + * collector can complete the pending RPC before send() returns. + */ +private class ReplyDuringSendTransport : McpTransport { + private val _incoming = MutableSharedFlow(extraBufferCapacity = 16) + override val incoming: SharedFlow = _incoming + override val sessionId: String? = null + override var protocolVersion: String = "2025-06-18" + override val lastEventId: String? = null + override val negotiatedHttpMode: McpHttpMode = McpHttpMode.LEGACY_2024_11_05 + + override suspend fun start() = Unit + + override suspend fun send(message: JsonRpcEnvelope) { + if (message.method == "initialize") { + _incoming.emit( + JsonRpcEnvelope( + id = message.id, + result = encodeParams( + McpInitializeResult( + serverInfo = McpImplementation(name = "race", version = "1"), + ), + ), + ), + ) + } + } + + override suspend fun close() = Unit } diff --git a/core-network/src/commonTest/kotlin/com/reqlab/core/network/mcp/McpSamplingLlmTest.kt b/core-network/src/commonTest/kotlin/com/reqlab/core/network/mcp/McpSamplingLlmTest.kt new file mode 100644 index 0000000..b8e9c6a --- /dev/null +++ b/core-network/src/commonTest/kotlin/com/reqlab/core/network/mcp/McpSamplingLlmTest.kt @@ -0,0 +1,57 @@ +package com.reqlab.core.network.mcp + +import com.reqlab.core.model.McpContent +import com.reqlab.core.model.McpCreateMessageRequest +import com.reqlab.core.model.McpSamplingMessage +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.engine.mock.toByteArray +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class McpSamplingLlmTest { + + @Test + fun maps_openai_chat_completion_to_sampling_result() = runTest { + val engine = MockEngine { request -> + val body = request.body.toByteArray().decodeToString() + assertTrue(body.contains("\"messages\"")) + assertTrue(body.contains("Say hi")) + assertEquals("Bearer llm-test-key", request.headers[HttpHeaders.Authorization]) + respond( + content = """{"id":"chatcmpl-1","model":"mock-gpt","choices":[{"index":0,"message":{"role":"assistant","content":"Hello from ReqLab"},"finish_reason":"stop"}]}""", + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + } + val client = HttpClient(engine) { expectSuccess = false } + val result = forwardMcpSampling( + httpClient = client, + url = "https://llm.example/v1/chat/completions", + bearerToken = "llm-test-key", + request = McpCreateMessageRequest( + messages = listOf( + McpSamplingMessage(role = "user", content = McpContent(type = "text", text = "Say hi")), + ), + maxTokens = 32, + ), + ) + assertEquals("Hello from ReqLab", result.content.text) + assertEquals("assistant", result.role) + assertEquals("mock-gpt", result.model) + assertEquals("endTurn", result.stopReason) + } + + @Test + fun maps_finish_reason_length_to_max_tokens() { + assertEquals("maxTokens", mapFinishReason("length")) + assertEquals("endTurn", mapFinishReason("stop")) + assertEquals("contentFilter", mapFinishReason("content_filter")) + } +} diff --git a/core-network/src/commonTest/kotlin/com/reqlab/core/network/mcp/McpStdioArgvTest.kt b/core-network/src/commonTest/kotlin/com/reqlab/core/network/mcp/McpStdioArgvTest.kt new file mode 100644 index 0000000..aa064a6 --- /dev/null +++ b/core-network/src/commonTest/kotlin/com/reqlab/core/network/mcp/McpStdioArgvTest.kt @@ -0,0 +1,103 @@ +package com.reqlab.core.network.mcp + +import kotlin.test.Test +import kotlin.test.assertEquals + +class McpStdioArgvTest { + @Test + fun splits_command_and_flag_when_args_empty() { + assertEquals( + listOf("sample-server", "--stdio"), + mcpStdioArgv("sample-server --stdio", emptyList()), + ) + } + + @Test + fun appends_explicit_args_after_tokenized_command() { + assertEquals( + listOf("npx", "-y", "@modelcontextprotocol/server-everything"), + mcpStdioArgv("npx", listOf("-y", "@modelcontextprotocol/server-everything")), + ) + } + + @Test + fun respects_quoted_paths_with_spaces() { + assertEquals( + listOf("/Applications/My Server/bin/mcp", "--stdio"), + tokenizeCommandLine("\"/Applications/My Server/bin/mcp\" --stdio"), + ) + } + + @Test + fun path_lookup_resolves_command_on_path() { + val argv = resolveStdioArgv( + command = "sample-server", + args = emptyList(), + workingDir = null, + userDir = "/repo", + pathEnv = "/usr/local/bin", + exists = { it == "/usr/local/bin/sample-server" }, + ) + assertEquals(listOf("/usr/local/bin/sample-server"), argv) + } + + @Test + fun bare_command_is_unchanged_when_not_on_path() { + val argv = resolveStdioArgv( + command = "sample-server", + args = emptyList(), + workingDir = null, + userDir = "/repo/ui-desktop", + exists = { false }, + ) + assertEquals(listOf("sample-server"), argv) + } + + @Test + fun resolves_relative_path_against_user_dir_when_file_exists() { + val argv = resolveStdioArgv( + command = "sample-server/mcp-stdio", + args = emptyList(), + workingDir = null, + userDir = "/repo", + exists = { it == "/repo/sample-server/mcp-stdio" }, + ) + assertEquals(listOf("/repo/sample-server/mcp-stdio"), argv) + } + + @Test + fun does_not_walk_parent_directories_for_relative_paths() { + val argv = resolveStdioArgv( + command = "sample-server/mcp-stdio", + args = emptyList(), + workingDir = null, + userDir = "/repo/ui-desktop", + exists = { it == "/repo/sample-server/mcp-stdio" }, + ) + assertEquals(listOf("sample-server/mcp-stdio"), argv) + } + + @Test + fun leaves_path_commands_unchanged_when_missing() { + assertEquals( + listOf("reqlab-no-such-binary"), + resolveStdioArgv("reqlab-no-such-binary", emptyList(), null, "/repo") { _ -> false }, + ) + } + + @Test + fun which_on_path_finds_first_match() { + assertEquals( + "/opt/homebrew/bin/npx", + whichOnPath("npx", "/opt/homebrew/bin:/usr/bin", ":", { it == "/opt/homebrew/bin/npx" }), + ) + } + + @Test + fun merge_path_prefers_login_shell_then_process_path() { + assertEquals( + "/opt/homebrew/bin:/usr/bin:/bin", + mergePath("/opt/homebrew/bin:/usr/bin", "/usr/bin:/bin", ":"), + ) + } +} diff --git a/core-network/src/desktopMain/kotlin/com/reqlab/core/network/mcp/McpPlatform.desktop.kt b/core-network/src/desktopMain/kotlin/com/reqlab/core/network/mcp/McpPlatform.desktop.kt index a23f1cf..f33e9c5 100644 --- a/core-network/src/desktopMain/kotlin/com/reqlab/core/network/mcp/McpPlatform.desktop.kt +++ b/core-network/src/desktopMain/kotlin/com/reqlab/core/network/mcp/McpPlatform.desktop.kt @@ -10,19 +10,39 @@ import java.awt.Desktop import java.net.ServerSocket import java.net.URI import java.security.SecureRandom +import java.util.concurrent.TimeUnit import kotlin.concurrent.thread actual val mcpStdioSupported: Boolean = true actual fun createStdioTransport(config: McpConnectionConfig): McpTransport { - val command = config.command.trim() - require(command.isNotBlank()) { "stdio command is required" } - val builder = ProcessBuilder(listOf(command) + config.args) + val pathEnv = loginShellPath() + val windows = System.getProperty("os.name").orEmpty().lowercase().contains("win") + val argv = resolveStdioArgv( + command = config.command, + args = config.args, + workingDir = config.workingDir, + userDir = System.getProperty("user.dir").orEmpty(), + exists = { java.io.File(it).isFile }, + pathEnv = pathEnv, + pathSeparator = java.io.File.pathSeparator, + extraExtensions = if (windows) listOf(".cmd", ".exe", ".bat") else emptyList(), + ) + require(argv.isNotEmpty()) { "stdio command is required" } + val builder = ProcessBuilder(argv) if (!config.workingDir.isNullOrBlank()) builder.directory(java.io.File(config.workingDir)) val env = builder.environment() + if (pathEnv.isNotBlank()) env["PATH"] = pathEnv config.env.forEach { (k, v) -> env[k] = v } builder.redirectErrorStream(false) - val process = builder.start() + val process = try { + builder.start() + } catch (e: java.io.IOException) { + throw McpTransportException( + "Cannot run program \"${argv.first()}\" (${argv.joinToString(" ")}): ${e.message}", + e, + ) + } val lines = Channel(Channel.UNLIMITED) val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) val stdoutJob = scope.launch { @@ -96,3 +116,40 @@ actual suspend fun mcpOpenAuthorizeUrlAndAwaitCode(authorizeUrl: String, redirec server.close() } } + +/** + * GUI-launched apps often have a short PATH. Load the login-shell PATH so + * commands such as `npx` and Homebrew binaries resolve. Cache the first lookup. + */ +internal fun loginShellPath(): String { + cachedLoginShellPath?.let { return it } + val fallback = System.getenv("PATH").orEmpty() + val os = System.getProperty("os.name").orEmpty().lowercase() + if (os.contains("win")) { + cachedLoginShellPath = fallback + return fallback + } + val shell = System.getenv("SHELL")?.takeIf { it.isNotBlank() } ?: "/bin/sh" + val path = try { + val process = ProcessBuilder(shell, "-ilc", "printf %s \"\$PATH\"") + .redirectErrorStream(true) + .start() + val finished = process.waitFor(3, TimeUnit.SECONDS) + if (!finished) { + process.destroyForcibly() + fallback + } else { + val out = process.inputStream.bufferedReader().readText() + out.lineSequence().map { it.trim() }.lastOrNull { it.contains('/') && it.contains(':') } + ?: fallback + } + } catch (_: Exception) { + fallback + } + val merged = mergePath(path, fallback, java.io.File.pathSeparator) + cachedLoginShellPath = merged + return merged +} + +@Volatile +private var cachedLoginShellPath: String? = null diff --git a/docs/images/mcp-tools.png b/docs/images/mcp-tools.png new file mode 100644 index 0000000000000000000000000000000000000000..0a0f5465a329a9256c661015864e877cfdb63eb5 GIT binary patch literal 511431 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ð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 z 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

rr&g|R7SiY~|<>xVR@U9*@ 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

9+a`1FZ5*X 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%0DTN?WPJr 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 start directory. + +--- + +## Keyboard shortcuts (MCP) + +| Shortcut | Action | +|---|---| +| `⌘ + Enter` / `Ctrl + Enter` | Run or stop the selected tool, resource read, or get-prompt | + +Connect / Disconnect is the connection-bar button, not Send. + +--- + +## Try the sample collection + +Import [qa-tests/fixtures/reqlab-test-collection.json](../qa-tests/fixtures/reqlab-test-collection.json) and [qa-tests/fixtures/reqlab-test-environment.json](../qa-tests/fixtures/reqlab-test-environment.json). Folder **MCP (Model Context Protocol)** covers Streamable HTTP, auth variants, query params, legacy SSE, stdio, sampling, roots, and elicitation. + +Start the mock with `./gradlew :sample-server:run`. Routes, mock tools (`echo`, `add`, `trigger_*`, …), and stdio install: [DEVELOPMENT.md](../DEVELOPMENT.md). How tests assert this: [docs/tests.md](tests.md). + +--- + +## Implementation map + +| Area | Location | +|---|---| +| Client, handshake, pending RPC | `core-network` `McpClient` | +| Streamable HTTP / legacy SSE / stdio | `StreamableHttpTransport`, `LegacyHttpSseTransport`, `McpStdio.kt` + `McpPlatform.desktop.kt` | +| Session / UI | `ui-shared` `McpSessionState`, `McpPanel` | +| Mock protocol + HTTP routes | `sample-server` `McpMock`, `McpRoutes` | + +Related: [DEVELOPMENT.md](../DEVELOPMENT.md), [docs/tests.md](tests.md), [docs/architecture.md](architecture.md). diff --git a/docs/tests.md b/docs/tests.md index 368dc76..03c3ec0 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -37,12 +37,20 @@ LLM streaming E2E against the embedded sample-server: ./gradlew :qa-tests:jvmTest --tests com.reqlab.qa.LlmApiE2ETest ``` -MCP Streamable HTTP + OAuth E2E: +MCP Streamable HTTP, legacy HTTP+SSE, stdio, sampling/roots/elicitation, LLM generate, and OAuth E2E: ```bash ./gradlew :qa-tests:jvmTest --tests com.reqlab.qa.McpHttpE2ETest +./gradlew :ui-desktop:desktopTest --tests com.reqlab.ui.desktop.McpSessionCallbackE2ETest +./gradlew :ui-desktop:desktopTest --tests com.reqlab.ui.desktop.McpCallbackPaneUiTest ``` +### MCP fixtures + +Import [qa-tests/fixtures/reqlab-test-collection.json](../qa-tests/fixtures/reqlab-test-collection.json) and [qa-tests/fixtures/reqlab-test-environment.json](../qa-tests/fixtures/reqlab-test-environment.json). Folder **MCP (Model Context Protocol)** covers Streamable HTTP (plain + auth + query params), legacy SSE, stdio (`{{mcpStdioCommand}}` = `sample-server`), sampling mock/manual/LLM (`{{llmBaseUrl}}` + `{{llmApiKey}}`), roots, and elicitation accept/decline. + +The JS collection validator skips `STDIO` and `LEGACY_2024_11_05`. Kotlin e2e (`:qa-tests:jvmTest --tests com.reqlab.qa.McpHttpE2ETest`) is the protocol source of truth. Product guide: [docs/mcp.md](mcp.md). Mock routes and PATH shim: [DEVELOPMENT.md](../DEVELOPMENT.md). + Run full project checks: ```bash diff --git a/editor-core/src/commonMain/kotlin/com/reqlab/editor/core/JsonMode.kt b/editor-core/src/commonMain/kotlin/com/reqlab/editor/core/JsonMode.kt index 4d046f0..cac1a14 100644 --- a/editor-core/src/commonMain/kotlin/com/reqlab/editor/core/JsonMode.kt +++ b/editor-core/src/commonMain/kotlin/com/reqlab/editor/core/JsonMode.kt @@ -1,7 +1,12 @@ package com.reqlab.editor.core import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +private const val JSON_UNWRAP_MAX_DEPTH = 8 object JsonMode : LanguageModeProvider { override val mode = LanguageMode.JSON @@ -16,9 +21,33 @@ object JsonMode : LanguageModeProvider { @OptIn(kotlinx.serialization.ExperimentalSerializationApi::class) override fun format(text: String): String = try { val element = prettyJson.decodeFromString(JsonElement.serializer(), text) - prettyJson.encodeToString(JsonElement.serializer(), element) + prettyJson.encodeToString(JsonElement.serializer(), unwrapEmbeddedJson(element)) } catch (_: Throwable) { text } + /** + * Recursively replace string values that are JSON objects/arrays with the parsed value + * so Pretty/Format match viewers that expand nested JSON. + */ + internal fun unwrapEmbeddedJson(element: JsonElement, depth: Int = 0): JsonElement { + if (depth >= JSON_UNWRAP_MAX_DEPTH) return element + return when (element) { + is JsonObject -> JsonObject(element.mapValues { (_, value) -> unwrapEmbeddedJson(value, depth + 1) }) + is JsonArray -> JsonArray(element.map { unwrapEmbeddedJson(it, depth + 1) }) + is JsonPrimitive -> unwrapJsonString(element, depth) + } + } + + private fun unwrapJsonString(primitive: JsonPrimitive, depth: Int): JsonElement { + if (!primitive.isString) return primitive + val trimmed = primitive.content.trim() + if (trimmed.isEmpty() || (trimmed[0] != '{' && trimmed[0] != '[')) return primitive + val parsed = runCatching { prettyJson.parseToJsonElement(trimmed) }.getOrNull() ?: return primitive + return when (parsed) { + is JsonObject, is JsonArray -> unwrapEmbeddedJson(parsed, depth + 1) + else -> primitive + } + } + override fun tokenizeLine(line: String, lineNumber: Int, state: Any?): Pair, Any?> { val tokens = mutableListOf() var i = 0 diff --git a/editor-core/src/commonTest/kotlin/com/reqlab/editor/core/JsonModeTest.kt b/editor-core/src/commonTest/kotlin/com/reqlab/editor/core/JsonModeTest.kt index f053f73..3463ef7 100644 --- a/editor-core/src/commonTest/kotlin/com/reqlab/editor/core/JsonModeTest.kt +++ b/editor-core/src/commonTest/kotlin/com/reqlab/editor/core/JsonModeTest.kt @@ -106,4 +106,42 @@ class JsonModeTest { assertEquals(1, regions[0].startLine) assertEquals(4, regions[0].endLine) } + + @Test + fun formatUnwrapsJsonEncodedObjectString() { + val input = """{"text":"{\"jsonrpc\":\"2.0\",\"id\":\"srv-sample\"}"}""" + val result = JsonMode.format(input) + assertTrue(result.contains("\"text\": {") || result.contains("\"text\":{"), result) + assertTrue(result.contains("\"srv-sample\""), result) + assertTrue(!result.contains("\\\""), result) + } + + @Test + fun formatUnwrapsJsonEncodedArrayString() { + val result = JsonMode.format("""{"items":"[1,2]"}""") + assertTrue(result.contains("\"items\": [") || result.contains("\"items\":["), result) + assertTrue(result.contains("1"), result) + } + + @Test + fun formatLeavesPlainString() { + val result = JsonMode.format("""{"text":"hello"}""") + assertTrue(result.contains("\"hello\""), result) + assertTrue(!result.contains("\"text\": {") && !result.contains("\"text\":{"), result) + } + + @Test + fun formatPreservesNumericValue() { + val result = JsonMode.format("""{"text":6}""") + assertTrue(result.contains("\"text\": 6") || result.contains("\"text\":6"), result) + } + + @Test + fun formatLeavesBooleanAndNumberStrings() { + val boolResult = JsonMode.format("""{"text":"true"}""") + assertTrue(boolResult.contains("\"true\""), boolResult) + val numResult = JsonMode.format("""{"text":"6"}""") + assertTrue(numResult.contains("\"6\""), numResult) + assertTrue(!numResult.contains("\"text\": 6") && !numResult.contains("\"text\":6"), numResult) + } } diff --git a/qa-tests/build.gradle.kts b/qa-tests/build.gradle.kts index 5dccf15..b2033c8 100644 --- a/qa-tests/build.gradle.kts +++ b/qa-tests/build.gradle.kts @@ -38,4 +38,5 @@ kotlin { tasks.withType().configureEach { useJUnit() + dependsOn(":sample-server:installDist") } diff --git a/qa-tests/collection-validator.mjs b/qa-tests/collection-validator.mjs index f0e591f..2b2c721 100644 --- a/qa-tests/collection-validator.mjs +++ b/qa-tests/collection-validator.mjs @@ -336,6 +336,22 @@ async function main() { let bodyText; if ((req.kind || '').toUpperCase() === 'MCP') { + const transport = String(req.mcpTransport || 'STREAMABLE_HTTP').toUpperCase(); + const httpMode = String(req.mcpHttpMode || 'AUTO').toUpperCase(); + if (transport === 'STDIO' || httpMode === 'LEGACY_2024_11_05') { + results.push({ + name: req.name, + path: req.__path, + method: 'MCP', + url: resolvedUrl, + status: 200, + responseTimeMs: 0, + responseSizeBytes: 0, + passed: true, + errors: [], + }); + continue; + } const started = performance.now(); const mcpHeaders = { 'Content-Type': 'application/json', diff --git a/qa-tests/fixtures/reqlab-test-collection.json b/qa-tests/fixtures/reqlab-test-collection.json index bdc7687..bf2f2f3 100644 --- a/qa-tests/fixtures/reqlab-test-collection.json +++ b/qa-tests/fixtures/reqlab-test-collection.json @@ -1101,6 +1101,85 @@ "url": "{{mcpTenantUrl}}", "mcpTransport": "STREAMABLE_HTTP", "mcpHttpMode": "AUTO" + }, + { + "name": "MCP Legacy HTTP+SSE", + "kind": "MCP", + "method": "POST", + "url": "{{mcpLegacyUrl}}", + "mcpTransport": "STREAMABLE_HTTP", + "mcpHttpMode": "LEGACY_2024_11_05" + }, + { + "name": "MCP stdio", + "kind": "MCP", + "method": "POST", + "url": "", + "mcpTransport": "STDIO", + "mcpHttpMode": "AUTO", + "mcpCommand": "{{mcpStdioCommand}}" + }, + { + "name": "MCP Sampling mock", + "kind": "MCP", + "method": "POST", + "url": "{{mcpBaseUrl}}", + "mcpTransport": "STREAMABLE_HTTP", + "mcpHttpMode": "AUTO", + "mcpSamplingMode": "MOCK" + }, + { + "name": "MCP Sampling manual", + "kind": "MCP", + "method": "POST", + "url": "{{mcpBaseUrl}}", + "mcpTransport": "STREAMABLE_HTTP", + "mcpHttpMode": "AUTO", + "mcpSamplingMode": "MANUAL" + }, + { + "name": "MCP Sampling LLM", + "kind": "MCP", + "method": "POST", + "url": "{{mcpBaseUrl}}", + "mcpTransport": "STREAMABLE_HTTP", + "mcpHttpMode": "AUTO", + "mcpSamplingMode": "FORWARD_LLM", + "mcpSamplingForwardUrl": "{{llmBaseUrl}}/v1/chat/completions", + "mcpSamplingForwardToken": "{{llmApiKey}}", + "mcpSamplingMaxTokens": 256 + }, + { + "name": "MCP Roots", + "kind": "MCP", + "method": "POST", + "url": "{{mcpBaseUrl}}", + "mcpTransport": "STREAMABLE_HTTP", + "mcpHttpMode": "AUTO", + "mcpRoots": [ + { + "uri": "file:///tmp/reqlab", + "name": "tmp" + } + ] + }, + { + "name": "MCP Elicitation auto-accept", + "kind": "MCP", + "method": "POST", + "url": "{{mcpBaseUrl}}", + "mcpTransport": "STREAMABLE_HTTP", + "mcpHttpMode": "AUTO", + "mcpAutoRespondElicitation": true + }, + { + "name": "MCP Elicitation decline", + "kind": "MCP", + "method": "POST", + "url": "{{mcpBaseUrl}}", + "mcpTransport": "STREAMABLE_HTTP", + "mcpHttpMode": "AUTO", + "mcpAutoRespondElicitation": false } ] } diff --git a/qa-tests/fixtures/reqlab-test-environment.json b/qa-tests/fixtures/reqlab-test-environment.json index 9b660fb..d477f2c 100644 --- a/qa-tests/fixtures/reqlab-test-environment.json +++ b/qa-tests/fixtures/reqlab-test-environment.json @@ -24,6 +24,8 @@ "mcpTenantUrl": "http://localhost:8080/mcp?requireTenant=true&tenant=acme", "mcpBearerToken": "reqlab-mcp-token", "mcpApiKey": "reqlab-key", - "mcpJwtToken": "reqlab-mcp-jwt" + "mcpJwtToken": "reqlab-mcp-jwt", + "mcpLegacyUrl": "http://localhost:8080/mcp/sse", + "mcpStdioCommand": "sample-server" } } diff --git a/qa-tests/src/test/kotlin/com/reqlab/qa/McpHttpE2ETest.kt b/qa-tests/src/test/kotlin/com/reqlab/qa/McpHttpE2ETest.kt index abee0cb..86b94f8 100644 --- a/qa-tests/src/test/kotlin/com/reqlab/qa/McpHttpE2ETest.kt +++ b/qa-tests/src/test/kotlin/com/reqlab/qa/McpHttpE2ETest.kt @@ -7,10 +7,14 @@ import com.reqlab.core.model.McpConnectionConfig import com.reqlab.core.model.McpHttpMode import com.reqlab.core.model.McpOAuthConfig import com.reqlab.core.model.McpOAuthGrantType +import com.reqlab.core.model.McpRoot +import com.reqlab.core.model.McpSamplingMode +import com.reqlab.core.model.McpTransportType import com.reqlab.core.network.mcp.McpClient import com.reqlab.core.network.mcp.McpOAuthClient import com.reqlab.core.network.mcp.McpUnauthorizedException import com.reqlab.core.network.mcp.NdjsonStdioTransport +import com.reqlab.core.network.mcp.createStdioTransport import com.reqlab.core.network.mcp.mcpStdioSupported import com.reqlab.server.module import io.ktor.client.HttpClient @@ -21,8 +25,11 @@ import io.ktor.server.engine.EmbeddedServer import io.ktor.server.engine.embeddedServer import io.ktor.server.netty.Netty import io.ktor.server.netty.NettyApplicationEngine +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.runBlocking @@ -35,6 +42,7 @@ import kotlinx.serialization.json.put import org.junit.AfterClass import org.junit.BeforeClass import org.junit.Test +import java.io.File import java.net.ServerSocket import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -369,6 +377,292 @@ class McpHttpE2ETest { assertTrue(mcpStdioSupported) } + @Test + fun legacy_http_sse_initialize_and_tools_list() = runBlocking { + val client = McpClient(this, http(), callTimeoutMs = 10_000) + val init = client.connect( + McpConnectionConfig( + url = "$BASE_URL/mcp/sse", + httpMode = McpHttpMode.LEGACY_2024_11_05, + ), + ) + assertEquals("ReqLab MCP Mock", init.serverInfo.name) + val tools = client.listTools().map { it.name } + assertTrue(tools.contains("echo")) + client.disconnect() + } + + @Test + fun stdio_sample_server_initialize_and_echo() = runBlocking { + val java = ProcessHandle.current().info().command().orElse("java") + val process = ProcessBuilder( + java, + "-cp", + System.getProperty("java.class.path"), + "com.reqlab.server.ApplicationKt", + "--stdio", + ).start() + try { + val inbound = Channel(Channel.UNLIMITED) + val readerJob = launch(Dispatchers.IO) { + process.inputStream.bufferedReader().useLines { lines -> + lines.forEach { inbound.trySend(it) } + } + } + val transport = NdjsonStdioTransport( + scope = this, + incomingLines = inbound, + writeLine = { + process.outputStream.write((it + "\n").toByteArray(Charsets.UTF_8)) + process.outputStream.flush() + }, + onClose = { process.destroy() }, + ) + val client = McpClient(this, stdioFactory = { transport }, callTimeoutMs = 15_000) + val init = client.connect( + McpConnectionConfig(transport = McpTransportType.STDIO, command = "unused"), + ) + assertEquals("ReqLab MCP Mock", init.serverInfo.name) + val echo = client.callTool("echo", buildJsonObject { put("text", "hi") }) + assertEquals("hi", echo.content.single().text) + client.disconnect() + readerJob.cancel() + } finally { + process.destroyForcibly() + } + } + + @Test + fun stdio_command_line_splits_and_launches_sample_server() = runBlocking { + val transport = createStdioTransport( + McpConnectionConfig(transport = McpTransportType.STDIO, command = repoMcpStdioLauncher()), + ) + try { + val client = McpClient(this, stdioFactory = { transport }, callTimeoutMs = 15_000) + val init = client.connect( + McpConnectionConfig(transport = McpTransportType.STDIO, command = "unused"), + ) + assertEquals("ReqLab MCP Mock", init.serverInfo.name) + client.disconnect() + } finally { + transport.close() + } + } + + @Test + fun trigger_sampling_returns_mock_reply() = runBlocking { + verifyCallback( + McpConnectionConfig( + url = "$BASE_URL/mcp", + httpMode = McpHttpMode.STREAMABLE_2025_06_18, + samplingMode = McpSamplingMode.MOCK, + ), + tool = "trigger_sampling", + mustContain = listOf("mock reply from ReqLab"), + mustNotContain = listOf("cancelled", "Invalid request"), + ) + } + + @Test + fun trigger_sampling_manual_is_cancelled() = runBlocking { + verifyCallback( + McpConnectionConfig( + url = "$BASE_URL/mcp", + httpMode = McpHttpMode.STREAMABLE_2025_06_18, + samplingMode = McpSamplingMode.MANUAL, + ), + tool = "trigger_sampling", + mustContain = listOf("cancelled"), + mustNotContain = listOf("mock reply from ReqLab", "Invalid request"), + ) + } + + @Test + fun trigger_sampling_forward_llm_echoes_chat_completion() = runBlocking { + verifyCallback( + McpConnectionConfig( + url = "$BASE_URL/mcp", + httpMode = McpHttpMode.STREAMABLE_2025_06_18, + samplingMode = McpSamplingMode.FORWARD_LLM, + samplingForwardUrl = "$BASE_URL/v1/chat/completions", + samplingForwardToken = "llm-test-key", + ), + tool = "trigger_sampling", + mustContain = listOf("Hello from ReqLab"), + mustNotContain = listOf("mock reply from ReqLab", "Invalid request"), + ) + } + + @Test + fun trigger_roots_returns_configured_roots() = runBlocking { + verifyCallback( + McpConnectionConfig( + url = "$BASE_URL/mcp", + httpMode = McpHttpMode.STREAMABLE_2025_06_18, + roots = listOf(McpRoot("file:///tmp/reqlab", "tmp")), + ), + tool = "trigger_roots", + mustContain = listOf("file:///tmp/reqlab", "tmp"), + mustNotContain = listOf("Invalid request"), + ) + } + + @Test + fun trigger_roots_empty_list_is_not_tmp() = runBlocking { + verifyCallback( + McpConnectionConfig( + url = "$BASE_URL/mcp", + httpMode = McpHttpMode.STREAMABLE_2025_06_18, + roots = emptyList(), + ), + tool = "trigger_roots", + mustContain = listOf("\"roots\":[]"), + mustNotContain = listOf("file:///tmp/reqlab", "Invalid request"), + ) + } + + @Test + fun trigger_elicitation_accept_when_auto() = runBlocking { + verifyCallback( + McpConnectionConfig( + url = "$BASE_URL/mcp", + httpMode = McpHttpMode.STREAMABLE_2025_06_18, + autoRespondElicitation = true, + ), + tool = "trigger_elicitation", + mustContain = listOf("accept"), + mustNotContain = listOf("decline", "Invalid request"), + ) + } + + @Test + fun trigger_elicitation_decline_when_disabled() = runBlocking { + verifyCallback( + McpConnectionConfig( + url = "$BASE_URL/mcp", + httpMode = McpHttpMode.STREAMABLE_2025_06_18, + autoRespondElicitation = false, + ), + tool = "trigger_elicitation", + mustContain = listOf("decline"), + mustNotContain = listOf("\"accept\"", "Invalid request"), + ) + } + + @Test + fun trigger_ping_echoes_empty_result() = runBlocking { + verifyCallback( + McpConnectionConfig( + url = "$BASE_URL/mcp", + httpMode = McpHttpMode.STREAMABLE_2025_06_18, + ), + tool = "trigger_ping", + mustContain = listOf("srv-ping"), + mustNotContain = listOf("Invalid request"), + ) + } + + @Test + fun trigger_roots_legacy_sse_echoes_configured_roots() = runBlocking { + verifyCallback( + McpConnectionConfig( + url = "$BASE_URL/mcp/sse", + httpMode = McpHttpMode.LEGACY_2024_11_05, + roots = listOf(McpRoot("file:///tmp/reqlab", "tmp")), + ), + tool = "trigger_roots", + mustContain = listOf("file:///tmp/reqlab", "tmp"), + mustNotContain = listOf("Invalid request"), + ) + } + + @Test + fun stdio_trigger_roots_echoes_configured_roots() = runBlocking { + val java = ProcessHandle.current().info().command().orElse("java") + val process = ProcessBuilder( + java, + "-cp", + System.getProperty("java.class.path"), + "com.reqlab.server.ApplicationKt", + "--stdio", + ).start() + try { + val inbound = Channel(Channel.UNLIMITED) + val readerJob = launch(Dispatchers.IO) { + process.inputStream.bufferedReader().useLines { lines -> + lines.forEach { inbound.trySend(it) } + } + } + val transport = NdjsonStdioTransport( + scope = this, + incomingLines = inbound, + writeLine = { + process.outputStream.write((it + "\n").toByteArray(Charsets.UTF_8)) + process.outputStream.flush() + }, + onClose = { process.destroy() }, + ) + val client = McpClient(this, stdioFactory = { transport }, callTimeoutMs = 15_000) + client.connect( + McpConnectionConfig( + transport = McpTransportType.STDIO, + command = "unused", + roots = listOf(McpRoot("file:///tmp/reqlab", "tmp")), + ), + ) + val result = client.callTool("trigger_roots") + assertCallbackOracle( + toolText = result.content.single().text.orEmpty(), + isError = result.isError, + lastPayload = client.lastReceivedPayload.orEmpty(), + mustContain = listOf("file:///tmp/reqlab", "tmp"), + mustNotContain = listOf("Invalid request"), + ) + client.disconnect() + readerJob.cancel() + } finally { + process.destroyForcibly() + } + } + + private suspend fun CoroutineScope.verifyCallback( + config: McpConnectionConfig, + tool: String, + mustContain: List, + mustNotContain: List = emptyList(), + ) { + val client = McpClient(this, http(), callTimeoutMs = 10_000) + client.connect(config) + val result = client.callTool(tool) + assertCallbackOracle( + toolText = result.content.single().text.orEmpty(), + isError = result.isError, + lastPayload = client.lastReceivedPayload.orEmpty(), + mustContain = mustContain, + mustNotContain = mustNotContain, + ) + client.disconnect() + } + + private fun assertCallbackOracle( + toolText: String, + isError: Boolean, + lastPayload: String, + mustContain: List, + mustNotContain: List, + ) { + assertEquals(false, isError, "tool error: $toolText") + mustContain.forEach { needle -> + assertTrue(toolText.contains(needle), "tool text missing '$needle': $toolText") + } + mustNotContain.forEach { needle -> + assertTrue(!toolText.contains(needle), "tool text should not contain '$needle': $toolText") + } + assertTrue(lastPayload.contains("\"result\""), "lastReceivedPayload is not a tools/call result: $lastPayload") + assertTrue(!lastPayload.contains("Invalid request"), "lastReceivedPayload has Invalid request: $lastPayload") + assertTrue(!lastPayload.contains("-32600"), "lastReceivedPayload has -32600: $lastPayload") + } + companion object { private var server: EmbeddedServer? = null private var port: Int = 0 @@ -392,5 +686,16 @@ class McpHttpE2ETest { fun stopServer() { server?.stop(1000, 2000) } + + /** Test-only: locate this checkout's stdio launcher without product walk-up. */ + private fun repoMcpStdioLauncher(): String { + var dir = File(System.getProperty("user.dir")) + repeat(8) { + val candidate = File(dir, "sample-server/mcp-stdio") + if (candidate.isFile) return candidate.absolutePath + dir = dir.parentFile ?: return "sample-server/mcp-stdio" + } + return "sample-server/mcp-stdio" + } } } diff --git a/sample-server/build.gradle.kts b/sample-server/build.gradle.kts index 804fd78..31f793b 100644 --- a/sample-server/build.gradle.kts +++ b/sample-server/build.gradle.kts @@ -20,6 +20,13 @@ dependencies { // Logging (required by Netty) implementation("ch.qos.logback:logback-classic:1.5.13") + + testImplementation(libs.kotlin.test) + testImplementation(libs.junit4) +} + +tasks.test { + useJUnit() } tasks.named("run") { @@ -33,3 +40,18 @@ tasks.register("runServer") { description = "Starts the ReqLab sample API server at http://localhost:8080" dependsOn("run") } + +/** + * Writes ~/.local/bin/sample-server (macOS/Linux) so the mock is on the login PATH + * as an MCP stdio process. The HTTP server is still `./gradlew :sample-server:run`. + */ +tasks.register("installMcpCommand") { + group = "application" + description = "Installs `sample-server` on PATH as an MCP stdio server" + dependsOn("installDist", "classes") + classpath = sourceSets.main.get().runtimeClasspath + mainClass.set("com.reqlab.server.InstallMcpCommandKt") + val unixLauncher = layout.projectDirectory.file("mcp-stdio") + val windowsLauncher = layout.buildDirectory.file("install/sample-server/bin/sample-server.bat") + args(unixLauncher.asFile.absolutePath, windowsLauncher.get().asFile.absolutePath) +} diff --git a/sample-server/mcp-stdio b/sample-server/mcp-stdio new file mode 100755 index 0000000..dff6a77 --- /dev/null +++ b/sample-server/mcp-stdio @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Launches the ReqLab sample MCP mock over stdio (MCP 2025-06-18). +# Used by the test collection env var mcpStdioCommand. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +BIN="$ROOT/sample-server/build/install/sample-server/bin/sample-server" +if [[ ! -x "$BIN" ]]; then + echo "Building sample-server distribution (once)…" >&2 + (cd "$ROOT" && ./gradlew :sample-server:installDist -q) >&2 +fi +exec "$BIN" --stdio "$@" diff --git a/sample-server/src/main/kotlin/com/reqlab/server/InstallMcpCommand.kt b/sample-server/src/main/kotlin/com/reqlab/server/InstallMcpCommand.kt new file mode 100644 index 0000000..06f17d6 --- /dev/null +++ b/sample-server/src/main/kotlin/com/reqlab/server/InstallMcpCommand.kt @@ -0,0 +1,15 @@ +package com.reqlab.server + +import java.io.File + +fun main(args: Array) { + require(args.size >= 2) { "usage: unixLauncher windowsLauncher" } + val dest = McpCommandShim.install( + homeDir = System.getProperty("user.home"), + osName = System.getProperty("os.name"), + unixLauncher = File(args[0]), + windowsLauncher = File(args[1]), + ) + println("Installed MCP stdio command: ${dest.absolutePath}") + println("Command: sample-server") +} diff --git a/sample-server/src/main/kotlin/com/reqlab/server/McpCommandShim.kt b/sample-server/src/main/kotlin/com/reqlab/server/McpCommandShim.kt new file mode 100644 index 0000000..884c591 --- /dev/null +++ b/sample-server/src/main/kotlin/com/reqlab/server/McpCommandShim.kt @@ -0,0 +1,40 @@ +package com.reqlab.server + +import java.io.File + +/** + * Puts `sample-server` on the login-shell PATH as an MCP stdio process. + * The Gradle HTTP start script and stdio mock share a binary; this shim + * always launches stdio. + */ +object McpCommandShim { + fun unixScript(launcherPath: String): String = + "#!/usr/bin/env bash\nexec \"$launcherPath\" \"\$@\"\n" + + fun windowsCmd(launcherPath: String): String = + "@echo off\r\n\"$launcherPath\" --stdio %*\r\n" + + fun installDir(homeDir: String, osName: String): File { + val windows = osName.lowercase().contains("win") + return if (windows) File(homeDir, "AppData/Local/ReqLab/bin") + else File(homeDir, ".local/bin") + } + + fun install( + homeDir: String, + osName: String, + unixLauncher: File, + windowsLauncher: File, + ): File { + val windows = osName.lowercase().contains("win") + val dir = installDir(homeDir, osName) + dir.mkdirs() + val dest = File(dir, if (windows) "sample-server.cmd" else "sample-server") + dest.writeText( + if (windows) windowsCmd(windowsLauncher.canonicalPath) + else unixScript(unixLauncher.canonicalPath), + ) + dest.setExecutable(true, false) + return dest + } +} diff --git a/sample-server/src/main/kotlin/com/reqlab/server/McpMock.kt b/sample-server/src/main/kotlin/com/reqlab/server/McpMock.kt index 7c55272..2c9fb60 100644 --- a/sample-server/src/main/kotlin/com/reqlab/server/McpMock.kt +++ b/sample-server/src/main/kotlin/com/reqlab/server/McpMock.kt @@ -13,6 +13,9 @@ import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.withTimeoutOrNull import java.security.MessageDigest import java.util.Base64 import java.util.UUID @@ -20,10 +23,20 @@ import java.util.concurrent.ConcurrentHashMap internal val mcpMockJson = Json { ignoreUnknownKeys = true; isLenient = true } +internal const val MCP_WAIT_FOR_KEY = "_reqlabWaitFor" +internal const val MCP_CALLBACK_SAMPLE = "srv-sample" +internal const val MCP_CALLBACK_ELICIT = "srv-elicit" +internal const val MCP_CALLBACK_ROOTS = "srv-roots" +internal const val MCP_CALLBACK_PING = "srv-ping" +internal const val MCP_CALLBACK_TIMEOUT_MS = 60_000L + data class McpMockSession( val id: String = UUID.randomUUID().toString(), val subscribed: MutableSet = ConcurrentHashMap.newKeySet(), var logLevel: String = "info", + val lastReplies: ConcurrentHashMap = ConcurrentHashMap(), + val callbackReplies: ConcurrentHashMap> = ConcurrentHashMap(), + val serverPushes: Channel = Channel(Channel.UNLIMITED), ) data class McpOutbound( @@ -60,6 +73,11 @@ object McpMockProtocol { val id = obj["id"] val params = obj["params"] as? JsonObject if (method == null) { + val isResponse = obj.containsKey("result") || obj.containsKey("error") + if (isResponse && id != null && id !is JsonNull) { + completeCallback(session, jsonIdKey(id), obj) + return null + } return if (id != null && id !is JsonNull) rpcError(id, -32600, "Invalid request") else null } if (id == null || id is JsonNull) { @@ -124,6 +142,7 @@ object McpMockProtocol { tool("trigger_sampling", "Ask the client to sample a message"), tool("trigger_elicitation", "Ask the client to fill a form"), tool("trigger_roots", "Ask the client for roots/list"), + tool("trigger_ping", "Ask the client to answer ping"), ) return if (cursor == "page2") { buildJsonObject { put("tools", buildJsonArray {}) } @@ -173,62 +192,132 @@ object McpMockProtocol { } rpcResult(id, toolText("done")) } - "trigger_sampling" -> { - extraOut += McpOutbound( - buildJsonObject { - put("jsonrpc", "2.0") - put("id", "srv-sample") - put("method", "sampling/createMessage") - put("params", buildJsonObject { - put("messages", buildJsonArray { - add(buildJsonObject { - put("role", "user") - put("content", buildJsonObject { - put("type", "text") - put("text", "Say hi") - }) + "trigger_sampling" -> triggerCallback( + session = session, + extraOut = extraOut, + callId = id, + callbackId = MCP_CALLBACK_SAMPLE, + envelope = buildJsonObject { + put("jsonrpc", "2.0") + put("id", MCP_CALLBACK_SAMPLE) + put("method", "sampling/createMessage") + put("params", buildJsonObject { + put("messages", buildJsonArray { + add(buildJsonObject { + put("role", "user") + put("content", buildJsonObject { + put("type", "text") + put("text", "Say hi") }) }) - put("maxTokens", 32) }) - }, - ) - rpcResult(id, toolText("sampling requested")) - } - "trigger_elicitation" -> { - extraOut += McpOutbound( - buildJsonObject { - put("jsonrpc", "2.0") - put("id", "srv-elicit") - put("method", "elicitation/create") - put("params", buildJsonObject { - put("message", "What is your name?") - put("requestedSchema", buildJsonObject { - put("type", "object") - put("properties", buildJsonObject { - put("name", buildJsonObject { put("type", "string") }) - }) + put("maxTokens", 32) + }) + }, + ) + "trigger_elicitation" -> triggerCallback( + session = session, + extraOut = extraOut, + callId = id, + callbackId = MCP_CALLBACK_ELICIT, + envelope = buildJsonObject { + put("jsonrpc", "2.0") + put("id", MCP_CALLBACK_ELICIT) + put("method", "elicitation/create") + put("params", buildJsonObject { + put("message", "What is your name?") + put("requestedSchema", buildJsonObject { + put("type", "object") + put("properties", buildJsonObject { + put("name", buildJsonObject { put("type", "string") }) }) }) - }, - ) - rpcResult(id, toolText("elicitation requested")) - } - "trigger_roots" -> { - extraOut += McpOutbound( - buildJsonObject { - put("jsonrpc", "2.0") - put("id", "srv-roots") - put("method", "roots/list") - put("params", buildJsonObject {}) - }, - ) - rpcResult(id, toolText("roots requested")) - } + }) + }, + ) + "trigger_roots" -> triggerCallback( + session = session, + extraOut = extraOut, + callId = id, + callbackId = MCP_CALLBACK_ROOTS, + envelope = buildJsonObject { + put("jsonrpc", "2.0") + put("id", MCP_CALLBACK_ROOTS) + put("method", "roots/list") + put("params", buildJsonObject {}) + }, + ) + "trigger_ping" -> triggerCallback( + session = session, + extraOut = extraOut, + callId = id, + callbackId = MCP_CALLBACK_PING, + envelope = buildJsonObject { + put("jsonrpc", "2.0") + put("id", MCP_CALLBACK_PING) + put("method", "ping") + put("params", buildJsonObject {}) + }, + ) else -> rpcError(id, -32602, "Unknown tool $name") } } + private fun triggerCallback( + session: McpMockSession, + extraOut: MutableList, + callId: JsonElement, + callbackId: String, + envelope: JsonObject, + ): JsonObject { + session.callbackReplies.getOrPut(callbackId) { CompletableDeferred() } + extraOut += McpOutbound(envelope) + return waitMarker(callId, callbackId) + } + + fun waitMarker(callId: JsonElement, callbackId: String) = buildJsonObject { + put("jsonrpc", "2.0") + put("id", callId) + put(MCP_WAIT_FOR_KEY, callbackId) + } + + fun waitForCallbackId(result: JsonObject?): String? = + result?.get(MCP_WAIT_FOR_KEY)?.jsonPrimitive?.contentOrNull + + fun completeCallback(session: McpMockSession, id: String?, envelope: JsonObject) { + if (id.isNullOrBlank()) return + session.lastReplies[id] = envelope + session.callbackReplies.getOrPut(id) { CompletableDeferred() }.complete(envelope) + } + + suspend fun awaitCallback( + session: McpMockSession, + id: String, + timeoutMs: Long = MCP_CALLBACK_TIMEOUT_MS, + ): JsonObject? { + session.lastReplies[id]?.let { return it } + val deferred = session.callbackReplies.getOrPut(id) { CompletableDeferred() } + session.lastReplies[id]?.let { return it } + return withTimeoutOrNull(timeoutMs) { deferred.await() } + } + + suspend fun resolveWaitResult(session: McpMockSession, result: JsonObject?): JsonObject? { + val waitFor = waitForCallbackId(result) ?: return result + val callId = result?.get("id") ?: return result + val echoed = awaitCallback(session, waitFor) + return if (echoed == null) { + rpcResult(callId, toolText("Timed out waiting for client reply to $waitFor", isError = true)) + } else { + rpcResult(callId, toolText(echoed.toString())) + } + } + + fun jsonIdKey(id: JsonElement?): String? { + if (id == null || id is JsonNull) return null + val primitive = id as? JsonPrimitive ?: return id.toString() + return primitive.content + } + private fun resourcesList() = buildJsonObject { put("resources", buildJsonArray { add(buildJsonObject { @@ -311,7 +400,7 @@ object McpMockProtocol { fun sseEndpointFrame(path: String): String = "event: endpoint\ndata: $path\n\n" - private fun toolText(text: String, isError: Boolean = false) = buildJsonObject { + internal fun toolText(text: String, isError: Boolean = false) = buildJsonObject { put("content", buildJsonArray { add(buildJsonObject { put("type", "text") diff --git a/sample-server/src/main/kotlin/com/reqlab/server/McpRoutes.kt b/sample-server/src/main/kotlin/com/reqlab/server/McpRoutes.kt index 9e69ef0..5abc9fa 100644 --- a/sample-server/src/main/kotlin/com/reqlab/server/McpRoutes.kt +++ b/sample-server/src/main/kotlin/com/reqlab/server/McpRoutes.kt @@ -3,6 +3,7 @@ package com.reqlab.server import io.ktor.http.ContentType import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode +import io.ktor.http.content.OutgoingContent import io.ktor.server.application.ApplicationCall import io.ktor.server.request.header import io.ktor.server.request.receiveText @@ -16,6 +17,8 @@ import io.ktor.server.routing.delete import io.ktor.server.routing.get import io.ktor.server.routing.options import io.ktor.server.routing.post +import io.ktor.utils.io.ByteWriteChannel +import io.ktor.utils.io.writeStringUtf8 import kotlinx.coroutines.channels.Channel import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive @@ -51,7 +54,9 @@ fun Route.mcpAndOAuthRoutes() { delete("/mcp") { call.applyMcpCors() val sid = call.request.header("Mcp-Session-Id") - if (sid != null) McpMockProtocol.sessions.remove(sid) + if (sid != null) { + McpMockProtocol.sessions.remove(sid)?.serverPushes?.close() + } call.respond(HttpStatusCode.NoContent) } @@ -181,8 +186,9 @@ private suspend fun ApplicationCall.handleMcpPost(gate: McpAuthGate) { val extra = mutableListOf() val session = if (stateless) McpMockSession() else McpMockProtocol.requireOrCreate(sessionHeader) if (!stateless) McpMockProtocol.sessions[session.id] = session - val result = McpMockProtocol.handle(body, session, extra) - if (result != null && result["result"] != null) { + val handled = McpMockProtocol.handle(body, session, extra) + extra.forEach { outbound -> session.serverPushes.trySend(outbound) } + if (handled != null && handled["result"] != null) { val method = runCatching { mcpMockJson.parseToJsonElement(body) as JsonObject }.getOrNull()?.get("method")?.let { (it as? JsonPrimitive)?.content } @@ -192,29 +198,25 @@ private suspend fun ApplicationCall.handleMcpPost(gate: McpAuthGate) { } val accept = request.header(HttpHeaders.Accept).orEmpty() val wantsSse = accept.contains("text/event-stream") - if (wantsSse && (extra.isNotEmpty() || (result != null && accept.contains("text/event-stream") && extra.isNotEmpty()))) { - respondTextWriter(contentType = ContentType.parse("text/event-stream")) { - extra.forEach { outbound -> - write(McpMockProtocol.sseFrame(outbound, eventIds.getAndIncrement())) - flush() - } - if (result != null) { - write(McpMockProtocol.sseFrame(McpOutbound(result), eventIds.getAndIncrement())) - flush() - } - } - return - } if (wantsSse && extra.isNotEmpty()) { - respondTextWriter(contentType = ContentType.parse("text/event-stream")) { - extra.forEach { write(McpMockProtocol.sseFrame(it, eventIds.getAndIncrement())); flush() } - if (result != null) { - write(McpMockProtocol.sseFrame(McpOutbound(result), eventIds.getAndIncrement())) - flush() + response.header(HttpHeaders.CacheControl, "no-cache") + respond(object : OutgoingContent.WriteChannelContent() { + override val contentType: ContentType = ContentType.parse("text/event-stream") + override suspend fun writeTo(channel: ByteWriteChannel) { + extra.forEach { outbound -> + channel.writeStringUtf8(McpMockProtocol.sseFrame(outbound, eventIds.getAndIncrement())) + channel.flush() + } + val result = McpMockProtocol.resolveWaitResult(session, handled) + if (result != null) { + channel.writeStringUtf8(McpMockProtocol.sseFrame(McpOutbound(result), eventIds.getAndIncrement())) + channel.flush() + } } - } + }) return } + val result = McpMockProtocol.resolveWaitResult(session, handled) if (result == null) { respond(HttpStatusCode.Accepted) return @@ -224,7 +226,19 @@ private suspend fun ApplicationCall.handleMcpPost(gate: McpAuthGate) { private suspend fun ApplicationCall.handleMcpGet() { applyMcpCors() - respond(HttpStatusCode.MethodNotAllowed, "GET SSE optional; not used by this mock") + val sessionHeader = request.header("Mcp-Session-Id") + val session = McpMockProtocol.session(sessionHeader) + if (session == null) { + respond(HttpStatusCode.MethodNotAllowed, "GET SSE optional; session required") + return + } + response.header(HttpHeaders.CacheControl, "no-cache") + respondTextWriter(contentType = ContentType.parse("text/event-stream")) { + for (outbound in session.serverPushes) { + write(McpMockProtocol.sseFrame(outbound, eventIds.getAndIncrement())) + flush() + } + } } private suspend fun ApplicationCall.handleLegacySse() { @@ -250,7 +264,8 @@ private suspend fun ApplicationCall.handleLegacyMessage() { val extra = mutableListOf() val result = McpMockProtocol.handle(receiveText(), session, extra) extra.forEach { channel?.send(McpMockProtocol.sseFrame(it, eventIds.getAndIncrement())) } - if (result != null) channel?.send(McpMockProtocol.sseFrame(McpOutbound(result), eventIds.getAndIncrement())) + val resolved = McpMockProtocol.resolveWaitResult(session, result) + if (resolved != null) channel?.send(McpMockProtocol.sseFrame(McpOutbound(resolved), eventIds.getAndIncrement())) respond(HttpStatusCode.Accepted) } @@ -317,8 +332,30 @@ fun runMcpStdio() { val line = reader.readLine() ?: break if (line.isBlank()) continue val extra = mutableListOf() - val result = McpMockProtocol.handle(line, session, extra) + val handled = McpMockProtocol.handle(line, session, extra) extra.forEach { System.out.println(it.envelope.toString()) } + System.out.flush() + val waitFor = McpMockProtocol.waitForCallbackId(handled) + val result = if (waitFor != null) { + val replyLine = reader.readLine() + if (!replyLine.isNullOrBlank()) { + McpMockProtocol.handle(replyLine, session, mutableListOf()) + } + val echoed = session.lastReplies[waitFor] + val callId = handled?.get("id") + if (callId == null) { + null + } else if (echoed == null) { + McpMockProtocol.rpcResult( + callId, + McpMockProtocol.toolText("Timed out waiting for client reply to $waitFor", isError = true), + ) + } else { + McpMockProtocol.rpcResult(callId, McpMockProtocol.toolText(echoed.toString())) + } + } else { + handled + } if (result != null) System.out.println(result.toString()) System.out.flush() } diff --git a/sample-server/src/test/kotlin/com/reqlab/server/McpCommandShimTest.kt b/sample-server/src/test/kotlin/com/reqlab/server/McpCommandShimTest.kt new file mode 100644 index 0000000..3b44741 --- /dev/null +++ b/sample-server/src/test/kotlin/com/reqlab/server/McpCommandShimTest.kt @@ -0,0 +1,55 @@ +package com.reqlab.server + +import org.junit.Test +import java.io.File +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class McpCommandShimTest { + + @Test + fun unix_shim_execs_repo_launcher() { + val script = McpCommandShim.unixScript("/repo/sample-server/mcp-stdio") + assertTrue(script.startsWith("#!/usr/bin/env bash")) + assertTrue(script.contains("exec \"/repo/sample-server/mcp-stdio\" \"\$@\"")) + } + + @Test + fun windows_shim_passes_stdio_flag() { + val cmd = McpCommandShim.windowsCmd("C:\\repo\\sample-server.bat") + assertTrue(cmd.contains("\"C:\\repo\\sample-server.bat\" --stdio %*")) + } + + @Test + fun unix_install_dir_is_local_bin() { + val dir = McpCommandShim.installDir("/Users/me", "Mac OS X") + assertTrue(dir.path.replace('\\', '/').endsWith(".local/bin")) + } + + @Test + fun windows_install_dir_is_local_reqlab_bin() { + val dir = McpCommandShim.installDir("C:\\Users\\me", "Windows 11") + assertTrue(dir.path.replace('\\', '/').endsWith("AppData/Local/ReqLab/bin")) + } + + @Test + fun install_writes_executable_shim_into_temp_home() { + val home = File.createTempFile("reqlab-mcp-home", "").apply { + delete() + mkdirs() + } + val launcher = File.createTempFile("mcp-stdio", "").apply { + writeText("#!/bin/sh\n") + setExecutable(true) + } + try { + val dest = McpCommandShim.install(home.absolutePath, "Mac OS X", launcher, launcher) + assertEquals(File(home, ".local/bin/sample-server").canonicalFile, dest.canonicalFile) + assertTrue(dest.canExecute()) + assertTrue(dest.readText().contains(launcher.canonicalPath)) + } finally { + home.deleteRecursively() + launcher.delete() + } + } +} diff --git a/sample-server/src/test/kotlin/com/reqlab/server/McpMockProtocolTest.kt b/sample-server/src/test/kotlin/com/reqlab/server/McpMockProtocolTest.kt new file mode 100644 index 0000000..61d51ee --- /dev/null +++ b/sample-server/src/test/kotlin/com/reqlab/server/McpMockProtocolTest.kt @@ -0,0 +1,126 @@ +package com.reqlab.server + +import kotlinx.coroutines.runBlocking +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class McpMockProtocolTest { + + @Test + fun tools_list_includes_trigger_tools() { + val extra = mutableListOf() + val result = McpMockProtocol.handle( + """{"jsonrpc":"2.0","id":1,"method":"tools/list"}""", + McpMockSession(), + extra, + ) + val text = result.toString() + assertTrue(text.contains("echo")) + assertTrue(text.contains("trigger_sampling")) + assertTrue(text.contains("trigger_elicitation")) + assertTrue(text.contains("trigger_roots")) + assertTrue(text.contains("trigger_ping")) + assertTrue(extra.isEmpty()) + } + + @Test + fun trigger_sampling_emits_create_message() { + val extra = mutableListOf() + val result = McpMockProtocol.handle( + """{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"trigger_sampling"}}""", + McpMockSession(), + extra, + ) + assertEquals(MCP_CALLBACK_SAMPLE, McpMockProtocol.waitForCallbackId(result)) + assertEquals(1, extra.size) + assertTrue(extra.single().envelope.toString().contains("sampling/createMessage")) + } + + @Test + fun trigger_elicitation_emits_elicit_create() { + val extra = mutableListOf() + val result = McpMockProtocol.handle( + """{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"trigger_elicitation"}}""", + McpMockSession(), + extra, + ) + assertEquals(MCP_CALLBACK_ELICIT, McpMockProtocol.waitForCallbackId(result)) + assertEquals(1, extra.size) + assertTrue(extra.single().envelope.toString().contains("elicitation/create")) + } + + @Test + fun trigger_roots_emits_roots_list() { + val extra = mutableListOf() + val result = McpMockProtocol.handle( + """{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"trigger_roots"}}""", + McpMockSession(), + extra, + ) + assertEquals(MCP_CALLBACK_ROOTS, McpMockProtocol.waitForCallbackId(result)) + assertEquals(1, extra.size) + assertTrue(extra.single().envelope.toString().contains("roots/list")) + } + + @Test + fun trigger_ping_emits_ping() { + val extra = mutableListOf() + val result = McpMockProtocol.handle( + """{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"trigger_ping"}}""", + McpMockSession(), + extra, + ) + assertEquals(MCP_CALLBACK_PING, McpMockProtocol.waitForCallbackId(result)) + assertTrue(extra.single().envelope.toString().contains("\"ping\"")) + } + + @Test + fun json_rpc_response_is_accepted_and_stored() { + val session = McpMockSession() + val result = McpMockProtocol.handle( + """{"jsonrpc":"2.0","id":"srv-roots","result":{"roots":[{"uri":"file:///tmp/reqlab","name":"tmp"}]}}""", + session, + ) + assertNull(result) + val stored = session.lastReplies[MCP_CALLBACK_ROOTS] + assertTrue(stored.toString().contains("file:///tmp/reqlab")) + } + + @Test + fun json_rpc_notification_returns_null() { + val result = McpMockProtocol.handle( + """{"jsonrpc":"2.0","method":"notifications/initialized"}""", + McpMockSession(), + ) + assertNull(result) + } + + @Test + fun await_callback_times_out_when_client_does_not_reply() = runBlocking { + val echoed = McpMockProtocol.awaitCallback(McpMockSession(), "missing", timeoutMs = 50) + assertNull(echoed) + } + + @Test + fun resolve_wait_result_echoes_client_reply() = runBlocking { + val session = McpMockSession() + val marker = McpMockProtocol.waitMarker(kotlinx.serialization.json.JsonPrimitive(9), MCP_CALLBACK_ROOTS) + McpMockProtocol.handle( + """{"jsonrpc":"2.0","id":"srv-roots","result":{"roots":[]}}""", + session, + ) + val resolved = McpMockProtocol.resolveWaitResult(session, marker) + val text = resolved.toString() + assertTrue(session.lastReplies[MCP_CALLBACK_ROOTS].toString().contains("roots")) + assertTrue(text.contains("roots")) + assertTrue(!text.contains(MCP_WAIT_FOR_KEY)) + } + + @Test + fun sse_endpoint_frame_is_legacy_event() { + val frame = McpMockProtocol.sseEndpointFrame("/mcp/messages?sessionId=abc") + assertEquals("event: endpoint\ndata: /mcp/messages?sessionId=abc\n\n", frame) + } +} diff --git a/ui-desktop/build.gradle.kts b/ui-desktop/build.gradle.kts index 60969c8..0cd8835 100644 --- a/ui-desktop/build.gradle.kts +++ b/ui-desktop/build.gradle.kts @@ -33,6 +33,8 @@ kotlin { implementation(project(":sample-server")) implementation(libs.ktor.server.core) implementation(libs.ktor.server.netty) + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.cio) implementation(libs.serialization.json) } } diff --git a/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/McpCallbackPaneUiTest.kt b/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/McpCallbackPaneUiTest.kt new file mode 100644 index 0000000..ce14393 --- /dev/null +++ b/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/McpCallbackPaneUiTest.kt @@ -0,0 +1,94 @@ +package com.reqlab.ui.desktop + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextReplacement +import androidx.compose.ui.unit.dp +import com.reqlab.core.model.McpCreateMessageResult +import com.reqlab.core.model.McpElicitRequest +import com.reqlab.ui.shared.components.McpElicitationForm +import com.reqlab.ui.shared.components.McpSamplingResultForm +import com.reqlab.ui.shared.state.AppState +import com.reqlab.ui.shared.theme.ReqLabTheme +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.junit.Rule +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +class McpCallbackPaneUiTest { + + @get:Rule + val composeRule = createComposeRule() + + @Test + fun sampling_result_form_approve_sends_typed_fields() { + var approved: McpCreateMessageResult? = null + composeRule.setContent { + ReqLabTheme { + Box(Modifier.size(800.dp, 600.dp)) { + McpSamplingResultForm( + initial = McpCreateMessageResult(), + onApprove = { approved = it }, + onCancel = {}, + ) + } + } + } + composeRule.onNodeWithTag("mcp-sampling-content", useUnmergedTree = true) + .performTextReplacement("typed-from-test") + composeRule.onNodeWithTag("mcp-sampling-role").performClick() + composeRule.onNodeWithTag("mcp-sampling-role-user").performClick() + composeRule.onNodeWithTag("mcp-sampling-model", useUnmergedTree = true) + .performTextReplacement("test-model") + composeRule.onNodeWithTag("mcp-sampling-stop-reason").performClick() + composeRule.onNodeWithTag("mcp-sampling-stop-reason-maxTokens").performClick() + composeRule.onNodeWithTag("mcp-sampling-approve-send").performClick() + val result = assertNotNull(approved) + assertEquals("typed-from-test", result.content.text) + assertEquals("user", result.role) + assertEquals("test-model", result.model) + assertEquals("maxTokens", result.stopReason) + } + + @Test + fun elicitation_form_accept_sends_schema_field() { + var acceptedArgs: String? = null + val schema = buildJsonObject { + put("type", "object") + put("properties", buildJsonObject { + put("name", buildJsonObject { put("type", "string") }) + }) + } + val state = AppState(openDefaultTab = false, withDemoData = false) + composeRule.setContent { + ReqLabTheme { + Box(Modifier.size(800.dp, 600.dp)) { + var args by remember { mutableStateOf("""{"name":""}""") } + McpElicitationForm( + state = state, + request = McpElicitRequest(message = "What is your name?", requestedSchema = schema), + argsJson = args, + onArgsChange = { args = it }, + onAccept = { acceptedArgs = args }, + onDecline = {}, + ) + } + } + } + composeRule.onNodeWithTag("mcp-elicit-args-name", useUnmergedTree = true) + .performTextReplacement("typed-from-test") + composeRule.onNodeWithTag("mcp-elicit-accept").performClick() + assertNotNull(acceptedArgs) + assertEquals(true, acceptedArgs!!.contains("typed-from-test")) + } +} diff --git a/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/McpSessionCallbackE2ETest.kt b/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/McpSessionCallbackE2ETest.kt new file mode 100644 index 0000000..4e804af --- /dev/null +++ b/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/McpSessionCallbackE2ETest.kt @@ -0,0 +1,171 @@ +package com.reqlab.ui.desktop + +import com.reqlab.core.model.McpConnectionConfig +import com.reqlab.core.model.McpContent +import com.reqlab.core.model.McpCreateMessageResult +import com.reqlab.core.model.McpHttpMode +import com.reqlab.core.model.McpSamplingMode +import com.reqlab.core.network.mcp.McpClient +import com.reqlab.server.module +import com.reqlab.ui.shared.mcp.McpPendingSampling +import com.reqlab.ui.shared.mcp.McpSessionState +import io.ktor.server.engine.EmbeddedServer +import io.ktor.server.engine.embeddedServer +import io.ktor.server.netty.Netty +import io.ktor.server.netty.NettyApplicationEngine +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.AfterClass +import org.junit.BeforeClass +import org.junit.Test +import java.net.ServerSocket +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class McpSessionCallbackE2ETest { + + @Test + fun sampling_pane_typed_result_is_echoed_by_live_server() = withLiveSession { session -> + session.connect( + McpConnectionConfig( + url = "$BASE_URL/mcp", + httpMode = McpHttpMode.STREAMABLE_2025_06_18, + samplingMode = McpSamplingMode.MANUAL, + ), + ) + val call = async { session.callSelectedTool("trigger_sampling", null) } + withTimeout(10_000) { + session.pendingSampling.first { it is McpPendingSampling.ReviewRequest } + } + session.approveSamplingGenerate() + withTimeout(10_000) { + session.pendingSampling.first { it is McpPendingSampling.ReviewResult && !it.generating } + } + session.submitSamplingResult( + McpCreateMessageResult( + role = "assistant", + content = McpContent(type = "text", text = "typed-from-test"), + model = "test-model", + stopReason = "endTurn", + ), + ) + call.await() + val text = session.lastToolResult.value?.content?.single()?.text.orEmpty() + val payload = session.client?.lastReceivedPayload.orEmpty() + assertEquals(false, session.lastToolResult.value?.isError) + assertTrue(text.contains("typed-from-test"), "tool text: $text") + assertTrue(!text.contains("mock reply from ReqLab"), "tool text: $text") + assertTrue(payload.contains("\"result\""), payload) + assertTrue(!payload.contains("-32600"), payload) + } + + @Test + fun sampling_pane_cancel_echoes_cancelled() = withLiveSession { session -> + session.connect( + McpConnectionConfig( + url = "$BASE_URL/mcp", + httpMode = McpHttpMode.STREAMABLE_2025_06_18, + samplingMode = McpSamplingMode.MANUAL, + ), + ) + val call = async { session.callSelectedTool("trigger_sampling", null) } + withTimeout(10_000) { + session.pendingSampling.first { it is McpPendingSampling.ReviewRequest } + } + session.cancelSampling() + call.await() + val text = session.lastToolResult.value?.content?.single()?.text.orEmpty() + assertEquals(false, session.lastToolResult.value?.isError) + assertTrue(text.contains("cancelled"), "tool text: $text") + } + + @Test + fun elicitation_pane_accept_echoes_field() = withLiveSession { session -> + session.connect( + McpConnectionConfig( + url = "$BASE_URL/mcp", + httpMode = McpHttpMode.STREAMABLE_2025_06_18, + autoRespondElicitation = false, + ), + ) + val call = async { session.callSelectedTool("trigger_elicitation", null) } + withTimeout(10_000) { + session.pendingElicitation.first { it != null } + } + session.updatePendingElicitArgs("""{"name":"typed-from-test"}""") + session.submitElicitation() + call.await() + val text = session.lastToolResult.value?.content?.single()?.text.orEmpty() + val payload = session.client?.lastReceivedPayload.orEmpty() + assertEquals(false, session.lastToolResult.value?.isError) + assertTrue(text.contains("accept"), "tool text: $text") + assertTrue(text.contains("typed-from-test"), "tool text: $text") + assertTrue(payload.contains("\"result\""), payload) + assertTrue(!payload.contains("-32600"), payload) + } + + @Test + fun elicitation_pane_decline_echoes_decline() = withLiveSession { session -> + session.connect( + McpConnectionConfig( + url = "$BASE_URL/mcp", + httpMode = McpHttpMode.STREAMABLE_2025_06_18, + autoRespondElicitation = false, + ), + ) + val call = async { session.callSelectedTool("trigger_elicitation", null) } + withTimeout(10_000) { + session.pendingElicitation.first { it != null } + } + session.declineElicitation() + call.await() + val text = session.lastToolResult.value?.content?.single()?.text.orEmpty() + assertEquals(false, session.lastToolResult.value?.isError) + assertTrue(text.contains("decline"), "tool text: $text") + } + + private fun withLiveSession(block: suspend kotlinx.coroutines.CoroutineScope.(McpSessionState) -> Unit) = + runBlocking { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val session = McpSessionState(scope) { clientScope -> + McpClient(clientScope, callTimeoutMs = 15_000) + } + try { + block(session) + } finally { + session.disconnect() + scope.cancel() + } + } + + companion object { + private var server: EmbeddedServer? = null + private var port: Int = 0 + var BASE_URL: String = "" + + @JvmStatic + @BeforeClass + fun startServer() { + port = ServerSocket(0).use { it.localPort } + BASE_URL = "http://127.0.0.1:$port" + server = embeddedServer(Netty, port = port, module = { module() }) + server!!.start(wait = false) + repeat(50) { + runCatching { java.net.Socket("127.0.0.1", port).close(); return } + Thread.sleep(100) + } + } + + @JvmStatic + @AfterClass + fun stopServer() { + server?.stop(1000, 2000) + } + } +} diff --git a/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/QaFixesStateTest.kt b/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/QaFixesStateTest.kt index 9f098be..40d4804 100644 --- a/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/QaFixesStateTest.kt +++ b/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/QaFixesStateTest.kt @@ -90,6 +90,16 @@ class QaFixesStateTest { assertEquals("Request started", state.networkEventLogs.last().message) } + @Test + fun `logNetworkEvent echoToConsole false writes logs only`() { + val state = AppState() + val consoleStart = state.consoleLogs.size + state.logNetworkEvent("MCP SENT initialize", echoToConsole = false) + assertEquals(consoleStart, state.consoleLogs.size, "Console should stay unchanged") + assertEquals(1, state.networkEventLogs.size) + assertEquals("MCP SENT initialize", state.networkEventLogs.last().message) + } + @Test fun `M-3 regular log() does NOT write to networkEventLogs`() { val state = AppState() diff --git a/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/persistence/ImportExportFixturesIntegrationTest.kt b/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/persistence/ImportExportFixturesIntegrationTest.kt index 442de90..a398843 100644 --- a/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/persistence/ImportExportFixturesIntegrationTest.kt +++ b/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/persistence/ImportExportFixturesIntegrationTest.kt @@ -1,9 +1,14 @@ package com.reqlab.ui.shared.persistence +import com.reqlab.core.model.McpSamplingMode +import com.reqlab.core.model.McpTransportType +import com.reqlab.core.model.RequestKind import com.reqlab.ui.shared.state.AppState +import com.reqlab.ui.shared.state.CollectionNode import java.io.File import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNotNull import kotlin.test.assertTrue class ImportExportFixturesIntegrationTest { @@ -57,6 +62,40 @@ class ImportExportFixturesIntegrationTest { assertTrue(env.variables.any { it.key == "mcpAuthedUrl" && it.value == "http://localhost:8080/mcp/authed" }) assertTrue(env.variables.any { it.key == "mcpBearerUrl" && it.value == "http://localhost:8080/mcp/auth/bearer" }) assertTrue(env.variables.any { it.key == "mcpTenantUrl" && it.value.contains("requireTenant=true") }) + assertTrue(env.variables.any { it.key == "mcpLegacyUrl" && it.value.contains("/mcp/sse") }) + assertTrue(env.variables.any { it.key == "mcpStdioCommand" && it.value == "sample-server" }) + + val llm = findRequest(restored.collections, "MCP Sampling LLM") + assertNotNull(llm) + assertEquals(RequestKind.MCP, llm.kind) + assertEquals(McpSamplingMode.FORWARD_LLM, llm.mcpConfig?.samplingMode) + assertEquals("{{llmBaseUrl}}/v1/chat/completions", llm.mcpConfig?.samplingForwardUrl) + val stdio = findRequest(restored.collections, "MCP stdio") + assertNotNull(stdio) + assertEquals(McpTransportType.STDIO, stdio.mcpConfig?.transport) + assertEquals("{{mcpStdioCommand}}", stdio.mcpConfig?.command) + } + + @Test + fun imported_mcp_sampling_llm_and_stdio_fields_are_present() { + val state = AppState(openDefaultTab = false, withDemoData = false) + ImportExportRepository.importCollectionFromString(state, collectionFixture.readText()) + val llm = findRequest(state.collections, "MCP Sampling LLM") + assertNotNull(llm) + assertEquals(McpSamplingMode.FORWARD_LLM, llm.mcpConfig!!.samplingMode) + assertEquals("{{llmBaseUrl}}/v1/chat/completions", llm.mcpConfig!!.samplingForwardUrl) + val stdio = findRequest(state.collections, "MCP stdio") + assertNotNull(stdio) + assertEquals(McpTransportType.STDIO, stdio.mcpConfig!!.transport) + assertEquals("{{mcpStdioCommand}}", stdio.mcpConfig!!.command) + } + + private fun findRequest(nodes: List, name: String): CollectionNode? { + for (node in nodes) { + if (!node.isFolder && node.name == name) return node + if (node.isFolder) findRequest(node.children, name)?.let { return it } + } + return null } private fun resolveFixture(name: String): File { diff --git a/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/state/AppStateCollectionTest.kt b/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/state/AppStateCollectionTest.kt index b807d2a..fdfafa9 100644 --- a/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/state/AppStateCollectionTest.kt +++ b/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/state/AppStateCollectionTest.kt @@ -3,7 +3,11 @@ package com.reqlab.ui.shared.state import androidx.compose.runtime.mutableStateListOf import com.reqlab.core.model.BodyType import com.reqlab.core.model.HttpMethodType +import com.reqlab.core.model.McpRoot +import com.reqlab.core.model.McpSamplingMode +import com.reqlab.core.model.RequestKind import com.reqlab.ui.shared.components.moveRequestToCollection +import com.reqlab.ui.shared.persistence.ImportExportRepository import org.junit.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -255,6 +259,45 @@ class AppStateCollectionTest { assertEquals("Renamed request", node.name) } + @Test + fun syncTabToCollectionNode_writes_mcp_config_for_export() { + val state = AppState(withDemoData = true) + val collectionId = state.collections.first().id + state.addMcpConnectionToCollection(collectionId) + val tab = state.openTabs.last() + tab.mcpConfig = tab.mcpConfig.copy( + samplingMode = McpSamplingMode.MANUAL, + args = listOf("--keep"), + roots = listOf(McpRoot("file:///tmp/reqlab", "tmp")), + ) + assertTrue(state.syncTabToCollectionNode(tab)) + val node = state.collections.first().children.first { it.id == tab.id } + assertEquals(RequestKind.MCP, node.kind) + assertEquals(McpSamplingMode.MANUAL, node.mcpConfig!!.samplingMode) + assertEquals(listOf("--keep"), node.mcpConfig!!.args) + assertEquals("file:///tmp/reqlab", node.mcpConfig!!.roots.single().uri) + val exported = ImportExportRepository.exportCollectionToString(state.collections.first()) + assertTrue(exported.contains("MANUAL")) + assertTrue(exported.contains("file:///tmp/reqlab")) + assertTrue(exported.contains("--keep")) + } + + @Test + fun mcp_client_field_change_marks_tab_dirty() { + val tab = RequestTabState() + tab.kind = RequestKind.MCP + tab.markSaved() + assertFalse(tab.isDirty) + tab.mcpConfig = tab.mcpConfig.copy(autoRespondElicitation = false) + tab.markDirty() + assertTrue(tab.isDirty) + tab.markSaved() + assertFalse(tab.isDirty) + tab.mcpConfig = tab.mcpConfig.copy(roots = listOf(McpRoot("file:///tmp/reqlab", "tmp"))) + tab.markDirty() + assertTrue(tab.isDirty) + } + // ── Issue 1: Closing last tab ──────────────────────────────────── @Test diff --git a/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/MainScreen.kt b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/MainScreen.kt index 5794666..4453c71 100644 --- a/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/MainScreen.kt +++ b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/MainScreen.kt @@ -54,6 +54,7 @@ import com.reqlab.ui.shared.components.OperationProgressDialog import com.reqlab.ui.shared.components.RealtimePanel import com.reqlab.ui.shared.components.RequestEditor import com.reqlab.ui.shared.components.McpPanel +import com.reqlab.ui.shared.components.McpWorkspaceResponse import com.reqlab.ui.shared.components.RequestTabsBar import com.reqlab.ui.shared.components.ResponseViewer import com.reqlab.ui.shared.components.SettingsDialog @@ -143,7 +144,8 @@ fun MainScreen(state: AppState = remember { AppState() }) { // rows triggers auto-save even if bodyContent length is unchanged. val formCount = t?.formRows?.size ?: 0 val urlEncCount = t?.urlencodedRows?.size ?: 0 - "${state.openTabs.size}|${state.activeTabIndex}|${t?.name}|${t?.url}|${t?.method}|${t?.bodyType}|BL:$bodyLen|FR:$formCount|UE:$urlEncCount|$params|$headers|$auth|${t?.preRequestScript}|${t?.testScript}" + val mcp = t?.mcpClientFingerprint() ?: "" + "${state.openTabs.size}|${state.activeTabIndex}|${t?.name}|${t?.url}|${t?.method}|${t?.bodyType}|BL:$bodyLen|FR:$formCount|UE:$urlEncCount|$params|$headers|$auth|${t?.preRequestScript}|${t?.testScript}|$mcp" }.drop(1) .collect { if (state.settings.autoSaveRequests) { @@ -383,7 +385,7 @@ private fun ColumnScope.HttpWorkspaceContent( splitFraction = state.requestResponseSplit, onSplitChanged = { state.requestResponseSplit = it }, first = { McpPanel(state, tab) }, - second = { ResponseViewer(tab) }, + second = { McpWorkspaceResponse(state, tab) }, ) } else { VerticalSplitPane( @@ -391,7 +393,7 @@ private fun ColumnScope.HttpWorkspaceContent( splitFraction = state.mainVerticalSplit, onSplitChanged = { state.mainVerticalSplit = it }, first = { McpPanel(state, tab) }, - second = { ResponseViewer(tab) }, + second = { McpWorkspaceResponse(state, tab) }, ) } } else if (state.settings.responseLayout == ResponseLayout.RIGHT) { diff --git a/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/McpCallbackPane.kt b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/McpCallbackPane.kt new file mode 100644 index 0000000..7894b82 --- /dev/null +++ b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/McpCallbackPane.kt @@ -0,0 +1,406 @@ +package com.reqlab.ui.shared.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material3.Button +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.reqlab.core.model.McpContent +import com.reqlab.core.model.McpCreateMessageResult +import com.reqlab.core.model.McpElicitRequest +import com.reqlab.ui.shared.i18n.Strings +import com.reqlab.ui.shared.mcp.McpPendingElicitation +import com.reqlab.ui.shared.mcp.McpPendingSampling +import com.reqlab.ui.shared.mcp.McpSessionState +import com.reqlab.ui.shared.mcp.mcpPrettyJson +import com.reqlab.ui.shared.state.AppState +import com.reqlab.ui.shared.state.RequestTabState +import com.reqlab.ui.shared.theme.CodeFontFamily +import com.reqlab.ui.shared.theme.ReqLabColors + +@Composable +fun McpWorkspaceResponse(state: AppState, tab: RequestTabState) { + val session = remember(tab.id) { state.getOrCreateMcpSession(tab.id) } + val sampling by session.pendingSampling.collectAsState() + val elicit by session.pendingElicitation.collectAsState() + when { + sampling != null -> McpSamplingCallbackPane(session, sampling!!) + elicit != null -> McpElicitationCallbackPane(state, session, elicit!!) + else -> ResponseViewer(tab) + } +} + +@Composable +internal fun McpSamplingCallbackPane(session: McpSessionState, pending: McpPendingSampling) { + when (pending) { + is McpPendingSampling.ReviewRequest -> McpSamplingRequestForm( + requestJson = mcpPrettyJson.encodeToString( + com.reqlab.core.model.McpCreateMessageRequest.serializer(), + pending.request, + ), + onApproveGenerate = { session.approveSamplingGenerate() }, + onCancel = { session.cancelSampling() }, + ) + is McpPendingSampling.ReviewResult -> McpSamplingResultForm( + initial = pending.draft, + generateError = pending.generateError, + generating = pending.generating, + onApprove = { session.submitSamplingResult(it) }, + onCancel = { session.cancelSampling() }, + ) + } +} + +@Composable +internal fun McpElicitationCallbackPane( + state: AppState, + session: McpSessionState, + pending: McpPendingElicitation, +) { + McpElicitationForm( + state = state, + request = pending.request, + argsJson = pending.argsJson, + onArgsChange = { session.updatePendingElicitArgs(it) }, + onAccept = { session.submitElicitation() }, + onDecline = { session.declineElicitation() }, + ) +} + +@Composable +fun McpSamplingRequestForm( + requestJson: String, + onApproveGenerate: () -> Unit, + onCancel: () -> Unit, +) { + CallbackPaneScaffold( + title = Strings.t("mcp_sampling_review_request"), + testTag = "mcp-sampling-request", + actions = { + OutlinedButton(onClick = onCancel, modifier = Modifier.testTag("mcp-sampling-cancel")) { + Text(Strings.t("cancel")) + } + Button(onClick = onApproveGenerate, modifier = Modifier.testTag("mcp-sampling-approve-generate")) { + Text(Strings.t("mcp_sampling_approve_generate")) + } + }, + ) { + Text( + requestJson, + color = ReqLabColors.OnSurface, + fontSize = 12.sp, + fontFamily = CodeFontFamily, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(ReqLabColors.SurfaceContainer) + .padding(10.dp) + .testTag("mcp-sampling-request-json"), + ) + } +} + +@Composable +fun McpSamplingResultForm( + initial: McpCreateMessageResult, + generateError: String? = null, + generating: Boolean = false, + onApprove: (McpCreateMessageResult) -> Unit, + onCancel: () -> Unit, +) { + var content by remember(initial) { mutableStateOf(initial.content.text.orEmpty()) } + var role by remember(initial) { mutableStateOf(coerceSamplingRole(initial.role)) } + var model by remember(initial) { mutableStateOf(initial.model) } + var stopReason by remember(initial) { mutableStateOf(coerceSamplingStopReason(initial.stopReason)) } + CallbackPaneScaffold( + title = Strings.t("mcp_sampling_review_result"), + testTag = "mcp-sampling-result", + actions = { + OutlinedButton(onClick = onCancel, modifier = Modifier.testTag("mcp-sampling-cancel")) { + Text(Strings.t("cancel")) + } + Button( + onClick = { + onApprove( + McpCreateMessageResult( + role = role.ifBlank { "assistant" }, + content = McpContent(type = "text", text = content), + model = model.ifBlank { "mock" }, + stopReason = stopReason.ifBlank { "endTurn" }, + ), + ) + }, + enabled = !generating, + modifier = Modifier.testTag("mcp-sampling-approve-send"), + ) { + Text(Strings.t("mcp_sampling_approve_send")) + } + }, + ) { + if (generating) { + Text(Strings.t("mcp_sampling_generating"), color = ReqLabColors.OnSurfaceDim, fontSize = 12.sp) + } + if (!generateError.isNullOrBlank()) { + Text( + "${Strings.t("mcp_sampling_generate_error")}: $generateError", + color = ReqLabColors.Error, + fontSize = 12.sp, + modifier = Modifier.testTag("mcp-sampling-generate-error"), + ) + } + CallbackField(Strings.t("mcp_sampling_content"), required = true) { + CallbackTextArea(content, { content = it }, "mcp-sampling-content") + } + CallbackField(Strings.t("mcp_sampling_role"), required = true) { + CallbackDropdown( + value = role, + options = SamplingRoles, + onSelect = { role = it }, + testTag = "mcp-sampling-role", + ) + } + CallbackField(Strings.t("mcp_sampling_model"), required = true) { + CallbackTextLine(model, { model = it }, "mcp-sampling-model") + } + CallbackField(Strings.t("mcp_sampling_stop_reason")) { + CallbackDropdown( + value = stopReason, + options = SamplingStopReasons, + onSelect = { stopReason = it }, + testTag = "mcp-sampling-stop-reason", + ) + } + } +} + +@Composable +fun McpElicitationForm( + state: AppState, + request: McpElicitRequest, + argsJson: String, + onArgsChange: (String) -> Unit, + onAccept: () -> Unit, + onDecline: () -> Unit, +) { + CallbackPaneScaffold( + title = Strings.t("mcp_elicit_form"), + testTag = "mcp-elicit-form", + scrollContent = false, + actions = { + OutlinedButton(onClick = onDecline, modifier = Modifier.testTag("mcp-elicit-decline")) { + Text(Strings.t("mcp_elicit_decline")) + } + Button(onClick = onAccept, modifier = Modifier.testTag("mcp-elicit-accept")) { + Text(Strings.t("mcp_elicit_accept")) + } + }, + ) { + Text( + request.message, + color = ReqLabColors.OnSurface, + fontSize = 13.sp, + modifier = Modifier.testTag("mcp-elicit-message"), + ) + SchemaArgsEditor( + modifier = Modifier.weight(1f).fillMaxWidth(), + state = state, + schema = request.requestedSchema, + args = argsJson, + onArgsChange = onArgsChange, + testTagPrefix = "mcp-elicit-args", + ) + } +} + +@Composable +private fun CallbackPaneScaffold( + title: String, + testTag: String, + actions: @Composable () -> Unit, + scrollContent: Boolean = true, + content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit, +) { + Column( + Modifier + .fillMaxSize() + .background(ReqLabColors.Background) + .padding(12.dp) + .testTag(testTag), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + title, + color = ReqLabColors.OnSurface, + fontWeight = FontWeight.SemiBold, + fontSize = 14.sp, + modifier = Modifier.weight(1f), + ) + actions() + } + if (scrollContent) { + Column( + Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(10.dp), + content = content, + ) + } else { + Column( + Modifier.weight(1f).fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(10.dp), + content = content, + ) + } + } +} + +@Composable +private fun CallbackField(label: String, required: Boolean = false, content: @Composable () -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + if (required) "$label *" else label, + color = ReqLabColors.OnSurfaceVariant, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + ) + content() + } +} + +private val SamplingRoles = listOf("assistant", "user") +private val SamplingStopReasons = listOf("endTurn", "stopSequence", "maxTokens") + +private fun coerceSamplingRole(raw: String): String = + if (raw in SamplingRoles) raw else "assistant" + +private fun coerceSamplingStopReason(raw: String?): String = + if (raw in SamplingStopReasons) raw!! else "endTurn" + +@Composable +private fun CallbackDropdown( + value: String, + options: List, + onSelect: (String) -> Unit, + testTag: String, +) { + var expanded by remember { mutableStateOf(false) } + Box { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(ReqLabColors.Surface) + .border(1.dp, ReqLabColors.Border, RoundedCornerShape(8.dp)) + .clickable { expanded = true } + .padding(horizontal = 10.dp, vertical = 8.dp) + .testTag(testTag), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + value, + color = ReqLabColors.OnSurface, + fontSize = 13.sp, + fontFamily = CodeFontFamily, + modifier = Modifier.weight(1f), + ) + Icon( + Icons.Default.ArrowDropDown, + contentDescription = null, + tint = ReqLabColors.OnSurfaceDim, + modifier = Modifier.size(18.dp), + ) + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + options.forEach { option -> + DropdownMenuItem( + text = { Text(option, fontFamily = CodeFontFamily, fontSize = 13.sp) }, + onClick = { + onSelect(option) + expanded = false + }, + modifier = Modifier.testTag("$testTag-$option"), + ) + } + } + } +} + +@Composable +private fun CallbackTextLine(value: String, onValueChange: (String) -> Unit, testTag: String) { + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(ReqLabColors.Surface) + .border(1.dp, ReqLabColors.Border, RoundedCornerShape(8.dp)) + .padding(horizontal = 10.dp, vertical = 8.dp), + ) { + BasicTextField( + value = value, + onValueChange = onValueChange, + singleLine = true, + textStyle = TextStyle(color = ReqLabColors.OnSurface, fontSize = 13.sp, fontFamily = CodeFontFamily), + cursorBrush = SolidColor(ReqLabColors.Primary), + modifier = Modifier.fillMaxWidth().testTag(testTag), + ) + } +} + +@Composable +private fun CallbackTextArea(value: String, onValueChange: (String) -> Unit, testTag: String) { + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 120.dp) + .clip(RoundedCornerShape(8.dp)) + .background(ReqLabColors.Surface) + .border(1.dp, ReqLabColors.Border, RoundedCornerShape(8.dp)) + .padding(10.dp), + ) { + BasicTextField( + value = value, + onValueChange = onValueChange, + textStyle = TextStyle(color = ReqLabColors.OnSurface, fontSize = 13.sp, fontFamily = CodeFontFamily), + cursorBrush = SolidColor(ReqLabColors.Primary), + modifier = Modifier.fillMaxWidth().heightIn(min = 100.dp).testTag(testTag), + ) + } +} diff --git a/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/McpPanel.kt b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/McpPanel.kt index 203356a..74875bb 100644 --- a/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/McpPanel.kt +++ b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/McpPanel.kt @@ -6,11 +6,15 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn @@ -23,6 +27,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material3.Button import androidx.compose.material3.Checkbox @@ -30,8 +36,8 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Tab -import androidx.compose.material3.TabRow +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -51,6 +57,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -61,6 +68,8 @@ import com.reqlab.core.model.McpLogEntry import com.reqlab.core.model.McpLogEntryKind import com.reqlab.core.model.McpPrompt import com.reqlab.core.model.McpResource +import com.reqlab.core.model.McpRoot +import com.reqlab.core.model.McpSamplingMode import com.reqlab.core.model.McpTool import com.reqlab.core.model.McpTransportType import com.reqlab.ui.shared.i18n.Strings @@ -99,6 +108,8 @@ private const val SECTION_HEADERS = 4 private const val SECTION_PARAMS = 5 private const val SECTION_ACTIVITY = 6 private const val SECTION_CLIENT = 7 +/** Canonical UUID string length; longer session ids are truncated in the chip. */ +private const val MCP_SESSION_ID_MAX_VISIBLE = 36 @Composable fun McpPanel(state: AppState, tab: RequestTabState) { @@ -229,33 +240,36 @@ fun McpPanel(state: AppState, tab: RequestTabState) { modifier = Modifier .fillMaxSize() .background(ReqLabColors.Background) - .padding(12.dp) .testTag("mcp-panel"), - verticalArrangement = Arrangement.spacedBy(8.dp), ) { - ConnectionBar( - tab = tab, - state = state, - session = session, - connection = connection, - reconnectNeeded = reconnectNeeded, - showStdioConfirm = showStdioConfirm, - onShowStdioConfirm = { showStdioConfirm = it }, - onConnect = { requestConnect() }, - onDisconnect = { state.appScope.launch { session.disconnect() } }, - onReconnect = { - state.appScope.launch { - session.disconnect() - requestConnect() - } - }, - onConfirmStdio = { - session.confirmStdio = true - showStdioConfirm = false - connectNow() - }, - ) - error?.let { Text(it, color = ReqLabColors.Error, fontSize = 12.sp, modifier = Modifier.testTag("mcp-error")) } + Column( + modifier = Modifier.fillMaxWidth().padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + ConnectionBar( + tab = tab, + state = state, + session = session, + connection = connection, + reconnectNeeded = reconnectNeeded, + showStdioConfirm = showStdioConfirm, + onShowStdioConfirm = { showStdioConfirm = it }, + onConnect = { requestConnect() }, + onDisconnect = { state.appScope.launch { session.disconnect() } }, + onReconnect = { + state.appScope.launch { + session.disconnect() + requestConnect() + } + }, + onConfirmStdio = { + session.confirmStdio = true + showStdioConfirm = false + connectNow() + }, + ) + error?.let { Text(it, color = ReqLabColors.Error, fontSize = 12.sp, modifier = Modifier.testTag("mcp-error")) } + } val tabLabels = listOf( "${Strings.t("mcp_tools")}${countSuffix(tools.size)}", @@ -267,23 +281,13 @@ fun McpPanel(state: AppState, tab: RequestTabState) { Strings.t("mcp_activity"), Strings.t("mcp_client"), ) - TabRow( - selectedTabIndex = section, - modifier = Modifier.fillMaxWidth().testTag("mcp-tabs"), - containerColor = ReqLabColors.Background, - ) { - tabLabels.forEachIndexed { index, title -> - Tab( - selected = section == index, - onClick = { section = index }, - text = { - Text(title, fontSize = 11.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) - }, - ) - } - } + McpSectionTabBar( + labels = tabLabels, + selectedIndex = section, + onSelect = { section = it }, + ) - Box(Modifier.weight(1f).fillMaxWidth()) { + Box(Modifier.weight(1f).fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp)) { val connected = connection == McpConnectionState.CONNECTED when (section) { SECTION_TOOLS -> ToolsSection( @@ -362,8 +366,8 @@ fun McpPanel(state: AppState, tab: RequestTabState) { tab.mcpConfig = tab.mcpConfig.copy(url = tab.url) tab.markDirty() } - SECTION_ACTIVITY -> ActivitySection(logs) - else -> ClientSection(tab, session) + SECTION_ACTIVITY -> ActivitySection(logs, onClear = { session.clearLogs() }) + else -> ClientSection(state, tab, session, connected = connected) } } } @@ -397,24 +401,25 @@ private fun ConnectionBar( fontSize = 12.sp, fontWeight = FontWeight.SemiBold, ) - val negotiated = session.negotiatedLabel() - if (negotiated.isNotBlank()) { - Text(negotiated, color = ReqLabColors.OnSurfaceDim, fontSize = 11.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) - } else { - Spacer(Modifier.weight(1f)) - } - session.sessionId()?.let { sid -> - val short = if (sid.length > 16) sid.take(10) + "…" else sid - SelectionContainer { - Text( - "${Strings.t("mcp_session_id")} $short", - color = ReqLabColors.OnSurfaceDim, - fontSize = 11.sp, - fontFamily = CodeFontFamily, - maxLines = 1, - modifier = Modifier.testTag("mcp-session-id"), + } + val negotiated = session.negotiatedLabel() + val sessionId = session.sessionId() + if (negotiated.isNotBlank() || sessionId != null) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (negotiated.isNotBlank()) { + ConnectionMetaChip( + text = negotiated, + modifier = Modifier.weight(1f), + fillText = true, ) + } else { + Spacer(Modifier.weight(1f)) } + sessionId?.let { sid -> SessionIdChip(sid) } } } Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { @@ -575,7 +580,12 @@ private fun ToolsSection( return@SplitColumn } val missing = mcpMissingRequiredArgs(tool.inputSchema, args) - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + itemVerticalAlignment = Alignment.CenterVertically, + ) { Text(tool.name, color = ReqLabColors.OnSurface, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis) mcpToolHintChips(tool.annotations).forEach { chip -> val label = if (chip == "readOnly") Strings.t("mcp_readonly") else Strings.t("mcp_destructive") @@ -591,6 +601,7 @@ private fun ToolsSection( ) } SchemaArgsEditor( + modifier = Modifier.weight(1f).fillMaxWidth(), state = state, schema = tool.inputSchema, args = args, @@ -665,31 +676,39 @@ private fun ResourcesSection( } }, second = { - SplitColumn { - val res = resources.firstOrNull { it.uri == selected } - if (res == null) { + val res = resources.firstOrNull { it.uri == selected } + if (res == null) { + SplitColumn { EmptyState(Strings.t("mcp_resource_empty")) - return@SplitColumn } - Text(res.name, color = ReqLabColors.OnSurface, fontWeight = FontWeight.SemiBold) - Text(res.uri, color = ReqLabColors.OnSurfaceDim, fontSize = 11.sp, fontFamily = CodeFontFamily, maxLines = 2, overflow = TextOverflow.Ellipsis) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - RunButton( - busy = busy, - enabled = true, - label = Strings.t("mcp_read_resource"), - testTag = "mcp-read", - onClick = { onRead(res.uri) }, - onStop = onStop, - ) - if (supportsSubscribe) { - if (res.uri in subscribed) { - OutlinedButton(onClick = { onUnsubscribe(res.uri) }, enabled = !busy, modifier = Modifier.testTag("mcp-unsubscribe")) { - Text(Strings.t("mcp_unsubscribe")) - } - } else { - OutlinedButton(onClick = { onSubscribe(res.uri) }, enabled = !busy, modifier = Modifier.testTag("mcp-subscribe")) { - Text(Strings.t("mcp_subscribe")) + } else { + Column( + Modifier.fillMaxSize().padding(10.dp).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text(res.name, color = ReqLabColors.OnSurface, fontWeight = FontWeight.SemiBold) + Text(res.uri, color = ReqLabColors.OnSurfaceDim, fontSize = 11.sp, fontFamily = CodeFontFamily, maxLines = 2, overflow = TextOverflow.Ellipsis) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + RunButton( + busy = busy, + enabled = true, + label = Strings.t("mcp_read_resource"), + testTag = "mcp-read", + onClick = { onRead(res.uri) }, + onStop = onStop, + ) + if (supportsSubscribe) { + if (res.uri in subscribed) { + OutlinedButton(onClick = { onUnsubscribe(res.uri) }, enabled = !busy, modifier = Modifier.testTag("mcp-unsubscribe")) { + Text(Strings.t("mcp_unsubscribe")) + } + } else { + OutlinedButton(onClick = { onSubscribe(res.uri) }, enabled = !busy, modifier = Modifier.testTag("mcp-subscribe")) { + Text(Strings.t("mcp_subscribe")) + } } } } @@ -768,7 +787,12 @@ private fun PromptsSection( } val schema = mcpPromptSchema(prompt) val missing = mcpMissingRequiredArgs(schema, args) - Row(verticalAlignment = Alignment.CenterVertically) { + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + itemVerticalAlignment = Alignment.CenterVertically, + ) { Text(prompt.name, color = ReqLabColors.OnSurface, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis) RunButton( busy = busy, @@ -780,6 +804,7 @@ private fun PromptsSection( ) } SchemaArgsEditor( + modifier = Modifier.weight(1f).fillMaxWidth(), state = state, schema = schema, args = args, @@ -794,7 +819,8 @@ private fun PromptsSection( } @Composable -private fun SchemaArgsEditor( +internal fun SchemaArgsEditor( + modifier: Modifier = Modifier, state: AppState, schema: JsonObject, args: String, @@ -804,9 +830,12 @@ private fun SchemaArgsEditor( val formSupported = mcpSchemaFormSupported(schema) var preferForm by remember { mutableStateOf(true) } val showForm = formSupported && preferForm - Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Column(modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) { if (formSupported) { - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { TransportChip(Strings.t("mcp_form"), showForm) { preferForm = true } TransportChip(Strings.t("mcp_json"), !showForm) { preferForm = false } } @@ -825,7 +854,10 @@ private fun SchemaArgsEditor( when { field.enumValues.isNotEmpty() -> { val current = (mcpArgsGet(args, field.name) as? JsonPrimitive)?.content.orEmpty() - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { field.enumValues.forEach { opt -> TransportChip(opt, current == opt) { onArgsChange(mcpArgsPut(args, field.name, JsonPrimitive(opt))) @@ -883,12 +915,28 @@ private fun SchemaArgsEditor( } @Composable -private fun ActivitySection(logs: List) { - Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) { +private fun ActivitySection(logs: List, onClear: () -> Unit) { + Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(2.dp)) { if (logs.isEmpty()) { EmptyState(Strings.t("mcp_activity_empty")) return@Column } + Row( + modifier = Modifier.fillMaxWidth().height(20.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.End, + ) { + Text( + Strings.t("clear"), + color = ReqLabColors.OnSurfaceDim, + fontSize = 11.sp, + lineHeight = 14.sp, + modifier = Modifier + .testTag("mcp-activity-clear") + .clickable(onClick = onClear) + .padding(horizontal = 2.dp), + ) + } ScrollableLazyColumn( modifier = Modifier.weight(1f), listTestTag = "mcp-log", @@ -967,56 +1015,251 @@ private fun ActivityRow(entry: McpLogEntry) { } @Composable -private fun ClientSection(tab: RequestTabState, session: com.reqlab.ui.shared.mcp.McpSessionState) { +private fun ClientSection( + state: AppState, + tab: RequestTabState, + session: com.reqlab.ui.shared.mcp.McpSessionState, + connected: Boolean, +) { val scroll = rememberScrollState() + val http = tab.mcpConfig.transport == McpTransportType.STREAMABLE_HTTP Box(Modifier.fillMaxSize()) { - Column( - Modifier.fillMaxSize().verticalScroll(scroll).padding(end = 12.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - Text(Strings.t("mcp_transport"), color = ReqLabColors.OnSurface, fontWeight = FontWeight.SemiBold) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - TransportChip("HTTP", tab.mcpConfig.transport == McpTransportType.STREAMABLE_HTTP) { - tab.mcpConfig = tab.mcpConfig.copy(transport = McpTransportType.STREAMABLE_HTTP) - tab.markDirty() - } - if (session.stdioAvailable()) { - TransportChip("stdio", tab.mcpConfig.transport == McpTransportType.STDIO) { - tab.mcpConfig = tab.mcpConfig.copy(transport = McpTransportType.STDIO) - tab.markDirty() + Column( + Modifier.fillMaxSize().verticalScroll(scroll).padding(end = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + ClientSettingsCard(title = Strings.t("mcp_client_connection")) { + ClientSettingLabel(Strings.t("mcp_transport")) + val transportSegments = buildList { + add( + McpSegment("HTTP", tab.mcpConfig.transport == McpTransportType.STREAMABLE_HTTP) { + tab.mcpConfig = tab.mcpConfig.copy(transport = McpTransportType.STREAMABLE_HTTP) + tab.markDirty() + }, + ) + if (session.stdioAvailable()) { + add( + McpSegment("stdio", tab.mcpConfig.transport == McpTransportType.STDIO) { + tab.mcpConfig = tab.mcpConfig.copy(transport = McpTransportType.STDIO) + tab.markDirty() + }, + ) + } + } + McpSegmentedControl(transportSegments) + if (http) { + ClientSettingLabel(Strings.t("mcp_http_mode")) + McpSegmentedControl( + listOf( + McpSegment("Auto", tab.mcpConfig.httpMode == McpHttpMode.AUTO) { + tab.mcpConfig = tab.mcpConfig.copy(httpMode = McpHttpMode.AUTO) + tab.markDirty() + }, + McpSegment("2025-06-18", tab.mcpConfig.httpMode == McpHttpMode.STREAMABLE_2025_06_18) { + tab.mcpConfig = tab.mcpConfig.copy(httpMode = McpHttpMode.STREAMABLE_2025_06_18) + tab.markDirty() + }, + McpSegment("Legacy", tab.mcpConfig.httpMode == McpHttpMode.LEGACY_2024_11_05) { + tab.mcpConfig = tab.mcpConfig.copy(httpMode = McpHttpMode.LEGACY_2024_11_05) + tab.markDirty() + }, + ), + ) + } + if (connected) { + Text(Strings.t("mcp_client_reconnect"), color = ReqLabColors.OnSurfaceDim, fontSize = 11.sp) } } - } - Text(Strings.t("mcp_http_mode"), color = ReqLabColors.OnSurface, fontWeight = FontWeight.SemiBold) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - TransportChip("Auto", tab.mcpConfig.httpMode == McpHttpMode.AUTO) { - tab.mcpConfig = tab.mcpConfig.copy(httpMode = McpHttpMode.AUTO); tab.markDirty() - } - TransportChip("2025-06-18", tab.mcpConfig.httpMode == McpHttpMode.STREAMABLE_2025_06_18) { - tab.mcpConfig = tab.mcpConfig.copy(httpMode = McpHttpMode.STREAMABLE_2025_06_18); tab.markDirty() - } - TransportChip("Legacy", tab.mcpConfig.httpMode == McpHttpMode.LEGACY_2024_11_05) { - tab.mcpConfig = tab.mcpConfig.copy(httpMode = McpHttpMode.LEGACY_2024_11_05); tab.markDirty() + + ClientSettingsCard(title = Strings.t("mcp_client_callbacks")) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + Strings.t("mcp_auto_sampling"), + color = ReqLabColors.OnSurface, + fontWeight = FontWeight.SemiBold, + fontSize = 12.sp, + ) + Text(Strings.t("mcp_auto_sampling_explain"), color = ReqLabColors.OnSurfaceDim, fontSize = 11.sp) + } + Switch( + checked = tab.mcpConfig.samplingMode == McpSamplingMode.MOCK, + onCheckedChange = { on -> + tab.mcpConfig = tab.mcpConfig.copy( + samplingMode = if (on) McpSamplingMode.MOCK else McpSamplingMode.MANUAL, + ) + tab.markDirty() + }, + modifier = Modifier.testTag("mcp-auto-sampling"), + colors = SwitchDefaults.colors( + checkedThumbColor = ReqLabColors.OnPrimary, + checkedTrackColor = ReqLabColors.Primary, + uncheckedThumbColor = ReqLabColors.OnSurfaceDim, + uncheckedTrackColor = ReqLabColors.SurfaceHigh, + ), + ) + } + if (tab.mcpConfig.samplingMode != McpSamplingMode.MOCK) { + ClientSettingLabel(Strings.t("mcp_llm_url")) + SearchField( + value = tab.mcpConfig.samplingForwardUrl.orEmpty(), + onValueChange = { + tab.mcpConfig = tab.mcpConfig.copy(samplingForwardUrl = it.ifBlank { null }) + tab.markDirty() + }, + placeholder = "http://localhost:8080/v1/chat/completions", + testTag = "mcp-llm-url", + state = state, + ) + ClientSettingLabel(Strings.t("mcp_llm_token")) + SearchField( + value = tab.mcpConfig.samplingForwardToken.orEmpty(), + onValueChange = { + tab.mcpConfig = tab.mcpConfig.copy(samplingForwardToken = it.ifBlank { null }) + tab.markDirty() + }, + placeholder = Strings.t("mcp_llm_token"), + testTag = "mcp-llm-token", + ) + ClientSettingLabel(Strings.t("mcp_llm_max_tokens")) + SearchField( + value = tab.mcpConfig.samplingMaxTokens?.toString().orEmpty(), + onValueChange = { + tab.mcpConfig = tab.mcpConfig.copy(samplingMaxTokens = it.toIntOrNull()) + tab.markDirty() + }, + placeholder = "256", + testTag = "mcp-llm-max-tokens", + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + Strings.t("mcp_auto_elicit"), + color = ReqLabColors.OnSurface, + fontWeight = FontWeight.SemiBold, + fontSize = 12.sp, + ) + Text(Strings.t("mcp_elicit_explain"), color = ReqLabColors.OnSurfaceDim, fontSize = 11.sp) + } + Switch( + checked = tab.mcpConfig.autoRespondElicitation, + onCheckedChange = { + tab.mcpConfig = tab.mcpConfig.copy(autoRespondElicitation = it) + tab.markDirty() + }, + modifier = Modifier.testTag("mcp-auto-elicit"), + colors = SwitchDefaults.colors( + checkedThumbColor = ReqLabColors.OnPrimary, + checkedTrackColor = ReqLabColors.Primary, + uncheckedThumbColor = ReqLabColors.OnSurfaceDim, + uncheckedTrackColor = ReqLabColors.SurfaceHigh, + ), + ) + } } - } - Text(Strings.t("mcp_sampling_mode"), color = ReqLabColors.OnSurface, fontWeight = FontWeight.SemiBold) - Text("${tab.mcpConfig.samplingMode}", color = ReqLabColors.OnSurface, fontFamily = CodeFontFamily, fontSize = 13.sp) - Text(Strings.t("mcp_roots"), color = ReqLabColors.OnSurface, fontWeight = FontWeight.SemiBold) - if (tab.mcpConfig.roots.isEmpty()) { - Text("\u2014", color = ReqLabColors.OnSurfaceDim) - } else { - tab.mcpConfig.roots.forEach { root -> - Text(root.uri, color = ReqLabColors.OnSurfaceDim, fontFamily = CodeFontFamily, fontSize = 12.sp) + + ClientSettingsCard(title = Strings.t("mcp_roots")) { + if (tab.mcpConfig.roots.isEmpty()) { + Text(Strings.t("mcp_roots_empty"), color = ReqLabColors.OnSurfaceDim, fontSize = 12.sp) + } else { + RootsTableRow { + Text( + Strings.t("mcp_root_uri"), + color = ReqLabColors.OnSurfaceDim, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f), + ) + Text( + Strings.t("mcp_root_name"), + color = ReqLabColors.OnSurfaceDim, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f), + ) + Spacer(Modifier.size(28.dp)) + } + tab.mcpConfig.roots.forEachIndexed { index, root -> + RootsTableRow { + Box( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(6.dp)) + .background(ReqLabColors.SurfaceContainer) + .border(1.dp, ReqLabColors.Border, RoundedCornerShape(6.dp)) + .padding(horizontal = 8.dp, vertical = 6.dp), + ) { + VariableAwareTextField( + value = root.uri, + onValueChange = { next -> + val roots = tab.mcpConfig.roots.toMutableList() + roots[index] = root.copy(uri = next) + tab.mcpConfig = tab.mcpConfig.copy(roots = roots) + tab.markDirty() + }, + placeholder = Strings.t("mcp_root_uri"), + modifier = Modifier.fillMaxWidth().testTag("mcp-root-uri-$index"), + ) + } + Box( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(6.dp)) + .background(ReqLabColors.SurfaceContainer) + .border(1.dp, ReqLabColors.Border, RoundedCornerShape(6.dp)) + .padding(horizontal = 8.dp, vertical = 6.dp), + ) { + VariableAwareTextField( + value = root.name.orEmpty(), + onValueChange = { next -> + val roots = tab.mcpConfig.roots.toMutableList() + roots[index] = root.copy(name = next.ifBlank { null }) + tab.mcpConfig = tab.mcpConfig.copy(roots = roots) + tab.markDirty() + }, + placeholder = Strings.t("mcp_root_name"), + modifier = Modifier.fillMaxWidth().testTag("mcp-root-name-$index"), + ) + } + IconButton( + onClick = { + tab.mcpConfig = tab.mcpConfig.copy(roots = tab.mcpConfig.roots.filterIndexed { i, _ -> i != index }) + tab.markDirty() + }, + modifier = Modifier.size(28.dp).testTag("mcp-root-remove-$index"), + ) { + Icon( + Icons.Default.Close, + contentDescription = Strings.t("mcp_remove_root"), + tint = ReqLabColors.OnSurfaceDim, + modifier = Modifier.size(14.dp), + ) + } + } + } + } + TextButton( + onClick = { + tab.mcpConfig = tab.mcpConfig.copy(roots = tab.mcpConfig.roots + McpRoot(uri = "file://", name = "")) + tab.markDirty() + }, + modifier = Modifier.testTag("mcp-add-root"), + ) { + Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(14.dp)) + Spacer(Modifier.size(4.dp)) + Text(Strings.t("mcp_add_root")) + } } } - Row(verticalAlignment = Alignment.CenterVertically) { - Checkbox(checked = tab.mcpConfig.autoRespondElicitation, onCheckedChange = { - tab.mcpConfig = tab.mcpConfig.copy(autoRespondElicitation = it) - tab.markDirty() - }) - Text(Strings.t("mcp_auto_elicit"), color = ReqLabColors.OnSurface) - } - } PlatformColumnVerticalScrollbar( scrollState = scroll, modifier = Modifier.align(Alignment.CenterEnd).insetScrollbar(), @@ -1025,6 +1268,123 @@ private fun ClientSection(tab: RequestTabState, session: com.reqlab.ui.shared.mc } } +@Composable +private fun RootsTableRow(content: @Composable RowScope.() -> Unit) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + content = content, + ) +} + +@Composable +private fun ClientSettingsCard( + title: String, + description: String? = null, + content: @Composable ColumnScope.() -> Unit, +) { + SplitCard(Modifier.fillMaxWidth()) { + Column( + Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text(title, color = ReqLabColors.OnSurface, fontWeight = FontWeight.SemiBold, fontSize = 13.sp) + if (description != null) { + Text(description, color = ReqLabColors.OnSurfaceDim, fontSize = 11.sp) + } + content() + } + } +} + +@Composable +private fun ClientSettingLabel(text: String) { + Text(text, color = ReqLabColors.OnSurfaceVariant, fontSize = 11.sp, fontWeight = FontWeight.SemiBold) +} + +private data class McpSegment(val label: String, val selected: Boolean, val onClick: () -> Unit) + +@Composable +private fun McpSegmentedControl(segments: List) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(6.dp)) + .border(1.dp, ReqLabColors.Border, RoundedCornerShape(6.dp)), + ) { + segments.forEach { segment -> + Text( + segment.label, + fontSize = 12.sp, + fontWeight = if (segment.selected) FontWeight.SemiBold else FontWeight.Normal, + color = if (segment.selected) ReqLabColors.Primary else ReqLabColors.OnSurfaceDim, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + modifier = Modifier + .weight(1f) + .background(if (segment.selected) ReqLabColors.SelectedItem else Color.Transparent) + .clickable(onClick = segment.onClick) + .padding(horizontal = 10.dp, vertical = 7.dp), + ) + } + } +} + +@Composable +private fun McpSectionTabBar( + labels: List, + selectedIndex: Int, + onSelect: (Int) -> Unit, +) { + Box( + modifier = Modifier + .fillMaxWidth() + .background(ReqLabColors.Surface) + .testTag("mcp-tabs"), + ) { + Row(Modifier.fillMaxWidth().height(36.dp)) { + labels.forEachIndexed { index, title -> + val selected = index == selectedIndex + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .clickable { onSelect(index) }, + contentAlignment = Alignment.Center, + ) { + Text( + title, + fontSize = 12.sp, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, + color = if (selected) ReqLabColors.Primary else ReqLabColors.OnSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 4.dp), + ) + if (selected) { + Box( + Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .height(2.dp) + .background(ReqLabColors.Primary), + ) + } + } + } + } + Box( + Modifier + .align(Alignment.BottomStart) + .fillMaxWidth() + .height(1.dp) + .background(ReqLabColors.Border), + ) + } +} + @Composable private fun ScrollableLazyColumn( modifier: Modifier, @@ -1048,7 +1408,13 @@ private fun ScrollableLazyColumn( } @Composable -private fun SearchField(value: String, onValueChange: (String) -> Unit, placeholder: String, testTag: String) { +private fun SearchField( + value: String, + onValueChange: (String) -> Unit, + placeholder: String, + testTag: String, + state: AppState? = null, +) { Box( modifier = Modifier .fillMaxWidth() @@ -1061,6 +1427,7 @@ private fun SearchField(value: String, onValueChange: (String) -> Unit, placehol value = value, onValueChange = onValueChange, placeholder = placeholder, + state = state, modifier = Modifier.fillMaxWidth().testTag(testTag), ) } @@ -1174,6 +1541,77 @@ private fun SplitColumn(content: @Composable androidx.compose.foundation.layout. ) } +@Composable +private fun SessionIdChip(sessionId: String) { + val visibleId = visibleSessionId(sessionId) + Row( + modifier = Modifier + .clip(RoundedCornerShape(6.dp)) + .background(ReqLabColors.Surface) + .border(1.dp, ReqLabColors.Border, RoundedCornerShape(6.dp)) + .padding(start = 8.dp, end = 2.dp) + .testTag("mcp-session-id"), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + Strings.t("mcp_session_id"), + color = ReqLabColors.OnSurfaceDim, + fontSize = 11.sp, + lineHeight = 16.sp, + ) + Text( + visibleId, + color = ReqLabColors.OnSurface, + fontSize = 11.sp, + lineHeight = 16.sp, + fontFamily = CodeFontFamily, + maxLines = 1, + ) + IconButton( + onClick = { copyToClipboard(sessionId) }, + modifier = Modifier.size(28.dp).testTag("mcp-session-id-copy"), + ) { + Icon( + Icons.Default.ContentCopy, + contentDescription = Strings.copy, + tint = ReqLabColors.OnSurfaceDim, + modifier = Modifier.size(14.dp), + ) + } + } +} + +private fun visibleSessionId(sessionId: String): String = + if (sessionId.length <= MCP_SESSION_ID_MAX_VISIBLE) sessionId + else sessionId.take(MCP_SESSION_ID_MAX_VISIBLE) + "…" + +@Composable +private fun ConnectionMetaChip( + text: String, + modifier: Modifier = Modifier, + fillText: Boolean = false, +) { + Box( + modifier = modifier + .clip(RoundedCornerShape(6.dp)) + .background(ReqLabColors.Surface) + .border(1.dp, ReqLabColors.Border, RoundedCornerShape(6.dp)) + .padding(horizontal = 8.dp, vertical = 5.dp), + contentAlignment = Alignment.CenterStart, + ) { + Text( + text, + color = ReqLabColors.OnSurfaceDim, + fontSize = 11.sp, + lineHeight = 16.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = if (fillText) Modifier.fillMaxWidth() else Modifier, + ) + } +} + @Composable private fun StatusDot(state: McpConnectionState) { val color = when (state) { diff --git a/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/RequestTabsBar.kt b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/RequestTabsBar.kt index 4784f0b..f52f7f5 100644 --- a/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/RequestTabsBar.kt +++ b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/RequestTabsBar.kt @@ -25,7 +25,6 @@ import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Hub import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon @@ -212,12 +211,7 @@ private fun RequestTabChip( horizontalArrangement = Arrangement.spacedBy(6.dp), ) { if (tab.kind == com.reqlab.core.model.RequestKind.MCP) { - Icon( - Icons.Default.Hub, - contentDescription = "MCP", - tint = ReqLabColors.Primary, - modifier = Modifier.size(12.dp).testTag("tab-mcp-badge-${tab.id}"), - ) + McpMethodBadge(compact = true, modifier = Modifier.testTag("tab-mcp-badge-${tab.id}")) } else { MethodBadge(tab.method, compact = true) } diff --git a/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/Sidebar.kt b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/Sidebar.kt index ce664a2..fa04c41 100644 --- a/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/Sidebar.kt +++ b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/Sidebar.kt @@ -1191,6 +1191,20 @@ fun MethodBadge(method: HttpMethodType, compact: Boolean = false) { ) } +@Composable +fun McpMethodBadge(compact: Boolean = false, modifier: Modifier = Modifier) { + Text( + text = "MCP", + color = ReqLabColors.Primary, + fontSize = if (compact) 10.sp else 12.sp, + fontWeight = FontWeight.Bold, + modifier = modifier + .clip(RoundedCornerShape(4.dp)) + .background(ReqLabColors.Primary.copy(alpha = 0.12f)) + .padding(horizontal = if (compact) 4.dp else 6.dp, vertical = 2.dp), + ) +} + @OptIn(ExperimentalComposeUiApi::class) @Composable private fun CollectionTreeNode( @@ -1417,12 +1431,7 @@ private fun CollectionTreeNode( tint = ReqLabColors.OnSurfaceDim.copy(alpha = 0.4f), modifier = Modifier.size(12.dp), ) - Icon( - Icons.Default.Hub, - contentDescription = "MCP", - tint = ReqLabColors.Primary, - modifier = Modifier.size(14.dp).testTag("mcp-badge-${node.id}"), - ) + McpMethodBadge(compact = true, modifier = Modifier.testTag("mcp-badge-${node.id}")) } else if (node.method != null) { Icon( Icons.Default.DragIndicator, diff --git a/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/mcp/McpSessionState.kt b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/mcp/McpSessionState.kt index 94d9d05..cebef7f 100644 --- a/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/mcp/McpSessionState.kt +++ b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/mcp/McpSessionState.kt @@ -3,6 +3,11 @@ package com.reqlab.ui.shared.mcp import com.reqlab.core.model.KeyValueEntry import com.reqlab.core.model.McpConnectionConfig import com.reqlab.core.model.McpConnectionState +import com.reqlab.core.model.McpCreateMessageRequest +import com.reqlab.core.model.McpCreateMessageResult +import com.reqlab.core.model.McpElicitAction +import com.reqlab.core.model.McpElicitRequest +import com.reqlab.core.model.McpElicitResult import com.reqlab.core.model.McpGetPromptResult import com.reqlab.core.model.McpHttpMode import com.reqlab.core.model.McpInitializeResult @@ -11,15 +16,20 @@ import com.reqlab.core.model.McpLogEntryKind import com.reqlab.core.model.McpPrompt import com.reqlab.core.model.McpReadResourceResult import com.reqlab.core.model.McpResource +import com.reqlab.core.model.McpSamplingMode import com.reqlab.core.model.McpTool import com.reqlab.core.model.McpToolResult import com.reqlab.core.model.McpTransportType import com.reqlab.core.model.ResponseDefinition import com.reqlab.core.model.ResponseMetrics import com.reqlab.core.network.mcp.McpClient +import com.reqlab.core.network.mcp.cancelledMcpSamplingResult +import com.reqlab.core.network.mcp.emptyMcpSamplingResult import com.reqlab.core.network.mcp.mcpStdioSupported +import com.reqlab.editor.core.JsonMode import com.reqlab.ui.shared.state.LogLevel import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow @@ -79,11 +89,38 @@ data class McpOperationResult( } } +/** Longer than sample-server callback wait (60s) so tools/call is not cancelled first. */ +const val MCP_UI_CALL_TIMEOUT_MS = 90_000L + +sealed class McpPendingSampling { + abstract val request: McpCreateMessageRequest + abstract val deferred: CompletableDeferred + + data class ReviewRequest( + override val request: McpCreateMessageRequest, + override val deferred: CompletableDeferred, + ) : McpPendingSampling() + + data class ReviewResult( + override val request: McpCreateMessageRequest, + override val deferred: CompletableDeferred, + val draft: McpCreateMessageResult, + val generateError: String? = null, + val generating: Boolean = false, + ) : McpPendingSampling() +} + +data class McpPendingElicitation( + val request: McpElicitRequest, + val argsJson: String, + val deferred: CompletableDeferred, +) + class McpSessionState( private val scope: CoroutineScope, - /** Optional bridge that forwards MCP activity to the global console. */ + /** Optional bridge that forwards MCP activity summaries to the bottom Logs tab. */ private val onConsole: ((String, LogLevel) -> Unit)? = null, - private val clientFactory: (CoroutineScope) -> McpClient = { McpClient(it) }, + private val clientFactory: (CoroutineScope) -> McpClient = { McpClient(it, callTimeoutMs = MCP_UI_CALL_TIMEOUT_MS) }, ) { var client: McpClient? = null private set @@ -115,6 +152,10 @@ class McpSessionState( val error: StateFlow = _error private val _initializeResult = MutableStateFlow(null) val initializeResult: StateFlow = _initializeResult + private val _pendingSampling = MutableStateFlow(null) + val pendingSampling: StateFlow = _pendingSampling + private val _pendingElicitation = MutableStateFlow(null) + val pendingElicitation: StateFlow = _pendingElicitation var confirmStdio: Boolean = false /** Cmd/Ctrl+Enter target set by the MCP panel for the active section. */ var pendingShortcut: (() -> Unit)? = null @@ -123,6 +164,10 @@ class McpSessionState( private var callJob: Job? = null private var connectedFingerprint: String? = null + fun clearLogs() { + _logs.value = emptyList() + } + fun stdioAvailable(): Boolean = mcpStdioSupported /** True when the connected server advertises the resources/subscribe capability. */ @@ -150,6 +195,7 @@ class McpSessionState( _connectionState.value = McpConnectionState.CONNECTING try { val init = created.connect(config, variableLayers) + installInteractiveHandlers(created) _initializeResult.value = init connectedFingerprint = connectionFingerprint(config) _connectionState.value = McpConnectionState.CONNECTED @@ -248,6 +294,7 @@ class McpSessionState( timestampMs = Clock.System.now().toEpochMilliseconds(), ) } finally { + failPendingCallbacks() _busy.value = false } } @@ -286,11 +333,76 @@ class McpSessionState( } fun cancelCall() { + failPendingCallbacks() callJob?.cancel() callJob = null _busy.value = false } + fun approveSamplingGenerate() { + val pending = _pendingSampling.value as? McpPendingSampling.ReviewRequest ?: return + val url = client?.config?.samplingForwardUrl + if (url.isNullOrBlank()) { + _pendingSampling.value = McpPendingSampling.ReviewResult( + request = pending.request, + deferred = pending.deferred, + draft = emptyMcpSamplingResult(), + generateError = "No LLM URL", + ) + return + } + _pendingSampling.value = McpPendingSampling.ReviewResult( + request = pending.request, + deferred = pending.deferred, + draft = emptyMcpSamplingResult(), + generating = true, + ) + scope.launch { + val (draft, err) = try { + val generated = client?.generateSampling(pending.request) ?: emptyMcpSamplingResult() + generated to null + } catch (e: Exception) { + emptyMcpSamplingResult() to (e.message ?: e.toString()) + } + val current = _pendingSampling.value + if (current is McpPendingSampling.ReviewResult && current.deferred === pending.deferred) { + _pendingSampling.value = current.copy(draft = draft, generateError = err, generating = false) + } + } + } + + fun submitSamplingResult(result: McpCreateMessageResult) { + val pending = _pendingSampling.value ?: return + _pendingSampling.value = null + pending.deferred.complete(result) + } + + fun cancelSampling() { + val pending = _pendingSampling.value ?: return + _pendingSampling.value = null + pending.deferred.complete(cancelledMcpSamplingResult()) + } + + fun updatePendingElicitArgs(argsJson: String) { + val pending = _pendingElicitation.value ?: return + _pendingElicitation.value = pending.copy(argsJson = argsJson) + } + + fun submitElicitation(content: JsonObject? = null) { + val pending = _pendingElicitation.value ?: return + _pendingElicitation.value = null + val parsed = content ?: runCatching { + mcpPrettyJson.parseToJsonElement(pending.argsJson) as? JsonObject + }.getOrNull() ?: JsonObject(emptyMap()) + pending.deferred.complete(McpElicitResult(action = McpElicitAction.ACCEPT, content = parsed)) + } + + fun declineElicitation() { + val pending = _pendingElicitation.value ?: return + _pendingElicitation.value = null + pending.deferred.complete(McpElicitResult(action = McpElicitAction.DECLINE)) + } + fun isReconnectNeeded(config: McpConnectionConfig): Boolean = when (_connectionState.value) { McpConnectionState.ERROR -> true @@ -300,6 +412,7 @@ class McpSessionState( } suspend fun disconnect() { + failPendingCallbacks() callJob?.cancel() callJob = null logJob?.cancel() @@ -331,6 +444,51 @@ class McpSessionState( fun sessionId(): String? = client?.sessionId + private fun installInteractiveHandlers(created: McpClient) { + val cfg = created.config + if (cfg.samplingMode == McpSamplingMode.MANUAL) { + created.handlers.onSampling = { req -> + val deferred = CompletableDeferred() + _pendingSampling.value = McpPendingSampling.ReviewRequest(req, deferred) + try { + deferred.await() + } finally { + if (_pendingSampling.value?.deferred === deferred) { + _pendingSampling.value = null + } + } + } + } + if (!cfg.autoRespondElicitation) { + created.handlers.onElicit = { req -> + val deferred = CompletableDeferred() + _pendingElicitation.value = McpPendingElicitation( + request = req, + argsJson = mcpDefaultArgsJson(req.requestedSchema), + deferred = deferred, + ) + try { + deferred.await() + } finally { + if (_pendingElicitation.value?.deferred === deferred) { + _pendingElicitation.value = null + } + } + } + } + } + + private fun failPendingCallbacks() { + _pendingSampling.value?.let { pending -> + _pendingSampling.value = null + pending.deferred.complete(cancelledMcpSamplingResult()) + } + _pendingElicitation.value?.let { pending -> + _pendingElicitation.value = null + pending.deferred.complete(McpElicitResult(action = McpElicitAction.DECLINE)) + } + } + private fun latestWireJson(): String? = client?.lastReceivedPayload?.takeIf { it.isNotBlank() }?.let(::mcpPrettyWireJson) @@ -358,18 +516,7 @@ private fun Map>.toKeyValueEntries(): List = internal val mcpPrettyJson = Json { prettyPrint = true; encodeDefaults = true; ignoreUnknownKeys = true } -/** Pretty-print a JSON-RPC wire frame without re-encoding through MCP data classes. */ -internal val mcpWirePretty = Json { - prettyPrint = true - encodeDefaults = false - explicitNulls = false - ignoreUnknownKeys = true -} - -internal fun mcpPrettyWireJson(raw: String): String = - runCatching { - mcpWirePretty.encodeToString(JsonElement.serializer(), mcpWirePretty.parseToJsonElement(raw)) - }.getOrDefault(raw) +internal fun mcpPrettyWireJson(raw: String): String = JsonMode.format(raw) internal fun mcpDefaultArgsJson(schema: JsonObject): String { val properties = schema["properties"] as? JsonObject @@ -493,6 +640,12 @@ internal fun connectionFingerprint(config: McpConnectionConfig): String = config.auth.type.name, config.auth.params.entries.sortedBy { it.key }.joinToString { "${it.key}=${it.value}" }, config.headers.filter { it.enabled && it.key.isNotBlank() }.joinToString { "${it.key}=${it.value}" }, + config.samplingMode.name, + config.samplingForwardUrl.orEmpty(), + if (config.samplingForwardToken.isNullOrBlank()) "0" else "1", + config.samplingMaxTokens?.toString().orEmpty(), + config.autoRespondElicitation.toString(), + config.roots.joinToString { "${it.uri}|${it.name.orEmpty()}" }, ).joinToString("|") internal fun mcpParseScalar(raw: String, type: String): JsonElement { diff --git a/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/persistence/ImportExportRepository.kt b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/persistence/ImportExportRepository.kt index abb7b94..892ac27 100644 --- a/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/persistence/ImportExportRepository.kt +++ b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/persistence/ImportExportRepository.kt @@ -21,6 +21,7 @@ import kotlinx.serialization.json.booleanOrNull import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive @@ -86,6 +87,12 @@ data class RequestDto( val mcpCommand: String? = null, val mcpArgs: List = emptyList(), val mcpEnv: Map = emptyMap(), + val mcpSamplingMode: String? = null, + val mcpSamplingForwardUrl: String? = null, + val mcpSamplingForwardToken: String? = null, + val mcpSamplingMaxTokens: Int? = null, + val mcpAutoRespondElicitation: Boolean? = null, + val mcpRoots: List> = emptyList(), ) data class ReqLabEnvironmentDto( @@ -423,6 +430,21 @@ object ImportExportRepository { if (mcp.command.isNotBlank()) put("mcpCommand", mcp.command) if (mcp.args.isNotEmpty()) put("mcpArgs", buildJsonArray { mcp.args.forEach { add(JsonPrimitive(it)) } }) if (mcp.env.isNotEmpty()) put("mcpEnv", buildJsonObject { mcp.env.forEach { (k, v) -> put(k, v) } }) + put("mcpSamplingMode", mcp.samplingMode.name) + mcp.samplingForwardUrl?.takeIf { it.isNotBlank() }?.let { put("mcpSamplingForwardUrl", it) } + mcp.samplingForwardToken?.takeIf { it.isNotBlank() }?.let { put("mcpSamplingForwardToken", it) } + mcp.samplingMaxTokens?.let { put("mcpSamplingMaxTokens", it) } + put("mcpAutoRespondElicitation", mcp.autoRespondElicitation) + if (mcp.roots.isNotEmpty()) { + put("mcpRoots", buildJsonArray { + mcp.roots.forEach { root -> + add(buildJsonObject { + put("uri", root.uri) + root.name?.let { put("name", it) } + }) + } + }) + } } } } @@ -552,6 +574,21 @@ object ImportExportRepository { dto.mcpCommand?.takeIf { it.isNotBlank() }?.let { put("mcpCommand", it) } if (dto.mcpArgs.isNotEmpty()) put("mcpArgs", buildJsonArray { dto.mcpArgs.forEach { add(JsonPrimitive(it)) } }) if (dto.mcpEnv.isNotEmpty()) put("mcpEnv", buildJsonObject { dto.mcpEnv.forEach { (k, v) -> put(k, v) } }) + dto.mcpSamplingMode?.let { put("mcpSamplingMode", it) } + dto.mcpSamplingForwardUrl?.takeIf { it.isNotBlank() }?.let { put("mcpSamplingForwardUrl", it) } + dto.mcpSamplingForwardToken?.takeIf { it.isNotBlank() }?.let { put("mcpSamplingForwardToken", it) } + dto.mcpSamplingMaxTokens?.let { put("mcpSamplingMaxTokens", it) } + dto.mcpAutoRespondElicitation?.let { put("mcpAutoRespondElicitation", it) } + if (dto.mcpRoots.isNotEmpty()) { + put("mcpRoots", buildJsonArray { + dto.mcpRoots.forEach { (uri, name) -> + add(buildJsonObject { + put("uri", uri) + name?.let { put("name", it) } + }) + } + }) + } } private fun environmentDtoToJson(dto: ReqLabEnvironmentDto): JsonObject = @@ -666,6 +703,16 @@ object ImportExportRepository { mcpCommand = root["mcpCommand"]?.jsonPrimitive?.contentOrNull, mcpArgs = root["mcpArgs"]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList(), mcpEnv = root["mcpEnv"]?.jsonObject?.mapValues { it.value.jsonPrimitive.content } ?: emptyMap(), + mcpSamplingMode = root["mcpSamplingMode"]?.jsonPrimitive?.contentOrNull, + mcpSamplingForwardUrl = root["mcpSamplingForwardUrl"]?.jsonPrimitive?.contentOrNull, + mcpSamplingForwardToken = root["mcpSamplingForwardToken"]?.jsonPrimitive?.contentOrNull, + mcpSamplingMaxTokens = root["mcpSamplingMaxTokens"]?.jsonPrimitive?.intOrNull, + mcpAutoRespondElicitation = root["mcpAutoRespondElicitation"]?.jsonPrimitive?.booleanOrNull, + mcpRoots = root["mcpRoots"]?.jsonArray?.mapNotNull { el -> + val obj = el.jsonObject + val uri = obj["uri"]?.jsonPrimitive?.contentOrNull ?: return@mapNotNull null + uri to obj["name"]?.jsonPrimitive?.contentOrNull + } ?: emptyList(), ) } @@ -816,6 +863,16 @@ object ImportExportRepository { command = dto.mcpCommand.orEmpty(), args = dto.mcpArgs, env = dto.mcpEnv, + samplingMode = runCatching { + com.reqlab.core.model.McpSamplingMode.valueOf(dto.mcpSamplingMode ?: "MOCK") + }.getOrDefault(com.reqlab.core.model.McpSamplingMode.MOCK), + samplingForwardUrl = dto.mcpSamplingForwardUrl, + samplingForwardToken = dto.mcpSamplingForwardToken, + samplingMaxTokens = dto.mcpSamplingMaxTokens, + autoRespondElicitation = dto.mcpAutoRespondElicitation ?: true, + roots = dto.mcpRoots.map { (uri, name) -> + com.reqlab.core.model.McpRoot(uri = uri, name = name) + }, ) } else null return CollectionNode( diff --git a/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/platform/FileChooserMemory.kt b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/platform/FileChooserMemory.kt new file mode 100644 index 0000000..d0f9604 --- /dev/null +++ b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/platform/FileChooserMemory.kt @@ -0,0 +1,46 @@ +package com.reqlab.ui.shared.platform + +/** + * Remembers the last file-dialog folder so import/export open there next time. + * Paths are stored as native absolute strings (Java [java.io.File.absolutePath]); + * [directoryToOpen] ignores a stored path that no longer exists so a missing + * drive/share on Windows or an unmounted volume on macOS/Linux falls back to + * the OS default (user home). + * + * Browsers cannot set `` start directories; wasm ignores this. + */ +internal object FileChooserMemory { + const val LAST_DIR_KEY = "fileChooser.lastDirectory" + + fun parentPath(path: String): String? { + if (path.isBlank()) return null + val trimmed = path.trimEnd('/', '\\') + if (trimmed.isEmpty()) return null + val slash = maxOf(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\')) + if (slash < 0) return null + if (slash == 0) return "/" + if (slash == 2 && trimmed.length >= 2 && trimmed[1] == ':') { + return trimmed.substring(0, 3) + } + return trimmed.substring(0, slash) + } + + fun directoryToOpen( + stored: String?, + existsAndIsDirectory: (String) -> Boolean, + ): String? { + if (stored.isNullOrBlank()) return null + if (existsAndIsDirectory(stored)) return stored + val parent = parentPath(stored) + return parent?.takeIf(existsAndIsDirectory) + } + + fun directoryToRemember( + selectedPath: String, + existsAndIsDirectory: (String) -> Boolean, + ): String? { + if (selectedPath.isBlank()) return null + if (existsAndIsDirectory(selectedPath)) return selectedPath + return parentPath(selectedPath)?.takeIf(existsAndIsDirectory) + } +} diff --git a/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/platform/PlatformApi.kt b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/platform/PlatformApi.kt index a0d30df..5dbbaea 100644 --- a/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/platform/PlatformApi.kt +++ b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/platform/PlatformApi.kt @@ -60,7 +60,9 @@ expect fun Modifier.platformResizeCursorStyle(isHorizontal: Boolean): Modifier /** * Pick a file from the filesystem and deliver its text content to [onResult]. - * On desktop this opens a JFileChooser; on web it triggers an . + * On desktop this opens a JFileChooser starting at the last imported/exported + * folder (user home on first use). On web it triggers an ``; + * browsers do not allow setting that dialog's start directory. */ expect fun pickFileForImport(onResult: (String) -> Unit) diff --git a/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/state/AppState.kt b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/state/AppState.kt index ec0e4f2..7b0a71f 100644 --- a/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/state/AppState.kt +++ b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/state/AppState.kt @@ -391,9 +391,24 @@ class RequestTabState( formRowsSnapshot, urlencodedRowsSnapshot, kind.name, + mcpClientFingerprint(), + ).joinToString("#") + } + + /** Compact fingerprint of Client-tab MCP settings for dirty tracking and auto-save. */ + fun mcpClientFingerprint(): String { + val roots = mcpConfig.roots.joinToString(";") { "${it.uri}|${it.name.orEmpty()}" } + return listOf( mcpConfig.url, mcpConfig.transport.name, mcpConfig.httpMode.name, + mcpConfig.command, + mcpConfig.samplingMode.name, + mcpConfig.samplingForwardUrl.orEmpty(), + mcpConfig.samplingForwardToken.orEmpty(), + mcpConfig.samplingMaxTokens?.toString().orEmpty(), + mcpConfig.autoRespondElicitation.toString(), + roots, ).joinToString("#") } @@ -575,7 +590,9 @@ class AppState(openDefaultTab: Boolean = true, withDemoData: Boolean = false) { /** Returns the persistent MCP session for [tabId], creating it on first use. */ fun getOrCreateMcpSession(tabId: String): McpSessionState = mcpSessions.getOrPut(tabId) { - McpSessionState(appScope, onConsole = { message, level -> logNetworkEvent(message, level) }) + McpSessionState(appScope, onConsole = { message, level -> + logNetworkEvent(message, level, echoToConsole = false) + }) } /** Disconnects and forgets the MCP session for [tabId] (called on tab close). */ @@ -1072,12 +1089,13 @@ class AppState(openDefaultTab: Boolean = true, withDemoData: Boolean = false) { /** * Appends a network-level event to the structured Logs tab. - * Also echoes to the Console for unified visibility (fixes M-3). + * REST traffic also echoes to Console ([echoToConsole] default true). MCP + * summaries pass false so Console stays for scripts and app messages. */ - fun logNetworkEvent(message: String, level: LogLevel = LogLevel.INFO) { + fun logNetworkEvent(message: String, level: LogLevel = LogLevel.INFO, echoToConsole: Boolean = true) { val entry = ConsoleEntry(message, level) networkEventLogs.add(0, entry) - consoleLogs.add(0, entry) + if (echoToConsole) consoleLogs.add(0, entry) } fun notifyCollectionsChanged() { @@ -1536,6 +1554,10 @@ class AppState(openDefaultTab: Boolean = true, withDemoData: Boolean = false) { userHeaders = userHeadersSnapshot, preRequestScript = tab.preRequestScript.ifBlank { null }, testScript = tab.testScript.ifBlank { null }, + kind = tab.kind, + mcpConfig = tab.mcpConfig.copy( + url = tab.url.ifBlank { tab.mcpConfig.url }, + ), ) return true } diff --git a/ui-shared/src/commonMain/resources/i18n/de.json b/ui-shared/src/commonMain/resources/i18n/de.json index 5d87e4d..647c356 100644 --- a/ui-shared/src/commonMain/resources/i18n/de.json +++ b/ui-shared/src/commonMain/resources/i18n/de.json @@ -211,9 +211,9 @@ "mcp_activity_hint": "Live-Ereignisse vom Server (Fortschritt, Logs, Updates) plus jede gesendete oder empfangene JSON-RPC-Nachricht.", "mcp_activity_empty": "Noch kein Verkehr. Verbinden und ein Tool ausführen.", "mcp_client_hint": "Wie dieser Client antwortet, wenn der Server zurückruft. Sampling = der Server will eine Modellantwort. Roots = erlaubte Ordner. Elicitation = der Server stellt ein Formular.", - "mcp_sampling_explain": "Wenn der Server eine Completion anfordert, liefert ReqLab derzeit eine Mock-Antwort.", + "mcp_sampling_explain": "Wenn der Server eine Completion anfordert, sendet Auto-Antwort eine Mock-Antwort. Ausgeschaltet: Anfrage prüfen, mit einem LLM erzeugen und das Ergebnis im Antwortbereich bearbeiten.", "mcp_roots_explain": "Ordner, die dieser Client meldet, wenn der Server roots/list fragt.", - "mcp_elicit_explain": "Wenn der Server eine Frage stellt, antwortet Auto-Accept ohne Formular.", + "mcp_elicit_explain": "Formular überspringen, oder in Antwort ausfüllen.", "mcp_tool_error": "Tool-Fehler", "mcp_tool_ok": "Erfolg", "mcp_disconnected": "Getrennt", @@ -242,5 +242,33 @@ "mcp_destructive": "Zerstörend", "mcp_transport": "Transport", "mcp_http_mode": "HTTP-Modus", + "mcp_sampling_mock": "Mock", + "mcp_sampling_manual": "Manuell", + "mcp_add_root": "Root hinzufügen", + "mcp_remove_root": "Root entfernen", + "mcp_client_connection": "Verbindung", + "mcp_client_callbacks": "Server-Rückrufe", + "mcp_client_reconnect": "Neu verbinden, um Verbindungs- und Rückrufänderungen zu übernehmen.", + "mcp_root_uri": "URI", + "mcp_root_name": "Name", + "mcp_roots_empty": "Noch keine Ordner", + "mcp_auto_sampling": "Sampling automatisch beantworten", + "mcp_auto_sampling_explain": "Mock-Antwort, oder in Antwort prüfen und bearbeiten.", + "mcp_llm_url": "LLM-URL", + "mcp_llm_token": "API-Token", + "mcp_llm_max_tokens": "Max. Tokens", + "mcp_sampling_review_request": "Sampling-Anfrage prüfen", + "mcp_sampling_approve_generate": "Erzeugen bestätigen", + "mcp_sampling_review_result": "Sampling-Ergebnis prüfen", + "mcp_sampling_approve_send": "Senden bestätigen", + "mcp_sampling_content": "Inhalt", + "mcp_sampling_role": "Rolle", + "mcp_sampling_model": "Modell", + "mcp_sampling_stop_reason": "Stop-Grund", + "mcp_sampling_generate_error": "Erzeugen fehlgeschlagen", + "mcp_sampling_generating": "Erzeugen…", + "mcp_elicit_form": "Elicitation", + "mcp_elicit_accept": "Akzeptieren", + "mcp_elicit_decline": "Ablehnen", "new_mcp_connection": "Neue MCP-Verbindung" } diff --git a/ui-shared/src/commonMain/resources/i18n/en.json b/ui-shared/src/commonMain/resources/i18n/en.json index e01e21a..8a5ba48 100644 --- a/ui-shared/src/commonMain/resources/i18n/en.json +++ b/ui-shared/src/commonMain/resources/i18n/en.json @@ -265,9 +265,9 @@ "mcp_activity_hint": "Live events the server pushes (progress, logs, resource updates) plus every JSON-RPC message sent or received. Use this to debug the connection.", "mcp_activity_empty": "Nothing yet. Connect and run a tool to see traffic here.", "mcp_client_hint": "How this client answers if the server calls back. Sampling = the server asks ReqLab for a model reply. Roots = folders you allow. Elicitation = the server asking you to fill a form.", - "mcp_sampling_explain": "When the server asks for a model completion, ReqLab currently returns a mock reply (this is a test harness).", + "mcp_sampling_explain": "When the server asks for a model completion, auto-respond sends a mock reply. Turn it off to review the request, generate with an LLM, and edit the result in the Response pane.", "mcp_roots_explain": "Workspace folders this client will report if the server asks for roots/list.", - "mcp_elicit_explain": "If the server asks a question, auto-accept replies without showing a form.", + "mcp_elicit_explain": "Skip the form, or fill it in Response.", "mcp_tool_error": "Tool error", "mcp_tool_ok": "Success", "mcp_disconnected": "Disconnected", @@ -296,5 +296,33 @@ "mcp_destructive": "Destructive", "mcp_transport": "Transport", "mcp_http_mode": "HTTP mode", + "mcp_sampling_mock": "Mock", + "mcp_sampling_manual": "Manual", + "mcp_add_root": "Add root", + "mcp_remove_root": "Remove root", + "mcp_client_connection": "Connection", + "mcp_client_callbacks": "Server callbacks", + "mcp_client_reconnect": "Reconnect to apply connection and callback changes.", + "mcp_root_uri": "URI", + "mcp_root_name": "Name", + "mcp_roots_empty": "No folders yet", + "mcp_auto_sampling": "Auto-respond sampling", + "mcp_auto_sampling_explain": "Mock reply, or review and edit in Response.", + "mcp_llm_url": "LLM URL", + "mcp_llm_token": "API token", + "mcp_llm_max_tokens": "Max tokens", + "mcp_sampling_review_request": "Review sampling request", + "mcp_sampling_approve_generate": "Approve generate", + "mcp_sampling_review_result": "Review sampling result", + "mcp_sampling_approve_send": "Approve send", + "mcp_sampling_content": "Content", + "mcp_sampling_role": "Role", + "mcp_sampling_model": "Model", + "mcp_sampling_stop_reason": "Stop reason", + "mcp_sampling_generate_error": "Could not generate", + "mcp_sampling_generating": "Generating…", + "mcp_elicit_form": "Elicitation", + "mcp_elicit_accept": "Accept", + "mcp_elicit_decline": "Decline", "new_mcp_connection": "New MCP Connection" } diff --git a/ui-shared/src/commonMain/resources/i18n/es.json b/ui-shared/src/commonMain/resources/i18n/es.json index a821ffc..ab7b969 100644 --- a/ui-shared/src/commonMain/resources/i18n/es.json +++ b/ui-shared/src/commonMain/resources/i18n/es.json @@ -211,9 +211,9 @@ "mcp_activity_hint": "Eventos en vivo del servidor (progreso, logs, actualizaciones) y cada mensaje JSON-RPC enviado o recibido.", "mcp_activity_empty": "Aún no hay tráfico. Conecta y ejecuta una herramienta.", "mcp_client_hint": "Cómo responde este cliente si el servidor llama hacia atrás. Sampling = el servidor pide una respuesta de modelo. Roots = carpetas permitidas. Elicitation = el servidor pide un formulario.", - "mcp_sampling_explain": "Si el servidor pide una completion, ReqLab devuelve ahora una respuesta simulada.", + "mcp_sampling_explain": "Si el servidor pide una completion, auto-responder envía una respuesta simulada. Desactívalo para revisar la petición, generar con un LLM y editar el resultado en Respuesta.", "mcp_roots_explain": "Carpetas que este cliente reportará si el servidor pide roots/list.", - "mcp_elicit_explain": "Si el servidor hace una pregunta, auto-aceptar responde sin mostrar un formulario.", + "mcp_elicit_explain": "Omitir el formulario, o rellenarlo en Respuesta.", "mcp_tool_error": "Error de herramienta", "mcp_tool_ok": "Correcto", "mcp_disconnected": "Desconectado", @@ -242,5 +242,33 @@ "mcp_destructive": "Destructivo", "mcp_transport": "Transporte", "mcp_http_mode": "Modo HTTP", + "mcp_sampling_mock": "Simulado", + "mcp_sampling_manual": "Manual", + "mcp_add_root": "Añadir root", + "mcp_remove_root": "Quitar root", + "mcp_client_connection": "Conexión", + "mcp_client_callbacks": "Devoluciones del servidor", + "mcp_client_reconnect": "Vuelve a conectar para aplicar los cambios de conexión y de devolución.", + "mcp_root_uri": "URI", + "mcp_root_name": "Nombre", + "mcp_roots_empty": "Aún no hay carpetas", + "mcp_auto_sampling": "Auto-responder sampling", + "mcp_auto_sampling_explain": "Respuesta simulada, o revisar y editar en Respuesta.", + "mcp_llm_url": "URL del LLM", + "mcp_llm_token": "Token de API", + "mcp_llm_max_tokens": "Máx. tokens", + "mcp_sampling_review_request": "Revisar petición de sampling", + "mcp_sampling_approve_generate": "Aprobar generación", + "mcp_sampling_review_result": "Revisar resultado de sampling", + "mcp_sampling_approve_send": "Aprobar envío", + "mcp_sampling_content": "Contenido", + "mcp_sampling_role": "Rol", + "mcp_sampling_model": "Modelo", + "mcp_sampling_stop_reason": "Motivo de parada", + "mcp_sampling_generate_error": "No se pudo generar", + "mcp_sampling_generating": "Generando…", + "mcp_elicit_form": "Elicitación", + "mcp_elicit_accept": "Aceptar", + "mcp_elicit_decline": "Rechazar", "new_mcp_connection": "Nueva conexión MCP" } diff --git a/ui-shared/src/commonMain/resources/i18n/fr.json b/ui-shared/src/commonMain/resources/i18n/fr.json index 0ef5cfb..f64103b 100644 --- a/ui-shared/src/commonMain/resources/i18n/fr.json +++ b/ui-shared/src/commonMain/resources/i18n/fr.json @@ -211,9 +211,9 @@ "mcp_activity_hint": "Événements envoyés par le serveur (progression, journaux, mises à jour) et chaque message JSON-RPC émis ou reçu.", "mcp_activity_empty": "Rien pour l'instant. Connectez-vous et exécutez un outil.", "mcp_client_hint": "Comment ce client répond si le serveur rappelle. Sampling = le serveur demande une réponse de modèle. Roots = dossiers autorisés. Elicitation = le serveur pose un formulaire.", - "mcp_sampling_explain": "Si le serveur demande une completion, ReqLab renvoie actuellement une réponse simulée.", + "mcp_sampling_explain": "Si le serveur demande une completion, la réponse auto envoie une réponse simulée. Désactivez pour revoir la requête, générer avec un LLM et modifier le résultat dans Réponse.", "mcp_roots_explain": "Dossiers que ce client déclarera si le serveur demande roots/list.", - "mcp_elicit_explain": "Si le serveur pose une question, l'acceptation auto répond sans afficher de formulaire.", + "mcp_elicit_explain": "Ignorer le formulaire, ou le remplir dans Réponse.", "mcp_tool_error": "Erreur d'outil", "mcp_tool_ok": "Succès", "mcp_disconnected": "Déconnecté", @@ -242,5 +242,33 @@ "mcp_destructive": "Destructif", "mcp_transport": "Transport", "mcp_http_mode": "Mode HTTP", + "mcp_sampling_mock": "Mock", + "mcp_sampling_manual": "Manuel", + "mcp_add_root": "Ajouter une root", + "mcp_remove_root": "Retirer la root", + "mcp_client_connection": "Connexion", + "mcp_client_callbacks": "Rappels du serveur", + "mcp_client_reconnect": "Reconnectez-vous pour appliquer les changements de connexion et de rappel.", + "mcp_root_uri": "URI", + "mcp_root_name": "Nom", + "mcp_roots_empty": "Aucun dossier pour l’instant", + "mcp_auto_sampling": "Répondre auto au sampling", + "mcp_auto_sampling_explain": "Réponse simulée, ou revoir et modifier dans Réponse.", + "mcp_llm_url": "URL du LLM", + "mcp_llm_token": "Jeton API", + "mcp_llm_max_tokens": "Tokens max", + "mcp_sampling_review_request": "Revoir la requête de sampling", + "mcp_sampling_approve_generate": "Approuver la génération", + "mcp_sampling_review_result": "Revoir le résultat de sampling", + "mcp_sampling_approve_send": "Approuver l'envoi", + "mcp_sampling_content": "Contenu", + "mcp_sampling_role": "Rôle", + "mcp_sampling_model": "Modèle", + "mcp_sampling_stop_reason": "Raison d'arrêt", + "mcp_sampling_generate_error": "Génération impossible", + "mcp_sampling_generating": "Génération…", + "mcp_elicit_form": "Élicitation", + "mcp_elicit_accept": "Accepter", + "mcp_elicit_decline": "Refuser", "new_mcp_connection": "Nouvelle connexion MCP" } diff --git a/ui-shared/src/commonTest/kotlin/com/reqlab/ui/shared/i18n/I18nCompletenessTest.kt b/ui-shared/src/commonTest/kotlin/com/reqlab/ui/shared/i18n/I18nCompletenessTest.kt index b4cc9b6..35e5919 100644 --- a/ui-shared/src/commonTest/kotlin/com/reqlab/ui/shared/i18n/I18nCompletenessTest.kt +++ b/ui-shared/src/commonTest/kotlin/com/reqlab/ui/shared/i18n/I18nCompletenessTest.kt @@ -65,12 +65,15 @@ class I18nCompletenessTest { "mcp_connection", "new_mcp_connection", "mcp_tools", "mcp_resources", "mcp_prompts", "mcp_url", "mcp_command", "mcp_notifications", "mcp_timeline", "mcp_client", "mcp_call_tool", "mcp_stdio_confirm", "mcp_roots", "mcp_sampling_mode", "mcp_auto_elicit", + "mcp_auto_sampling", "mcp_llm_url", "mcp_sampling_approve_send", "mcp_elicit_accept", "mcp_run", "mcp_arguments", "mcp_response", "mcp_activity", "mcp_connect_hint", "mcp_tools_hint", "mcp_resources_hint", "mcp_prompts_hint", "mcp_activity_hint", "mcp_client_hint", "mcp_tool_ok", "mcp_tool_error", "mcp_connected", "mcp_disconnected", "mcp_session_id", "mcp_subscribe", "mcp_unsubscribe", "mcp_subscribed", "mcp_subscribe_explain", "mcp_response_in_viewer", "mcp_activity_expand", "mcp_form", "mcp_json", "mcp_search_tools", "mcp_reconnect", + "mcp_client_connection", "mcp_client_callbacks", "mcp_client_reconnect", + "mcp_root_uri", "mcp_root_name", "mcp_roots_empty", ) // ── Completeness ── diff --git a/ui-shared/src/commonTest/kotlin/com/reqlab/ui/shared/mcp/McpSessionStateTest.kt b/ui-shared/src/commonTest/kotlin/com/reqlab/ui/shared/mcp/McpSessionStateTest.kt index ff039da..3e4d15c 100644 --- a/ui-shared/src/commonTest/kotlin/com/reqlab/ui/shared/mcp/McpSessionStateTest.kt +++ b/ui-shared/src/commonTest/kotlin/com/reqlab/ui/shared/mcp/McpSessionStateTest.kt @@ -2,6 +2,7 @@ package com.reqlab.ui.shared.mcp import com.reqlab.core.model.McpConnectionConfig import com.reqlab.core.model.McpConnectionState +import com.reqlab.core.model.McpSamplingMode import com.reqlab.core.model.McpTransportType import com.reqlab.core.network.mcp.McpClient import com.reqlab.core.network.mcp.NdjsonStdioTransport @@ -231,6 +232,25 @@ class McpSessionStateTest { assertTrue(!pretty.contains("null"), pretty) } + @Test + fun pretty_wire_json_unwraps_callback_payload_string() { + val raw = """{"jsonrpc":"2.0","id":6,"result":{"content":[{"type":"text","text":"{\"jsonrpc\":\"2.0\",\"id\":\"srv-sample\",\"result\":{\"role\":\"assistant\",\"content\":{\"type\":\"text\",\"text\":\"mock reply from ReqLab\"},\"model\":\"mock\",\"stopReason\":\"endTurn\"}}"}],"isError":false}}""" + val pretty = mcpPrettyWireJson(raw) + assertTrue(pretty.contains("\"text\": {") || pretty.contains("\"text\":{"), pretty) + assertTrue(pretty.contains("\"srv-sample\""), pretty) + assertTrue(pretty.contains("mock reply from ReqLab"), pretty) + assertTrue(!pretty.contains("\\\"jsonrpc\\\""), pretty) + } + + @Test + fun pretty_wire_json_unwraps_roots_callback_string() { + val raw = """{"jsonrpc":"2.0","id":5,"result":{"content":[{"type":"text","text":"{\"jsonrpc\":\"2.0\",\"id\":\"srv-roots\",\"result\":{\"roots\":[{\"uri\":\"file:///tmp/reqlab\",\"name\":\"tmp\"}]}}"}],"isError":false}}""" + val pretty = mcpPrettyWireJson(raw) + assertTrue(pretty.contains("\"srv-roots\""), pretty) + assertTrue(pretty.contains("file:///tmp/reqlab"), pretty) + assertTrue(pretty.contains("\"text\": {") || pretty.contains("\"text\":{"), pretty) + } + @Test fun default_args_json_from_schema() { val schema = buildJsonObject { @@ -307,4 +327,88 @@ class McpSessionStateTest { assertTrue(session.isReconnectNeeded(cfg.copy(command = "y"))) session.disconnect() } + + @Test + fun tools_call_timeout_clears_pending_sampling_overlay() = runTest { + val inbound = Channel(Channel.UNLIMITED) + val written = Channel(Channel.UNLIMITED) + val transport = NdjsonStdioTransport(this, inbound, { written.send(it) }) + val session = McpSessionState(this) { scope -> + McpClient(scope, stdioFactory = { transport }, callTimeoutMs = 5_000) + } + session.confirmStdio = true + val connect = async { + session.connect( + McpConnectionConfig( + transport = McpTransportType.STDIO, + command = "x", + samplingMode = McpSamplingMode.MANUAL, + ), + ) + } + written.receive() + inbound.send("""{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{"tools":{}},"serverInfo":{"name":"s","version":"1"}}}""") + written.receive() + written.receive() + inbound.send("""{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"echo","inputSchema":{"type":"object"}}]}}""") + connect.await() + + val call = async { session.callSelectedTool("echo", null) } + written.receive() + inbound.send("""{"jsonrpc":"2.0","id":"srv-1","method":"sampling/createMessage","params":{"messages":[],"maxTokens":8}}""") + withTimeout(5_000) { + session.pendingSampling.first { it is McpPendingSampling.ReviewRequest } + } + inbound.send( + """{"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"Timed out waiting for client reply to srv-sample"}],"isError":true}}""", + ) + call.await() + assertEquals(null, session.pendingSampling.value) + val op = session.lastOperation.value + assertTrue(op != null && op.isError) + assertTrue(op!!.bodyJson.contains("Timed out waiting for client reply")) + session.disconnect() + } + + @Test + fun clear_logs_empties_activity_without_disconnect() = runTest { + val inbound = Channel(Channel.UNLIMITED) + val written = Channel(Channel.UNLIMITED) + val transport = NdjsonStdioTransport(this, inbound, { written.send(it) }) + val session = McpSessionState(this) { scope -> + McpClient(scope, stdioFactory = { transport }, callTimeoutMs = 5_000) + } + session.confirmStdio = true + val job = async { + session.connect(McpConnectionConfig(transport = McpTransportType.STDIO, command = "x")) + } + written.receive() + inbound.send("""{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{"tools":{}},"serverInfo":{"name":"s","version":"1"}}}""") + written.receive() + written.receive() + inbound.send("""{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"echo","inputSchema":{"type":"object"}}]}}""") + job.await() + withTimeout(5_000) { + session.logs.first { it.isNotEmpty() } + } + assertTrue(session.logs.value.isNotEmpty()) + session.clearLogs() + assertTrue(session.logs.value.isEmpty()) + assertEquals(McpConnectionState.CONNECTED, session.connectionState.value) + session.disconnect() + } + + @Test + fun fingerprint_includes_sampling_roots_and_elicitation() { + val base = McpConnectionConfig(url = "http://localhost/mcp") + assertTrue(connectionFingerprint(base) != connectionFingerprint(base.copy(samplingMode = com.reqlab.core.model.McpSamplingMode.MANUAL))) + assertTrue(connectionFingerprint(base) != connectionFingerprint(base.copy(samplingForwardUrl = "http://localhost:8080/v1/chat/completions"))) + assertTrue(connectionFingerprint(base) != connectionFingerprint(base.copy(samplingForwardToken = "secret"))) + assertTrue(connectionFingerprint(base) != connectionFingerprint(base.copy(autoRespondElicitation = false))) + assertTrue( + connectionFingerprint(base) != connectionFingerprint( + base.copy(roots = listOf(com.reqlab.core.model.McpRoot("file:///tmp/reqlab", "tmp"))), + ), + ) + } } diff --git a/ui-shared/src/commonTest/kotlin/com/reqlab/ui/shared/persistence/McpWorkspaceBackwardCompatTest.kt b/ui-shared/src/commonTest/kotlin/com/reqlab/ui/shared/persistence/McpWorkspaceBackwardCompatTest.kt index a7d100f..2f1651b 100644 --- a/ui-shared/src/commonTest/kotlin/com/reqlab/ui/shared/persistence/McpWorkspaceBackwardCompatTest.kt +++ b/ui-shared/src/commonTest/kotlin/com/reqlab/ui/shared/persistence/McpWorkspaceBackwardCompatTest.kt @@ -49,4 +49,44 @@ class McpWorkspaceBackwardCompatTest { assertEquals("reqlab-mcp-token", mcp!!.auth.params["token"]) assertEquals("reqlab-key", mcp.headers.single { it.key == "X-Api-Key" }.value) } + + @Test + fun mcp_client_fields_round_trip() { + val json = """ + {"type":"reqLabWorkspace","version":"1.0", + "collections":[{"type":"reqLabCollection","version":"1.0","name":"Mcp", + "folders":[],"requests":[{ + "name":"Client MCP", + "kind":"MCP", + "method":"POST", + "url":"http://localhost/mcp", + "mcpTransport":"STREAMABLE_HTTP", + "mcpSamplingMode":"MANUAL", + "mcpSamplingForwardUrl":"http://localhost:8080/v1/chat/completions", + "mcpSamplingForwardToken":"llm-test-key", + "mcpSamplingMaxTokens":128, + "mcpAutoRespondElicitation":false, + "mcpRoots":[{"uri":"file:///tmp/reqlab","name":"tmp"}] + }]}], + "environments":[]} + """.trimIndent() + val state = AppState(openDefaultTab = false, withDemoData = false) + ImportExportRepository.importWorkspaceFromString(state, json) + val req = state.collections.first().children.first { !it.isFolder } + val mcp = req.mcpConfig!! + assertEquals(com.reqlab.core.model.McpSamplingMode.MANUAL, mcp.samplingMode) + assertEquals("http://localhost:8080/v1/chat/completions", mcp.samplingForwardUrl) + assertEquals("llm-test-key", mcp.samplingForwardToken) + assertEquals(128, mcp.samplingMaxTokens) + assertEquals(false, mcp.autoRespondElicitation) + assertEquals("file:///tmp/reqlab", mcp.roots.single().uri) + assertEquals("tmp", mcp.roots.single().name) + val exported = ImportExportRepository.exportWorkspaceToString(state) + assertTrue(exported.contains("MANUAL")) + assertTrue(exported.contains("file:///tmp/reqlab")) + assertTrue(exported.contains("mcpSamplingForwardUrl")) + assertTrue(exported.contains("mcpSamplingForwardToken")) + assertTrue(exported.contains("mcpSamplingMaxTokens")) + assertTrue(exported.contains("mcpAutoRespondElicitation")) + } } diff --git a/ui-shared/src/commonTest/kotlin/com/reqlab/ui/shared/platform/FileChooserMemoryTest.kt b/ui-shared/src/commonTest/kotlin/com/reqlab/ui/shared/platform/FileChooserMemoryTest.kt new file mode 100644 index 0000000..cc02683 --- /dev/null +++ b/ui-shared/src/commonTest/kotlin/com/reqlab/ui/shared/platform/FileChooserMemoryTest.kt @@ -0,0 +1,69 @@ +package com.reqlab.ui.shared.platform + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class FileChooserMemoryTest { + @Test + fun parent_unix_file() { + assertEquals("/Users/me/collections", FileChooserMemory.parentPath("/Users/me/collections/api.json")) + } + + @Test + fun parent_unix_root_child() { + assertEquals("/", FileChooserMemory.parentPath("/api.json")) + } + + @Test + fun parent_windows_file() { + assertEquals("C:\\Users\\me\\collections", FileChooserMemory.parentPath("C:\\Users\\me\\collections\\api.json")) + } + + @Test + fun parent_windows_drive_root_child() { + assertEquals("C:\\", FileChooserMemory.parentPath("C:\\api.json")) + } + + @Test + fun opens_stored_directory_when_it_still_exists() { + assertEquals( + "/Users/me/imports", + FileChooserMemory.directoryToOpen("/Users/me/imports") { it == "/Users/me/imports" }, + ) + } + + @Test + fun opens_parent_when_stored_path_was_a_file() { + val dirs = setOf("/Users/me/imports") + assertEquals( + "/Users/me/imports", + FileChooserMemory.directoryToOpen("/Users/me/imports/gone.json") { it in dirs }, + ) + } + + @Test + fun ignores_missing_directory_so_os_default_is_used() { + assertNull(FileChooserMemory.directoryToOpen("D:\\removed") { false }) + assertNull(FileChooserMemory.directoryToOpen(null) { true }) + assertNull(FileChooserMemory.directoryToOpen(" ") { true }) + } + + @Test + fun remembers_parent_of_selected_file() { + val dirs = setOf("/home/me/qa-tests/fixtures") + assertEquals( + "/home/me/qa-tests/fixtures", + FileChooserMemory.directoryToRemember("/home/me/qa-tests/fixtures/reqlab-test-collection.json") { it in dirs }, + ) + } + + @Test + fun remembers_windows_directory_of_selected_file() { + val dirs = setOf("C:\\Users\\me\\Docs") + assertEquals( + "C:\\Users\\me\\Docs", + FileChooserMemory.directoryToRemember("C:\\Users\\me\\Docs\\workspace.json") { it in dirs }, + ) + } +} diff --git a/ui-shared/src/desktopMain/kotlin/com/reqlab/ui/shared/platform/PlatformApi.desktop.kt b/ui-shared/src/desktopMain/kotlin/com/reqlab/ui/shared/platform/PlatformApi.desktop.kt index 422b88c..a038d25 100644 --- a/ui-shared/src/desktopMain/kotlin/com/reqlab/ui/shared/platform/PlatformApi.desktop.kt +++ b/ui-shared/src/desktopMain/kotlin/com/reqlab/ui/shared/platform/PlatformApi.desktop.kt @@ -54,17 +54,22 @@ actual fun Modifier.platformResizeCursorStyle(isHorizontal: Boolean): Modifier = actual fun pickFileForImport(onResult: (String) -> Unit) { val chooser = JFileChooser() + chooser.applyRememberedDirectory() chooser.fileFilter = FileNameExtensionFilter("JSON files", "json") if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) { - runCatching { chooser.selectedFile.readText() } + val selected = chooser.selectedFile + rememberChooserDirectory(selected) + runCatching { selected.readText() } .onSuccess { onResult(it) } } } actual fun pickBinaryFileForRequest(onResult: (PickedBinaryFile) -> Unit) { val chooser = JFileChooser() + chooser.applyRememberedDirectory() if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) { val selected = chooser.selectedFile + rememberChooserDirectory(selected) runCatching { selected.readBytes() } .onSuccess { bytes -> val base64 = Base64.getEncoder().encodeToString(bytes) @@ -75,15 +80,31 @@ actual fun pickBinaryFileForRequest(onResult: (PickedBinaryFile) -> Unit) { actual fun saveFileForExport(content: String, defaultFilename: String) { val chooser = JFileChooser() - chooser.selectedFile = File(defaultFilename) + chooser.applyRememberedDirectory() + chooser.selectedFile = File(chooser.currentDirectory, defaultFilename) chooser.fileFilter = FileNameExtensionFilter("JSON files", "json") if (chooser.showSaveDialog(null) == JFileChooser.APPROVE_OPTION) { var file = chooser.selectedFile if (!file.name.endsWith(".json")) file = File(file.absolutePath + ".json") + rememberChooserDirectory(file) runCatching { file.writeText(content) } } } +private fun JFileChooser.applyRememberedDirectory() { + val path = FileChooserMemory.directoryToOpen(PlatformStorage.getString(FileChooserMemory.LAST_DIR_KEY)) { candidate -> + File(candidate).isDirectory + } + if (path != null) currentDirectory = File(path) +} + +private fun rememberChooserDirectory(file: File) { + val path = FileChooserMemory.directoryToRemember(file.absolutePath) { candidate -> + File(candidate).isDirectory + } + if (path != null) PlatformStorage.putString(FileChooserMemory.LAST_DIR_KEY, path) +} + actual object PlatformStorage { private val prefs: Preferences = Preferences.userNodeForPackage(PlatformStorage::class.java) From 1a0c6295cc9a21fef3e9397b1fafcbd8b01aa609 Mon Sep 17 00:00:00 2001 From: Sanjay Sharma Date: Sun, 30 Aug 2026 23:46:05 +0530 Subject: [PATCH 3/5] Add SSE changes --- DEVELOPMENT.md | 2 + FEATURES.md | 2 +- README.md | 4 +- docs/testing.md | 3 + docs/tests.md | 10 +- qa-tests/fixtures/reqlab-test-collection.json | 37 +++++++ .../com/reqlab/qa/SampleCollectionE2ETest.kt | 33 +++++++ .../kotlin/com/reqlab/qa/SseApiE2ETest.kt | 99 +++++++++++++++++++ .../kotlin/com/reqlab/server/Application.kt | 30 ++++++ .../reqlab/ui/desktop/NewFeaturesUiTest.kt | 74 +++++++++++++- .../reqlab/ui/desktop/UiRegressionFixTest.kt | 2 + .../ImportExportFixturesIntegrationTest.kt | 20 ++++ .../desktop/state/AppStateCollectionTest.kt | 96 ++++++++++++++++++ .../ui/shared/components/RequestTabsBar.kt | 9 +- .../reqlab/ui/shared/components/Sidebar.kt | 71 +++++++++++-- .../com/reqlab/ui/shared/state/AppState.kt | 65 ++++++++++-- .../src/commonMain/resources/i18n/de.json | 3 +- .../src/commonMain/resources/i18n/en.json | 3 +- .../src/commonMain/resources/i18n/es.json | 3 +- .../src/commonMain/resources/i18n/fr.json | 3 +- .../ui/shared/i18n/I18nCompletenessTest.kt | 2 +- .../reqlab/ui/shared/state/SseAcceptTest.kt | 33 +++++++ 22 files changed, 577 insertions(+), 27 deletions(-) create mode 100644 qa-tests/src/test/kotlin/com/reqlab/qa/SseApiE2ETest.kt create mode 100644 ui-shared/src/commonTest/kotlin/com/reqlab/ui/shared/state/SseAcceptTest.kt diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 4387cd8..c47dce0 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -168,6 +168,8 @@ Available endpoints (selected): | POST | `/v1/chat/ndjson` | Ollama-style NDJSON chat stream | | POST | `/v1/embeddings` | Fixed-length embedding vector | | GET | `/v1/chat/slow` | Delayed non-stream chat completion | +| GET | `/sse` | Finite SSE (`text/event-stream`); `?count=` (default 3), `?delayMs=` | +| POST | `/sse` | Finite SSE; last event echoes a body snippet; same `count` / `delayMs` | | POST | `/mcp` | MCP Streamable HTTP (JSON-RPC) | | POST | `/mcp/auth/bearer` | MCP Bearer `reqlab-mcp-token` | | POST | `/mcp/auth/basic` | MCP Basic `admin` / `password` | diff --git a/FEATURES.md b/FEATURES.md index e3b2c32..a51e0e8 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -24,7 +24,7 @@ ReqLab supports end-to-end API testing with: - Request body support for JSON, GraphQL, form-style payloads, and raw text - Authentication modes: None, Basic, Bearer, API Key, JWT (OAuth2 planned) - Retry controls and timeout behavior -- HTTP streaming for SSE (`text/event-stream`) and NDJSON (OpenAI-style `"stream": true`) +- HTTP streaming for SSE (`text/event-stream`) and NDJSON (OpenAI-style `"stream": true`). Items with `Accept: text/event-stream` show an **SSE** badge in the HTTP method color; folder ⋮ → **New SSE Request**. - **MCP client** for Streamable HTTP (2025-06-18), legacy HTTP+SSE, and desktop stdio. Tools (Form/JSON arguments), resources (read + subscribe), prompts, Activity JSON-RPC inspector, sampling/roots/elicitation, and auth (None, Basic, Bearer, API Key, JWT) match the REST workspace. See [docs/mcp.md](docs/mcp.md). - Copy request as `curl` diff --git a/README.md b/README.md index d99b6c4..e4645b9 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ You can import these sample fixtures from the repo: - Collection sample: [qa-tests/fixtures/reqlab-test-collection.json](qa-tests/fixtures/reqlab-test-collection.json) - Environment sample: [qa-tests/fixtures/reqlab-test-environment.json](qa-tests/fixtures/reqlab-test-environment.json) -The collection includes an **LLM (OpenAI-compatible)** folder and an **MCP (Model Context Protocol)** folder. Start the sample server, then send **LLM Chat Completions Stream (visible)** to watch a token stream, or open an MCP item and Connect. See [docs/mcp.md](docs/mcp.md). +The collection includes an **LLM (OpenAI-compatible)** folder, an **SSE** folder, and an **MCP (Model Context Protocol)** folder. Start the sample server, then send **LLM Chat Completions Stream (visible)** or an SSE item to watch events arrive, or open an MCP item and Connect. See [docs/mcp.md](docs/mcp.md). ## Features @@ -68,7 +68,7 @@ The collection includes an **LLM (OpenAI-compatible)** folder and an **MCP (Mode - Body types: JSON, GraphQL, form-data, x-www-form-urlencoded, raw text, binary - Auth: None, Basic, Bearer Token, API Key, JWT - Retry controls and per-request timeout -- HTTP streaming: SSE (`text/event-stream`) and NDJSON on a single request (OpenAI-style `"stream": true`) +- HTTP streaming: SSE (`text/event-stream`) and NDJSON on a single request (OpenAI-style `"stream": true`). Collection items with `Accept: text/event-stream` show an **SSE** badge in the HTTP method color; folder ⋮ → **New SSE Request** - Copy request as `curl` ### MCP diff --git a/docs/testing.md b/docs/testing.md index b250ca0..5536ee9 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -156,6 +156,7 @@ JVM integration and E2E tests against the running `sample-server`. - `NetworkClientWsAndMultipartE2ETest` — WebSocket connect/send/receive/disconnect; multipart upload - `SampleCollectionE2ETest` — scripted collection run against the sample collection fixture - `LlmApiE2ETest` — OpenAI-compatible chat, SSE/NDJSON assembly, embeddings, tools, JSON mode, error statuses, visible `demo=true` stream +- `SseApiE2ETest` — generic `GET`/`POST /sse` (`text/event-stream`), finite ping events, POST body echo, `?count=` - `McpHttpE2ETest` — MCP Streamable HTTP handshake, tools, resources, prompts, subscribe notifications, Bearer+API key auth, variable interpolation, OAuth Bearer, GET SSE notifications - `ScriptingDocsIntegrationTest` — scripting doc examples execute correctly end-to-end - `WebSocketE2ETest` — WebSocket lifecycle: connect, send, receive, close, reconnect @@ -190,6 +191,8 @@ JVM integration and E2E tests against the running `sample-server`. - `POST /v1/chat/ndjson` - `POST /v1/embeddings` - `GET /v1/chat/slow?ms=` +- `GET /sse` (`?count=`, `?delayMs=`) +- `POST /sse` (echoes a body snippet as the last event) - `WS /ws` Simulation parameters used by current endpoints: diff --git a/docs/tests.md b/docs/tests.md index 03c3ec0..313d5f6 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -37,6 +37,12 @@ LLM streaming E2E against the embedded sample-server: ./gradlew :qa-tests:jvmTest --tests com.reqlab.qa.LlmApiE2ETest ``` +Generic SSE (`GET`/`POST /sse`) E2E: + +```bash +./gradlew :qa-tests:jvmTest --tests com.reqlab.qa.SseApiE2ETest +``` + MCP Streamable HTTP, legacy HTTP+SSE, stdio, sampling/roots/elicitation, LLM generate, and OAuth E2E: ```bash @@ -51,6 +57,8 @@ Import [qa-tests/fixtures/reqlab-test-collection.json](../qa-tests/fixtures/reql The JS collection validator skips `STDIO` and `LEGACY_2024_11_05`. Kotlin e2e (`:qa-tests:jvmTest --tests com.reqlab.qa.McpHttpE2ETest`) is the protocol source of truth. Product guide: [docs/mcp.md](mcp.md). Mock routes and PATH shim: [DEVELOPMENT.md](../DEVELOPMENT.md). +Folder **SSE** is generic `text/event-stream` (`GET`/`POST /sse`). The **LLM (OpenAI-compatible)** folder remains OpenAI chat completions. + Run full project checks: ```bash @@ -68,7 +76,7 @@ Run full project checks: ## Coverage Highlights - HTTP methods, body types (JSON, form-data, urlencoded, raw, binary), auth (Basic, Bearer, API Key) -- SSE/NDJSON streaming and OpenAI-compatible `/v1` chat mocks (including `?demo=true`) +- SSE/NDJSON streaming, generic `/sse` mocks, and OpenAI-compatible `/v1` chat mocks (including `?demo=true`) - Variable interpolation (`{{var}}`), scope precedence, and scripting runtime (pre-request, post-response, assertions, `response.llm.*`) - Retry/error handling and WebSocket lifecycle (connect, send, receive, disconnect, reconnect) - Settings dialog persistence and impact on request behavior diff --git a/qa-tests/fixtures/reqlab-test-collection.json b/qa-tests/fixtures/reqlab-test-collection.json index bf2f2f3..c3bb707 100644 --- a/qa-tests/fixtures/reqlab-test-collection.json +++ b/qa-tests/fixtures/reqlab-test-collection.json @@ -1014,6 +1014,43 @@ } ] }, + { + "name": "SSE", + "folders": [], + "requests": [ + { + "name": "SSE GET events", + "method": "GET", + "url": "{{baseUrl}}/sse", + "headers": [ + { "key": "Accept", "value": "text/event-stream" } + ], + "testScript": "reqlab.test(\"status is 200\", function() {\n reqlab.expect(reqlab.response.code).to.equal(200);\n});\nreqlab.test(\"has stream events\", function() {\n reqlab.expect(reqlab.response.streamEvents.length).to.be.above(0);\n});\nreqlab.test(\"ping event\", function() {\n reqlab.expect(reqlab.response.streamEvents.join(\"\\n\")).to.include(\"ping-0\");\n});" + }, + { + "name": "SSE GET delayed", + "method": "GET", + "url": "{{baseUrl}}/sse?delayMs=50&count=3", + "headers": [ + { "key": "Accept", "value": "text/event-stream" } + ], + "testScript": "reqlab.test(\"status is 200\", function() {\n reqlab.expect(reqlab.response.code).to.equal(200);\n});\nreqlab.test(\"three ping events\", function() {\n reqlab.expect(reqlab.response.streamEvents.length).to.equal(3);\n});\nreqlab.test(\"last ping\", function() {\n reqlab.expect(reqlab.response.streamEvents.join(\"\\n\")).to.include(\"ping-2\");\n});" + }, + { + "name": "SSE POST events", + "method": "POST", + "url": "{{baseUrl}}/sse", + "headers": [ + { "key": "Accept", "value": "text/event-stream" } + ], + "body": { + "type": "JSON", + "content": "{\n \"message\": \"hello-sse\"\n}" + }, + "testScript": "reqlab.test(\"status is 200\", function() {\n reqlab.expect(reqlab.response.code).to.equal(200);\n});\nreqlab.test(\"has stream events\", function() {\n reqlab.expect(reqlab.response.streamEvents.length).to.be.above(0);\n});\nreqlab.test(\"echoes body\", function() {\n reqlab.expect(reqlab.response.streamEvents.join(\"\\n\")).to.include(\"hello-sse\");\n});" + } + ] + }, { "name": "MCP (Model Context Protocol)", "folders": [], diff --git a/qa-tests/src/test/kotlin/com/reqlab/qa/SampleCollectionE2ETest.kt b/qa-tests/src/test/kotlin/com/reqlab/qa/SampleCollectionE2ETest.kt index 900f4ee..24945a8 100644 --- a/qa-tests/src/test/kotlin/com/reqlab/qa/SampleCollectionE2ETest.kt +++ b/qa-tests/src/test/kotlin/com/reqlab/qa/SampleCollectionE2ETest.kt @@ -764,4 +764,37 @@ class SampleCollectionE2ETest { assertEquals("GET", body["method"]?.jsonPrimitive?.content) } } + + // ========================================================================= + // SSE + // ========================================================================= + + @Test + fun sse_get_events() { + runBlocking { + val r = client.get("$baseUrl/sse") { + header("Accept", "text/event-stream") + } + assertEquals(HttpStatusCode.OK, r.status) + assertTrue(r.contentType()?.toString().orEmpty().contains("text/event-stream")) + val body = r.bodyAsText() + assertTrue("ping-0" in body) + assertTrue("data:" in body) + } + } + + @Test + fun sse_post_events() { + runBlocking { + val r = client.post("$baseUrl/sse") { + header("Accept", "text/event-stream") + contentType(ContentType.Application.Json) + setBody("""{"message":"hello-sse"}""") + } + assertEquals(HttpStatusCode.OK, r.status) + val body = r.bodyAsText() + assertTrue("ping-0" in body) + assertTrue("hello-sse" in body) + } + } } diff --git a/qa-tests/src/test/kotlin/com/reqlab/qa/SseApiE2ETest.kt b/qa-tests/src/test/kotlin/com/reqlab/qa/SseApiE2ETest.kt new file mode 100644 index 0000000..32f52db --- /dev/null +++ b/qa-tests/src/test/kotlin/com/reqlab/qa/SseApiE2ETest.kt @@ -0,0 +1,99 @@ +package com.reqlab.qa + +import com.reqlab.core.model.BodyType +import com.reqlab.core.model.HttpMethodType +import com.reqlab.core.model.KeyValueEntry +import com.reqlab.core.model.RequestBody +import com.reqlab.core.model.RequestDefinition +import com.reqlab.core.network.KtorApiClient +import com.reqlab.core.network.NetworkEvent +import com.reqlab.core.network.RetryPolicy +import com.reqlab.server.module +import io.ktor.server.engine.EmbeddedServer +import io.ktor.server.engine.embeddedServer +import io.ktor.server.netty.Netty +import io.ktor.server.netty.NettyApplicationEngine +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import org.junit.AfterClass +import org.junit.BeforeClass +import org.junit.Test +import java.net.ServerSocket +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class SseApiE2ETest { + + private fun client() = KtorApiClient(retryPolicy = RetryPolicy(maxAttempts = 1), idleTimeoutMs = 10_000) + + private fun request( + method: HttpMethodType, + path: String, + body: String? = null, + ) = RequestDefinition( + id = "sse-${path.hashCode()}", + name = path, + method = method, + url = "$BASE_URL$path", + headers = listOf(KeyValueEntry("Accept", "text/event-stream")), + body = if (body != null) RequestBody(BodyType.JSON, content = body) else RequestBody(), + createdAtEpochMillis = 1L, + updatedAtEpochMillis = 1L, + ) + + @Test + fun get_sse_emits_chunks_and_completes() = runBlocking { + val events = client().execute(request(HttpMethodType.GET, "/sse")).toList() + assertTrue(events.filterIsInstance().isNotEmpty()) + val success = events.last() as NetworkEvent.Success + assertEquals(200, success.response.statusCode) + assertTrue(success.response.contentType.orEmpty().contains("text/event-stream")) + assertTrue(success.response.streamEvents.isNotEmpty()) + assertTrue(success.response.streamEvents.any { it.contains("ping-0") }) + } + + @Test + fun get_sse_count_emits_three_data_events() = runBlocking { + val events = client().execute(request(HttpMethodType.GET, "/sse?count=3")).toList() + val success = events.last() as NetworkEvent.Success + assertEquals(200, success.response.statusCode) + assertEquals(3, success.response.streamEvents.size) + assertEquals(listOf("ping-0", "ping-1", "ping-2"), success.response.streamEvents) + } + + @Test + fun post_sse_echoes_body_snippet_and_completes() = runBlocking { + val events = client().execute( + request(HttpMethodType.POST, "/sse", """{"message":"hello-sse"}"""), + ).toList() + val success = events.last() as NetworkEvent.Success + assertEquals(200, success.response.statusCode) + assertTrue(success.response.streamEvents.isNotEmpty()) + assertTrue(success.response.streamEvents.any { it.contains("hello-sse") }) + } + + companion object { + private var server: EmbeddedServer? = null + private var port: Int = 0 + var BASE_URL: String = "" + + @BeforeClass + @JvmStatic + fun startServer() { + port = ServerSocket(0).use { it.localPort } + BASE_URL = "http://localhost:$port" + server = embeddedServer(Netty, port = port, module = { module() }) + server!!.start(wait = false) + repeat(50) { + runCatching { java.net.Socket("localhost", port).close(); return } + Thread.sleep(100) + } + } + + @AfterClass + @JvmStatic + fun stopServer() { + server?.stop(100, 500) + } + } +} diff --git a/sample-server/src/main/kotlin/com/reqlab/server/Application.kt b/sample-server/src/main/kotlin/com/reqlab/server/Application.kt index fe12824..1d642d5 100644 --- a/sample-server/src/main/kotlin/com/reqlab/server/Application.kt +++ b/sample-server/src/main/kotlin/com/reqlab/server/Application.kt @@ -64,6 +64,7 @@ fun main(args: Array = emptyArray()) { println(" Listening on http://localhost:8080") println(" WebSocket ws://localhost:8080/ws") println(" LLM mock POST /v1/chat/completions (?demo=true for a visible token stream)") + println(" SSE GET/POST /sse") println(" MCP POST /mcp (OAuth-protected: POST /mcp/secure)") println(" MCP legacy GET /mcp/sse") println(" Press Ctrl+C to stop") @@ -1184,6 +1185,16 @@ module.exports = api;""", call.respond(openAiChatCompletionJson(content = reply, finishReason = "stop")) } + // ── Generic SSE (finite event-stream; not OpenAI chat) ────────────── + get("/sse") { + call.respondGenericSse(defaultCount = 3) + } + post("/sse") { + val body = runCatching { call.receiveText() }.getOrDefault("") + val snippet = body.replace("\r", " ").replace("\n", " ").take(200) + call.respondGenericSse(defaultCount = 2, extraLast = "echo:$snippet") + } + // ── WebSocket – echo ─────────────────────────────────────────────── webSocket("/ws") { send(Frame.Text("Connected to ReqLab WebSocket echo server. Send any message and it will be echoed.")) @@ -1200,6 +1211,25 @@ module.exports = api;""", } } +private suspend fun ApplicationCall.respondGenericSse(defaultCount: Int, extraLast: String? = null) { + val count = request.queryParameters["count"]?.toIntOrNull()?.coerceIn(1, 50) ?: defaultCount + val delayMs = request.queryParameters["delayMs"]?.toLongOrNull()?.coerceIn(0, 5_000) ?: 0L + response.header("Cache-Control", "no-cache") + respondTextWriter(contentType = ContentType.parse("text/event-stream")) { + repeat(count) { i -> + if (delayMs > 0L && i > 0) delay(delayMs) + write("data: ping-$i\n\n") + flush() + } + if (!extraLast.isNullOrEmpty()) { + write("data: $extraLast\n\n") + flush() + } + write("data: [DONE]\n\n") + flush() + } +} + private suspend fun ApplicationCall.requireLlmBearer(): Boolean { val header = request.header("Authorization").orEmpty() val token = if (header.startsWith("Bearer ")) header.removePrefix("Bearer ").trim() else "" diff --git a/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/NewFeaturesUiTest.kt b/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/NewFeaturesUiTest.kt index 97c8b9f..3b90529 100644 --- a/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/NewFeaturesUiTest.kt +++ b/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/NewFeaturesUiTest.kt @@ -19,7 +19,10 @@ import com.reqlab.core.model.ResponseMetrics import com.reqlab.ui.shared.state.AppState import org.junit.Rule import org.junit.Test +import kotlin.test.assertEquals import kotlin.test.assertTrue +import com.reqlab.core.model.RequestKind +import com.reqlab.ui.shared.state.hasSseAccept class NewFeaturesUiTest { @@ -304,13 +307,80 @@ class NewFeaturesUiTest { composeRule.onNodeWithTag("collection-add-$firstCollectionId", useUnmergedTree = true).performClick() composeRule.waitForIdle() - // State updated assertTrue(state.collections.first().children.size == before + 1) - // UI shows the new node val newId = state.collections.first().children.last().id composeRule.onNodeWithTag("collection-node-$newId", useUnmergedTree = true).assertIsDisplayed() } + @Test + fun add_request_from_context_menu_adds_node() { + val state = AppState(withDemoData = true) + val root = state.collections.first() + val before = root.children.size + composeRule.setContent { MainScreen(state) } + + composeRule.onNodeWithTag("collection-actions-${root.id}", useUnmergedTree = true).performClick() + composeRule.onNodeWithTag("collection-menu-add-request", useUnmergedTree = true).performClick() + composeRule.waitForIdle() + + assertTrue(root.children.size == before + 1) + val newId = root.children.last().id + composeRule.onNodeWithTag("collection-node-$newId", useUnmergedTree = true).assertIsDisplayed() + } + + @Test + fun add_mcp_from_context_menu_adds_node_and_opens_tab() { + val state = AppState(withDemoData = true) + val root = state.collections.first() + val before = root.children.size + val tabsBefore = state.openTabs.size + composeRule.setContent { MainScreen(state) } + + composeRule.onNodeWithTag("collection-actions-${root.id}", useUnmergedTree = true).performClick() + composeRule.onNodeWithTag("collection-menu-new-mcp", useUnmergedTree = true).performClick() + composeRule.waitForIdle() + + assertTrue(root.children.size == before + 1) + assertEquals(RequestKind.MCP, root.children.last().kind) + assertTrue(state.openTabs.size == tabsBefore + 1) + composeRule.onNodeWithTag("collection-node-${root.children.last().id}", useUnmergedTree = true).assertIsDisplayed() + } + + @Test + fun add_sse_from_context_menu_adds_node_and_opens_tab() { + val state = AppState(withDemoData = true) + val root = state.collections.first() + val before = root.children.size + val tabsBefore = state.openTabs.size + composeRule.setContent { MainScreen(state) } + + composeRule.onNodeWithTag("collection-actions-${root.id}", useUnmergedTree = true).performClick() + composeRule.onNodeWithTag("collection-menu-new-sse", useUnmergedTree = true).performClick() + composeRule.waitForIdle() + + assertTrue(root.children.size == before + 1) + assertTrue(root.children.last().hasSseAccept()) + assertTrue(state.openTabs.size == tabsBefore + 1) + composeRule.onNodeWithTag("collection-node-${root.children.last().id}", useUnmergedTree = true).assertIsDisplayed() + } + + @Test + fun add_request_from_nested_folder_context_menu() { + val state = AppState(withDemoData = true) + val root = state.collections.first() + val folder = com.reqlab.ui.shared.components.addSubfolderInCollections(state.collections, root.id, "Nested") + ?: error("Folder creation failed") + composeRule.setContent { MainScreen(state) } + + composeRule.onNodeWithTag("collection-actions-${folder.id}", useUnmergedTree = true).performClick() + composeRule.onNodeWithTag("collection-menu-add-request", useUnmergedTree = true).performClick() + composeRule.waitForIdle() + + assertTrue(folder.children.any { it.name == "New Request" }) + val created = folder.children.single { it.name == "New Request" } + composeRule.onNodeWithTag("collection-node-${created.id}", useUnmergedTree = true).assertIsDisplayed() + } + @Test fun open_request_shows_selected_indicator() { val state = AppState(withDemoData = true) diff --git a/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/UiRegressionFixTest.kt b/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/UiRegressionFixTest.kt index d1541f3..9c643b6 100644 --- a/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/UiRegressionFixTest.kt +++ b/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/UiRegressionFixTest.kt @@ -53,6 +53,8 @@ class UiRegressionFixTest { "Expand All", "Collapse All", "Add Request", + "New MCP Connection", + "New SSE Request", "Export Collection", "Duplicate Collection", "Rename", diff --git a/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/persistence/ImportExportFixturesIntegrationTest.kt b/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/persistence/ImportExportFixturesIntegrationTest.kt index a398843..cf37e88 100644 --- a/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/persistence/ImportExportFixturesIntegrationTest.kt +++ b/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/persistence/ImportExportFixturesIntegrationTest.kt @@ -1,5 +1,6 @@ package com.reqlab.ui.shared.persistence +import com.reqlab.core.model.HttpMethodType import com.reqlab.core.model.McpSamplingMode import com.reqlab.core.model.McpTransportType import com.reqlab.core.model.RequestKind @@ -51,6 +52,7 @@ class ImportExportFixturesIntegrationTest { assertTrue(root != null) assertTrue(root.children.any { it.isFolder && it.name == "HTTP Methods" }) assertTrue(root.children.any { it.isFolder && it.name == "LLM (OpenAI-compatible)" }) + assertTrue(root.children.any { it.isFolder && it.name == "SSE" }) assertTrue(root.children.any { it.isFolder && it.name == "MCP (Model Context Protocol)" }) val env = restored.environments.firstOrNull { it.name == "Local Dev – Sample Server" } @@ -90,6 +92,24 @@ class ImportExportFixturesIntegrationTest { assertEquals("{{mcpStdioCommand}}", stdio.mcpConfig!!.command) } + @Test + fun imported_sse_folder_has_accept_event_stream_and_stays_http() { + val state = AppState(openDefaultTab = false, withDemoData = false) + ImportExportRepository.importCollectionFromString(state, collectionFixture.readText()) + val getEvents = findRequest(state.collections, "SSE GET events") + assertNotNull(getEvents) + assertEquals(RequestKind.HTTP, getEvents.kind) + assertTrue( + getEvents.userHeaders.any { + it.first.equals("Accept", ignoreCase = true) && it.second.contains("text/event-stream") + }, + ) + val postEvents = findRequest(state.collections, "SSE POST events") + assertNotNull(postEvents) + assertEquals(RequestKind.HTTP, postEvents.kind) + assertEquals(HttpMethodType.POST, postEvents.method) + } + private fun findRequest(nodes: List, name: String): CollectionNode? { for (node in nodes) { if (!node.isFolder && node.name == name) return node diff --git a/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/state/AppStateCollectionTest.kt b/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/state/AppStateCollectionTest.kt index fdfafa9..6e20643 100644 --- a/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/state/AppStateCollectionTest.kt +++ b/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/state/AppStateCollectionTest.kt @@ -41,6 +41,52 @@ class AppStateCollectionTest { assertEquals(tabsBefore, state.openTabs.size) } + @Test + fun addRequestToCollection_adds_into_nested_folder() { + val state = AppState(withDemoData = true) + val root = state.collections.first() + val nested = CollectionNode( + id = "nested-folder", + name = "Nested", + isFolder = true, + children = mutableStateListOf(), + ) + root.children.add(nested) + val rootCount = root.children.size + + state.addRequestToCollection(nested.id) + + assertEquals(rootCount, root.children.size) + assertEquals(1, nested.children.size) + assertEquals("New Request", nested.children.single().name) + assertEquals(root.id, state.selectedCollectionId) + assertEquals(nested.children.single().id, state.selectedRequestId) + } + + @Test + fun addMcpAndSse_add_into_nested_folder() { + val state = AppState(withDemoData = true) + val root = state.collections.first() + val nested = CollectionNode( + id = "nested-folder-mcp-sse", + name = "Nested", + isFolder = true, + children = mutableStateListOf(), + ) + root.children.add(nested) + + state.addMcpConnectionToCollection(nested.id) + state.addSseRequestToCollection(nested.id) + + assertEquals(2, nested.children.size) + assertEquals(RequestKind.MCP, nested.children[0].kind) + assertEquals("New MCP Connection", nested.children[0].name) + assertEquals(RequestKind.HTTP, nested.children[1].kind) + assertEquals("New SSE Request", nested.children[1].name) + assertTrue(nested.children[1].hasSseAccept()) + assertEquals(root.id, state.selectedCollectionId) + } + @Test fun addTabInSelectedCollection_uses_selected_collection() { val state = AppState(withDemoData = true) @@ -298,6 +344,56 @@ class AppStateCollectionTest { assertTrue(tab.isDirty) } + @Test + fun addSseRequestToCollection_prefills_get_and_accept_header() { + val state = AppState(withDemoData = true) + val collectionId = state.collections.first().id + state.addSseRequestToCollection(collectionId) + val node = state.collections.first().children.first { it.name == "New SSE Request" } + assertEquals(RequestKind.HTTP, node.kind) + assertEquals(HttpMethodType.GET, node.method) + assertEquals("{{baseUrl}}/sse", node.url) + assertTrue(node.hasSseAccept()) + val tab = state.openTabs.last() + assertEquals("New SSE Request", tab.name) + assertEquals(HttpMethodType.GET, tab.method) + assertEquals("{{baseUrl}}/sse", tab.url) + assertTrue(tab.hasSseAccept()) + val accept = tab.headers.first { it.key.equals("Accept", ignoreCase = true) } + assertEquals("text/event-stream", accept.value) + } + + @Test + fun addSseRequestToCollection_generates_unique_name() { + val state = AppState(withDemoData = true) + val collectionId = state.collections.first().id + state.addSseRequestToCollection(collectionId) + state.addSseRequestToCollection(collectionId) + val names = state.collections.first().children.map { it.name } + assertTrue("New SSE Request" in names) + assertTrue("New SSE Request 2" in names) + } + + @Test + fun syncTabToCollectionNode_persists_sse_accept_and_clears_when_json() { + val state = AppState(withDemoData = true) + val collectionId = state.collections.first().id + state.addSseRequestToCollection(collectionId) + val tab = state.openTabs.last() + assertTrue(state.syncTabToCollectionNode(tab)) + val nodeAfterSave = state.collections.first().children.first { it.id == tab.id } + assertTrue(nodeAfterSave.hasSseAccept()) + assertTrue( + nodeAfterSave.userHeaders.any { + it.first.equals("Accept", ignoreCase = true) && it.second.contains("text/event-stream") + }, + ) + tab.headers.first { it.key.equals("Accept", ignoreCase = true) }.value = "application/json" + assertTrue(state.syncTabToCollectionNode(tab)) + val nodeAfterClear = state.collections.first().children.first { it.id == tab.id } + assertFalse(nodeAfterClear.hasSseAccept()) + } + // ── Issue 1: Closing last tab ──────────────────────────────────── @Test diff --git a/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/RequestTabsBar.kt b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/RequestTabsBar.kt index f52f7f5..963d7e2 100644 --- a/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/RequestTabsBar.kt +++ b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/RequestTabsBar.kt @@ -66,6 +66,7 @@ import androidx.compose.ui.graphics.SolidColor import com.reqlab.ui.shared.i18n.Strings import com.reqlab.ui.shared.state.AppState import com.reqlab.ui.shared.state.RequestTabState +import com.reqlab.ui.shared.state.hasSseAccept import com.reqlab.ui.shared.theme.ReqLabColors import com.reqlab.ui.shared.theme.CodeFontFamily import com.reqlab.ui.shared.platform.horizontalResizeCursor @@ -213,7 +214,13 @@ private fun RequestTabChip( if (tab.kind == com.reqlab.core.model.RequestKind.MCP) { McpMethodBadge(compact = true, modifier = Modifier.testTag("tab-mcp-badge-${tab.id}")) } else { - MethodBadge(tab.method, compact = true) + val sse = tab.hasSseAccept() + MethodBadge( + tab.method, + compact = true, + sse = sse, + modifier = if (sse) Modifier.testTag("tab-sse-badge-${tab.id}") else Modifier, + ) } if (renameMode) { BasicTextField( diff --git a/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/Sidebar.kt b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/Sidebar.kt index fa04c41..6157ef4 100644 --- a/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/Sidebar.kt +++ b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/Sidebar.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsHoveredAsState @@ -37,6 +38,7 @@ import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Bolt import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Delete @@ -104,6 +106,7 @@ import com.reqlab.ui.shared.state.CollectionNode import com.reqlab.ui.shared.state.EnvState import com.reqlab.ui.shared.state.HistoryItem import com.reqlab.ui.shared.state.LogLevel +import com.reqlab.ui.shared.state.hasSseAccept import com.reqlab.ui.shared.theme.CodeFontFamily import com.reqlab.ui.shared.theme.ReqLabColors import com.reqlab.ui.shared.theme.httpMethodColor @@ -545,6 +548,14 @@ fun Sidebar(state: AppState) { state.addRequestToCollection(target.id) persistWorkspaceAsync() }, + onAddMcpConnection = { target -> + state.addMcpConnectionToCollection(target.id) + persistWorkspaceAsync() + }, + onAddSseRequest = { target -> + state.addSseRequestToCollection(target.id) + persistWorkspaceAsync() + }, onRenameRequest = { target -> renameRequestTarget = target renameRequestValue = target.name @@ -1177,14 +1188,19 @@ private fun HistoryRow(state: AppState, item: HistoryItem, onClick: () -> Unit) } @Composable -fun MethodBadge(method: HttpMethodType, compact: Boolean = false) { +fun MethodBadge( + method: HttpMethodType, + compact: Boolean = false, + sse: Boolean = false, + modifier: Modifier = Modifier, +) { val color = httpMethodColor(method) Text( - text = if (compact) method.name.take(3) else method.name, + text = if (sse) "SSE" else if (compact) method.name.take(3) else method.name, color = color, fontSize = if (compact) 10.sp else 12.sp, fontWeight = FontWeight.Bold, - modifier = Modifier + modifier = modifier .clip(RoundedCornerShape(4.dp)) .background(color.copy(alpha = 0.12f)) .padding(horizontal = if (compact) 4.dp else 6.dp, vertical = 2.dp), @@ -1219,6 +1235,8 @@ private fun CollectionTreeNode( onDeleteCollection: (CollectionNode) -> Unit, onAddFolder: (CollectionNode) -> Unit, onAddRequest: (CollectionNode) -> Unit, + onAddMcpConnection: (CollectionNode) -> Unit, + onAddSseRequest: (CollectionNode) -> Unit, onRenameRequest: (CollectionNode) -> Unit, onDeleteRequest: (CollectionNode) -> Unit, onMoveRequest: (requestId: String, direction: Int) -> Unit, @@ -1313,17 +1331,17 @@ private fun CollectionTreeNode( Row( modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(6.dp)) .alpha(if (isDragSource || isCollectionDragSource) 0.4f else 1f) .bringIntoViewRequester(bringIntoViewRequester) .background( - when { + color = when { isSelectedRequest -> ReqLabColors.SelectedItem isDropCollectionTarget -> ReqLabColors.Primary.copy(alpha = 0.14f) isDragSource || isCollectionDragSource -> ReqLabColors.SurfaceHigh isHovered -> ReqLabColors.HoverOverlay else -> Color.Transparent - } + }, + shape = RoundedCornerShape(6.dp), ) .onGloballyPositioned { coordinates -> val position = coordinates.positionInRoot() @@ -1439,7 +1457,14 @@ private fun CollectionTreeNode( tint = ReqLabColors.OnSurfaceDim.copy(alpha = 0.4f), modifier = Modifier.size(12.dp), ) - MethodBadge(node.method, compact = true) + val openTab = state.openTabs.find { it.id == node.id } + val sse = openTab?.hasSseAccept() ?: node.hasSseAccept() + MethodBadge( + node.method, + compact = true, + sse = sse, + modifier = if (sse) Modifier.testTag("sse-badge-${node.id}") else Modifier, + ) } Text( text = node.name, @@ -1460,6 +1485,15 @@ private fun CollectionTreeNode( } if (isFolderNode || isRequest) { + val actionsInteraction = remember { MutableInteractionSource() } + Row( + modifier = Modifier.clickable( + interactionSource = actionsInteraction, + indication = null, + onClick = { /* consume so the folder row does not toggle */ }, + ), + verticalAlignment = Alignment.CenterVertically, + ) { if (isCollectionRoot) { IconButton( onClick = { onAddRequest(node) }, @@ -1485,12 +1519,17 @@ private fun CollectionTreeNode( modifier = Modifier.size(14.dp), ) } - DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) { + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false }, + scrollState = rememberScrollState(), + ) { if (isFolderNode) { DropdownMenuItem( text = { Text(Strings.t("add_folder")) }, leadingIcon = { Icon(Icons.Default.Folder, contentDescription = null, modifier = Modifier.size(16.dp)) }, onClick = { showMenu = false; onAddFolder(node) }, + modifier = Modifier.testTag("collection-menu-add-folder"), ) DropdownMenuItem( text = { Text(Strings.t("expand")) }, @@ -1528,14 +1567,25 @@ private fun CollectionTreeNode( text = { Text(Strings.t("add_request")) }, leadingIcon = { Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(16.dp)) }, onClick = { showMenu = false; onAddRequest(node) }, + modifier = Modifier.testTag("collection-menu-add-request"), ) DropdownMenuItem( text = { Text(Strings.t("new_mcp_connection")) }, leadingIcon = { Icon(Icons.Default.Hub, contentDescription = null, modifier = Modifier.size(16.dp)) }, onClick = { showMenu = false - state.addMcpConnectionToCollection(node.id) + onAddMcpConnection(node) }, + modifier = Modifier.testTag("collection-menu-new-mcp"), + ) + DropdownMenuItem( + text = { Text(Strings.t("new_sse_request")) }, + leadingIcon = { Icon(Icons.Default.Bolt, contentDescription = null, modifier = Modifier.size(16.dp)) }, + onClick = { + showMenu = false + onAddSseRequest(node) + }, + modifier = Modifier.testTag("collection-menu-new-sse"), ) if (isCollectionRoot) { DropdownMenuItem( @@ -1588,6 +1638,7 @@ private fun CollectionTreeNode( } } } + } } } // end Row @@ -1631,6 +1682,8 @@ private fun CollectionTreeNode( onDuplicateRequest = onDuplicateRequest, onDeleteCollection = onDeleteCollection, onAddRequest = onAddRequest, + onAddMcpConnection = onAddMcpConnection, + onAddSseRequest = onAddSseRequest, onRenameRequest = onRenameRequest, onDeleteRequest = onDeleteRequest, onAddFolder = onAddFolder, diff --git a/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/state/AppState.kt b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/state/AppState.kt index 7b0a71f..0ff3b2d 100644 --- a/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/state/AppState.kt +++ b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/state/AppState.kt @@ -180,6 +180,19 @@ object SystemHeaderRules { } } +const val SSE_ACCEPT_MEDIA_TYPE = "text/event-stream" + +fun isSseAccept(key: String, value: String, enabled: Boolean = true): Boolean = + enabled && + key.equals(SystemHeaderRules.ACCEPT, ignoreCase = true) && + value.contains(SSE_ACCEPT_MEDIA_TYPE, ignoreCase = true) + +fun RequestTabState.hasSseAccept(): Boolean = + headers.any { isSseAccept(it.key, it.value, it.enabled) } + +fun CollectionNode.hasSseAccept(): Boolean = + userHeaders.any { isSseAccept(it.first, it.second) } + // ── Environment model ─────────────────────────────────────────── class EnvState( @@ -877,6 +890,18 @@ class AppState(openDefaultTab: Boolean = true, withDemoData: Boolean = false) { return null } + /** Folder anywhere in the tree (root or nested). */ + fun findFolder(id: String): CollectionNode? = + findNodeById(collections, id)?.takeIf { it.isFolder } + + private fun rootCollectionIdContaining(nodeId: String): String? { + for (root in collections) { + if (root.id == nodeId) return root.id + if (findNodeById(listOf(root), nodeId) != null) return root.id + } + return null + } + private fun findRequestBySignature( nodes: List, name: String, @@ -1126,7 +1151,7 @@ class AppState(openDefaultTab: Boolean = true, withDemoData: Boolean = false) { /** Add a new request node inside the given collection (folder). Opens it as a tab. */ fun addRequestToCollection(collectionId: String) { - val folder = collections.firstOrNull { it.id == collectionId && it.isFolder } ?: return + val folder = findFolder(collectionId) ?: return val siblingNames = folder.children.map { it.name }.toSet() val name = generateUniqueName("New Request", siblingNames) val requestId = generateUuid() @@ -1140,14 +1165,13 @@ class AppState(openDefaultTab: Boolean = true, withDemoData: Boolean = false) { ) folder.children.add(node) notifyCollectionsChanged() - selectedCollectionId = collectionId + selectedCollectionId = rootCollectionIdContaining(collectionId) ?: collectionId selectedRequestId = requestId - // Also open a tab for the new request addTab(requestId = requestId, name = name, method = HttpMethodType.GET, url = "") } fun addMcpConnectionToCollection(collectionId: String) { - val folder = collections.firstOrNull { it.id == collectionId && it.isFolder } ?: return + val folder = findFolder(collectionId) ?: return val siblingNames = folder.children.map { it.name }.toSet() val name = generateUniqueName("New MCP Connection", siblingNames) val requestId = generateUuid() @@ -1164,15 +1188,37 @@ class AppState(openDefaultTab: Boolean = true, withDemoData: Boolean = false) { ) folder.children.add(node) notifyCollectionsChanged() - selectedCollectionId = collectionId + selectedCollectionId = rootCollectionIdContaining(collectionId) ?: collectionId selectedRequestId = requestId addTab(requestId = requestId, name = name, method = HttpMethodType.POST, url = mcp.url) } + fun addSseRequestToCollection(collectionId: String) { + val folder = findFolder(collectionId) ?: return + val siblingNames = folder.children.map { it.name }.toSet() + val name = generateUniqueName("New SSE Request", siblingNames) + val requestId = generateUuid() + val url = "{{baseUrl}}/sse" + val node = CollectionNode( + id = requestId, + requestRef = generateUuid(), + name = name, + isFolder = false, + method = HttpMethodType.GET, + url = url, + userHeaders = listOf(SystemHeaderRules.ACCEPT to SSE_ACCEPT_MEDIA_TYPE), + ) + folder.children.add(node) + notifyCollectionsChanged() + selectedCollectionId = rootCollectionIdContaining(collectionId) ?: collectionId + selectedRequestId = requestId + addTab(requestId = requestId, name = name, method = HttpMethodType.GET, url = url) + } + /** Create a request in the selected collection, ensuring a default collection exists when needed. */ fun addTabInSelectedCollection() { val collId = selectedCollectionId - val folder = if (collId != null) collections.firstOrNull { it.id == collId && it.isFolder } else null + val folder = if (collId != null) findFolder(collId) else null if (folder != null) { addRequestToCollection(folder.id) } else { @@ -1527,6 +1573,13 @@ class AppState(openDefaultTab: Boolean = true, withDemoData: Boolean = false) { val userHeadersSnapshot = tab.headers .filter { it.kind == HeaderKind.USER } .map { it.key to it.value } + .toMutableList() + val sseAccept = tab.headers.firstOrNull { isSseAccept(it.key, it.value, it.enabled) } + if (sseAccept != null && + userHeadersSnapshot.none { it.first.equals(SystemHeaderRules.ACCEPT, ignoreCase = true) } + ) { + userHeadersSnapshot.add(sseAccept.key to sseAccept.value) + } val bodyContentsSnapshot: Map = tab.bodyContents.entries.associate { it.key.name to it.value } val formEntriesSnapshot = tab.formRows.map { r -> diff --git a/ui-shared/src/commonMain/resources/i18n/de.json b/ui-shared/src/commonMain/resources/i18n/de.json index 647c356..a2b9575 100644 --- a/ui-shared/src/commonMain/resources/i18n/de.json +++ b/ui-shared/src/commonMain/resources/i18n/de.json @@ -270,5 +270,6 @@ "mcp_elicit_form": "Elicitation", "mcp_elicit_accept": "Akzeptieren", "mcp_elicit_decline": "Ablehnen", - "new_mcp_connection": "Neue MCP-Verbindung" + "new_mcp_connection": "Neue MCP-Verbindung", + "new_sse_request": "Neue SSE-Anfrage" } diff --git a/ui-shared/src/commonMain/resources/i18n/en.json b/ui-shared/src/commonMain/resources/i18n/en.json index 8a5ba48..1eb6924 100644 --- a/ui-shared/src/commonMain/resources/i18n/en.json +++ b/ui-shared/src/commonMain/resources/i18n/en.json @@ -324,5 +324,6 @@ "mcp_elicit_form": "Elicitation", "mcp_elicit_accept": "Accept", "mcp_elicit_decline": "Decline", - "new_mcp_connection": "New MCP Connection" + "new_mcp_connection": "New MCP Connection", + "new_sse_request": "New SSE Request" } diff --git a/ui-shared/src/commonMain/resources/i18n/es.json b/ui-shared/src/commonMain/resources/i18n/es.json index ab7b969..fc02dc4 100644 --- a/ui-shared/src/commonMain/resources/i18n/es.json +++ b/ui-shared/src/commonMain/resources/i18n/es.json @@ -270,5 +270,6 @@ "mcp_elicit_form": "Elicitación", "mcp_elicit_accept": "Aceptar", "mcp_elicit_decline": "Rechazar", - "new_mcp_connection": "Nueva conexión MCP" + "new_mcp_connection": "Nueva conexión MCP", + "new_sse_request": "Nueva petición SSE" } diff --git a/ui-shared/src/commonMain/resources/i18n/fr.json b/ui-shared/src/commonMain/resources/i18n/fr.json index f64103b..c0bab54 100644 --- a/ui-shared/src/commonMain/resources/i18n/fr.json +++ b/ui-shared/src/commonMain/resources/i18n/fr.json @@ -270,5 +270,6 @@ "mcp_elicit_form": "Élicitation", "mcp_elicit_accept": "Accepter", "mcp_elicit_decline": "Refuser", - "new_mcp_connection": "Nouvelle connexion MCP" + "new_mcp_connection": "Nouvelle connexion MCP", + "new_sse_request": "Nouvelle requête SSE" } diff --git a/ui-shared/src/commonTest/kotlin/com/reqlab/ui/shared/i18n/I18nCompletenessTest.kt b/ui-shared/src/commonTest/kotlin/com/reqlab/ui/shared/i18n/I18nCompletenessTest.kt index 35e5919..2f534cb 100644 --- a/ui-shared/src/commonTest/kotlin/com/reqlab/ui/shared/i18n/I18nCompletenessTest.kt +++ b/ui-shared/src/commonTest/kotlin/com/reqlab/ui/shared/i18n/I18nCompletenessTest.kt @@ -62,7 +62,7 @@ class I18nCompletenessTest { "import_collection", "export_collection", "import_success", "export_success", "operation_failed", // MCP - "mcp_connection", "new_mcp_connection", "mcp_tools", "mcp_resources", "mcp_prompts", + "mcp_connection", "new_mcp_connection", "new_sse_request", "mcp_tools", "mcp_resources", "mcp_prompts", "mcp_url", "mcp_command", "mcp_notifications", "mcp_timeline", "mcp_client", "mcp_call_tool", "mcp_stdio_confirm", "mcp_roots", "mcp_sampling_mode", "mcp_auto_elicit", "mcp_auto_sampling", "mcp_llm_url", "mcp_sampling_approve_send", "mcp_elicit_accept", diff --git a/ui-shared/src/commonTest/kotlin/com/reqlab/ui/shared/state/SseAcceptTest.kt b/ui-shared/src/commonTest/kotlin/com/reqlab/ui/shared/state/SseAcceptTest.kt new file mode 100644 index 0000000..02e3116 --- /dev/null +++ b/ui-shared/src/commonTest/kotlin/com/reqlab/ui/shared/state/SseAcceptTest.kt @@ -0,0 +1,33 @@ +package com.reqlab.ui.shared.state + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class SseAcceptTest { + + @Test + fun enabled_text_event_stream_is_sse() { + assertTrue(isSseAccept("Accept", "text/event-stream")) + } + + @Test + fun combined_accept_with_event_stream_is_sse() { + assertTrue(isSseAccept("Accept", "application/json, text/event-stream")) + } + + @Test + fun key_and_value_are_case_insensitive() { + assertTrue(isSseAccept("accept", "Text/Event-Stream")) + } + + @Test + fun json_accept_is_not_sse() { + assertFalse(isSseAccept("Accept", "application/json")) + } + + @Test + fun disabled_event_stream_is_not_sse() { + assertFalse(isSseAccept("Accept", "text/event-stream", enabled = false)) + } +} From 9eb5847a275f0d2ad279d9bc06fd9f99d68fc914 Mon Sep 17 00:00:00 2001 From: Sanjay Sharma Date: Mon, 31 Aug 2026 02:14:23 +0530 Subject: [PATCH 4/5] Add support for json5 --- FEATURES.md | 7 +- README.md | 2 +- .../com/reqlab/core/model/json/Json5.kt | 317 ++++++++++++++ .../com/reqlab/core/model/json/Json5Test.kt | 159 +++++++ .../com/reqlab/core/network/KtorApiClient.kt | 28 +- .../com/reqlab/core/network/StreamSupport.kt | 10 +- .../reqlab/core/network/KtorApiClientTest.kt | 136 +++++- .../core/network/LlmTextAssemblerTest.kt | 23 + docs/tests.md | 4 +- editor-core/build.gradle.kts | 1 + .../com/reqlab/editor/core/EditorEngine.kt | 9 +- .../reqlab/editor/core/Json5EditorSupport.kt | 410 ++++++++++++++++++ .../editor/core/Json5EditorSupportTest.kt | 127 ++++++ .../com/reqlab/editor/ui/EditorHighlighter.kt | 10 +- .../com/reqlab/editor/ui/EditorRenderer.kt | 75 +++- .../com/reqlab/editor/ui/EditorViewModel.kt | 117 +++-- .../kotlin/com/reqlab/editor/ui/LineView.kt | 48 +- .../reqlab/editor/ui/TokenColorRegistry.kt | 1 + .../editor/ui/EditorViewModelFixTest.kt | 82 +++- qa-tests/collection-validator.mjs | 33 +- qa-tests/fixtures/reqlab-test-collection.json | 34 ++ .../com/reqlab/qa/SampleCollectionE2ETest.kt | 75 ++++ .../ui/desktop/EditorNoWrapRegressionTest.kt | 138 ++++++ .../com/reqlab/ui/desktop/EditorQaUiTest.kt | 151 +++++++ .../EditorScrollKeyClickBackspaceUndoTest.kt | 150 +++++++ .../ui/desktop/EditorTabSwitchLeakTest.kt | 193 +++++++++ .../ui/desktop/EditorV2RegressionTest.kt | 7 +- .../reqlab/ui/desktop/ResponseBodyUiTest.kt | 12 + .../reqlab/ui/desktop/SettingsDialogUiTest.kt | 19 + .../integration/CopyCommandIntegrationTest.kt | 54 +++ .../ImportExportFixturesIntegrationTest.kt | 29 ++ .../persistence/SettingsRepositoryTest.kt | 24 + .../kotlin/com/reqlab/ui/shared/MainScreen.kt | 43 +- .../reqlab/ui/shared/components/BodyEditor.kt | 62 +-- .../reqlab/ui/shared/components/CodeEditor.kt | 97 ++++- .../ui/shared/components/RequestEditor.kt | 6 +- .../ui/shared/components/RequestExecutor.kt | 41 +- .../ui/shared/components/SettingsDialog.kt | 8 + .../ui/shared/components/SyntaxHighlighter.kt | 13 +- .../shared/persistence/SettingsRepository.kt | 2 + .../com/reqlab/ui/shared/state/AppState.kt | 27 +- .../src/commonMain/resources/i18n/de.json | 2 + .../src/commonMain/resources/i18n/en.json | 2 + .../src/commonMain/resources/i18n/es.json | 2 + .../src/commonMain/resources/i18n/fr.json | 2 + .../components/SyntaxHighlighterTest.kt | 42 ++ .../ui/shared/i18n/I18nCompletenessTest.kt | 2 +- .../network/NetworkClientFactory.desktop.kt | 1 + .../network/NetworkClientFactory.wasmJs.kt | 1 + 49 files changed, 2630 insertions(+), 208 deletions(-) create mode 100644 core-model/src/commonMain/kotlin/com/reqlab/core/model/json/Json5.kt create mode 100644 core-model/src/commonTest/kotlin/com/reqlab/core/model/json/Json5Test.kt create mode 100644 editor-core/src/commonMain/kotlin/com/reqlab/editor/core/Json5EditorSupport.kt create mode 100644 editor-core/src/commonTest/kotlin/com/reqlab/editor/core/Json5EditorSupportTest.kt create mode 100644 ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/EditorTabSwitchLeakTest.kt diff --git a/FEATURES.md b/FEATURES.md index a51e0e8..980cd75 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -22,6 +22,7 @@ ReqLab supports end-to-end API testing with: - URL editing with query parameter table synchronization - Header editing and request body editing - Request body support for JSON, GraphQL, form-style payloads, and raw text +- JSON bodies accept JSON5 by default (comments, trailing commas, unquoted keys). Send converts to strict JSON; turn off in Settings to restore strict JSON. - Authentication modes: None, Basic, Bearer, API Key, JWT (OAuth2 planned) - Retry controls and timeout behavior - HTTP streaming for SSE (`text/event-stream`) and NDJSON (OpenAI-style `"stream": true`). Items with `Accept: text/event-stream` show an **SSE** badge in the HTTP method color; folder ⋮ → **New SSE Request**. @@ -62,7 +63,7 @@ ReqLab features a full-featured code editor used across request body editing, sc - Keyboard-driven toggle (toolbar button) **Formatting** — Auto-format source code: -- JSON pretty-print (indented with 2-space indent) +- JSON pretty-print (indented with 2-space indent). With JSON5 on, Format pretty-prints JSON5 and keeps comments, unquoted keys, single quotes, and trailing commas; Send still converts to strict JSON. With JSON5 off, Format is a no-op on invalid JSON. - XML / HTML indentation - JavaScript formatting (including script editor) - Toggle on/off from toolbar @@ -126,7 +127,7 @@ Pre-request scripts can mutate outgoing request values: ### Collections and Test Automation -- Collection import/export using `qa-tests/fixtures/reqlab-test-collection.json` +- Collection import/export using `qa-tests/fixtures/reqlab-test-collection.json` (includes a **JSON5** folder under Body Types) - **Postman Collection v2 / v2.1 import** — auto-detected and converted to ReqLab format - Folders, requests, headers, auth (bearer / basic / API key), body (raw JSON, form-data, urlencoded, GraphQL, binary), and scripts - Postman `pm.*` script namespace automatically rewritten to `reqlab.*` @@ -134,7 +135,7 @@ Pre-request scripts can mutate outgoing request values: - `pm.execution.setNextRequest`, `pm.execution.skipRequest`, and `postman.setNextRequest` rewritten to `reqlab.execution.*` - **Postman Environment import** — name and enabled variables imported; disabled variables skipped - Request-level pre-request and post-request scripts in collection items -- Automated collection validation via `qa-tests/collection-validator.mjs` +- Automated collection validation via `qa-tests/collection-validator.mjs` (JSON5 requests are skipped, not counted as passed; Kotlin `SampleCollectionE2ETest` is the JSON5 send coverage) - Deterministic sample-server endpoints for reproducible test runs ### Sample Server diff --git a/README.md b/README.md index e4645b9..fecb58d 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ The collection includes an **LLM (OpenAI-compatible)** folder, an **SSE** folder - Methods: `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS`, `HEAD` - URL editing with live query-parameter table synchronisation - Request headers editor (key/value table) -- Body types: JSON, GraphQL, form-data, x-www-form-urlencoded, raw text, binary +- Body types: JSON (JSON5 authoring by default; Send converts to strict JSON), GraphQL, form-data, x-www-form-urlencoded, raw text, binary - Auth: None, Basic, Bearer Token, API Key, JWT - Retry controls and per-request timeout - HTTP streaming: SSE (`text/event-stream`) and NDJSON on a single request (OpenAI-style `"stream": true`). Collection items with `Accept: text/event-stream` show an **SSE** badge in the HTTP method color; folder ⋮ → **New SSE Request** diff --git a/core-model/src/commonMain/kotlin/com/reqlab/core/model/json/Json5.kt b/core-model/src/commonMain/kotlin/com/reqlab/core/model/json/Json5.kt new file mode 100644 index 0000000..be02ab4 --- /dev/null +++ b/core-model/src/commonMain/kotlin/com/reqlab/core/model/json/Json5.kt @@ -0,0 +1,317 @@ +package com.reqlab.core.model.json + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.JsonUnquotedLiteral + +/** + * JSON5 authoring helper: parse comments, trailing commas, unquoted keys, and + * related JSON5 syntax into [JsonElement], then emit RFC 8259 JSON for the wire. + * + * [Infinity] / [NaN] are rejected — they are not JSON. + */ +object Json5 { + + private val strictJson = Json { ignoreUnknownKeys = true } + + @OptIn(ExperimentalSerializationApi::class) + private val prettyJson = Json { prettyPrint = true; prettyPrintIndent = " " } + + fun parseToJsonElement(text: String): Result = + runCatching { Json5Parser(text).parse() } + + /** + * If [text] is already valid RFC 8259 JSON, return it unchanged. + * Otherwise parse JSON5 and pretty-print strict JSON. + */ + fun toWireJson(text: String): Result { + if (text.isBlank()) return Result.success(text) + if (runCatching { strictJson.parseToJsonElement(text) }.isSuccess) return Result.success(text) + return toCanonicalJson(text) + } + + /** Always re-encode as pretty 2-space JSON (comments and JSON5 syntax dropped). */ + fun toCanonicalJson(text: String): Result = + parseToJsonElement(text).map { prettyJson.encodeToString(JsonElement.serializer(), it) } +} + +class Json5ParseException(message: String, val offset: Int) : IllegalArgumentException(message) + +@OptIn(ExperimentalSerializationApi::class) +private class Json5Parser(private val text: String) { + private var i = 0 + + fun parse(): JsonElement { + skip() + if (i >= text.length) throw error("Empty JSON5") + val value = parseValue() + skip() + if (i < text.length) throw error("Unexpected trailing input") + return value + } + + private fun parseValue(): JsonElement { + skip() + if (i >= text.length) throw error("Unexpected end of input") + return when (val c = text[i]) { + '{' -> parseObject() + '[' -> parseArray() + '"', '\'' -> JsonPrimitive(parseQuotedString()) + 't' -> { expectWord("true"); JsonPrimitive(true) } + 'f' -> { expectWord("false"); JsonPrimitive(false) } + 'n' -> { + if (text.startsWith("null", i)) { expectWord("null"); JsonNull } + else if (text.startsWith("NaN", i)) throw error("NaN is not valid JSON") + else throw error("Unexpected token") + } + 'I' -> { + if (text.startsWith("Infinity", i)) throw error("Infinity is not valid JSON") + else throw error("Unexpected token") + } + '+', '-', '.', in '0'..'9' -> parseNumber() + else -> throw error("Unexpected character '$c'") + } + } + + private fun parseObject(): JsonObject { + expect('{') + skip() + val map = linkedMapOf() + if (peek('}')) { + expect('}') + return JsonObject(map) + } + while (true) { + skip() + val key = when { + i >= text.length -> throw error("Unterminated object") + text[i] == '"' || text[i] == '\'' -> parseQuotedString() + else -> parseIdentifier() + } + skip() + expect(':') + skip() + map[key] = parseValue() + skip() + when { + peek(',') -> { + expect(',') + skip() + if (peek('}')) { + expect('}') + return JsonObject(map) + } + } + peek('}') -> { + expect('}') + return JsonObject(map) + } + else -> throw error("Expected ',' or '}' in object") + } + } + } + + private fun parseArray(): JsonArray { + expect('[') + skip() + val items = mutableListOf() + if (peek(']')) { + expect(']') + return JsonArray(items) + } + while (true) { + skip() + items.add(parseValue()) + skip() + when { + peek(',') -> { + expect(',') + skip() + if (peek(']')) { + expect(']') + return JsonArray(items) + } + } + peek(']') -> { + expect(']') + return JsonArray(items) + } + else -> throw error("Expected ',' or ']' in array") + } + } + } + + private fun parseIdentifier(): String { + if (i >= text.length) throw error("Expected identifier") + val start = i + val c = text[i] + if (!(c.isLetter() || c == '_' || c == '$')) throw error("Expected property name") + i++ + while (i < text.length) { + val n = text[i] + if (n.isLetterOrDigit() || n == '_' || n == '$') i++ else break + } + val id = text.substring(start, i) + if (id == "Infinity" || id == "NaN") throw error("$id is not valid JSON") + return id + } + + private fun parseQuotedString(): String { + val quote = text[i] + if (quote != '"' && quote != '\'') throw error("Expected string") + i++ + val sb = StringBuilder() + while (i < text.length) { + when (val c = text[i]) { + quote -> { i++; return sb.toString() } + '\\' -> parseStringEscape(sb) + '\n', '\r' -> throw error("Unescaped line terminator in string") + else -> { sb.append(c); i++ } + } + } + throw error("Unterminated string") + } + + /** JSON5 spec 5.1: named escapes, \xHH, \uHHHH, \0, line continuation; reject \1–\9. */ + private fun parseStringEscape(sb: StringBuilder) { + i++ + if (i >= text.length) throw error("Unterminated string escape") + when (val e = text[i]) { + '"', '\'', '\\', '/' -> { sb.append(e); i++ } + 'b' -> { sb.append('\b'); i++ } + 'f' -> { sb.append('\u000c'); i++ } + 'n' -> { sb.append('\n'); i++ } + 'r' -> { sb.append('\r'); i++ } + 't' -> { sb.append('\t'); i++ } + 'v' -> { sb.append('\u000B'); i++ } + '0' -> { + i++ + if (i < text.length && text[i].isDigit()) throw error("Invalid octal escape") + sb.append('\u0000') + } + in '1'..'9' -> throw error("Invalid escape") + 'x' -> { + i++ + if (i + 2 > text.length) throw error("Invalid hex escape") + val hex = text.substring(i, i + 2) + val cp = hex.toIntOrNull(16) ?: throw error("Invalid hex escape") + sb.append(cp.toChar()) + i += 2 + } + 'u' -> { + i++ + if (i + 4 > text.length) throw error("Invalid unicode escape") + val hex = text.substring(i, i + 4) + val cp = hex.toIntOrNull(16) ?: throw error("Invalid unicode escape") + sb.append(cp.toChar()) + i += 4 + } + '\n' -> i++ + '\r' -> { + i++ + if (i < text.length && text[i] == '\n') i++ + } + '\u2028', '\u2029' -> i++ + else -> { sb.append(e); i++ } + } + } + + private fun parseNumber(): JsonPrimitive { + val start = i + if (peek('+') || peek('-')) { + if (text[i] == '+') i++ else i++ + } + if (text.startsWith("Infinity", i)) throw error("Infinity is not valid JSON") + if (text.startsWith("NaN", i)) throw error("NaN is not valid JSON") + + if (i < text.length && (text[i] == '0') && i + 1 < text.length && (text[i + 1] == 'x' || text[i + 1] == 'X')) { + val signStart = start + i += 2 + val hexStart = i + while (i < text.length && text[i].isHexDigit()) i++ + if (i == hexStart) throw error("Invalid hex number") + val hex = text.substring(hexStart, i) + val mag = hex.toLongOrNull(16) ?: throw error("Hex number out of range") + val neg = text[signStart] == '-' + return JsonUnquotedLiteral(if (neg) (-mag).toString() else mag.toString()) + } + + if (i < text.length && text[i] == '.') { + i++ + if (i >= text.length || !text[i].isDigit()) throw error("Invalid number") + while (i < text.length && text[i].isDigit()) i++ + } else { + if (i >= text.length || !text[i].isDigit()) throw error("Invalid number") + val first = text[i] + i++ + if (first == '0' && i < text.length && text[i].isDigit()) { + throw error("Leading zero is not valid") + } + while (i < text.length && text[i].isDigit()) i++ + if (i < text.length && text[i] == '.') { + i++ + while (i < text.length && text[i].isDigit()) i++ + } + } + if (i < text.length && (text[i] == 'e' || text[i] == 'E')) { + i++ + if (i < text.length && (text[i] == '+' || text[i] == '-')) i++ + if (i >= text.length || !text[i].isDigit()) throw error("Invalid exponent") + while (i < text.length && text[i].isDigit()) i++ + } + var raw = text.substring(start, i) + if (raw.startsWith("+")) raw = raw.substring(1) + if (raw.startsWith(".")) raw = "0$raw" + if (raw.startsWith("-.")) raw = "-0${raw.substring(1)}" + if (raw.endsWith(".") && !raw.contains('e', ignoreCase = true)) raw = raw.dropLast(1) + return JsonUnquotedLiteral(raw) + } + + private fun skip() { + while (i < text.length) { + when { + text[i].isWhitespace() || text[i] == '\uFEFF' -> i++ + text.startsWith("//", i) -> { + i += 2 + while (i < text.length && text[i] != '\n' && text[i] != '\r') i++ + } + text.startsWith("/*", i) -> { + i += 2 + val end = text.indexOf("*/", i) + if (end < 0) throw error("Unterminated block comment") + i = end + 2 + } + else -> return + } + } + } + + private fun expect(c: Char) { + skip() + if (i >= text.length || text[i] != c) throw error("Expected '$c'") + i++ + } + + private fun expectWord(word: String) { + if (!text.startsWith(word, i)) throw error("Expected $word") + i += word.length + if (i < text.length && (text[i].isLetterOrDigit() || text[i] == '_' || text[i] == '$')) { + throw error("Unexpected identifier after $word") + } + } + + private fun peek(c: Char): Boolean { + skip() + return i < text.length && text[i] == c + } + + private fun error(msg: String) = Json5ParseException("$msg at offset $i", i) + + private fun Char.isHexDigit(): Boolean = + this in '0'..'9' || this in 'a'..'f' || this in 'A'..'F' +} diff --git a/core-model/src/commonTest/kotlin/com/reqlab/core/model/json/Json5Test.kt b/core-model/src/commonTest/kotlin/com/reqlab/core/model/json/Json5Test.kt new file mode 100644 index 0000000..880868e --- /dev/null +++ b/core-model/src/commonTest/kotlin/com/reqlab/core/model/json/Json5Test.kt @@ -0,0 +1,159 @@ +package com.reqlab.core.model.json + +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class Json5Test { + + @Test + fun line_and_block_comments_omit_commented_out_field() { + val text = """ + { + "name": "Ada", + // "role": "admin", + /* "debug": true, */ + "active": true, + } + """.trimIndent() + val wire = Json5.toWireJson(text).getOrThrow() + assertTrue(!wire.contains("//"), wire) + assertTrue(!wire.contains("role"), wire) + assertTrue(!wire.contains("debug"), wire) + val obj = Json5.parseToJsonElement(text).getOrThrow().jsonObject + assertEquals("Ada", obj["name"]?.jsonPrimitive?.content) + assertEquals(true, obj["active"]?.jsonPrimitive?.boolean) + assertEquals(2, obj.size) + } + + @Test + fun slash_slash_inside_string_stays_string() { + val text = """{"url":"http://x","note":"a // b"}""" + val obj = Json5.parseToJsonElement(text).getOrThrow().jsonObject + assertEquals("http://x", obj["url"]?.jsonPrimitive?.content) + assertEquals("a // b", obj["note"]?.jsonPrimitive?.content) + assertEquals(text, Json5.toWireJson(text).getOrThrow()) + } + + @Test + fun trailing_commas_unquoted_keys_single_quotes() { + val text = """{ name: 'Ada', tags: ['api',], }""" + val obj = Json5.parseToJsonElement(text).getOrThrow().jsonObject + assertEquals("Ada", obj["name"]?.jsonPrimitive?.content) + assertEquals("api", obj["tags"]?.jsonArray?.get(0)?.jsonPrimitive?.content) + } + + @Test + fun nested_objects_and_arrays() { + val text = """{ user: { name: "Bob", }, items: [1, 2,], }""" + val obj = Json5.parseToJsonElement(text).getOrThrow().jsonObject + assertEquals("Bob", obj["user"]?.jsonObject?.get("name")?.jsonPrimitive?.content) + assertEquals(2, obj["items"]?.jsonArray?.size) + } + + @Test + fun hex_and_leading_decimal_encode_as_json_numbers() { + val text = """{ hex: 0xFF, frac: .5, plus: +2, trailing: 5. }""" + val obj = Json5.parseToJsonElement(text).getOrThrow().jsonObject + assertEquals(255, obj["hex"]?.jsonPrimitive?.int) + val wire = Json5.toCanonicalJson(text).getOrThrow() + assertTrue(wire.contains("255"), wire) + assertTrue(wire.contains("0.5") || wire.contains("0.50"), wire) + assertTrue(!wire.contains("0x"), wire) + } + + @Test + fun infinity_and_nan_are_rejected() { + assertTrue(Json5.parseToJsonElement("Infinity").isFailure) + assertTrue(Json5.parseToJsonElement("NaN").isFailure) + assertTrue(Json5.parseToJsonElement("{ a: Infinity }").isFailure) + assertTrue(Json5.toWireJson("{ a: NaN }").isFailure) + } + + @Test + fun leftover_garbage_fails() { + assertTrue(Json5.parseToJsonElement("{ a: 1 } extra").isFailure) + assertTrue(Json5.parseToJsonElement("{ a: }").isFailure) + } + + @Test + fun strict_compact_json_toWireJson_is_identity() { + val compact = """{"name":"Alice","age":30}""" + assertEquals(compact, Json5.toWireJson(compact).getOrThrow()) + } + + @Test + fun variable_placeholder_inside_string_is_unchanged() { + val text = """{"id":"{{n}}"}""" + assertEquals(text, Json5.toWireJson(text).getOrThrow()) + assertEquals("{{n}}", Json5.parseToJsonElement(text).getOrThrow().jsonObject["id"]?.jsonPrimitive?.content) + } + + @Test + fun pretty_strict_json_is_also_identity_for_toWireJson() { + val pretty = "{\n \"a\": 1\n}" + assertEquals(pretty, Json5.toWireJson(pretty).getOrThrow()) + } + + @Test + fun canonical_json_is_object() { + val text = """{ a: 1, }""" + val canonical = Json5.toCanonicalJson(text).getOrThrow() + assertTrue(canonical.contains("\"a\""), canonical) + assertTrue(!canonical.trimEnd().endsWith(",}") && !canonical.contains(",\n}"), canonical) + } + + @Test + fun line_continuations_join_string() { + assertEquals("abcd", Json5.parseToJsonElement("'ab\\\ncd'").getOrThrow().jsonPrimitive.content) + assertEquals("abcd", Json5.parseToJsonElement("'ab\\\r\ncd'").getOrThrow().jsonPrimitive.content) + assertEquals("abcd", Json5.parseToJsonElement("'ab\\\rcd'").getOrThrow().jsonPrimitive.content) + assertEquals("abcd", Json5.parseToJsonElement("'ab\\\u2028cd'").getOrThrow().jsonPrimitive.content) + assertEquals("abcd", Json5.parseToJsonElement("'ab\\\u2029cd'").getOrThrow().jsonPrimitive.content) + } + + @Test + fun named_and_hex_escapes() { + val s = Json5.parseToJsonElement("'\\v\\0\\x0f'").getOrThrow().jsonPrimitive.content + assertEquals("\u000B\u0000\u000F", s) + } + + @Test + fun unknown_escape_is_the_character_itself() { + assertEquals("a", Json5.parseToJsonElement("'\\a'").getOrThrow().jsonPrimitive.content) + assertEquals("/", Json5.parseToJsonElement("'\\/'").getOrThrow().jsonPrimitive.content) + } + + @Test + fun unescaped_newline_in_string_fails() { + assertTrue(Json5.parseToJsonElement("\"hello\nworld\"").isFailure) + assertTrue(Json5.parseToJsonElement("'hello\rworld'").isFailure) + } + + @Test + fun digit_escape_other_than_zero_fails() { + assertTrue(Json5.parseToJsonElement("'\\1'").isFailure) + assertTrue(Json5.parseToJsonElement("'\\01'").isFailure) + } + + @Test + fun zero_forms_are_valid_but_leading_zero_integers_are_not() { + val ok = Json5.parseToJsonElement("[0,0.,0e0]").getOrThrow().jsonArray + assertEquals(3, ok.size) + assertTrue(Json5.parseToJsonElement("01").isFailure) + assertTrue(Json5.parseToJsonElement("[01]").isFailure) + assertTrue(Json5.parseToJsonElement("+01").isFailure) + } + + @Test + fun bom_before_object_is_whitespace() { + val obj = Json5.parseToJsonElement("\uFEFF{a:1}").getOrThrow().jsonObject + assertEquals(1, obj["a"]?.jsonPrimitive?.int) + } +} diff --git a/core-network/src/commonMain/kotlin/com/reqlab/core/network/KtorApiClient.kt b/core-network/src/commonMain/kotlin/com/reqlab/core/network/KtorApiClient.kt index 73baab1..6b2e73a 100644 --- a/core-network/src/commonMain/kotlin/com/reqlab/core/network/KtorApiClient.kt +++ b/core-network/src/commonMain/kotlin/com/reqlab/core/network/KtorApiClient.kt @@ -7,6 +7,7 @@ import com.reqlab.core.model.KeyValueEntry import com.reqlab.core.model.RequestDefinition import com.reqlab.core.model.ResponseDefinition import com.reqlab.core.model.ResponseMetrics +import com.reqlab.core.model.json.Json5 import io.ktor.client.HttpClient import io.ktor.client.plugins.HttpTimeout import io.ktor.client.plugins.HttpTimeoutConfig @@ -59,6 +60,7 @@ class KtorApiClient( prettyPrint = true }, private val idleTimeoutMs: Long = 30_000L, + private val allowJson5InJsonBodies: Boolean = true, ) : ApiClient { override fun execute( @@ -184,7 +186,7 @@ class KtorApiClient( applyAuth(builder, request, variableLayers) applyBody(builder, request, variableLayers) - if (requestLooksLikeStreaming(request)) { + if (requestLooksLikeStreaming(request, allowJson5InJsonBodies)) { runCatching { builder.timeout { requestTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS @@ -286,9 +288,16 @@ class KtorApiClient( contentType: ContentType, rawContent: String?, variableLayers: List>, + canonicalizeJson5: Boolean = false, ) { builder.contentType(contentType) - builder.setBody(VariableResolver.resolve(rawContent.orEmpty(), variableLayers)) + val resolved = VariableResolver.resolve(rawContent.orEmpty(), variableLayers) + val wire = if (canonicalizeJson5) { + Json5.toWireJson(resolved).getOrElse { throw it } + } else { + resolved + } + builder.setBody(wire) } private fun applyBody( @@ -299,7 +308,13 @@ class KtorApiClient( val body = request.body when (body.type) { BodyType.NONE -> Unit - BodyType.JSON -> applyRawBody(builder, ContentType.Application.Json, body.content, variableLayers) + BodyType.JSON -> applyRawBody( + builder, + ContentType.Application.Json, + body.content, + variableLayers, + canonicalizeJson5 = allowJson5InJsonBodies, + ) BodyType.RAW_TEXT -> applyRawBody(builder, ContentType.Text.Plain, body.content, variableLayers) BodyType.XML -> applyRawBody(builder, ContentType.Application.Xml, body.content, variableLayers) BodyType.HTML -> applyRawBody(builder, ContentType.Text.Html, body.content, variableLayers) @@ -320,7 +335,12 @@ class KtorApiClient( } if (!variables.isNullOrBlank()) { append(",\"variables\":") - append(variables) + if (allowJson5InJsonBodies) { + val resolvedVars = VariableResolver.resolve(variables, variableLayers) + append(Json5.toWireJson(resolvedVars).getOrElse { throw it }) + } else { + append(variables) + } } append("}") } diff --git a/core-network/src/commonMain/kotlin/com/reqlab/core/network/StreamSupport.kt b/core-network/src/commonMain/kotlin/com/reqlab/core/network/StreamSupport.kt index d496e04..710d7ad 100644 --- a/core-network/src/commonMain/kotlin/com/reqlab/core/network/StreamSupport.kt +++ b/core-network/src/commonMain/kotlin/com/reqlab/core/network/StreamSupport.kt @@ -1,6 +1,7 @@ package com.reqlab.core.network import com.reqlab.core.model.RequestDefinition +import com.reqlab.core.model.json.Json5 import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject @@ -25,14 +26,19 @@ fun isNdjsonContentType(contentType: String?): Boolean { fun isStreamingContentType(contentType: String?): Boolean = isSseContentType(contentType) || isNdjsonContentType(contentType) -fun requestLooksLikeStreaming(request: RequestDefinition): Boolean { +fun requestLooksLikeStreaming(request: RequestDefinition, allowJson5: Boolean = true): Boolean { val acceptStreaming = request.headers.any { it.enabled && it.key.equals("Accept", ignoreCase = true) && (it.value.contains("text/event-stream", ignoreCase = true) || it.value.contains("ndjson", ignoreCase = true)) } val body = request.body.content.orEmpty() - val streamTrue = Regex(""""stream"\s*:\s*true""").containsMatchIn(body) + val streamProbe = if (allowJson5) { + Json5.toWireJson(body).getOrDefault(body) + } else { + body + } + val streamTrue = Regex(""""stream"\s*:\s*true""").containsMatchIn(streamProbe) return acceptStreaming || streamTrue } diff --git a/core-network/src/commonTest/kotlin/com/reqlab/core/network/KtorApiClientTest.kt b/core-network/src/commonTest/kotlin/com/reqlab/core/network/KtorApiClientTest.kt index 7ba63cc..3ac111f 100644 --- a/core-network/src/commonTest/kotlin/com/reqlab/core/network/KtorApiClientTest.kt +++ b/core-network/src/commonTest/kotlin/com/reqlab/core/network/KtorApiClientTest.kt @@ -1,10 +1,11 @@ package com.reqlab.core.network +import com.reqlab.core.model.BodyType +import com.reqlab.core.model.GraphQlBody import com.reqlab.core.model.HttpMethodType import com.reqlab.core.model.KeyValueEntry import com.reqlab.core.model.RequestBody import com.reqlab.core.model.RequestDefinition -import com.reqlab.core.model.BodyType import io.ktor.client.HttpClient import io.ktor.client.engine.mock.MockEngine import io.ktor.client.engine.mock.respond @@ -19,6 +20,7 @@ import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.Json import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertTrue class KtorApiClientTest { @@ -331,4 +333,136 @@ class KtorApiClientTest { assertEquals("Hello", success.response.assembledText) assertEquals(1, success.response.streamEvents.size) } + + @Test + fun json5_on_compact_json_body_bytes_are_unchanged() = runTest { + var capturedBody = "" + val client = capturingClient { capturedBody = it } + val compact = """{"name":"Alice","age":30}""" + val events = client.execute(jsonRequest(compact)).toList() + assertTrue(events.last() is NetworkEvent.Success) + assertEquals(compact, capturedBody) + } + + @Test + fun json5_on_comments_and_trailing_comma_arrive_as_strict_json() = runTest { + var capturedBody = "" + val client = capturingClient { capturedBody = it } + val json5 = """ + { + "name": "Ada", + // "role": "admin", + "active": true, + } + """.trimIndent() + val events = client.execute(jsonRequest(json5)).toList() + assertTrue(events.last() is NetworkEvent.Success) + assertTrue(!capturedBody.contains("//"), capturedBody) + assertTrue(!capturedBody.contains("role"), capturedBody) + assertTrue(capturedBody.contains("Ada"), capturedBody) + assertTrue(capturedBody.contains("active"), capturedBody) + } + + @Test + fun json5_on_invalid_json5_does_not_send() = runTest { + var sent = false + val client = capturingClient { sent = true } + val events = client.execute(jsonRequest("{ not json5")).toList() + assertFalse(sent) + assertTrue(events.last() is NetworkEvent.Failure) + } + + @Test + fun json5_off_sends_comments_as_is() = runTest { + var capturedBody = "" + val client = capturingClient(allowJson5 = false) { capturedBody = it } + val json5 = """ + { + "name": "Ada", + // "role": "admin", + "active": true, + } + """.trimIndent() + val events = client.execute(jsonRequest(json5)).toList() + assertTrue(events.last() is NetworkEvent.Success) + assertTrue(capturedBody.contains("//"), capturedBody) + assertTrue(capturedBody.contains("role"), capturedBody) + } + + @Test + fun json5_on_resolves_vars_in_strings_and_omits_comment_only_secrets() = runTest { + var capturedBody = "" + val client = capturingClient { capturedBody = it } + val json5 = """ + { + "id": "{{n}}", + // "secret": "{{secret}}" + } + """.trimIndent() + val events = client.execute( + jsonRequest(json5), + listOf(mapOf("n" to "42", "secret" to "s3cret-value")), + ).toList() + assertTrue(events.last() is NetworkEvent.Success) + assertTrue(capturedBody.contains("42"), capturedBody) + assertTrue(!capturedBody.contains("{{n}}"), capturedBody) + assertTrue(!capturedBody.contains("s3cret-value"), capturedBody) + assertTrue(!capturedBody.contains("secret"), capturedBody) + } + + @Test + fun json5_on_graphql_variables_with_comment_are_strict_json() = runTest { + var capturedBody = "" + val client = capturingClient { capturedBody = it } + val request = RequestDefinition( + id = "req-gql", + name = "GQL", + method = HttpMethodType.POST, + url = "https://api.test/graphql", + body = RequestBody( + type = BodyType.GRAPHQL, + graphQl = GraphQlBody( + query = "query { user }", + variablesJson = "{\n // skip\n \"id\": \"1\",\n}", + ), + ), + createdAtEpochMillis = 1L, + updatedAtEpochMillis = 1L, + ) + val events = client.execute(request).toList() + assertTrue(events.last() is NetworkEvent.Success) + assertTrue(!capturedBody.contains("//"), capturedBody) + assertTrue(capturedBody.contains("\"id\""), capturedBody) + assertTrue(capturedBody.contains("1"), capturedBody) + } + + private fun capturingClient( + allowJson5: Boolean = true, + onCapture: (String) -> Unit, + ): KtorApiClient { + val mockEngine = MockEngine { request -> + onCapture(request.body.toByteArray().decodeToString()) + respond( + content = "ok", + status = HttpStatusCode.OK, + headers = io.ktor.http.headersOf(HttpHeaders.ContentType, ContentType.Text.Plain.toString()), + ) + } + val http = HttpClient(mockEngine) { expectSuccess = false } + return KtorApiClient( + httpClient = http, + retryPolicy = RetryPolicy(maxAttempts = 1), + allowJson5InJsonBodies = allowJson5, + ) + } + + private fun jsonRequest(content: String) = RequestDefinition( + id = "req-json", + name = "JSON", + method = HttpMethodType.POST, + url = "https://api.test/json", + body = RequestBody(BodyType.JSON, content = content), + createdAtEpochMillis = 1L, + updatedAtEpochMillis = 1L, + ) } diff --git a/core-network/src/commonTest/kotlin/com/reqlab/core/network/LlmTextAssemblerTest.kt b/core-network/src/commonTest/kotlin/com/reqlab/core/network/LlmTextAssemblerTest.kt index b780805..ae9620a 100644 --- a/core-network/src/commonTest/kotlin/com/reqlab/core/network/LlmTextAssemblerTest.kt +++ b/core-network/src/commonTest/kotlin/com/reqlab/core/network/LlmTextAssemblerTest.kt @@ -63,4 +63,27 @@ class LlmTextAssemblerTest { ) assertTrue(requestLooksLikeStreaming(accept)) } + + @Test + fun requestLooksLikeStreaming_json5_on_ignores_commented_stream_true() { + val commented = RequestDefinition( + id = "2", + name = "s", + method = HttpMethodType.POST, + url = "https://api.test/v1/chat/completions", + body = RequestBody( + BodyType.JSON, + content = """ + { + // "stream": true + "n": 1 + } + """.trimIndent(), + ), + createdAtEpochMillis = 1, + updatedAtEpochMillis = 1, + ) + assertFalse(requestLooksLikeStreaming(commented, allowJson5 = true)) + assertTrue(requestLooksLikeStreaming(commented, allowJson5 = false)) + } } diff --git a/docs/tests.md b/docs/tests.md index 313d5f6..7987e3c 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -57,6 +57,8 @@ Import [qa-tests/fixtures/reqlab-test-collection.json](../qa-tests/fixtures/reql The JS collection validator skips `STDIO` and `LEGACY_2024_11_05`. Kotlin e2e (`:qa-tests:jvmTest --tests com.reqlab.qa.McpHttpE2ETest`) is the protocol source of truth. Product guide: [docs/mcp.md](mcp.md). Mock routes and PATH shim: [DEVELOPMENT.md](../DEVELOPMENT.md). +Folder **JSON5** under Body Types authors JSON5 in `body.content` strings. The JS collection validator **skips** paths that include `JSON5` and does **not** count them as passed (Node `fetch` does not convert). Kotlin e2e (`:qa-tests:jvmTest --tests com.reqlab.qa.SampleCollectionE2ETest`) through `KtorApiClient` is the only JSON5 send coverage. + Folder **SSE** is generic `text/event-stream` (`GET`/`POST /sse`). The **LLM (OpenAI-compatible)** folder remains OpenAI chat completions. Run full project checks: @@ -75,7 +77,7 @@ Run full project checks: ## Coverage Highlights -- HTTP methods, body types (JSON, form-data, urlencoded, raw, binary), auth (Basic, Bearer, API Key) +- HTTP methods, body types (JSON, JSON5 authoring, form-data, urlencoded, raw, binary), auth (Basic, Bearer, API Key) - SSE/NDJSON streaming, generic `/sse` mocks, and OpenAI-compatible `/v1` chat mocks (including `?demo=true`) - Variable interpolation (`{{var}}`), scope precedence, and scripting runtime (pre-request, post-response, assertions, `response.llm.*`) - Retry/error handling and WebSocket lifecycle (connect, send, receive, disconnect, reconnect) diff --git a/editor-core/build.gradle.kts b/editor-core/build.gradle.kts index fe133cb..a7e68dc 100644 --- a/editor-core/build.gradle.kts +++ b/editor-core/build.gradle.kts @@ -16,6 +16,7 @@ kotlin { sourceSets { commonMain.dependencies { + implementation(project(":core-model")) implementation(libs.serialization.json) } commonTest.dependencies { diff --git a/editor-core/src/commonMain/kotlin/com/reqlab/editor/core/EditorEngine.kt b/editor-core/src/commonMain/kotlin/com/reqlab/editor/core/EditorEngine.kt index f7052e4..bab84e8 100644 --- a/editor-core/src/commonMain/kotlin/com/reqlab/editor/core/EditorEngine.kt +++ b/editor-core/src/commonMain/kotlin/com/reqlab/editor/core/EditorEngine.kt @@ -101,9 +101,10 @@ class EditorEngine { fun moveCursorRight(state: EditorState): EditorState = state.copy(cursor = state.cursor.moveRight(state.document), selection = SelectionModel.EMPTY) - fun validate(text: String, languageMode: LanguageMode): List { + fun validate(text: String, languageMode: LanguageMode, allowJson5: Boolean = false): List { if (text.isBlank()) return emptyList() - return LanguageRegistry.getProvider(languageMode).validate(text) + val provider = jsonProvider(languageMode, allowJson5) + return provider.validate(text) } fun visibleLines(state: EditorState): List> { @@ -117,4 +118,8 @@ class EditorEngine { val foldRegions = if (state.foldingEnabled) provider.foldingRegions(state.document) else emptyList() return state.copy(diagnostics = diagnostics, folding = state.folding.updateRegions(foldRegions)) } + + private fun jsonProvider(languageMode: LanguageMode, allowJson5: Boolean): LanguageModeProvider = + if (allowJson5 && languageMode == LanguageMode.JSON) Json5EditorSupport + else LanguageRegistry.getProvider(languageMode) } diff --git a/editor-core/src/commonMain/kotlin/com/reqlab/editor/core/Json5EditorSupport.kt b/editor-core/src/commonMain/kotlin/com/reqlab/editor/core/Json5EditorSupport.kt new file mode 100644 index 0000000..0afc6bc --- /dev/null +++ b/editor-core/src/commonMain/kotlin/com/reqlab/editor/core/Json5EditorSupport.kt @@ -0,0 +1,410 @@ +package com.reqlab.editor.core + +import com.reqlab.core.model.json.Json5 +import com.reqlab.core.model.json.Json5ParseException + +/** + * JSON5 editor adapter. Used only when the JSON5 setting is on. + * [JsonMode] remains the strict JSON provider. + */ +object Json5EditorSupport : LanguageModeProvider { + override val mode = LanguageMode.JSON + override val displayName = "JSON5" + override val fileExtensions = listOf("json5", "jsonc") + override val mimeTypes = listOf("application/json", "text/json") + override val foldingStyle = FoldingStyle.BRACE + + data class TokenState( + val inBlockComment: Boolean = false, + val inString: Char? = null, + ) + + override fun tokenizeLine(line: String, lineNumber: Int, state: Any?): Pair, Any?> { + val tokens = mutableListOf() + val prev = state as? TokenState ?: TokenState() + var i = 0 + var inBlock = prev.inBlockComment + var inString = prev.inString + + if (inBlock) { + val endIdx = line.indexOf("*/") + if (endIdx >= 0) { + tokens.add(Token(0, endIdx + 2, TokenType.COMMENT)) + i = endIdx + 2 + inBlock = false + } else { + tokens.add(Token(0, line.length, TokenType.COMMENT)) + return tokens to TokenState(true, inString) + } + } + + if (inString != null) { + val end = findUnescaped(line, inString, 0) + if (end >= 0) { + tokens.add(Token(0, end + 1, TokenType.STRING)) + i = end + 1 + inString = null + } else { + tokens.add(Token(0, line.length, TokenType.STRING)) + return tokens to TokenState(false, inString) + } + } + + while (i < line.length) { + val c = line[i] + when { + c.isWhitespace() -> i++ + c == '/' && i + 1 < line.length && line[i + 1] == '/' -> { + tokens.add(Token(i, line.length, TokenType.COMMENT)) + i = line.length + } + c == '/' && i + 1 < line.length && line[i + 1] == '*' -> { + val endIdx = line.indexOf("*/", i + 2) + if (endIdx >= 0) { + tokens.add(Token(i, endIdx + 2, TokenType.COMMENT)) + i = endIdx + 2 + } else { + tokens.add(Token(i, line.length, TokenType.COMMENT)) + inBlock = true + i = line.length + } + } + c == '"' || c == '\'' -> { + val end = findUnescaped(line, c, i + 1) + if (end >= 0) { + tokens.add(Token(i, end + 1, TokenType.STRING)) + i = end + 1 + } else { + tokens.add(Token(i, line.length, TokenType.STRING)) + inString = c + i = line.length + } + } + c == ':' || c == ',' || c == '{' || c == '}' || c == '[' || c == ']' -> { + tokens.add(Token(i, i + 1, TokenType.PUNCTUATION)); i++ + } + c == 't' || c == 'f' -> { + val word = if (c == 't') "true" else "false" + if (line.startsWith(word, i) && !continuesIdent(line, i + word.length)) { + tokens.add(Token(i, i + word.length, TokenType.KEYWORD)); i += word.length + } else { + val end = scanIdent(line, i) + tokens.add(Token(i, end, TokenType.PROPERTY)); i = end + } + } + c == 'n' -> { + if (line.startsWith("null", i) && !continuesIdent(line, i + 4)) { + tokens.add(Token(i, i + 4, TokenType.KEYWORD)); i += 4 + } else { + val end = scanIdent(line, i) + tokens.add(Token(i, end, TokenType.PROPERTY)); i = end + } + } + c == '+' || c == '-' || c == '.' || c.isDigit() -> { + val end = scanNumber(line, i) + tokens.add(Token(i, end, TokenType.NUMBER)); i = end + } + c.isLetter() || c == '_' || c == '$' -> { + val end = scanIdent(line, i) + tokens.add(Token(i, end, TokenType.PROPERTY)); i = end + } + else -> { tokens.add(Token(i, i + 1, TokenType.ERROR)); i++ } + } + } + return tokens to TokenState(inBlock, inString) + } + + override fun foldingRegions(document: EditorDocument): List { + val regions = mutableListOf() + val stack = ArrayDeque() + val text = document.text + var line = 1 + var inStr: Char? = null + var escaped = false + var inLineComment = false + var inBlockComment = false + var i = 0 + while (i < text.length) { + val ch = text[i] + if (inLineComment) { + if (ch == '\n') { inLineComment = false; line++ } + i++; continue + } + if (inBlockComment) { + if (ch == '*' && i + 1 < text.length && text[i + 1] == '/') { + inBlockComment = false; i += 2; continue + } + if (ch == '\n') line++ + i++; continue + } + if (escaped) { escaped = false; i++; continue } + if (inStr != null) { + if (ch == '\\') { escaped = true; i++; continue } + if (ch == inStr) inStr = null + if (ch == '\n') line++ + i++; continue + } + when { + ch == '/' && i + 1 < text.length && text[i + 1] == '/' -> { inLineComment = true; i += 2 } + ch == '/' && i + 1 < text.length && text[i + 1] == '*' -> { inBlockComment = true; i += 2 } + ch == '"' || ch == '\'' -> { inStr = ch; i++ } + ch == '{' || ch == '[' -> { stack.addLast(line); i++ } + ch == '}' || ch == ']' -> { + if (stack.isNotEmpty()) { + val s = stack.removeLast() + if (line > s) regions.add(FoldRegion(s, line)) + } + i++ + } + ch == '\n' -> { line++; i++ } + else -> i++ + } + } + return regions.sortedBy { it.startLine } + } + + override fun validate(text: String): List { + if (text.isBlank()) return emptyList() + return Json5.parseToJsonElement(text).fold( + onSuccess = { emptyList() }, + onFailure = { e -> + val offset = (e as? Json5ParseException)?.offset + ?: extractOffset(e.message.orEmpty()) + ?: 0 + val pos = offsetToLineCol(text, offset.coerceIn(0, text.length)) + listOf(InlineEditorError(pos.first, pos.second, e.message ?: "Invalid JSON5")) + }, + ) + } + + override fun format(text: String): String { + val strict = JsonMode.format(text) + if (strict != text) return strict + if (Json5.parseToJsonElement(text).isFailure) return text + val indented = indentJson5(text) + return if (Json5.parseToJsonElement(indented).isFailure) text else indented + } + + /** + * Pretty-print JSON5 without rewriting quotes, keys, or comments. + * Structural whitespace only: 2-space indent, newline after `{` `[` `,`, + * space after `:`. + */ + private fun indentJson5(text: String): String { + if (text.isBlank()) return text + val input = text.replace("\r\n", "\n").replace('\r', '\n') + val out = StringBuilder(input.length + 64) + var i = 0 + var indent = 0 + var atLineStart = true + var pendingSpace = false + + fun appendIndentIfNeeded() { + if (!atLineStart) return + repeat(indent.coerceAtLeast(0)) { out.append(" ") } + atLineStart = false + } + + fun appendNewLine() { + if (out.isNotEmpty() && out.last() != '\n') out.append('\n') + atLineStart = true + pendingSpace = false + } + + fun flushPendingSpace() { + if (pendingSpace && out.isNotEmpty() && out.last() != '\n' && out.last() != ' ') { + out.append(' ') + } + pendingSpace = false + } + + fun peek(offset: Int = 1): Char? = input.getOrNull(i + offset) + + fun isWs(c: Char) = c.isWhitespace() || c == '\uFEFF' + + while (i < input.length) { + val c = input[i] + val n = peek() + + if (isWs(c)) { + i++ + continue + } + + if (c == '/' && n == '/') { + appendIndentIfNeeded() + flushPendingSpace() + out.append("//") + i += 2 + while (i < input.length && input[i] != '\n') { + out.append(input[i]) + i++ + } + if (i < input.length && input[i] == '\n') { + out.append('\n') + atLineStart = true + pendingSpace = false + i++ + } + continue + } + + if (c == '/' && n == '*') { + appendIndentIfNeeded() + flushPendingSpace() + out.append("/*") + i += 2 + while (i < input.length) { + val ch = input[i] + out.append(ch) + if (ch == '\n') { + atLineStart = true + pendingSpace = false + } else { + atLineStart = false + } + if (ch == '*' && peek() == '/') { + out.append('/') + i += 2 + pendingSpace = true + break + } + i++ + } + continue + } + + if (c == '"' || c == '\'') { + appendIndentIfNeeded() + flushPendingSpace() + out.append(c) + i++ + var escaped = false + while (i < input.length) { + val ch = input[i] + out.append(ch) + if (escaped) { + escaped = false + } else if (ch == '\\') { + escaped = true + } else if (ch == c) { + i++ + break + } + i++ + } + atLineStart = false + continue + } + + when (c) { + '{', '[' -> { + appendIndentIfNeeded() + flushPendingSpace() + out.append(c) + indent++ + pendingSpace = false + appendNewLine() + } + '}', ']' -> { + indent = (indent - 1).coerceAtLeast(0) + if (!atLineStart) appendNewLine() + appendIndentIfNeeded() + out.append(c) + pendingSpace = false + atLineStart = false + } + ',' -> { + while (out.isNotEmpty() && out.last() == ' ') out.deleteAt(out.lastIndex) + out.append(',') + pendingSpace = false + appendNewLine() + } + ':' -> { + out.append(':') + pendingSpace = true + } + else -> { + appendIndentIfNeeded() + flushPendingSpace() + while (i < input.length) { + val ch = input[i] + if (isWs(ch) || ch == '{' || ch == '}' || ch == '[' || ch == ']' || + ch == ',' || ch == ':' || ch == '"' || ch == '\'' || + (ch == '/' && (peek() == '/' || peek() == '*')) + ) break + out.append(ch) + i++ + } + atLineStart = false + continue + } + } + i++ + } + return out.toString().trimEnd() + } + + private fun findUnescaped(line: String, quote: Char, start: Int): Int { + var i = start + while (i < line.length) { + when (line[i]) { + '\\' -> i += 2 + quote -> return i + else -> i++ + } + } + return -1 + } + + private fun scanIdent(line: String, start: Int): Int { + var i = start + while (i < line.length) { + val c = line[i] + if (c.isLetterOrDigit() || c == '_' || c == '$') i++ else break + } + return i + } + + private fun continuesIdent(line: String, index: Int): Boolean { + if (index >= line.length) return false + val c = line[index] + return c.isLetterOrDigit() || c == '_' || c == '$' + } + + private fun scanNumber(line: String, start: Int): Int { + var i = start + if (i < line.length && (line[i] == '+' || line[i] == '-')) i++ + if (i < line.length && line[i] == '0' && i + 1 < line.length && (line[i + 1] == 'x' || line[i + 1] == 'X')) { + i += 2 + while (i < line.length && (line[i].isDigit() || line[i] in 'a'..'f' || line[i] in 'A'..'F')) i++ + return i + } + if (i < line.length && line[i] == '.') i++ + while (i < line.length && line[i].isDigit()) i++ + if (i < line.length && line[i] == '.') { i++; while (i < line.length && line[i].isDigit()) i++ } + if (i < line.length && (line[i] == 'e' || line[i] == 'E')) { + i++; if (i < line.length && (line[i] == '+' || line[i] == '-')) i++ + while (i < line.length && line[i].isDigit()) i++ + } + return i + } + + private fun extractOffset(message: String): Int? { + val idx = message.indexOf("offset ") + if (idx == -1) return null + val start = idx + 7 + var end = start + while (end < message.length && message[end].isDigit()) end++ + return message.substring(start, end).toIntOrNull() + } + + private fun offsetToLineCol(text: String, offset: Int): Pair { + var line = 1 + var col = 1 + for (i in 0 until minOf(offset, text.length)) { + if (text[i] == '\n') { line++; col = 1 } else col++ + } + return line to col + } +} diff --git a/editor-core/src/commonTest/kotlin/com/reqlab/editor/core/Json5EditorSupportTest.kt b/editor-core/src/commonTest/kotlin/com/reqlab/editor/core/Json5EditorSupportTest.kt new file mode 100644 index 0000000..3fd451f --- /dev/null +++ b/editor-core/src/commonTest/kotlin/com/reqlab/editor/core/Json5EditorSupportTest.kt @@ -0,0 +1,127 @@ +package com.reqlab.editor.core + +import com.reqlab.core.model.json.Json5 +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class Json5EditorSupportTest { + + @BeforeTest + fun setup() { + LanguageRegistry.registerBuiltins() + } + + @Test + fun tokenize_line_and_block_comments_as_comment() { + val (line, _) = Json5EditorSupport.tokenizeLine("""{ a: 1, // skip""", 1, null) + assertTrue(line.any { it.type == TokenType.COMMENT }, line.toString()) + val (block, _) = Json5EditorSupport.tokenizeLine("""{ /* x */ "k": 1 }""", 1, null) + assertTrue(block.any { it.type == TokenType.COMMENT }, block.toString()) + } + + @Test + fun tokenize_unquoted_key_as_property() { + val (tokens, _) = Json5EditorSupport.tokenizeLine("name: 'Ada'", 1, null) + assertTrue(tokens.any { it.type == TokenType.PROPERTY }, tokens.toString()) + assertTrue(tokens.any { it.type == TokenType.STRING }, tokens.toString()) + } + + @Test + fun validate_accepts_trailing_comma_and_comments() { + val errors = Json5EditorSupport.validate( + """ + { + "name": "Ada", + // "role": "admin", + "active": true, + } + """.trimIndent(), + ) + assertEquals(0, errors.size, errors.toString()) + } + + @Test + fun format_of_valid_json_matches_json_mode() { + val compact = """{"name":"Alice","age":30}""" + assertEquals(JsonMode.format(compact), Json5EditorSupport.format(compact)) + } + + @Test + fun format_json5_preserves_comments_and_unquoted_keys() { + val text = """ + {a:1, // keep + b: 'Ada',} + """.trimIndent() + val formatted = Json5EditorSupport.format(text) + assertTrue(formatted.contains("//"), formatted) + assertTrue(formatted.contains("keep"), formatted) + assertTrue(formatted.contains("a"), formatted) + assertTrue(!formatted.contains("\"a\""), formatted) + assertTrue(formatted.contains("'Ada'"), formatted) + assertTrue(formatted.contains(","), formatted) + assertTrue(formatted.lines().size > 1, formatted) + } + + @Test + fun format_json5_twice_still_parses_and_keeps_dialect() { + val text = """{ a: 1, // c +b: 'x', }""" + val once = Json5EditorSupport.format(text) + val twice = Json5EditorSupport.format(once) + assertTrue(once.contains("//"), once) + assertTrue(once.contains("a"), once) + assertTrue(!once.contains("\"a\""), once) + assertTrue(Json5.parseToJsonElement(once).isSuccess, once) + assertTrue(Json5.parseToJsonElement(twice).isSuccess, twice) + assertTrue(twice.contains("//"), twice) + assertTrue(twice.contains("'x'"), twice) + assertEquals(once, twice) + } + + @Test + fun format_json5_invalid_returns_original() { + val text = "{ a: " + assertEquals(text, Json5EditorSupport.format(text)) + } + + @Test + fun format_json5_preserves_wire_json_for_dialect_fixtures() { + val fixtures = listOf( + "{a:1, // keep\nb: 'Ada',}", + "{a:1,}", + """{a:"x{y}"}""", + "{a:0xFF}", + "{a:.5}", + "[]", + "{}", + ) + for (input in fixtures) { + val formatted = Json5EditorSupport.format(input) + val originalWire = Json5.toCanonicalJson(input).getOrThrow() + val formattedWire = Json5.toCanonicalJson(formatted).getOrThrow() + assertEquals(originalWire, formattedWire, "format changed meaning for: $input\nformatted=$formatted") + if (input.contains("x{y}")) { + assertTrue(formatted.contains("x{y}"), formatted) + } + if (input.contains("0xFF")) { + assertTrue(formatted.contains("0xFF"), formatted) + } + } + } + + @Test + fun folding_ignores_braces_inside_comments() { + val text = """ + { + // { not a fold + "a": 1 + } + """.trimIndent() + val regions = Json5EditorSupport.foldingRegions(EditorDocument.create(text)) + assertEquals(1, regions.size) + assertEquals(1, regions[0].startLine) + assertEquals(4, regions[0].endLine) + } +} diff --git a/editor-ui/src/commonMain/kotlin/com/reqlab/editor/ui/EditorHighlighter.kt b/editor-ui/src/commonMain/kotlin/com/reqlab/editor/ui/EditorHighlighter.kt index 08624ec..d3d39bf 100644 --- a/editor-ui/src/commonMain/kotlin/com/reqlab/editor/ui/EditorHighlighter.kt +++ b/editor-ui/src/commonMain/kotlin/com/reqlab/editor/ui/EditorHighlighter.kt @@ -3,6 +3,7 @@ package com.reqlab.editor.ui import androidx.compose.ui.text.AnnotatedString import com.reqlab.editor.core.LanguageMode import com.reqlab.editor.core.LanguageRegistry +import com.reqlab.editor.core.Json5EditorSupport /** * Top-level highlighting entry points. @@ -39,8 +40,13 @@ fun highlightLine(line: String, mode: LanguageMode): AnnotatedString { * Delegates to the [com.reqlab.editor.core.TextFormatter] (via [LanguageRegistry]) registered * for [mode], so custom formatters registered with [LanguageRegistry.register] are picked up. */ -fun autoFormat(text: String, mode: LanguageMode): String { +fun autoFormat(text: String, mode: LanguageMode, allowJson5: Boolean = false): String { if (!LanguageRegistry.hasProvider(mode)) LanguageRegistry.registerBuiltins() - return LanguageRegistry.getProvider(mode).format(text) + val provider = if (allowJson5 && mode == LanguageMode.JSON) { + Json5EditorSupport + } else { + LanguageRegistry.getProvider(mode) + } + return provider.format(text) } diff --git a/editor-ui/src/commonMain/kotlin/com/reqlab/editor/ui/EditorRenderer.kt b/editor-ui/src/commonMain/kotlin/com/reqlab/editor/ui/EditorRenderer.kt index 94d4ea7..4ca264e 100644 --- a/editor-ui/src/commonMain/kotlin/com/reqlab/editor/ui/EditorRenderer.kt +++ b/editor-ui/src/commonMain/kotlin/com/reqlab/editor/ui/EditorRenderer.kt @@ -36,8 +36,8 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.ScrollState import androidx.compose.foundation.rememberScrollbarAdapter import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.DropdownMenu @@ -47,6 +47,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.remember @@ -128,7 +129,7 @@ private val NON_CHARACTER_KEYS = setOf( * @param language Language mode driving syntax highlighting. * @param theme Color theme. Defaults to [EditorTheme.Dark]. * @param wordWrap Whether long lines wrap or scroll horizontally. - * @param onTextChange Called (debounced 150 ms) whenever the document changes. + * @param onTextChange Called immediately whenever the document changes (no debounce). * @param onPasteRequest Called to fetch clipboard text on Ctrl/Cmd+V. Return null to skip. * @param onCopyRequest Called with the selected text on Ctrl/Cmd+C. Write it to the clipboard. * @param testTagPrefix Compose test-tag prefix for integration tests. @@ -154,6 +155,8 @@ fun EditorRenderer( onHorizontalScroll: ((Int) -> Unit)? = null, /** Called once after composition with the internal horizontal ScrollState. For testing. */ onScrollStateReady: ((androidx.compose.foundation.ScrollState) -> Unit)? = null, + /** Called once after composition with the vertical LazyListState. For testing. */ + onListStateReady: ((LazyListState) -> Unit)? = null, /** Called when the user primary-clicks/taps in the editor content area. */ onPrimaryTapOffset: ((Int) -> Unit)? = null, /** @@ -163,25 +166,38 @@ fun EditorRenderer( * Pass `null` (the default) to leave the editor with plain syntax colours. */ lineVariableSpans: ((lineText: String, lineStartOffset: Int) -> List>)? = null, + /** + * Optional focus requester for the editor surface. When null, an internal requester is used. + * Pass a shared instance from a parent (e.g. CodeEditor toolbar) to restore focus after + * toolbar clicks. + */ + focusRequester: FocusRequester? = null, ) { val state by viewModel.state.collectAsState() - val listState = rememberLazyListState() - val hScrollState = rememberScrollState() + val listStateCache = remember { mutableMapOf() } + val hScrollCache = remember { mutableMapOf() } + val listState = remember(viewModel) { + listStateCache.getOrPut(viewModel) { LazyListState() } + } + val hScrollState = remember(viewModel) { + hScrollCache.getOrPut(viewModel) { ScrollState(0) } + } val scope = rememberCoroutineScope() - val focus = remember { FocusRequester() } + val fallbackFocus = remember { FocusRequester() } + val focus = focusRequester ?: fallbackFocus // Tracks the widest line seen (px) so the dummy spacer keeps hScrollState.maxValue correct. - // Reset on every document edit (state.version) so deleted/replaced lines shrink the range. - var hMaxContentWidthPx by remember(state.version) { mutableStateOf(0) } + // Reset on every document edit (state.version) and on VM switch so deleted/replaced lines shrink. + var hMaxContentWidthPx by remember(viewModel, state.version) { mutableStateOf(0) } // Cache of per-displayLine TextLayoutResults fed back from LineView. // Used by the drag handler to map pointer coords → char offset accurately. - val layoutResultCache = remember { mutableStateMapOf() } + val layoutResultCache = remember(viewModel) { mutableStateMapOf() } // Context-menu state: show a DropdownMenu on secondary-button (right-click) press. var contextMenuVisible by remember { mutableStateOf(false) } var contextMenuOffset by remember { mutableStateOf(Offset.Zero) } var shiftPressed by remember { mutableStateOf(false) } // Plain mutable object (non-state) — tracks previous doc shape to detect paste. // Updated inside the scroll LaunchedEffect so there is no cross-coroutine race. - val prevDocState = remember { + val prevDocState = remember(viewModel) { object { var length = viewModel.document.length var lineCount = viewModel.document.lineCount @@ -218,9 +234,18 @@ fun EditorRenderer( label = "cursorAlpha", ) - // Expose hScrollState to tests after first successful composition. + // Expose scroll state to tests after first successful composition. androidx.compose.runtime.SideEffect { onScrollStateReady?.invoke(hScrollState) + onListStateReady?.invoke(listState) + } + + val lastWrap = remember(viewModel) { object { var value = wordWrap } } + LaunchedEffect(wordWrap, viewModel) { + if (lastWrap.value != wordWrap) { + hScrollState.scrollTo(0) + lastWrap.value = wordWrap + } } LaunchedEffect(listState.firstVisibleItemIndex, listState.layoutInfo) { @@ -239,24 +264,41 @@ fun EditorRenderer( try { focus.requestFocus() } catch (_: Exception) { } } + // Skip caret-follow once after a tab switch so a restored LazyListState keeps its + // viewport. Cursor/version keys change when `viewModel` changes even though this + // document was not edited. + val lastScrollVm = remember { object { var value: EditorViewModel? = null } } + // Auto-scroll the LazyColumn to keep the cursor line in view, but suppress after // paste. Keying on BOTH cursorOffset and version means this single effect handles // cursor navigation (only cursorOffset changes) and typed/pasted edits (both change). // Because the paste guard and the scroll decision live in the SAME coroutine there // is no possible race between a "set flag" effect and a "read flag" effect. - LaunchedEffect(state.cursorOffset, state.version) { + LaunchedEffect(state.cursorOffset, state.version, viewModel) { + val restoredTab = lastScrollVm.value !== viewModel + lastScrollVm.value = viewModel val newLen = viewModel.document.length val newLineCount = viewModel.document.lineCount val charDelta = kotlin.math.abs(newLen - prevDocState.length) val lineDelta = newLineCount - prevDocState.lineCount prevDocState.length = newLen prevDocState.lineCount = newLineCount - // Suppress scroll for large edits OR multi-line insertions (paste). + if (restoredTab) return@LaunchedEffect + // Suppress scroll for large edits OR multi-line insertions (paste / Format). // Pressing Enter adds exactly 1 line (lineDelta == 1) → still scrolls. // Pasting any multi-line content adds ≥ 2 → scroll suppressed. if (charDelta >= LARGE_EDIT_SCROLL_SUPPRESS_THRESHOLD_CHARS || lineDelta >= 2) { return@LaunchedEffect } + // Shrink (Format undo, select-all delete): do not jump to the caret if the + // current viewport still shows a valid line. If we scrolled past the new + // end, snap to the top. + if (lineDelta < 0) { + if (listState.firstVisibleItemIndex >= newLineCount) { + listState.scrollToItem(0) + } + return@LaunchedEffect + } val cursorDocLine = viewModel.document.lineAt(state.cursorOffset) val displayLine = viewModel.displayLineMap.displayFromDoc(cursorDocLine) if (displayLine < 0) return@LaunchedEffect @@ -490,6 +532,7 @@ fun EditorRenderer( val gutterWidthPx = with(density) { gutterWidth.toPx() } // ── Single LazyColumn: each item is [gutter | divider | content] ── + key(viewModel) { LazyColumn( state = listState, modifier = Modifier @@ -504,7 +547,7 @@ fun EditorRenderer( // Runs at PointerEventPass.Initial so it can consume vertical drag events // before LazyColumn's built-in scroll handler sees them, preventing // accidental scroll during text selection drag. - .pointerInput(gutterWidthPx) { + .pointerInput(gutterWidthPx, viewModel) { awaitEachGesture { // Observe DOWN without requiring it to be unconsumed // (LineView.awaitFirstDown also uses requireUnconsumed=false) @@ -707,13 +750,13 @@ fun EditorRenderer( val w = coords.size.width if (w > hMaxContentWidthPx) hMaxContentWidthPx = w } - ) - .padding(start = 8.dp, end = 16.dp, top = 1.dp, bottom = 1.dp), + ), ) } } } } + } // ── Scrollbars (overlay) ────────────────────────────────────── val scrollbarStyle = ScrollbarStyle( diff --git a/editor-ui/src/commonMain/kotlin/com/reqlab/editor/ui/EditorViewModel.kt b/editor-ui/src/commonMain/kotlin/com/reqlab/editor/ui/EditorViewModel.kt index d0a346e..b69d3a8 100644 --- a/editor-ui/src/commonMain/kotlin/com/reqlab/editor/ui/EditorViewModel.kt +++ b/editor-ui/src/commonMain/kotlin/com/reqlab/editor/ui/EditorViewModel.kt @@ -6,6 +6,7 @@ import com.reqlab.editor.core.FoldRegion import com.reqlab.editor.core.FoldingStyle import com.reqlab.editor.core.InlineEditorError import com.reqlab.editor.core.LanguageMode +import com.reqlab.editor.core.LanguageModeProvider import com.reqlab.editor.core.LanguageRegistry import com.reqlab.editor.core.StyleBuffer import kotlinx.coroutines.CoroutineScope @@ -24,7 +25,6 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext // ── Display state ──────────────────────────────────────────────── @@ -54,6 +54,7 @@ private data class EditCommand( class EditorViewModel( initialText: String, val languageMode: LanguageMode, + languageProvider: LanguageModeProvider? = null, ) { val document = DocumentModel(initialText) val styleBuffer = StyleBuffer(maxOf(initialText.length, 64)) @@ -62,7 +63,7 @@ class EditorViewModel( private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) private val mutex = Mutex() - private val provider = LanguageRegistry.getProvider(languageMode) + private val provider = languageProvider ?: LanguageRegistry.getProvider(languageMode) private val idleLexer = IdleLexer( document = document, @@ -83,8 +84,8 @@ class EditorViewModel( val state: StateFlow = _state.asStateFlow() // textChangedFlow — emitted immediately on every local edit. - // Debouncing (150 ms) is applied in the composable LaunchedEffect so that - // Compose tests can advance the clock past the debounce with waitForIdle(). + // The renderer collectLatest invokes onTextChange with getFullText() on the same + // path as a keystroke (no 150 ms debounce). private val _textChangedFlow = MutableSharedFlow(extraBufferCapacity = 64) val textChangedFlow: SharedFlow = _textChangedFlow.asSharedFlow() @@ -110,44 +111,65 @@ class EditorViewModel( fun onExternalTextChanged(text: String) { if (text == lastExternalText) return + editSequence++ lastExternalText = text clearHistory() - // Notify immediately: lastExternalText is already correct, so onTextChange fires - // before the background coroutine completes. The guard above prevents feedback loops - // when onTextChange → bodyContent update → LaunchedEffect → onExternalTextChanged. - notifyTextChanged() + // Document and lastExternalText stay in lockstep so typing cannot apply + // offsets from the new string against a stale buffer. + document.replaceAll(text) + styleBuffer.invalidateFrom(0) + displayLineMap.reset(document.lineCount) val capturedSeq = editSequence + val newVersion = document.version + val docLen = document.length + val hasTruncation = computeHasLineTruncation() + _state.update { + it.copy( + version = newVersion, + styleClock = styleBuffer.styleClock, + cursorOffset = it.cursorOffset.coerceIn(0, docLen), + selectionStart = -1, + selectionEnd = -1, + diagnostics = emptyList(), + totalDisplayLines = displayLineMap.totalDisplayLines, + hasLineTruncation = hasTruncation, + ) + } + notifyTextChanged() + idleLexer.scheduleFrom(0, scope) + scheduleDiagnostics() scope.launch(Dispatchers.Default) { mutex.withLock { if (editSequence != capturedSeq) return@withLock - document.replaceAll(text) - styleBuffer.invalidateFrom(0) - styleBuffer.grow(document.length) - displayLineMap.reset(document.lineCount) scheduleInitialFoldsInternal() } if (editSequence != capturedSeq) return@launch - val newVersion = document.version - val docLen = document.length - val hasTruncation = computeHasLineTruncation() - // StateFlow.update is @ThreadSafe — update directly on Default dispatcher - _state.update { - it.copy( - version = newVersion, - styleClock = styleBuffer.styleClock, - cursorOffset = it.cursorOffset.coerceIn(0, docLen), - selectionStart = -1, - selectionEnd = -1, - diagnostics = emptyList(), - totalDisplayLines = displayLineMap.totalDisplayLines, - hasLineTruncation = hasTruncation, - ) - } - idleLexer.scheduleFrom(0, scope) - scheduleDiagnostics() + emitFoldUpdate(computeHasLineTruncation()) } } + /** + * Replace the whole document as a single user edit (e.g. Format). + * Records undo; does **not** clear history. No-op when [newText] matches current text. + */ + fun replaceDocument(newText: String) { + if (newText == lastExternalText) return + editSequence++ + val st = _state.value + val old = lastExternalText + val oldLen = old.length + val oldCursor = st.cursorOffset.coerceIn(0, oldLen) + applyReplace( + from = 0, + to = oldLen, + insertText = newText, + cursorBefore = oldCursor, + cursorAfter = mapCursorByLineCol(old, oldCursor, newText), + recordHistory = true, + clearRedo = true, + ) + } + fun insertAtCursor(text: String) { if (text.isEmpty()) return editSequence++ @@ -800,3 +822,38 @@ class EditorViewModel( scope.cancel() } } + +/** + * Map [oldOffset] into [newText] by line/column. Caret at EOF of [oldText] stays at + * EOF of [newText]. Otherwise the same line index is used (clamped) and the column + * is coerced to that line's length. + */ +internal fun mapCursorByLineCol(oldText: String, oldOffset: Int, newText: String): Int { + val off = oldOffset.coerceIn(0, oldText.length) + if (off >= oldText.length) return newText.length + var line = 0 + var lineStart = 0 + var i = 0 + while (i < off) { + if (oldText[i] == '\n') { + line++ + lineStart = i + 1 + } + i++ + } + val col = off - lineStart + var newLine = 0 + var newStart = 0 + i = 0 + while (i < newText.length && newLine < line) { + if (newText[i] == '\n') { + newLine++ + newStart = i + 1 + } + i++ + } + if (newLine < line) return newText.length + val newLineEnd = newText.indexOf('\n', newStart).let { if (it < 0) newText.length else it } + val newCol = col.coerceAtMost((newLineEnd - newStart).coerceAtLeast(0)) + return newStart + newCol +} diff --git a/editor-ui/src/commonMain/kotlin/com/reqlab/editor/ui/LineView.kt b/editor-ui/src/commonMain/kotlin/com/reqlab/editor/ui/LineView.kt index 822d069..dca26fa 100644 --- a/editor-ui/src/commonMain/kotlin/com/reqlab/editor/ui/LineView.kt +++ b/editor-ui/src/commonMain/kotlin/com/reqlab/editor/ui/LineView.kt @@ -4,18 +4,15 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.foundation.Canvas import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown -import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable -import androidx.compose.runtime.SideEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import kotlinx.coroutines.withTimeoutOrNull -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect @@ -23,6 +20,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.isShiftPressed import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextLayoutResult @@ -92,10 +91,10 @@ internal fun LineView( // A value of -1f means no cursor on this line; treat as fully transparent. val effectiveCursorAlpha = cursorVisible.coerceAtLeast(0f) - val lineText: String = remember(version, docLine) { + val lineText: String = remember(version, docLine, document) { if (docLine < document.lineCount) document.lineText(docLine) else "" } - val lineStartOffset: Int = remember(version, docLine) { + val lineStartOffset: Int = remember(version, docLine, document) { if (docLine < document.lineCount) document.lineStart(docLine) else 0 } @@ -121,8 +120,6 @@ internal fun LineView( ) } - var layoutResult by remember { mutableStateOf(null) } - val measured: TextLayoutResult = remember(annotated, wordWrap, containerWidthPx) { // Compose's packed Constraints use 18 bits per dimension: max = (1 shl 18) - 1 = 262_143 px. // In non-wordWrap mode, measuring an unbounded single line can easily exceed this limit @@ -137,7 +134,7 @@ internal fun LineView( annotated, textStyle, softWrap = wordWrap, constraints = constraints, - ).also { layoutResult = it } + ) } // Notify caller whenever the layout is (re-)computed so the drag handler @@ -147,6 +144,8 @@ internal fun LineView( onLayoutMeasured?.invoke(measured) } + val measuredLatest = rememberUpdatedState(measured) + val lineLen = lineText.length val lineEndOff = lineStartOffset + lineLen val renderLen = measured.layoutInput.text.length @@ -165,16 +164,23 @@ internal fun LineView( val lineHeightDp = with(density) { measured.size.height.toDp() }.coerceAtLeast(20.dp) val lineWidthDp = with(density) { measured.size.width.toDp() } val wrapping = wordWrap && containerWidthPx > 0 + val padStartPx = with(density) { LINE_PAD_START.toPx() } + val padTopPx = with(density) { LINE_PAD_VERT.toPx() } Box( modifier = modifier .then(if (wrapping) Modifier.fillMaxWidth() else Modifier) - .height(lineHeightDp) - .pointerInput(lineStartOffset) { + .semantics { contentDescription = lineText } + .pointerInput(lineStartOffset, version, document, wordWrap, containerWidthPx) { + fun toLayout(p: Offset) = Offset( + (p.x - padStartPx).coerceAtLeast(0f), + (p.y - padTopPx).coerceAtLeast(0f), + ) awaitEachGesture { val down = awaitFirstDown(requireUnconsumed = false) - val lr0 = layoutResult ?: return@awaitEachGesture - val charOff0 = offsetInLayout(lr0, down.position) + val layout = measuredLatest.value + val charOff0 = if (down.position.x < padStartPx) 0 + else offsetInLayout(layout, toLayout(down.position)) val absOff0 = lineStartOffset + charOff0 // Immediately place cursor on first press — no delay. @@ -183,7 +189,7 @@ internal fun LineView( onTap(absOff0, shiftHeld) down.consume() - val lineHeightPx = with(density) { lineHeightDp.toPx() } + val lineHeightPx = with(density) { lineHeightDp.toPx() } + padTopPx * 2f // Drain pointer events until release, handling drag selection // with zero startup delay (drag starts on the very first move event). var released = false @@ -195,8 +201,8 @@ internal fun LineView( if (ptr.position != ptr.previousPosition) { dragged = true if (ptr.position.y in 0f..lineHeightPx) { - val lr = layoutResult ?: break - val charOff = offsetInLayout(lr, ptr.position) + val charOff = if (ptr.position.x < padStartPx) 0 + else offsetInLayout(measuredLatest.value, toLayout(ptr.position)) onDragTo?.invoke(lineStartOffset + charOff) } ptr.consume() @@ -215,7 +221,9 @@ internal fun LineView( } } } - }, + } + .padding(start = LINE_PAD_START, end = LINE_PAD_END, top = LINE_PAD_VERT, bottom = LINE_PAD_VERT) + .height(lineHeightDp), ) { Canvas( modifier = if (wrapping) Modifier.matchParentSize() @@ -274,6 +282,10 @@ private fun offsetInLayout(lr: TextLayoutResult, position: androidx.compose.ui.g private const val MAX_RENDER_CHARS_PER_LINE = 50_000 +internal val LINE_PAD_START = 8.dp +internal val LINE_PAD_END = 16.dp +internal val LINE_PAD_VERT = 1.dp + /** * Compose's Constraints representation caps each dimension at (1 shl 18) − 1 = 262_143 px. * Using 262_000 gives a small safety margin while maximising visible content. diff --git a/editor-ui/src/commonMain/kotlin/com/reqlab/editor/ui/TokenColorRegistry.kt b/editor-ui/src/commonMain/kotlin/com/reqlab/editor/ui/TokenColorRegistry.kt index 58ce4f0..ee10ae4 100644 --- a/editor-ui/src/commonMain/kotlin/com/reqlab/editor/ui/TokenColorRegistry.kt +++ b/editor-ui/src/commonMain/kotlin/com/reqlab/editor/ui/TokenColorRegistry.kt @@ -50,6 +50,7 @@ object TokenColorRegistry { TokenType.KEYWORD -> SyntaxColors.jsonBoolean TokenType.PROPERTY -> SyntaxColors.jsonKey TokenType.PUNCTUATION -> SyntaxColors.jsonBrace + TokenType.COMMENT -> SyntaxColors.jsComment TokenType.ERROR -> Color(0xFFFF6B6B) else -> SyntaxColors.plain } diff --git a/editor-ui/src/desktopTest/kotlin/com/reqlab/editor/ui/EditorViewModelFixTest.kt b/editor-ui/src/desktopTest/kotlin/com/reqlab/editor/ui/EditorViewModelFixTest.kt index fd4b998..82c762e 100644 --- a/editor-ui/src/desktopTest/kotlin/com/reqlab/editor/ui/EditorViewModelFixTest.kt +++ b/editor-ui/src/desktopTest/kotlin/com/reqlab/editor/ui/EditorViewModelFixTest.kt @@ -539,7 +539,7 @@ class EditorViewModelFixTest { v.insertAtCursor("Y") assertTrue(v.getFullText().endsWith("XY"), "Should have XY appended") - // External text completely replaced (simulates Format or Import) + // External text completely replaced (simulates Import / load, not Format) v.onExternalTextChanged("completely different content") // After a genuine external change, undo must not go back to the pre-import state @@ -547,4 +547,84 @@ class EditorViewModelFixTest { assertEquals("completely different content", v.getFullText(), "Undo must not cross an external-text-change boundary") } + + @Test + fun replaceDocument_keeps_undo_of_prior_edits() { + val v = vm("{\"a\":1}") + v.moveCursorTo(v.document.length) + v.insertAtCursor("X") + val afterType = v.getFullText() + v.replaceDocument("{\n \"a\": 1\n}X") + v.undo() + assertEquals(afterType, v.getFullText(), "Undo Format must restore text including typed suffix") + v.undo() + assertEquals("{\"a\":1}", v.getFullText(), "Undo after Format must still undo the keystroke") + } + + @Test + fun replaceDocument_maps_caret_at_start_to_start() { + val compact = "{\"a\":1}" + val pretty = "{\n \"a\": 1\n}" + val v = EditorViewModel(compact, LanguageMode.JSON) + v.moveCursorTo(0) + v.replaceDocument(pretty) + assertEquals(0, v.state.value.cursorOffset) + v.dispose() + } + + @Test + fun replaceDocument_eof_stays_at_end_of_pretty_text() { + val compact = "{\"a\":1}" + val pretty = "{\n \"a\": 1\n}" + val v = EditorViewModel(compact, LanguageMode.JSON) + v.moveCursorTo(compact.length) + v.replaceDocument(pretty) + assertEquals( + pretty.length, + v.state.value.cursorOffset, + "EOF caret must stay at the end of the pretty document, not on line 0", + ) + v.dispose() + } + + @Test + fun replaceDocument_maps_caret_by_line_and_column() { + val old = "aaa\nbbbb" + val new = "aaa\ncccccc" + val v = EditorViewModel(old, LanguageMode.PLAIN_TEXT) + v.moveCursorTo(6) // line 1, col 2 + v.replaceDocument(new) + assertEquals(6, v.state.value.cursorOffset, "Line 1 col 2 must map to the same line/col") + v.dispose() + } + + @Test + fun replaceDocument_noop_does_not_move_caret() { + val v = EditorViewModel("Hello", LanguageMode.PLAIN_TEXT) + v.moveCursorTo(3) + v.replaceDocument("Hello") + assertEquals(3, v.state.value.cursorOffset) + v.dispose() + } + + @Test + fun replaceDocument_undo_restores_pre_format_caret() { + val compact = "{\"a\":1}" + val pretty = "{\n \"a\": 1\n}" + val v = EditorViewModel(compact, LanguageMode.JSON) + v.moveCursorTo(compact.length) + v.replaceDocument(pretty) + v.undo() + assertEquals(compact, v.getFullText()) + assertEquals(compact.length, v.state.value.cursorOffset, "Undo Format must restore the pre-format caret") + v.dispose() + } + + @Test + fun mapCursorByLineCol_eof_and_line_col() { + val pretty = "{\n \"a\": 1\n}" + assertEquals(pretty.length, mapCursorByLineCol("{\"a\":1}", 7, pretty)) + assertEquals(0, mapCursorByLineCol("{\"a\":1}", 0, pretty)) + assertEquals(6, mapCursorByLineCol("aaa\nbbbb", 6, "aaa\ncccccc")) + } } diff --git a/qa-tests/collection-validator.mjs b/qa-tests/collection-validator.mjs index 2b2c721..7ab45d9 100644 --- a/qa-tests/collection-validator.mjs +++ b/qa-tests/collection-validator.mjs @@ -276,6 +276,21 @@ async function main() { const results = []; for (const req of requests) { + if (String(req.__path || '').includes('JSON5')) { + results.push({ + name: req.name, + path: req.__path, + method: (req.method || 'POST').toUpperCase(), + url: req.url, + status: 0, + responseTimeMs: 0, + responseSizeBytes: 0, + passed: false, + skipped: true, + issues: ['Skipped: Node fetch does not convert JSON5; covered by SampleCollectionE2ETest'], + }); + continue; + } const preScript = String(req.preRequestScript ?? ''); for (const match of preScript.matchAll(/pm\.environment\.set\("([^"]+)",\s*"([^"]*)"\)/g)) { runtimeVars[match[1]] = resolveTemplate(match[2], runtimeVars); @@ -496,13 +511,18 @@ async function main() { } } - const failed = results.filter(r => !r.passed); - const passed = results.length - failed.length; + const skipped = results.filter(r => r.skipped); + const failed = results.filter(r => !r.passed && !r.skipped); + const passed = results.filter(r => r.passed && !r.skipped).length; const issueLines = failed.length === 0 ? ['None'] : failed.map((f, idx) => `${idx + 1}. [${f.method}] ${f.url} (${f.name}) -> ${f.issues.join('; ')}`); + const skippedLines = skipped.length === 0 + ? ['None'] + : skipped.map((s, idx) => `${idx + 1}. [${s.method}] ${s.url} (${s.name}) -> ${(s.issues || []).join('; ')}`); + const report = [ 'ReqLab Collection Validation Report', '-----------------------------------', @@ -510,24 +530,29 @@ async function main() { `Total Requests: ${results.length}`, `Passed: ${passed}`, `Failed: ${failed.length}`, + `Skipped: ${skipped.length}`, '', 'Issues Found:', '-------------', ...issueLines, '', + 'Skipped:', + '--------', + ...skippedLines, + '', 'Fixes Applied:', '--------------', 'Pending (baseline run only)', '', 'Final Result:', '-------------', - failed.length === 0 ? 'All requests passing.' : 'Some requests failed. Fixes required.' + failed.length === 0 ? 'All executed requests passing.' : 'Some requests failed. Fixes required.' ].join('\n'); await fs.writeFile(resultsPath, JSON.stringify(results, null, 2)); await fs.writeFile(reportPath, report + '\n'); - console.log(`Executed ${results.length} requests: ${passed} passed, ${failed.length} failed.`); + console.log(`Executed ${passed + failed.length} requests: ${passed} passed, ${failed.length} failed, ${skipped.length} skipped.`); if (failed.length) { console.log('Failures:'); for (const f of failed) { diff --git a/qa-tests/fixtures/reqlab-test-collection.json b/qa-tests/fixtures/reqlab-test-collection.json index c3bb707..4292700 100644 --- a/qa-tests/fixtures/reqlab-test-collection.json +++ b/qa-tests/fixtures/reqlab-test-collection.json @@ -168,6 +168,40 @@ } ] }, + { + "name": "JSON5", + "folders": [], + "requests": [ + { + "name": "POST JSON5 comments", + "method": "POST", + "url": "{{baseUrl}}/api/json", + "body": { + "type": "JSON", + "content": "{\n \"name\": \"Ada\",\n // \"role\": \"admin\",\n /* \"debug\": true, */\n \"active\": true\n}" + }, + "testScript": "reqlab.test(\"echoed body has no comments\", function() {\n reqlab.expect(reqlab.response.json().body).to.not.include(\"//\")\n})\nreqlab.test(\"commented field omitted\", function() {\n reqlab.expect(reqlab.response.json().body).to.not.include(\"role\")\n})" + }, + { + "name": "POST JSON5 trailing comma", + "method": "POST", + "url": "{{baseUrl}}/api/json", + "body": { + "type": "JSON", + "content": "{\n \"name\": \"Ada\",\n \"active\": true,\n}" + } + }, + { + "name": "POST JSON5 unquoted keys", + "method": "POST", + "url": "{{baseUrl}}/api/json", + "body": { + "type": "JSON", + "content": "{ name: 'Ada', active: true }" + } + } + ] + }, { "name": "Raw Text", "folders": [], diff --git a/qa-tests/src/test/kotlin/com/reqlab/qa/SampleCollectionE2ETest.kt b/qa-tests/src/test/kotlin/com/reqlab/qa/SampleCollectionE2ETest.kt index 24945a8..29c2761 100644 --- a/qa-tests/src/test/kotlin/com/reqlab/qa/SampleCollectionE2ETest.kt +++ b/qa-tests/src/test/kotlin/com/reqlab/qa/SampleCollectionE2ETest.kt @@ -1,5 +1,12 @@ package com.reqlab.qa +import com.reqlab.core.model.BodyType +import com.reqlab.core.model.HttpMethodType +import com.reqlab.core.model.RequestBody +import com.reqlab.core.model.RequestDefinition +import com.reqlab.core.network.KtorApiClient +import com.reqlab.core.network.NetworkEvent +import com.reqlab.core.network.RetryPolicy import com.reqlab.server.module import io.ktor.client.HttpClient import io.ktor.client.engine.cio.CIO @@ -27,6 +34,7 @@ import io.ktor.server.engine.EmbeddedServer import io.ktor.server.engine.embeddedServer import io.ktor.server.netty.Netty import io.ktor.server.netty.NettyApplicationEngine +import kotlinx.coroutines.flow.toList import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject @@ -38,6 +46,7 @@ import org.junit.Test import java.net.ServerSocket import java.util.Base64 import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertTrue @@ -333,6 +342,72 @@ class SampleCollectionE2ETest { } } + @Test + fun body_json5_comments() { + runBlocking { + val body = """ + { + "name": "Ada", + // "role": "admin", + /* "debug": true, */ + "active": true + } + """.trimIndent() + val events = ktorClient().execute(json5Request(body)).toList() + val success = events.last() as NetworkEvent.Success + assertEquals(200, success.response.statusCode) + val echoed = parseJson(success.response.bodyText)["body"]?.jsonPrimitive?.content.orEmpty() + assertTrue(echoed.contains("Ada"), echoed) + assertFalse(echoed.contains("//"), echoed) + assertFalse(echoed.contains("role"), echoed) + assertFalse(echoed.contains("debug"), echoed) + } + } + + @Test + fun body_json5_trailing_comma() { + runBlocking { + val body = """ + { + "name": "Ada", + "active": true, + } + """.trimIndent() + val events = ktorClient().execute(json5Request(body)).toList() + val success = events.last() as NetworkEvent.Success + assertEquals(200, success.response.statusCode) + val echoed = parseJson(success.response.bodyText)["body"]?.jsonPrimitive?.content.orEmpty() + assertTrue(echoed.contains("Ada"), echoed) + assertFalse(echoed.trimEnd().endsWith(",}"), echoed) + } + } + + @Test + fun body_json5_unquoted_keys() { + runBlocking { + val body = "{ name: 'Ada', active: true }" + val events = ktorClient().execute(json5Request(body)).toList() + val success = events.last() as NetworkEvent.Success + assertEquals(200, success.response.statusCode) + val echoed = parseJson(success.response.bodyText)["body"]?.jsonPrimitive?.content.orEmpty() + assertTrue(echoed.contains("Ada"), echoed) + assertTrue(echoed.contains("\"name\""), echoed) + assertFalse(echoed.contains("'Ada'"), echoed) + } + } + + private fun ktorClient() = KtorApiClient(retryPolicy = RetryPolicy(maxAttempts = 1)) + + private fun json5Request(content: String) = RequestDefinition( + id = "json5", + name = "json5", + method = HttpMethodType.POST, + url = "$baseUrl/api/json", + body = RequestBody(BodyType.JSON, content = content), + createdAtEpochMillis = 1, + updatedAtEpochMillis = 1, + ) + @Test fun body_raw_text() { runBlocking { diff --git a/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/EditorNoWrapRegressionTest.kt b/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/EditorNoWrapRegressionTest.kt index 42afb13..7c30509 100644 --- a/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/EditorNoWrapRegressionTest.kt +++ b/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/EditorNoWrapRegressionTest.kt @@ -6,6 +6,9 @@ import androidx.compose.foundation.ScrollState import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.platform.testTag @@ -152,6 +155,73 @@ class NoWrapHorizontalScrollTest { } vm.dispose() } + + @Test + fun toggling_word_wrap_resets_horizontal_scroll() { + val content = "HORIZONTAL_".repeat(80) + val vm = EditorViewModel(content, LanguageMode.PLAIN_TEXT) + var wrap by mutableStateOf(false) + var capturedState: ScrollState? = null + + composeRule.setContent { + Box(Modifier.size(400.dp, 100.dp)) { + EditorRenderer( + viewModel = vm, + isReadOnly = false, + language = LanguageMode.PLAIN_TEXT, + wordWrap = wrap, + testTagPrefix = "hscroll_wrap", + onScrollStateReady = { capturedState = it }, + ) + } + } + composeRule.waitForIdle() + val scrollState = requireNotNull(capturedState) + composeRule.runOnIdle { scrollState.dispatchRawDelta(320f) } + composeRule.waitForIdle() + assertTrue(scrollState.value > 0, "fixture: must be scrolled horizontally") + + composeRule.runOnUiThread { wrap = true } + composeRule.waitForIdle() + composeRule.runOnUiThread { wrap = false } + composeRule.waitForIdle() + + assertEquals(0, scrollState.value, "Wrap toggle must zero horizontal scroll") + vm.dispose() + } + + @Test + fun switching_viewmodels_resets_horizontal_scroll_on_new_tab() { + val vmA = EditorViewModel("HORIZONTAL_".repeat(80), LanguageMode.PLAIN_TEXT) + val vmB = EditorViewModel("short", LanguageMode.PLAIN_TEXT) + var active by mutableStateOf(vmA) + val states = mutableMapOf() + + composeRule.setContent { + Box(Modifier.size(400.dp, 100.dp)) { + EditorRenderer( + viewModel = active, + isReadOnly = false, + language = LanguageMode.PLAIN_TEXT, + wordWrap = false, + testTagPrefix = "htab", + onScrollStateReady = { states[active] = it }, + ) + } + } + composeRule.waitForIdle() + val scrollA = requireNotNull(states[vmA]) + composeRule.runOnIdle { scrollA.dispatchRawDelta(320f) } + composeRule.waitForIdle() + assertTrue(scrollA.value > 0) + + composeRule.runOnUiThread { active = vmB } + composeRule.waitForIdle() + assertEquals(0, requireNotNull(states[vmB]).value, "New tab must not inherit A's horizontal scroll") + + vmA.dispose() + vmB.dispose() + } } /** @@ -255,6 +325,74 @@ class NoWrapClickRightOfTextTest { ) vm.dispose() } + + @Test + fun click_near_gutter_on_first_line_places_cursor_near_start() { + val content = listOf("Hello", "World", "Test").joinToString("\n") + val vm = EditorViewModel(content, LanguageMode.PLAIN_TEXT) + composeRule.setContent { + Box(Modifier.size(500.dp, 150.dp)) { + EditorRenderer( + viewModel = vm, + isReadOnly = false, + language = LanguageMode.PLAIN_TEXT, + wordWrap = false, + testTagPrefix = "click_gutter", + ) + } + } + composeRule.waitForIdle() + vm.moveCursorTo(5) + composeRule.waitForIdle() + + composeRule.onNodeWithTag("click_gutter-line-numbers") + .performTouchInput { + down(Offset(100.dp.toPx(), 10f)) + up() + } + composeRule.waitForIdle() + + val cursor = vm.state.value.cursorOffset + assertTrue( + cursor in 0..2, + "Click just after the gutter on 'Hello' must be near the start, not line end; got $cursor", + ) + vm.dispose() + } + + @Test + fun click_near_gutter_on_second_line_places_cursor_near_that_line_start() { + val content = listOf("Hello", "World", "Test").joinToString("\n") + val vm = EditorViewModel(content, LanguageMode.PLAIN_TEXT) + composeRule.setContent { + Box(Modifier.size(500.dp, 150.dp)) { + EditorRenderer( + viewModel = vm, + isReadOnly = false, + language = LanguageMode.PLAIN_TEXT, + wordWrap = false, + testTagPrefix = "click_gutter2", + ) + } + } + composeRule.waitForIdle() + vm.moveCursorTo(0) + composeRule.waitForIdle() + + composeRule.onNodeWithTag("click_gutter2-line-numbers") + .performTouchInput { + down(Offset(100.dp.toPx(), 30f)) + up() + } + composeRule.waitForIdle() + + val cursor = vm.state.value.cursorOffset + assertTrue( + cursor in 6..8, + "Click just after the gutter on 'World' must be near that line start, not line end; got $cursor", + ) + vm.dispose() + } } /** diff --git a/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/EditorQaUiTest.kt b/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/EditorQaUiTest.kt index 7629c8a..ad8514c 100644 --- a/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/EditorQaUiTest.kt +++ b/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/EditorQaUiTest.kt @@ -198,6 +198,157 @@ class BasicEditorInteractionTest { "Format must expand minified JSON to multiline; content: ${content.take(200)}") } + @Test + fun format_button_click_restores_editor_focus_so_cmd_z_undoes() { + val minified = """{"name":"Alice","age":30}""" + val state = AppState() + composeRule.runOnUiThread { + state.activeTab?.bodyType = BodyType.JSON + state.activeTab?.bodyContent = minified + state.activeTab?.selectedEditorTab = RequestEditorTab.BODY + } + composeRule.setContent { MainScreen(state) } + composeRule.waitForIdle() + + composeRule.onNodeWithTag("body-editor-format-toggle", useUnmergedTree = true) + .performClick() + composeRule.waitForIdle() + + val formatted = state.activeTab?.bodyContent ?: "" + assertTrue(formatted.contains('\n'), + "Format must expand minified JSON before undo; content: ${formatted.take(200)}") + + composeRule.onNodeWithTag("body-editor-input", useUnmergedTree = true).assertIsFocused() + composeRule.onNodeWithTag("body-editor-input", useUnmergedTree = true) + .performKeyInput { + keyDown(Key.MetaLeft) + pressKey(Key.Z) + keyUp(Key.MetaLeft) + } + composeRule.waitForIdle() + + assertEquals(minified, state.activeTab?.bodyContent, + "Cmd+Z after Format must restore the minified body without clicking the editor") + } + + @Test + fun word_wrap_toggle_click_restores_editor_focus() { + val state = AppState() + composeRule.runOnUiThread { + state.activeTab?.bodyType = BodyType.JSON + state.activeTab?.bodyContent = """{"long":"value".repeat(200)}""" + state.activeTab?.selectedEditorTab = RequestEditorTab.BODY + } + composeRule.setContent { MainScreen(state) } + composeRule.waitForIdle() + + composeRule.onNodeWithTag("body-editor-word-wrap-toggle", useUnmergedTree = true) + .performClick() + composeRule.waitForIdle() + + composeRule.onNodeWithTag("body-editor-input", useUnmergedTree = true).assertIsFocused() + } + + @Test + fun fold_all_click_restores_editor_focus() { + val state = AppState() + composeRule.runOnUiThread { + state.activeTab?.bodyType = BodyType.JSON + state.activeTab?.bodyContent = "{\n \"name\": \"Alice\",\n \"role\": \"admin\"\n}" + state.activeTab?.selectedEditorTab = RequestEditorTab.BODY + } + composeRule.setContent { MainScreen(state) } + composeRule.waitForIdle() + + composeRule.onNodeWithTag("body-editor-fold-all", useUnmergedTree = true).performClick() + composeRule.waitForIdle() + + composeRule.onNodeWithTag("body-editor-input", useUnmergedTree = true).assertIsFocused() + composeRule.onNodeWithTag("body-editor-input", useUnmergedTree = true) + .performTextInput(" ") + composeRule.waitForIdle() + assertTrue( + (state.activeTab?.bodyContent ?: "").contains(" "), + "Typing after Fold All must work without clicking the editor", + ) + } + + @Test + fun unfold_all_click_restores_editor_focus() { + val state = AppState() + composeRule.runOnUiThread { + state.activeTab?.bodyType = BodyType.JSON + state.activeTab?.bodyContent = "{\n \"name\": \"Alice\",\n \"role\": \"admin\"\n}" + state.activeTab?.selectedEditorTab = RequestEditorTab.BODY + } + composeRule.setContent { MainScreen(state) } + composeRule.waitForIdle() + + composeRule.onNodeWithTag("body-editor-fold-all", useUnmergedTree = true).performClick() + composeRule.waitForIdle() + composeRule.onNodeWithTag("body-editor-unfold-all", useUnmergedTree = true).performClick() + composeRule.waitForIdle() + + composeRule.onNodeWithTag("body-editor-input", useUnmergedTree = true).assertIsFocused() + } + + @Test + fun copy_button_click_restores_editor_focus() { + val state = AppState() + composeRule.runOnUiThread { + state.activeTab?.bodyType = BodyType.JSON + state.activeTab?.bodyContent = """{"key":"value"}""" + state.activeTab?.selectedEditorTab = RequestEditorTab.BODY + } + composeRule.setContent { MainScreen(state) } + composeRule.waitForIdle() + + composeRule.onNodeWithTag("body-editor-copy-button", useUnmergedTree = true).performClick() + composeRule.waitForIdle() + + composeRule.onNodeWithTag("body-editor-input", useUnmergedTree = true).assertIsFocused() + } + + @Test + fun search_close_via_toggle_restores_editor_focus() { + val state = AppState() + composeRule.runOnUiThread { + state.activeTab?.bodyType = BodyType.JSON + state.activeTab?.bodyContent = """{"key":"value"}""" + state.activeTab?.selectedEditorTab = RequestEditorTab.BODY + } + composeRule.setContent { MainScreen(state) } + composeRule.waitForIdle() + + composeRule.onNodeWithTag("body-editor-search-toggle", useUnmergedTree = true).performClick() + composeRule.waitForIdle() + composeRule.onNodeWithTag("body-editor-search-input", useUnmergedTree = true).assertIsFocused() + + composeRule.onNodeWithTag("body-editor-search-toggle", useUnmergedTree = true).performClick() + composeRule.waitForIdle() + composeRule.onNodeWithTag("body-editor-input", useUnmergedTree = true).assertIsFocused() + } + + @Test + fun search_close_button_restores_editor_focus() { + val state = AppState() + composeRule.runOnUiThread { + state.activeTab?.bodyType = BodyType.JSON + state.activeTab?.bodyContent = """{"key":"value"}""" + state.activeTab?.selectedEditorTab = RequestEditorTab.BODY + } + composeRule.setContent { MainScreen(state) } + composeRule.waitForIdle() + + composeRule.onNodeWithTag("body-editor-search-toggle", useUnmergedTree = true).performClick() + composeRule.waitForIdle() + composeRule.onNodeWithTag("body-editor-search-input", useUnmergedTree = true).assertIsFocused() + + composeRule.onNodeWithTag("body-editor-search-close", useUnmergedTree = true).performClick() + composeRule.waitForIdle() + composeRule.onNodeWithTag("body-editor-input", useUnmergedTree = true).assertIsFocused() + } + @Test fun word_wrap_toggle_click_does_not_crash() { val state = AppState() diff --git a/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/EditorScrollKeyClickBackspaceUndoTest.kt b/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/EditorScrollKeyClickBackspaceUndoTest.kt index d3c3782..757094a 100644 --- a/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/EditorScrollKeyClickBackspaceUndoTest.kt +++ b/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/EditorScrollKeyClickBackspaceUndoTest.kt @@ -4,6 +4,7 @@ package com.reqlab.ui.desktop import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.key.Key @@ -17,9 +18,11 @@ import androidx.compose.ui.test.pressKey import androidx.compose.ui.test.swipe import androidx.compose.ui.test.swipeLeft import androidx.compose.ui.unit.dp +import com.reqlab.editor.core.Json5EditorSupport import com.reqlab.editor.core.LanguageMode import com.reqlab.editor.ui.EditorRenderer import com.reqlab.editor.ui.EditorViewModel +import kotlinx.coroutines.runBlocking import org.junit.Rule import org.junit.Test import kotlin.test.assertEquals @@ -788,4 +791,151 @@ class UndoRedoTest { assertEquals("Hello World", vm.getFullText(), "Cmd+Shift+Z must redo the undone insert") vm.dispose() } + + @Test + fun replaceDocument_is_undoable_and_keeps_prior_edits() { + val vm = EditorViewModel("Hello", LanguageMode.PLAIN_TEXT) + vm.moveCursorTo(5) + vm.insertAtCursor("!") + assertEquals("Hello!", vm.getFullText()) + + vm.replaceDocument("Hello!\nWorld") + assertEquals("Hello!\nWorld", vm.getFullText()) + + vm.undo() + assertEquals("Hello!", vm.getFullText(), "Undo Format must restore pre-format text including prior edits") + vm.undo() + assertEquals("Hello", vm.getFullText(), "Undo after Format must still undo earlier keystrokes") + + vm.redo() + assertEquals("Hello!", vm.getFullText()) + vm.redo() + assertEquals("Hello!\nWorld", vm.getFullText(), "Redo must restore Format") + vm.dispose() + } + + @Test + fun replaceDocument_json5_format_undo_restores_authored_text() { + val compact = "{a:1,}" + val vm = EditorViewModel(compact, LanguageMode.JSON, Json5EditorSupport) + val pretty = Json5EditorSupport.format(compact) + assertTrue(pretty.lines().size > 1, pretty) + vm.replaceDocument(pretty) + assertEquals(pretty, vm.getFullText()) + vm.undo() + assertEquals(compact, vm.getFullText(), "Undo after JSON5 Format must restore authored JSON5") + vm.dispose() + } + + @Test + fun replaceDocument_json5_format_undo_restores_caret() { + val compact = "{a:1,}" + val vm = EditorViewModel(compact, LanguageMode.JSON, Json5EditorSupport) + val pretty = Json5EditorSupport.format(compact) + vm.moveCursorTo(compact.length) + vm.replaceDocument(pretty) + vm.undo() + assertEquals(compact, vm.getFullText()) + assertEquals(compact.length, vm.state.value.cursorOffset) + vm.dispose() + } + + @Test + fun replaceDocument_noop_when_unchanged() { + val vm = EditorViewModel("Hello", LanguageMode.PLAIN_TEXT) + vm.moveCursorTo(5) + vm.insertAtCursor("!") + vm.replaceDocument("Hello!") + assertEquals(6, vm.state.value.cursorOffset, "No-op replaceDocument must not move the caret") + vm.undo() + assertEquals("Hello", vm.getFullText(), "No-op replaceDocument must not push a dummy undo entry") + vm.dispose() + } + + @Test + fun format_then_undo_does_not_jump_viewport() { + val compact = "{\"k\":1}" + val pretty = "{\n \"k\": 1,\n \"x\": 2,\n \"y\": 3,\n \"z\": 4\n}" + val vm = EditorViewModel(compact, LanguageMode.JSON) + var listState: LazyListState? = null + composeRule.setContent { + Box(Modifier.size(400.dp, 200.dp)) { + EditorRenderer( + viewModel = vm, + isReadOnly = false, + language = LanguageMode.JSON, + testTagPrefix = "fmt_scroll", + onListStateReady = { listState = it }, + ) + } + } + composeRule.waitForIdle() + vm.moveCursorTo(compact.length) + vm.replaceDocument(pretty) + composeRule.waitForIdle() + assertEquals(0, requireNotNull(listState).firstVisibleItemIndex) + vm.undo() + composeRule.waitForIdle() + assertEquals(0, requireNotNull(listState).firstVisibleItemIndex, "Format undo must not jump the viewport") + vm.dispose() + } + + @Test + fun select_all_delete_from_scrolled_view_snaps_to_top() { + val vm = EditorViewModel((0..80).joinToString("\n") { "line-$it" }, LanguageMode.PLAIN_TEXT) + var listState: LazyListState? = null + composeRule.setContent { + Box(Modifier.size(400.dp, 160.dp)) { + EditorRenderer( + viewModel = vm, + isReadOnly = false, + language = LanguageMode.PLAIN_TEXT, + testTagPrefix = "seldel_scroll", + onListStateReady = { listState = it }, + ) + } + } + composeRule.waitForIdle() + val list = requireNotNull(listState) + runBlocking { list.scrollToItem(40) } + composeRule.waitForIdle() + assertTrue(list.firstVisibleItemIndex >= 20) + + vm.selectAll() + vm.deleteBeforeCursor() + composeRule.waitForIdle() + assertEquals(0, list.firstVisibleItemIndex, "Select-all delete must snap viewport to the top") + vm.dispose() + } + + @Test + fun enter_on_scrolled_view_still_follows_caret() { + val vm = EditorViewModel((0..80).joinToString("\n") { "line-$it" }, LanguageMode.PLAIN_TEXT) + var listState: LazyListState? = null + composeRule.setContent { + Box(Modifier.size(400.dp, 160.dp)) { + EditorRenderer( + viewModel = vm, + isReadOnly = false, + language = LanguageMode.PLAIN_TEXT, + testTagPrefix = "enter_scroll", + onListStateReady = { listState = it }, + ) + } + } + composeRule.waitForIdle() + val list = requireNotNull(listState) + runBlocking { list.scrollToItem(40) } + composeRule.waitForIdle() + vm.moveCursorTo(vm.document.length) + vm.insertNewlineWithAutoIndent() + composeRule.waitForIdle() + val lastLine = vm.document.lineCount - 1 + assertTrue( + list.firstVisibleItemIndex <= lastLine && + (list.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) >= lastLine, + "Enter must bring the new line into view", + ) + vm.dispose() + } } diff --git a/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/EditorTabSwitchLeakTest.kt b/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/EditorTabSwitchLeakTest.kt new file mode 100644 index 0000000..bbae7e1 --- /dev/null +++ b/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/EditorTabSwitchLeakTest.kt @@ -0,0 +1,193 @@ +@file:OptIn(androidx.compose.ui.test.ExperimentalTestApi::class) + +package com.reqlab.ui.desktop + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.test.click +import androidx.compose.ui.test.hasContentDescription +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.unit.dp +import com.reqlab.editor.core.LanguageMode +import com.reqlab.editor.ui.EditorRenderer +import com.reqlab.editor.ui.EditorViewModel +import kotlinx.coroutines.runBlocking +import org.junit.Rule +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Regression: switching the editor from an unedited document (version 0) to another + * reused LazyColumn row `"row-0"` and LineView `remember(version, docLine)`, so the + * previous tab's text stayed on screen. Clicks could also target the previous VM. + */ +class EditorTabSwitchLeakTest { + + @get:Rule + val composeRule = createComposeRule() + + @Test + fun switching_unedited_viewmodels_does_not_keep_previous_tab_text() { + val textA = """{"from":"tab-A-only"}""" + val textB = """{"from":"tab-B-only"}""" + val vmA = EditorViewModel(textA, LanguageMode.JSON) + val vmB = EditorViewModel(textB, LanguageMode.JSON) + assertEquals(0, vmA.state.value.version, "fixture: A must be unedited") + assertEquals(0, vmB.state.value.version, "fixture: B must be unedited") + + var active by mutableStateOf(vmA) + composeRule.setContent { + Box(Modifier.size(600.dp, 120.dp)) { + EditorRenderer( + viewModel = active, + isReadOnly = false, + language = LanguageMode.JSON, + testTagPrefix = "tabswitch", + ) + } + } + composeRule.waitForIdle() + composeRule.onNode(hasContentDescription("tab-A-only", substring = true)).assertExists() + + composeRule.runOnUiThread { active = vmB } + composeRule.waitForIdle() + + composeRule.onNode(hasContentDescription("tab-B-only", substring = true)).assertExists() + composeRule.onNode(hasContentDescription("tab-A-only", substring = true)).assertDoesNotExist() + + vmA.dispose() + vmB.dispose() + } + + @Test + fun switching_single_line_to_multiline_does_not_keep_line_zero() { + val textA = """{"from":"tab-A-only"}""" + val textB = """{"from":"tab-B-only"} +{"second":true}""" + val vmA = EditorViewModel(textA, LanguageMode.JSON) + val vmB = EditorViewModel(textB, LanguageMode.JSON) + assertEquals(0, vmA.state.value.version) + assertEquals(0, vmB.state.value.version) + + var active by mutableStateOf(vmA) + composeRule.setContent { + Box(Modifier.size(600.dp, 160.dp)) { + EditorRenderer( + viewModel = active, + isReadOnly = false, + language = LanguageMode.JSON, + testTagPrefix = "tabswitch_ml", + ) + } + } + composeRule.waitForIdle() + + composeRule.runOnUiThread { active = vmB } + composeRule.waitForIdle() + + composeRule.onNode(hasContentDescription("tab-B-only", substring = true)).assertExists() + composeRule.onNode(hasContentDescription("tab-A-only", substring = true)).assertDoesNotExist() + composeRule.onNode(hasContentDescription("second", substring = true)).assertExists() + + vmA.dispose() + vmB.dispose() + } + + @Test + fun click_after_tab_switch_moves_cursor_on_new_viewmodel() { + val textA = """{"from":"tab-A-only"}""" + val textB = """{"from":"tab-B-only-XXXX"}""" + val vmA = EditorViewModel(textA, LanguageMode.JSON) + val vmB = EditorViewModel(textB, LanguageMode.JSON) + + var active by mutableStateOf(vmA) + composeRule.setContent { + Box(Modifier.size(600.dp, 120.dp)) { + EditorRenderer( + viewModel = active, + isReadOnly = false, + language = LanguageMode.JSON, + testTagPrefix = "tabswitch_click", + ) + } + } + composeRule.waitForIdle() + + composeRule.runOnUiThread { active = vmB } + composeRule.waitForIdle() + composeRule.runOnUiThread { vmB.moveCursorTo(0) } + composeRule.waitForIdle() + + composeRule.onNodeWithTag("tabswitch_click-line-numbers") + .performTouchInput { + click(Offset(220.dp.toPx(), 12f)) + } + composeRule.waitForIdle() + + val cursor = vmB.state.value.cursorOffset + assertTrue( + cursor in 1..textB.length, + "Click on B must move cursor into the visible line, got $cursor", + ) + assertEquals( + 0, + vmA.state.value.cursorOffset, + "Click after switch must not move the previous tab's cursor", + ) + + vmA.dispose() + vmB.dispose() + } + + @Test + fun switching_viewmodels_resets_vertical_scroll_on_new_tab() { + val vmA = EditorViewModel((0..80).joinToString("\n") { "A-line-$it" }, LanguageMode.PLAIN_TEXT) + val vmB = EditorViewModel("B-only", LanguageMode.PLAIN_TEXT) + var active by mutableStateOf(vmA) + val listByVm = mutableMapOf() + + composeRule.setContent { + Box(Modifier.size(400.dp, 160.dp)) { + EditorRenderer( + viewModel = active, + isReadOnly = false, + language = LanguageMode.PLAIN_TEXT, + testTagPrefix = "tabscroll", + onListStateReady = { state -> listByVm[active] = state }, + ) + } + } + composeRule.waitForIdle() + + val listA = requireNotNull(listByVm[vmA]) + runBlocking { listA.scrollToItem(40) } + composeRule.waitForIdle() + assertTrue(listA.firstVisibleItemIndex >= 20, "fixture: A must be scrolled") + + composeRule.runOnUiThread { active = vmB } + composeRule.waitForIdle() + + val listB = requireNotNull(listByVm[vmB]) + assertEquals(0, listB.firstVisibleItemIndex, "New tab must start at the top") + + composeRule.runOnUiThread { active = vmA } + composeRule.waitForIdle() + val restoredA = requireNotNull(listByVm[vmA]) + assertTrue( + restoredA.firstVisibleItemIndex >= 20, + "Switching back to A must keep A's scroll position, got ${restoredA.firstVisibleItemIndex}", + ) + + vmA.dispose() + vmB.dispose() + } +} diff --git a/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/EditorV2RegressionTest.kt b/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/EditorV2RegressionTest.kt index bb99d18..380464a 100644 --- a/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/EditorV2RegressionTest.kt +++ b/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/EditorV2RegressionTest.kt @@ -194,14 +194,11 @@ class EditorV2RegressionTest { // Trigger an external text change (runs on background thread) vm.onExternalTextChanged("external_update") - - // Immediately type a character (bumps editSequence) vm.insertAtCursor("X") - // Wait for background job to complete - Thread.sleep(500) - val content = vm.getFullText() + assertEquals(vm.document.length, content.length) + assertEquals(vm.document.toFullString(), content) assertTrue( content.contains("X"), "User's typed character 'X' must survive despite concurrent external text change. Content: $content", diff --git a/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/ResponseBodyUiTest.kt b/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/ResponseBodyUiTest.kt index 264f689..a56d145 100644 --- a/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/ResponseBodyUiTest.kt +++ b/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/ResponseBodyUiTest.kt @@ -3,6 +3,7 @@ package com.reqlab.ui.desktop import com.reqlab.ui.shared.MainScreen import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsFocused import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText @@ -192,6 +193,17 @@ class ResponseBodyUiTest { composeRule.onNodeWithTag("response-download-button").assertIsDisplayed() } + @Test + fun download_button_click_restores_response_editor_focus() { + composeRule.setContent { MainScreen(stateWithJsonResponse()) } + composeRule.waitForIdle() + + composeRule.onNodeWithTag("response-download-button").performClick() + composeRule.waitForIdle() + + composeRule.onNodeWithTag("response-input", useUnmergedTree = true).assertIsFocused() + } + // ── Search bar ── @Test diff --git a/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/SettingsDialogUiTest.kt b/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/SettingsDialogUiTest.kt index e1ea75c..f7bf7f3 100644 --- a/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/SettingsDialogUiTest.kt +++ b/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/SettingsDialogUiTest.kt @@ -14,6 +14,8 @@ import com.reqlab.ui.shared.i18n.AppLanguage import com.reqlab.ui.shared.theme.ReqLabTheme import org.junit.Rule import org.junit.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue /** * Compose UI tests covering: @@ -155,4 +157,21 @@ class SettingsDialogUiTest { composeRule.onNodeWithText("Historial", useUnmergedTree = true).assertIsDisplayed() } + + @Test + fun json5_body_toggle_is_displayed_and_click_disables_setting() { + val state = AppState() + composeRule.setContent { MainScreen(state = state) } + + composeRule.onNodeWithTag("settings-button", useUnmergedTree = true).performClick() + composeRule.waitForIdle() + + composeRule.onNodeWithTag("json5-body-toggle", useUnmergedTree = true).assertIsDisplayed() + assertTrue(state.settings.allowJson5InJsonBodies) + + composeRule.onNodeWithTag("json5-body-toggle", useUnmergedTree = true).performClick() + composeRule.waitForIdle() + + assertFalse(state.settings.allowJson5InJsonBodies) + } } diff --git a/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/integration/CopyCommandIntegrationTest.kt b/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/integration/CopyCommandIntegrationTest.kt index 143d276..3a55eca 100644 --- a/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/integration/CopyCommandIntegrationTest.kt +++ b/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/integration/CopyCommandIntegrationTest.kt @@ -275,4 +275,58 @@ class CopyCommandIntegrationTest { tempCommandFile.delete() } + + @Test + fun json5_body_copy_strips_comments_when_enabled() { + val json5 = """ + { + "name": "Ada", + // "role": "admin", + "active": true + } + """.trimIndent() + val tab = RequestTabState( + name = "JSON5 copy", + method = HttpMethodType.POST, + url = "http://localhost:$PORT/api/json", + ).apply { + bodyType = BodyType.JSON + bodyContent = json5 + } + + val curlOn = buildCurlCommand(tab, emptyList(), allowJson5 = true) + val pythonOn = buildPythonCommand(tab, emptyList(), allowJson5 = true) + val powershellOn = buildPowerShellCommand(tab, emptyList(), allowJson5 = true) + assertFalse(curlOn.contains("// \"role\""), curlOn) + assertFalse(pythonOn.contains("role"), pythonOn) + assertFalse(powershellOn.contains("// \"role\""), powershellOn) + assertFalse(curlOn.contains("role"), curlOn) + assertTrue(curlOn.contains("Ada"), curlOn) + + val curlOff = buildCurlCommand(tab, emptyList(), allowJson5 = false) + val pythonOff = buildPythonCommand(tab, emptyList(), allowJson5 = false) + val powershellOff = buildPowerShellCommand(tab, emptyList(), allowJson5 = false) + assertTrue(curlOff.contains("// \"role\""), curlOff) + assertTrue(pythonOff.contains("role"), pythonOff) + assertTrue(powershellOff.contains("// \"role\""), powershellOff) + } + + @Test + fun compact_json_copy_is_unchanged() { + val compact = """{"name":"Alice","age":30}""" + val tab = RequestTabState( + name = "compact JSON", + method = HttpMethodType.POST, + url = "http://localhost:$PORT/api/json", + ).apply { + bodyType = BodyType.JSON + bodyContent = compact + } + val curl = buildCurlCommand(tab, emptyList(), allowJson5 = true) + val python = buildPythonCommand(tab, emptyList(), allowJson5 = true) + val powershell = buildPowerShellCommand(tab, emptyList(), allowJson5 = true) + assertTrue(curl.contains(compact), curl) + assertTrue(python.contains("\\\"name\\\":\\\"Alice\\\""), python) + assertTrue(powershell.contains(compact), powershell) + } } diff --git a/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/persistence/ImportExportFixturesIntegrationTest.kt b/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/persistence/ImportExportFixturesIntegrationTest.kt index cf37e88..2dbd364 100644 --- a/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/persistence/ImportExportFixturesIntegrationTest.kt +++ b/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/persistence/ImportExportFixturesIntegrationTest.kt @@ -110,6 +110,35 @@ class ImportExportFixturesIntegrationTest { assertEquals(HttpMethodType.POST, postEvents.method) } + @Test + fun imported_json5_folder_keeps_authored_comments() { + val state = AppState(openDefaultTab = false, withDemoData = false) + ImportExportRepository.importCollectionFromString(state, collectionFixture.readText()) + + val bodyTypes = findFolder(state.collections, "Body Types") + assertNotNull(bodyTypes) + assertTrue(bodyTypes.children.any { it.isFolder && it.name == "JSON5" }) + + val comments = findRequest(state.collections, "POST JSON5 comments") + assertNotNull(comments) + val authored = comments.bodyContent.orEmpty().ifBlank { + comments.bodyContents["JSON"].orEmpty() + } + assertTrue(authored.contains("//"), authored) + assertTrue(authored.contains("role"), authored) + + assertNotNull(findRequest(state.collections, "POST JSON5 trailing comma")) + assertNotNull(findRequest(state.collections, "POST JSON5 unquoted keys")) + } + + private fun findFolder(nodes: List, name: String): CollectionNode? { + for (node in nodes) { + if (node.isFolder && node.name == name) return node + if (node.isFolder) findFolder(node.children, name)?.let { return it } + } + return null + } + private fun findRequest(nodes: List, name: String): CollectionNode? { for (node in nodes) { if (!node.isFolder && node.name == name) return node diff --git a/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/persistence/SettingsRepositoryTest.kt b/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/persistence/SettingsRepositoryTest.kt index 45f4384..94d3466 100644 --- a/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/persistence/SettingsRepositoryTest.kt +++ b/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/persistence/SettingsRepositoryTest.kt @@ -30,6 +30,7 @@ class SettingsRepositoryTest { "settings.httpsProxy", "settings.scriptPrefix", "settings.selectedEnvName", + "settings.allowJson5InJsonBodies", ) @Before @@ -62,6 +63,7 @@ class SettingsRepositoryTest { assertFalse(settings.proxyEnabled) assertEquals("", settings.httpProxy) assertEquals("", settings.httpsProxy) + assertTrue(settings.allowJson5InJsonBodies) } // ── Round-trip ────────────────────────────────────────────────────────── @@ -83,6 +85,7 @@ class SettingsRepositoryTest { httpProxy = "http://proxy.example.com:8080" httpsProxy = "https://proxy.example.com:8443" scriptPrefix = "api" + allowJson5InJsonBodies = false } SettingsRepository.save(original) @@ -104,6 +107,7 @@ class SettingsRepositoryTest { assertEquals("http://proxy.example.com:8080", loaded.httpProxy) assertEquals("https://proxy.example.com:8443", loaded.httpsProxy) assertEquals("api", loaded.scriptPrefix) + assertFalse(loaded.allowJson5InJsonBodies) } // ── Theme enum ────────────────────────────────────────────────────────── @@ -184,6 +188,26 @@ class SettingsRepositoryTest { assertEquals("Staging", loaded.selectedEnvName) } + @Test + fun json5_in_json_bodies_defaults_to_true() { + val settings = AppSettings() + SettingsRepository.load(settings) + assertTrue(settings.allowJson5InJsonBodies) + } + + @Test + fun json5_in_json_bodies_round_trips_false_and_true() { + SettingsRepository.save(AppSettings().apply { allowJson5InJsonBodies = false }) + val off = AppSettings() + SettingsRepository.load(off) + assertFalse(off.allowJson5InJsonBodies) + + SettingsRepository.save(AppSettings().apply { allowJson5InJsonBodies = true }) + val on = AppSettings() + SettingsRepository.load(on) + assertTrue(on.allowJson5InJsonBodies) + } + @Test fun selected_env_name_survives_full_settings_round_trip() { val original = AppSettings().apply { diff --git a/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/MainScreen.kt b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/MainScreen.kt index 4453c71..34ccea0 100644 --- a/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/MainScreen.kt +++ b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/MainScreen.kt @@ -19,6 +19,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -117,7 +118,7 @@ fun MainScreen(state: AppState = remember { AppState() }) { snapshotFlow { with(state.settings) { "$autoSaveRequests|$confirmBeforeDelete|$defaultTimeoutSec|${theme.name}" + - "|${responseLayout.name}|${language.name}|$requestTimeoutSec|$followRedirects|$collectionsExpanded|$environmentsExpanded|$proxyEnabled|$httpProxy|$httpsProxy" + + "|${responseLayout.name}|${language.name}|$requestTimeoutSec|$followRedirects|$collectionsExpanded|$environmentsExpanded|$proxyEnabled|$httpProxy|$httpsProxy|$allowJson5InJsonBodies" + "|${state.selectedEnvironment?.name ?: ""}" } }.drop(1) @@ -402,16 +403,20 @@ private fun ColumnScope.HttpWorkspaceContent( splitFraction = state.requestResponseSplit, onSplitChanged = { state.requestResponseSplit = it }, first = { - RequestEditor( - tab = tab, - state = state, - onSend = { sendRequest(scope, state, tab) }, - onCancel = { tab.currentJob?.cancel(); tab.isLoading = false }, - onSave = { saveRequest(scope, state, tab) }, - ) + key(tab.id) { + RequestEditor( + tab = tab, + state = state, + onSend = { sendRequest(scope, state, tab) }, + onCancel = { tab.currentJob?.cancel(); tab.isLoading = false }, + onSave = { saveRequest(scope, state, tab) }, + ) + } }, second = { - ResponseViewer(tab) + key(tab.id) { + ResponseViewer(tab) + } }, ) } else { @@ -420,16 +425,20 @@ private fun ColumnScope.HttpWorkspaceContent( splitFraction = state.mainVerticalSplit, onSplitChanged = { state.mainVerticalSplit = it }, first = { - RequestEditor( - tab = tab, - state = state, - onSend = { sendRequest(scope, state, tab) }, - onCancel = { tab.currentJob?.cancel(); tab.isLoading = false }, - onSave = { saveRequest(scope, state, tab) }, - ) + key(tab.id) { + RequestEditor( + tab = tab, + state = state, + onSend = { sendRequest(scope, state, tab) }, + onCancel = { tab.currentJob?.cancel(); tab.isLoading = false }, + onSave = { saveRequest(scope, state, tab) }, + ) + } }, second = { - ResponseViewer(tab) + key(tab.id) { + ResponseViewer(tab) + } }, ) } diff --git a/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/BodyEditor.kt b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/BodyEditor.kt index f6f57ff..719f0f0 100644 --- a/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/BodyEditor.kt +++ b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/BodyEditor.kt @@ -29,13 +29,10 @@ import androidx.compose.material3.Checkbox import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -48,9 +45,6 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.reqlab.core.model.BodyType import com.reqlab.core.model.FormEntryType -import com.reqlab.editor.core.EditorEngine -import com.reqlab.editor.core.InlineEditorError -import com.reqlab.editor.core.LanguageMode import com.reqlab.ui.shared.platform.pickBinaryFileForRequest import com.reqlab.ui.shared.state.AppState import com.reqlab.ui.shared.state.MutableFormDataRow @@ -272,15 +266,6 @@ fun BodyEditor(tab: RequestTabState, state: AppState, onDirty: () -> Unit) { // RAW and GRAPHQL → unified inline code editor with // syntax highlighting, code folding, search, and format val language = bodyTypeToLanguage(tab.bodyType) - - // Compute inline diagnostics to underline in the editor. - // JSON: exact line/col from the parser. - // XML: no line info from the lightweight validator — underline line 1. - // Route all validation through EditorEngine from editor-core so that - // inline error logic is centralised and testable independently of the UI. - val editorEngine = remember { EditorEngine() } - var inlineErrors by remember { mutableStateOf>(emptyList()) } - var validationPaused by remember { mutableStateOf(false) } var bodyPopupVariable by remember { mutableStateOf(null) } // Compute the set of all defined variable names from all active layers // (env → collection → globals). Used to colour tokens orange (resolved) @@ -288,48 +273,8 @@ fun BodyEditor(tab: RequestTabState, state: AppState, onDirty: () -> Unit) { val definedVarNames: Set = state?.activeVariableLayers() ?.flatMap { it.keys }?.toSet() ?: emptySet() - // Debounce validation — avoid calling EditorEngine.validate() on - // every keystroke. For large files validation runs on Dispatchers.Default - // so the main/UI thread is NEVER blocked by O(n) validation work. - // We keep validation enabled up to 20 MB with a larger debounce window. - LaunchedEffect(tab.bodyContent, tab.bodyType) { - val content = tab.bodyContent - val bodyType = tab.bodyType - if (content.isBlank()) { - inlineErrors = emptyList() - validationPaused = false - return@LaunchedEffect - } - if (shouldPauseValidation(content.length)) { - // Clear stale errors immediately and surface a paused indicator - inlineErrors = emptyList() - validationPaused = true - return@LaunchedEffect - } - validationPaused = false - val delayMs = when { - content.length > 5_000_000 -> 1200L - content.length > 1_000_000 -> 900L - content.length > 100_000 -> 600L - else -> 300L - } - kotlinx.coroutines.delay(delayMs) - // Re-read after delay — user may have continued typing - if (content != tab.bodyContent) return@LaunchedEffect - val langMode = when (bodyType) { - BodyType.JSON -> LanguageMode.JSON - BodyType.XML -> LanguageMode.XML - BodyType.HTML -> LanguageMode.HTML - BodyType.JAVASCRIPT -> LanguageMode.JAVASCRIPT - else -> LanguageMode.PLAIN_TEXT - } - // Run the actual O(n) validation off the main thread - val result = withContext(Dispatchers.Default) { - editorEngine.validate(content, langMode) - } - // Guard: only apply if still current content - if (content == tab.bodyContent) inlineErrors = result - } + val allowJson5 = state.settings.allowJson5InJsonBodies + val validationPaused = shouldPauseValidation(tab.bodyContent.length) Column(modifier = Modifier.fillMaxSize()) { if (validationPaused) { @@ -347,6 +292,7 @@ fun BodyEditor(tab: RequestTabState, state: AppState, onDirty: () -> Unit) { bodyType = tab.bodyType, initialText = tab.bodyContent, languageMode = language.toLanguageMode(), + allowJson5 = allowJson5 && language == SyntaxLanguage.JSON, ) CodeEditor( text = tab.bodyContent, @@ -360,7 +306,7 @@ fun BodyEditor(tab: RequestTabState, state: AppState, onDirty: () -> Unit) { enableWordWrap = true, enableCopy = true, enableDownload = false, - inlineErrors = inlineErrors, + allowJson5 = allowJson5 && language == SyntaxLanguage.JSON, placeholder = when (tab.bodyType) { BodyType.JSON -> "{\n \n}" BodyType.XML -> "\n \n" diff --git a/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/CodeEditor.kt b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/CodeEditor.kt index 185ea10..e4d08a2 100644 --- a/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/CodeEditor.kt +++ b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/CodeEditor.kt @@ -36,12 +36,12 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor @@ -58,7 +58,6 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.reqlab.editor.core.InlineEditorError import com.reqlab.editor.core.LanguageMode import com.reqlab.editor.ui.EditorRenderer import com.reqlab.editor.ui.EditorTheme @@ -70,7 +69,13 @@ import com.reqlab.ui.shared.platform.readFromClipboard import com.reqlab.ui.shared.theme.CodeFontFamily import com.reqlab.ui.shared.theme.LocalAppColors import com.reqlab.ui.shared.theme.ReqLabColors -import kotlinx.coroutines.launch +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** Pretty-print of read-only bodies larger than this is offloaded off the composition thread. */ +internal const val READ_ONLY_FORMAT_OFFLOAD_CHARS = 64_000 + +internal fun shouldOffloadReadOnlyFormat(length: Int): Boolean = length > READ_ONLY_FORMAT_OFFLOAD_CHARS // ── Theme Helper ───────────────────────────────────────────────── @@ -114,8 +119,6 @@ private fun editorTheme(): EditorTheme { * @param enableDownload Show download-to-file button. * @param onDownload Callback for the download action. * @param placeholder Placeholder text shown when the editor is empty. - * @param inlineErrors Diagnostics to underline inline in editable mode. - * Errors show as a red underline; warnings as amber. * @param testTagPrefix Prefix for Compose test tags. */ @kotlinx.serialization.ExperimentalSerializationApi @@ -134,10 +137,10 @@ fun CodeEditor( enableDownload: Boolean = false, onDownload: (() -> Unit)? = null, placeholder: String = "", - inlineErrors: List = emptyList(), testTagPrefix: String = "code-editor", onCursorTap: ((Int) -> Unit)? = null, lineVariableSpans: ((lineText: String, lineStartOffset: Int) -> List>)? = null, + allowJson5: Boolean = false, /** * An already-created [EditorViewModel] to use instead of creating a new one. * When provided, [CodeEditor] does NOT dispose it on removal — the caller @@ -172,8 +175,22 @@ fun CodeEditor( // ── Format / display state ─────────────────────────────── var isFormatted by remember { mutableStateOf(isReadOnly) } - val displayText = remember(text, isFormatted, language) { - if (isFormatted && isReadOnly) autoFormat(text, language) else text + var offloadedFormatted by remember { mutableStateOf(null) } + LaunchedEffect(text, isFormatted, language, allowJson5, isReadOnly) { + if (!isReadOnly || !isFormatted || !shouldOffloadReadOnlyFormat(text.length)) { + offloadedFormatted = null + return@LaunchedEffect + } + offloadedFormatted = withContext(Dispatchers.Default) { + autoFormat(text, language, allowJson5) + } + } + val displayText = remember(text, isFormatted, language, allowJson5, offloadedFormatted, isReadOnly) { + when { + !isReadOnly || !isFormatted -> text + shouldOffloadReadOnlyFormat(text.length) -> offloadedFormatted ?: text + else -> autoFormat(text, language, allowJson5) + } } // Keep doc in sync with external text (or formatted text for read-only), // but skip no-op updates. Re-applying identical text after paste can @@ -189,7 +206,15 @@ fun CodeEditor( var showSearch by remember { mutableStateOf(false) } var searchQuery by remember { mutableStateOf("") } var activeMatchIndex by remember { mutableIntStateOf(0) } - val coroutineScope = rememberCoroutineScope() + val editorFocus = remember { FocusRequester() } + // Bump a tick from click handlers; requestFocus in LaunchedEffect so it is + // not nested inside IconButton/performClick (deadlocks desktop tests). + var restoreFocusTick by remember { mutableIntStateOf(0) } + LaunchedEffect(restoreFocusTick) { + if (restoreFocusTick == 0) return@LaunchedEffect + try { editorFocus.requestFocus() } catch (_: Exception) { } + } + val restoreEditorFocus: () -> Unit = { restoreFocusTick++ } // ── Fold regions (synchronous, for toolbar display) ─────── val allLines = remember(displayText) { displayText.split('\n') } @@ -215,7 +240,10 @@ fun CodeEditor( val toggleSearch: () -> Unit = { showSearch = !showSearch - if (!showSearch) searchQuery = "" + if (!showSearch) { + searchQuery = "" + restoreEditorFocus() + } } LaunchedEffect(searchMatches.size) { activeMatchIndex = if (searchMatches.isNotEmpty()) @@ -256,7 +284,10 @@ fun CodeEditor( isReadOnly = isReadOnly, wordWrap = wordWrap, onToggleWordWrap = if (enableWordWrap) { - { wordWrap = !wordWrap } + { + wordWrap = !wordWrap + restoreEditorFocus() + } } else null, isFormatted = isFormatted, onToggleFormat = if (enableFormat) { @@ -264,9 +295,11 @@ fun CodeEditor( if (isReadOnly) { isFormatted = !isFormatted } else { - val formatted = autoFormat(text, language) - if (formatted != text) onTextChange?.invoke(formatted) + val current = viewModel.getFullText() + val formatted = autoFormat(current, language, allowJson5) + if (formatted != current) viewModel.replaceDocument(formatted) } + restoreEditorFocus() } } else null, showSearch = showSearch, @@ -274,15 +307,23 @@ fun CodeEditor( toggleSearch } else null, onCopy = if (enableCopy) { - { platformCopyToClipboard(viewModel.getFullText()) } + { + platformCopyToClipboard(viewModel.getFullText()) + restoreEditorFocus() + } + } else null, + onDownload = if (enableDownload) { + { + onDownload?.invoke() + restoreEditorFocus() + } } else null, - onDownload = if (enableDownload) onDownload else null, hasFoldRegions = toolbarFoldRegions.isNotEmpty(), onFoldAll = if (enableFolding && toolbarFoldRegions.isNotEmpty()) { - { viewModel.foldAll() } + { viewModel.foldAll(); restoreEditorFocus() } } else null, onUnfoldAll = if (enableFolding && toolbarFoldRegions.isNotEmpty()) { - { viewModel.unfoldAll() } + { viewModel.unfoldAll(); restoreEditorFocus() } } else null, testTagPrefix = testTagPrefix, ) @@ -305,7 +346,7 @@ fun CodeEditor( activeMatchIndex = (activeMatchIndex - 1 + searchMatches.size) % searchMatches.size } }, - onClose = { showSearch = false; searchQuery = "" }, + onClose = { showSearch = false; searchQuery = ""; restoreEditorFocus() }, testTagPrefix = testTagPrefix, ) } @@ -329,6 +370,7 @@ fun CodeEditor( activeSearchMatch = activeSearchMatch, onPrimaryTapOffset = onCursorTap, lineVariableSpans = lineVariableSpans, + focusRequester = editorFocus, ) } } @@ -470,6 +512,7 @@ private fun ToolbarBtn( onClick = onClick, modifier = Modifier .size(28.dp) + .focusProperties { canFocus = false } .then(if (testTag.isNotEmpty()) Modifier.testTag(testTag) else Modifier), ) { Icon( @@ -548,13 +591,25 @@ private fun CodeEditorSearchBar( ) } - IconButton(onClick = onPrev, modifier = Modifier.size(24.dp)) { + IconButton( + onClick = onPrev, + modifier = Modifier.size(24.dp).focusProperties { canFocus = false }, + ) { Icon(Icons.Default.ArrowUpward, "Previous match", tint = ReqLabColors.OnSurfaceDim, modifier = Modifier.size(14.dp)) } - IconButton(onClick = onNext, modifier = Modifier.size(24.dp)) { + IconButton( + onClick = onNext, + modifier = Modifier.size(24.dp).focusProperties { canFocus = false }, + ) { Icon(Icons.Default.ArrowDownward, "Next match", tint = ReqLabColors.OnSurfaceDim, modifier = Modifier.size(14.dp)) } - IconButton(onClick = onClose, modifier = Modifier.size(24.dp)) { + IconButton( + onClick = onClose, + modifier = Modifier + .size(24.dp) + .focusProperties { canFocus = false } + .testTag("$testTagPrefix-search-close"), + ) { Icon(Icons.Default.Close, "Close search", tint = ReqLabColors.OnSurfaceDim, modifier = Modifier.size(14.dp)) } } diff --git a/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/RequestEditor.kt b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/RequestEditor.kt index f2bf369..49fd3a7 100644 --- a/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/RequestEditor.kt +++ b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/RequestEditor.kt @@ -168,9 +168,9 @@ private fun copyToClipboard(text: String) { private fun buildCopyFormats(tab: RequestTabState, state: AppState): List Unit>> { val layers = state.activeVariableLayers() return listOf( - "cURL" to { copyToClipboard(buildCurlCommand(tab, layers)) }, - "Python" to { copyToClipboard(buildPythonCommand(tab, layers)) }, - "PowerShell" to { copyToClipboard(buildPowerShellCommand(tab, layers)) }, + "cURL" to { copyToClipboard(buildCurlCommand(tab, layers, state.settings.allowJson5InJsonBodies)) }, + "Python" to { copyToClipboard(buildPythonCommand(tab, layers, state.settings.allowJson5InJsonBodies)) }, + "PowerShell" to { copyToClipboard(buildPowerShellCommand(tab, layers, state.settings.allowJson5InJsonBodies)) }, ) } diff --git a/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/RequestExecutor.kt b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/RequestExecutor.kt index 5f670ad..a89126e 100644 --- a/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/RequestExecutor.kt +++ b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/RequestExecutor.kt @@ -3,6 +3,7 @@ package com.reqlab.ui.shared.components import com.reqlab.core.model.AuthConfig import com.reqlab.core.model.AuthType import com.reqlab.core.model.BodyType +import com.reqlab.core.model.json.Json5 import com.reqlab.core.model.FormDataEntry import com.reqlab.core.model.HttpMethodType import com.reqlab.core.model.KeyValueEntry @@ -496,7 +497,11 @@ private fun parseBinaryAttachment(content: String): Pair? { } /** Builds a cURL command string for the given tab, resolving {{vars}} from variable layers. */ -fun buildCurlCommand(tab: RequestTabState, variableLayers: List> = emptyList()): String { +fun buildCurlCommand( + tab: RequestTabState, + variableLayers: List> = emptyList(), + allowJson5: Boolean = true, +): String { fun resolve(s: String) = VariableResolver.resolve(s, variableLayers, removeUnresolved = true) val parts = mutableListOf("curl", "-X ${tab.method.name}") @@ -528,7 +533,7 @@ fun buildCurlCommand(tab: RequestTabState, variableLayers: List> = emptyList()): String { +fun buildPythonCommand( + tab: RequestTabState, + variableLayers: List> = emptyList(), + allowJson5: Boolean = true, +): String { fun resolve(s: String) = VariableResolver.resolve(s, variableLayers, removeUnresolved = true) val sb = StringBuilder() @@ -573,7 +583,7 @@ fun buildPythonCommand(tab: RequestTabState, variableLayers: List> = emptyList()): String { +fun buildHTTPieCommand( + tab: RequestTabState, + variableLayers: List> = emptyList(), + allowJson5: Boolean = true, +): String { fun resolve(s: String) = VariableResolver.resolve(s, variableLayers, removeUnresolved = true) val parts = mutableListOf("http", tab.method.name) @@ -600,14 +614,18 @@ fun buildHTTPieCommand(tab: RequestTabState, variableLayers: List> = emptyList()): String { +fun buildPowerShellCommand( + tab: RequestTabState, + variableLayers: List> = emptyList(), + allowJson5: Boolean = true, +): String { fun resolve(s: String) = VariableResolver.resolve(s, variableLayers, removeUnresolved = true) val sb = StringBuilder() @@ -625,7 +643,7 @@ fun buildPowerShellCommand(tab: RequestTabState, variableLayers: List>, diff --git a/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/SettingsDialog.kt b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/SettingsDialog.kt index fb361cf..67942e7 100644 --- a/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/SettingsDialog.kt +++ b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/SettingsDialog.kt @@ -276,6 +276,14 @@ private fun GeneralSettings(s: AppSettings) { onCheckedChange = { s.confirmBeforeDelete = it }, ) SettingsDivider() + SettingToggle( + label = Strings.t("json5_in_json_bodies"), + description = Strings.t("settings_json5_in_json_bodies_desc"), + checked = s.allowJson5InJsonBodies, + onCheckedChange = { s.allowJson5InJsonBodies = it }, + tag = "json5-body-toggle", + ) + SettingsDivider() SettingNumberField( label = Strings.t("default_request_timeout_seconds"), value = s.defaultTimeoutSec, diff --git a/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/SyntaxHighlighter.kt b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/SyntaxHighlighter.kt index 3496027..42804ee 100644 --- a/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/SyntaxHighlighter.kt +++ b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/components/SyntaxHighlighter.kt @@ -2,6 +2,7 @@ package com.reqlab.ui.shared.components import androidx.compose.ui.text.AnnotatedString import com.reqlab.editor.core.ContentTypeUtil +import com.reqlab.editor.core.EditorEngine import com.reqlab.editor.core.LanguageMode import com.reqlab.editor.core.LanguageRegistry import com.reqlab.editor.core.XmlMode @@ -136,13 +137,13 @@ fun applySearchHighlights( fun formatXml(raw: String): String = XmlMode.format(raw) -fun tryPrettyPrint(raw: String): String { +fun tryPrettyPrint(raw: String, allowJson5: Boolean = false): String { if (!LanguageRegistry.hasProvider(LanguageMode.JSON)) LanguageRegistry.registerBuiltins() - return LanguageRegistry.getProvider(LanguageMode.JSON).format(raw) + return editorAutoFormat(raw, LanguageMode.JSON, allowJson5) } -fun autoFormat(raw: String, language: SyntaxLanguage): String = - editorAutoFormat(raw, language.toLanguageMode()) +fun autoFormat(raw: String, language: SyntaxLanguage, allowJson5: Boolean = false): String = + editorAutoFormat(raw, language.toLanguageMode(), allowJson5) // ── Validation ────────────────────────────────────────────────── @@ -152,10 +153,10 @@ data class JsonValidationError( val col: Int = -1, ) -fun validateJson(text: String): JsonValidationError? { +fun validateJson(text: String, allowJson5: Boolean = false): JsonValidationError? { if (text.isBlank()) return null if (!LanguageRegistry.hasProvider(LanguageMode.JSON)) LanguageRegistry.registerBuiltins() - val errors = LanguageRegistry.getProvider(LanguageMode.JSON).validate(text) + val errors = EditorEngine().validate(text, LanguageMode.JSON, allowJson5) if (errors.isEmpty()) return null val first = errors.first() return JsonValidationError( diff --git a/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/persistence/SettingsRepository.kt b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/persistence/SettingsRepository.kt index 225f9f0..8d8de12 100644 --- a/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/persistence/SettingsRepository.kt +++ b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/persistence/SettingsRepository.kt @@ -47,6 +47,7 @@ object SettingsRepository { settings.scriptPrefix = PlatformStorage.getString(PREFIX + "scriptPrefix") ?: settings.scriptPrefix settings.selectedEnvName = PlatformStorage.getString(PREFIX + "selectedEnvName") ?: settings.selectedEnvName + settings.allowJson5InJsonBodies = getBool("allowJson5InJsonBodies", settings.allowJson5InJsonBodies) } // ── Save ─────────────────────────────────────────────────────────────── @@ -69,6 +70,7 @@ object SettingsRepository { PlatformStorage.putString(PREFIX + "scriptPrefix", settings.scriptPrefix) PlatformStorage.putString(PREFIX + "selectedEnvName", settings.selectedEnvName) + putBool("allowJson5InJsonBodies", settings.allowJson5InJsonBodies) } // ── Helpers ──────────────────────────────────────────────────────────── diff --git a/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/state/AppState.kt b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/state/AppState.kt index 0ff3b2d..4924971 100644 --- a/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/state/AppState.kt +++ b/ui-shared/src/commonMain/kotlin/com/reqlab/ui/shared/state/AppState.kt @@ -12,6 +12,7 @@ import com.reqlab.core.model.FormEntryType import com.reqlab.core.model.HttpMethodType import com.reqlab.core.model.McpConnectionConfig import com.reqlab.core.model.RequestKind +import com.reqlab.editor.core.Json5EditorSupport import com.reqlab.editor.core.LanguageMode import com.reqlab.editor.ui.EditorViewModel import com.reqlab.editor.ui.SyntaxHighlighterRegistry @@ -242,6 +243,9 @@ class AppSettings { // Environment /** Name of the last selected environment; restored on app launch. Empty = first env. */ var selectedEnvName by mutableStateOf("") + + /** When true (default), JSON bodies accept JSON5; Send converts to strict JSON. */ + var allowJson5InJsonBodies by mutableStateOf(true) } // ── Per-tab state (one per open request tab) ──────────────────── @@ -336,12 +340,29 @@ class RequestTabState( // Keyed by BodyType so JSON/XML/etc. each keep independent undo stacks. // Not part of Compose state — not serialized, not tracked for dirty checking. private val bodyViewModelCache = HashMap() - - fun getOrCreateBodyViewModel(bodyType: BodyType, initialText: String, languageMode: LanguageMode): EditorViewModel { + private var json5EnabledForCachedJsonVm: Boolean? = null + + fun getOrCreateBodyViewModel( + bodyType: BodyType, + initialText: String, + languageMode: LanguageMode, + allowJson5: Boolean = true, + ): EditorViewModel { if (!SyntaxHighlighterRegistry.hasHighlighter(LanguageMode.PLAIN_TEXT)) { SyntaxHighlighterRegistry.registerBuiltinHighlighters() } - return bodyViewModelCache.getOrPut(bodyType) { EditorViewModel(initialText, languageMode) } + if (languageMode == LanguageMode.JSON) { + val cached = bodyViewModelCache[bodyType] + if (cached != null && json5EnabledForCachedJsonVm != null && json5EnabledForCachedJsonVm != allowJson5) { + cached.dispose() + bodyViewModelCache.remove(bodyType) + } + json5EnabledForCachedJsonVm = allowJson5 + } + return bodyViewModelCache.getOrPut(bodyType) { + val provider = if (allowJson5 && languageMode == LanguageMode.JSON) Json5EditorSupport else null + EditorViewModel(initialText, languageMode, provider) + } } /** Disposes all cached body EditorViewModels. Call before removing this tab. */ diff --git a/ui-shared/src/commonMain/resources/i18n/de.json b/ui-shared/src/commonMain/resources/i18n/de.json index a2b9575..9543ea8 100644 --- a/ui-shared/src/commonMain/resources/i18n/de.json +++ b/ui-shared/src/commonMain/resources/i18n/de.json @@ -87,6 +87,8 @@ "sending_request": "Anfrage wird gesendet…", "server_processing": "Serververarbeitung", "settings": "Einstellungen", + "json5_in_json_bodies": "JSON5 in JSON-Bodies", + "settings_json5_in_json_bodies_desc": "Kommentare, nachgestellte Kommas und unquoted Keys in JSON-Request-Bodies erlauben. Gesendet wird weiterhin striktes JSON. Aus: strikte JSON-Prüfung, Formatierung und Übertragung.", "success": "Erfolg", "system_theme": "System", "tcp_connect": "TCP-Verbindung", diff --git a/ui-shared/src/commonMain/resources/i18n/en.json b/ui-shared/src/commonMain/resources/i18n/en.json index 1eb6924..767846c 100644 --- a/ui-shared/src/commonMain/resources/i18n/en.json +++ b/ui-shared/src/commonMain/resources/i18n/en.json @@ -164,6 +164,8 @@ "settings_auto_save_desc": "Automatically save request changes before switching tabs", "settings_confirm_delete_desc": "Show confirmation dialog when deleting requests or collections", "settings_follow_redirects_desc": "Automatically follow HTTP 3xx redirect responses", + "json5_in_json_bodies": "JSON5 in JSON bodies", + "settings_json5_in_json_bodies_desc": "Allow comments, trailing commas, and unquoted keys in JSON request bodies. Send still transmits strict JSON. Turn off to restore strict JSON validation, formatting, and sending.", "settings_response_layout_desc": "Choose where response panel appears", "settings_enable_proxy_desc": "Route requests through a proxy server", "settings_language_change_hint": "Changes take effect immediately. The UI language will update when you close this dialog.", diff --git a/ui-shared/src/commonMain/resources/i18n/es.json b/ui-shared/src/commonMain/resources/i18n/es.json index fc02dc4..5e9a282 100644 --- a/ui-shared/src/commonMain/resources/i18n/es.json +++ b/ui-shared/src/commonMain/resources/i18n/es.json @@ -87,6 +87,8 @@ "sending_request": "Enviando solicitud…", "server_processing": "Procesamiento del servidor", "settings": "Configuración", + "json5_in_json_bodies": "JSON5 en cuerpos JSON", + "settings_json5_in_json_bodies_desc": "Permitir comentarios, comas finales y claves sin comillas en los cuerpos JSON. El envío sigue transmitiendo JSON estricto. Desactivar restaura la validación, el formato y el envío estrictos.", "success": "Éxito", "system_theme": "Sistema", "tcp_connect": "Conexión TCP", diff --git a/ui-shared/src/commonMain/resources/i18n/fr.json b/ui-shared/src/commonMain/resources/i18n/fr.json index c0bab54..43e4122 100644 --- a/ui-shared/src/commonMain/resources/i18n/fr.json +++ b/ui-shared/src/commonMain/resources/i18n/fr.json @@ -87,6 +87,8 @@ "sending_request": "Envoi de la requête…", "server_processing": "Traitement serveur", "settings": "Paramètres", + "json5_in_json_bodies": "JSON5 dans les corps JSON", + "settings_json5_in_json_bodies_desc": "Autoriser les commentaires, virgules finales et clés non citées dans les corps JSON. L'envoi transmet toujours du JSON strict. Désactiver restaure la validation, le formatage et l'envoi stricts.", "success": "Succès", "system_theme": "Système", "tcp_connect": "Connexion TCP", diff --git a/ui-shared/src/commonTest/kotlin/com/reqlab/ui/shared/components/SyntaxHighlighterTest.kt b/ui-shared/src/commonTest/kotlin/com/reqlab/ui/shared/components/SyntaxHighlighterTest.kt index 0bf8f12..94edf13 100644 --- a/ui-shared/src/commonTest/kotlin/com/reqlab/ui/shared/components/SyntaxHighlighterTest.kt +++ b/ui-shared/src/commonTest/kotlin/com/reqlab/ui/shared/components/SyntaxHighlighterTest.kt @@ -413,6 +413,39 @@ const a = 1; assertEquals(input, result) } + @Test + fun autoFormat_json5_on_formats_commented_body() { + val input = """ + { + "name": "Ada", + // "role": "admin", + "active": true + } + """.trimIndent() + val result = autoFormat(input, SyntaxLanguage.JSON, allowJson5 = true) + assertTrue(result.contains("//"), result) + assertTrue(result.contains("role"), result) + assertTrue(result.contains("Ada"), result) + + val compact = autoFormat("{a:1,}", SyntaxLanguage.JSON, allowJson5 = true) + assertTrue(compact.lines().size > 1, compact) + assertTrue(compact.contains("a"), compact) + assertTrue(!compact.contains("\"a\""), compact) + assertTrue(compact.contains(","), compact) + } + + @Test + fun autoFormat_json5_off_leaves_commented_body() { + val input = """ + { + "name": "Ada", + // "role": "admin", + "active": true + } + """.trimIndent() + assertEquals(input, autoFormat(input, SyntaxLanguage.JSON, allowJson5 = false)) + } + // ── tryPrettyPrint ────────────────────────────────────────── @Test @@ -439,3 +472,12 @@ const a = 1; assertEquals("js", fileExtensionForContentType("text/javascript")) } } + +class ReadOnlyFormatOffloadTest { + @Test + fun shouldOffloadReadOnlyFormat_cutoff() { + assertTrue(!shouldOffloadReadOnlyFormat(READ_ONLY_FORMAT_OFFLOAD_CHARS)) + assertTrue(!shouldOffloadReadOnlyFormat(1)) + assertTrue(shouldOffloadReadOnlyFormat(READ_ONLY_FORMAT_OFFLOAD_CHARS + 1)) + } +} diff --git a/ui-shared/src/commonTest/kotlin/com/reqlab/ui/shared/i18n/I18nCompletenessTest.kt b/ui-shared/src/commonTest/kotlin/com/reqlab/ui/shared/i18n/I18nCompletenessTest.kt index 2f534cb..dd5e1fd 100644 --- a/ui-shared/src/commonTest/kotlin/com/reqlab/ui/shared/i18n/I18nCompletenessTest.kt +++ b/ui-shared/src/commonTest/kotlin/com/reqlab/ui/shared/i18n/I18nCompletenessTest.kt @@ -47,7 +47,7 @@ class I18nCompletenessTest { "settings", "general", "theme", "network", "proxy", "language", "auto_save", "confirm_before_delete", "default_timeout", "response_layout", "follow_redirects", "dark_mode", "light_mode", - "system_theme", + "system_theme", "json5_in_json_bodies", "settings_json5_in_json_bodies_desc", // Global Variables "global_variables", "global_variables_desc", "add_variable", "no_global_variables", "variable_name", "value", diff --git a/ui-shared/src/desktopMain/kotlin/com/reqlab/ui/shared/network/NetworkClientFactory.desktop.kt b/ui-shared/src/desktopMain/kotlin/com/reqlab/ui/shared/network/NetworkClientFactory.desktop.kt index bb71e91..0684f67 100644 --- a/ui-shared/src/desktopMain/kotlin/com/reqlab/ui/shared/network/NetworkClientFactory.desktop.kt +++ b/ui-shared/src/desktopMain/kotlin/com/reqlab/ui/shared/network/NetworkClientFactory.desktop.kt @@ -66,6 +66,7 @@ actual object NetworkClientFactory { logger = logger, retryPolicy = retryPolicy, idleTimeoutMs = timeoutMs, + allowJson5InJsonBodies = settings.allowJson5InJsonBodies, ) } } diff --git a/ui-shared/src/wasmJsMain/kotlin/com/reqlab/ui/shared/network/NetworkClientFactory.wasmJs.kt b/ui-shared/src/wasmJsMain/kotlin/com/reqlab/ui/shared/network/NetworkClientFactory.wasmJs.kt index 4b051b7..1419026 100644 --- a/ui-shared/src/wasmJsMain/kotlin/com/reqlab/ui/shared/network/NetworkClientFactory.wasmJs.kt +++ b/ui-shared/src/wasmJsMain/kotlin/com/reqlab/ui/shared/network/NetworkClientFactory.wasmJs.kt @@ -50,6 +50,7 @@ actual object NetworkClientFactory { logger = logger, retryPolicy = retryPolicy, idleTimeoutMs = timeoutMs, + allowJson5InJsonBodies = settings.allowJson5InJsonBodies, ) } } From 2ceada9291abe74abc88698670898526f7c0783f Mon Sep 17 00:00:00 2001 From: Sanjay Sharma Date: Mon, 31 Aug 2026 02:25:45 +0530 Subject: [PATCH 5/5] Fix issue --- .../com/reqlab/core/network/mcp/McpClient.kt | 31 +++++++++++++++---- .../network/mcp/StreamableHttpTransport.kt | 4 ++- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/core-network/src/commonMain/kotlin/com/reqlab/core/network/mcp/McpClient.kt b/core-network/src/commonMain/kotlin/com/reqlab/core/network/mcp/McpClient.kt index c2a3ee8..aeb265e 100644 --- a/core-network/src/commonMain/kotlin/com/reqlab/core/network/mcp/McpClient.kt +++ b/core-network/src/commonMain/kotlin/com/reqlab/core/network/mcp/McpClient.kt @@ -44,8 +44,10 @@ import io.ktor.client.HttpClient import io.ktor.client.plugins.HttpTimeout import io.ktor.client.plugins.HttpTimeoutConfig import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -84,6 +86,10 @@ class McpClient( private var nextId = 1L private var transport: McpTransport? = null private var inboundJob: Job? = null + // Isolate GET-SSE / callback jobs so CIO ClosedSelectorException on disconnect + // cannot fail the caller's runBlocking (qa E2E). Recreated on each connect. + private lateinit var workers: Job + private lateinit var workerScope: CoroutineScope var config: McpConnectionConfig = McpConnectionConfig() private set var initializeResult: McpInitializeResult? = null @@ -109,12 +115,24 @@ class McpClient( var lastReceivedPayload: String? = null private set + init { + workerScope = newWorkerScope() + } + + private fun newWorkerScope(): CoroutineScope { + workers = SupervisorJob(scope.coroutineContext[Job]) + return CoroutineScope( + scope.coroutineContext + workers + CoroutineExceptionHandler { _, _ -> }, + ) + } + suspend fun connect( connection: McpConnectionConfig, variableLayers: List> = emptyList(), oauthRetry: Boolean = true, ): McpInitializeResult { disconnect() + workerScope = newWorkerScope() config = resolveMcpConfig(connection, variableLayers) applyConfigHandlers(config) _state.value = McpConnectionState.CONNECTING @@ -122,7 +140,7 @@ class McpClient( try { val created = createTransport(config) transport = created - inboundJob = scope.launch { created.incoming.collect { routeInbound(it) } } + inboundJob = workerScope.launch { created.incoming.collect { routeInbound(it) } } created.start() val result = handshake(created) created.onHandshakeComplete() @@ -152,6 +170,7 @@ class McpClient( failPending("Disconnected") inboundJob?.cancel() inboundJob = null + workers.cancel() runCatching { transport?.close() } transport = null initializeResult = null @@ -283,10 +302,10 @@ class McpClient( log(McpLogEntryKind.STATE, "Auto-detect falling back to legacy HTTP+SSE") inboundJob?.cancel() runCatching { active.close() } - val legacy = LegacyHttpSseTransport(httpClient, config.copy(url = config.url), scope) + val legacy = LegacyHttpSseTransport(httpClient, config.copy(url = config.url), workerScope) transport = legacy legacy.start() - inboundJob = scope.launch { legacy.incoming.collect { routeInbound(it) } } + inboundJob = workerScope.launch { legacy.incoming.collect { routeInbound(it) } } request("initialize", encodeParams(params)) } transport?.protocolVersion = result.protocolVersion.ifBlank { MCP_PROTOCOL_VERSION } @@ -298,11 +317,11 @@ class McpClient( return when (cfg.transport) { McpTransportType.STDIO -> stdioFactory(cfg) McpTransportType.STREAMABLE_HTTP -> when (cfg.httpMode) { - McpHttpMode.LEGACY_2024_11_05 -> LegacyHttpSseTransport(httpClient, cfg, scope) + McpHttpMode.LEGACY_2024_11_05 -> LegacyHttpSseTransport(httpClient, cfg, workerScope) else -> StreamableHttpTransport( httpClient, cfg, - scope, + workerScope, replyClient = mcpAuxHttpClient(), streamClient = mcpAuxHttpClient(), ) @@ -368,7 +387,7 @@ class McpClient( } message.isRequest() -> { log(McpLogEntryKind.RECEIVED, message.method.orEmpty(), encoded, message.method, message.idKey()) - scope.launch { handleServerRequest(message) } + workerScope.launch { handleServerRequest(message) } } message.isNotification() -> { log(McpLogEntryKind.NOTIFICATION, message.method.orEmpty(), encoded, message.method, null) diff --git a/core-network/src/commonMain/kotlin/com/reqlab/core/network/mcp/StreamableHttpTransport.kt b/core-network/src/commonMain/kotlin/com/reqlab/core/network/mcp/StreamableHttpTransport.kt index c3c5c78..79e612a 100644 --- a/core-network/src/commonMain/kotlin/com/reqlab/core/network/mcp/StreamableHttpTransport.kt +++ b/core-network/src/commonMain/kotlin/com/reqlab/core/network/mcp/StreamableHttpTransport.kt @@ -74,8 +74,10 @@ class StreamableHttpTransport( override suspend fun close() { closed = true - getJob?.cancel() + val job = getJob getJob = null + job?.cancel() + runCatching { job?.join() } val sid = sessionId if (!sid.isNullOrBlank()) { runCatching {