diff --git a/.gitignore b/.gitignore index ef94b2d..834a0a0 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ .mtj.tmp/ # Package Files # +*.jar *.war *.nar *.ear @@ -32,7 +33,11 @@ target # MacOS *.DS_Store -.vscode/ +.classpath +.project +.settings +.vscode +.idea # Ballerina velocity.log* diff --git a/ballerina/Ballerina.toml b/ballerina/Ballerina.toml index 7e3bc9a..c96c35c 100644 --- a/ballerina/Ballerina.toml +++ b/ballerina/Ballerina.toml @@ -1,7 +1,7 @@ [package] org = "ballerina" name = "mcp" -version = "0.1.0" +version = "0.4.0" authors = ["Ballerina"] keywords = ["mcp"] repository = "https://github.com/ballerina-platform/module-ballerina-mcp" @@ -11,3 +11,9 @@ distribution = "2201.12.0" [platform.java21] graalvmCompatible = true + +[[platform.java21.dependency]] +groupId = "io.ballerina.stdlib." +artifactId = "mcp-native" +version = "0.4.0" +path = "../native/build/libs/mcp-native-0.4.0-SNAPSHOT.jar" diff --git a/ballerina/Dependencies.toml b/ballerina/Dependencies.toml index d9cdb7f..49b11d0 100644 --- a/ballerina/Dependencies.toml +++ b/ballerina/Dependencies.toml @@ -7,6 +7,99 @@ dependencies-toml-version = "2" distribution-version = "2201.12.0" +[[package]] +org = "ballerina" +name = "auth" +version = "2.14.0" +dependencies = [ + {org = "ballerina", name = "crypto"}, + {org = "ballerina", name = "jballerina.java"}, + {org = "ballerina", name = "lang.array"}, + {org = "ballerina", name = "lang.string"}, + {org = "ballerina", name = "log"} +] + +[[package]] +org = "ballerina" +name = "cache" +version = "3.10.0" +dependencies = [ + {org = "ballerina", name = "constraint"}, + {org = "ballerina", name = "jballerina.java"}, + {org = "ballerina", name = "task"}, + {org = "ballerina", name = "time"} +] + +[[package]] +org = "ballerina" +name = "constraint" +version = "1.7.0" +dependencies = [ + {org = "ballerina", name = "jballerina.java"} +] + +[[package]] +org = "ballerina" +name = "crypto" +version = "2.9.0" +dependencies = [ + {org = "ballerina", name = "jballerina.java"}, + {org = "ballerina", name = "time"} +] + +[[package]] +org = "ballerina" +name = "data.jsondata" +version = "1.1.0" +dependencies = [ + {org = "ballerina", name = "jballerina.java"}, + {org = "ballerina", name = "lang.object"} +] + +[[package]] +org = "ballerina" +name = "file" +version = "1.12.0" +dependencies = [ + {org = "ballerina", name = "io"}, + {org = "ballerina", name = "jballerina.java"}, + {org = "ballerina", name = "os"}, + {org = "ballerina", name = "time"} +] + +[[package]] +org = "ballerina" +name = "http" +version = "2.14.1" +dependencies = [ + {org = "ballerina", name = "auth"}, + {org = "ballerina", name = "cache"}, + {org = "ballerina", name = "constraint"}, + {org = "ballerina", name = "crypto"}, + {org = "ballerina", name = "data.jsondata"}, + {org = "ballerina", name = "file"}, + {org = "ballerina", name = "io"}, + {org = "ballerina", name = "jballerina.java"}, + {org = "ballerina", name = "jwt"}, + {org = "ballerina", name = "lang.array"}, + {org = "ballerina", name = "lang.decimal"}, + {org = "ballerina", name = "lang.int"}, + {org = "ballerina", name = "lang.regexp"}, + {org = "ballerina", name = "lang.runtime"}, + {org = "ballerina", name = "lang.string"}, + {org = "ballerina", name = "lang.value"}, + {org = "ballerina", name = "log"}, + {org = "ballerina", name = "mime"}, + {org = "ballerina", name = "oauth2"}, + {org = "ballerina", name = "observe"}, + {org = "ballerina", name = "time"}, + {org = "ballerina", name = "url"} +] +modules = [ + {org = "ballerina", packageName = "http", moduleName = "http"}, + {org = "ballerina", packageName = "http", moduleName = "http.httpscerr"} +] + [[package]] org = "ballerina" name = "io" @@ -23,6 +116,90 @@ modules = [ org = "ballerina" name = "jballerina.java" version = "0.0.0" +modules = [ + {org = "ballerina", packageName = "jballerina.java", moduleName = "jballerina.java"} +] + +[[package]] +org = "ballerina" +name = "jwt" +version = "2.15.0" +dependencies = [ + {org = "ballerina", name = "cache"}, + {org = "ballerina", name = "crypto"}, + {org = "ballerina", name = "io"}, + {org = "ballerina", name = "jballerina.java"}, + {org = "ballerina", name = "lang.int"}, + {org = "ballerina", name = "lang.string"}, + {org = "ballerina", name = "log"}, + {org = "ballerina", name = "time"} +] + +[[package]] +org = "ballerina" +name = "lang.__internal" +version = "0.0.0" +dependencies = [ + {org = "ballerina", name = "jballerina.java"}, + {org = "ballerina", name = "lang.object"} +] + +[[package]] +org = "ballerina" +name = "lang.array" +version = "0.0.0" +dependencies = [ + {org = "ballerina", name = "jballerina.java"}, + {org = "ballerina", name = "lang.__internal"} +] + +[[package]] +org = "ballerina" +name = "lang.decimal" +version = "0.0.0" +dependencies = [ + {org = "ballerina", name = "jballerina.java"} +] + +[[package]] +org = "ballerina" +name = "lang.int" +version = "0.0.0" +dependencies = [ + {org = "ballerina", name = "jballerina.java"}, + {org = "ballerina", name = "lang.__internal"}, + {org = "ballerina", name = "lang.object"} +] + +[[package]] +org = "ballerina" +name = "lang.object" +version = "0.0.0" + +[[package]] +org = "ballerina" +name = "lang.regexp" +version = "0.0.0" +dependencies = [ + {org = "ballerina", name = "jballerina.java"} +] + +[[package]] +org = "ballerina" +name = "lang.runtime" +version = "0.0.0" +dependencies = [ + {org = "ballerina", name = "jballerina.java"} +] + +[[package]] +org = "ballerina" +name = "lang.string" +version = "0.0.0" +dependencies = [ + {org = "ballerina", name = "jballerina.java"}, + {org = "ballerina", name = "lang.regexp"} +] [[package]] org = "ballerina" @@ -32,14 +209,105 @@ dependencies = [ {org = "ballerina", name = "jballerina.java"} ] +[[package]] +org = "ballerina" +name = "log" +version = "2.12.0" +dependencies = [ + {org = "ballerina", name = "io"}, + {org = "ballerina", name = "jballerina.java"}, + {org = "ballerina", name = "lang.value"}, + {org = "ballerina", name = "observe"} +] + [[package]] org = "ballerina" name = "mcp" -version = "0.1.0" +version = "0.4.0" dependencies = [ - {org = "ballerina", name = "io"} + {org = "ballerina", name = "http"}, + {org = "ballerina", name = "io"}, + {org = "ballerina", name = "jballerina.java"} ] modules = [ {org = "ballerina", packageName = "mcp", moduleName = "mcp"} ] +[[package]] +org = "ballerina" +name = "mime" +version = "2.12.0" +dependencies = [ + {org = "ballerina", name = "io"}, + {org = "ballerina", name = "jballerina.java"}, + {org = "ballerina", name = "lang.int"}, + {org = "ballerina", name = "log"} +] + +[[package]] +org = "ballerina" +name = "oauth2" +version = "2.14.0" +dependencies = [ + {org = "ballerina", name = "cache"}, + {org = "ballerina", name = "crypto"}, + {org = "ballerina", name = "jballerina.java"}, + {org = "ballerina", name = "log"}, + {org = "ballerina", name = "time"}, + {org = "ballerina", name = "url"} +] + +[[package]] +org = "ballerina" +name = "observe" +version = "1.5.0" +dependencies = [ + {org = "ballerina", name = "jballerina.java"} +] + +[[package]] +org = "ballerina" +name = "os" +version = "1.10.0" +dependencies = [ + {org = "ballerina", name = "io"}, + {org = "ballerina", name = "jballerina.java"} +] + +[[package]] +org = "ballerina" +name = "task" +version = "2.10.0" +dependencies = [ + {org = "ballerina", name = "jballerina.java"}, + {org = "ballerina", name = "time"}, + {org = "ballerina", name = "uuid"} +] + +[[package]] +org = "ballerina" +name = "time" +version = "2.7.0" +dependencies = [ + {org = "ballerina", name = "jballerina.java"} +] + +[[package]] +org = "ballerina" +name = "url" +version = "2.6.0" +dependencies = [ + {org = "ballerina", name = "jballerina.java"} +] + +[[package]] +org = "ballerina" +name = "uuid" +version = "1.10.0" +dependencies = [ + {org = "ballerina", name = "crypto"}, + {org = "ballerina", name = "jballerina.java"}, + {org = "ballerina", name = "lang.int"}, + {org = "ballerina", name = "time"} +] + diff --git a/ballerina/build.gradle b/ballerina/build.gradle index 39835ec..7c8a749 100644 --- a/ballerina/build.gradle +++ b/ballerina/build.gradle @@ -51,7 +51,8 @@ ballerina { task updateTomlFiles { doLast { - def newConfig = ballerinaTomlFilePlaceHolder.text.replace("@toml.version@", tomlVersion) + def newConfig = ballerinaTomlFilePlaceHolder.text.replace("@project.version@", project.version) + newConfig = newConfig.replace("@toml.version@", tomlVersion) ballerinaTomlFile.text = newConfig } } @@ -91,6 +92,8 @@ publishing { updateTomlFiles.dependsOn copyStdlibs build.dependsOn "generatePomFileForMavenPublication" +build.dependsOn ":${packageName}-native:build" +test.dependsOn ":${packageName}-native:build" publishToMavenLocal.dependsOn build publish.dependsOn build diff --git a/ballerina/client.bal b/ballerina/client.bal new file mode 100644 index 0000000..1048d78 --- /dev/null +++ b/ballerina/client.bal @@ -0,0 +1,237 @@ +// Copyright (c) 2025 WSO2 LLC (http://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +# Configuration options for initializing an MCP client. +# +# + capabilities - Capabilities to be advertised by this client. +public type ClientConfiguration record {| + *ProtocolOptions; + ClientCapabilities capabilities?; +|}; + +# Represents an MCP client built on top of the Streamable HTTP transport. +public distinct isolated client class Client { + # MCP server URL. + private final string serverUrl; + # Client implementation details (e.g., name and version). + private final Implementation clientInfo; + # Capabilities supported by the client. + private final ClientCapabilities clientCapabilities; + + # Transport for communication with the MCP server. + private StreamableHttpClientTransport? transport = (); + # Server capabilities. + private ServerCapabilities? serverCapabilities = (); + # Server implementation information. + private Implementation? serverInfo = (); + # Request ID generator for tracking requests. + private int requestId = 0; + + # Initializes a new MCP client with the provided server URL and client details. + # + # + serverUrl - MCP server URL. + # + clientInfo - Client details, such as name and version. + # + config - Optional configuration containing client capabilities. + public isolated function init(string serverUrl, Implementation clientInfo, ClientConfiguration? config = ()) { + self.serverUrl = serverUrl; + self.clientInfo = clientInfo.cloneReadOnly(); + self.clientCapabilities = config?.capabilities.cloneReadOnly() ?: {}; + } + + # Establishes a connection to the MCP server and performs protocol initialization. + # + # + return - A ClientError if initialization fails, or nil on success. + isolated remote function initialize() returns ClientError? { + lock { + // Create and initialize transport. + StreamableHttpClientTransport newTransport = check new StreamableHttpClientTransport(self.serverUrl); + self.transport = newTransport; + + string? sessionId = newTransport.getSessionId(); + + // If a session ID exists, assume reconnection and skip initialization. + if sessionId is string { + return; + } + + // Prepare and send the initialization request. + InitializeRequest initRequest = { + method: "initialize", + params: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: self.clientCapabilities, + clientInfo: self.clientInfo + } + }; + + ServerResult response = check self.sendRequestMessage(initRequest); + + if response is InitializeResult { + final readonly & string protocolVersion = response.protocolVersion; + // Validate protocol compatibility. + if (!SUPPORTED_PROTOCOL_VERSIONS.some(v => v == protocolVersion)) { + return error ProtocolVersionError( + string `Server protocol version '${protocolVersion}' is not supported. Supported versions: ${SUPPORTED_PROTOCOL_VERSIONS.toString()}.` + ); + } + + // Store server capabilities and info. + self.serverCapabilities = response.capabilities; + self.serverInfo = response.serverInfo; + + // Send notification to complete initialization. + InitializedNotification initNotification = { + method: "notifications/initialized" + }; + check self.sendNotificationMessage(initNotification); + } else { + return error ClientInitializationError( + string `Initialization failed: unexpected response type '${(typeof response).toString()}' received from server.` + ); + } + } + } + + # Opens a server-sent events (SSE) stream for asynchronous server-to-client communication. + # + # + return - Stream of JsonRpcMessages or a ClientError. + isolated remote function subscribeToServerMessages() returns stream|ClientError { + lock { + StreamableHttpClientTransport? currentTransport = self.transport; + if currentTransport is () { + return error UninitializedTransportError( + "Subscription failed: client transport is not initialized. Call initialize() first." + ); + } + return currentTransport.establishEventStream(); + } + } + + # Retrieves the list of available tools from the server. + # + # + return - List of available tools or a ClientError. + isolated remote function listTools() returns ListToolsResult|ClientError { + ListToolsRequest listToolsRequest = { + method: "tools/list" + }; + + ServerResult result = check self.sendRequestMessage(listToolsRequest); + if result is ListToolsResult { + return result; + } else { + return error ListToolsError( + string `Tool listing failed: unexpected result type '${(typeof result).toString()}' received.` + ); + } + } + + # Executes a tool on the server with the given parameters. + # + # + params - Tool execution parameters, including name and arguments. + # + return - Result of the tool execution or a ClientError. + isolated remote function callTool(CallToolParams params) returns CallToolResult|ClientError { + CallToolRequest toolCallRequest = { + method: "tools/call", + params: params + }; + + ServerResult result = check self.sendRequestMessage(toolCallRequest); + if result is CallToolResult { + return result; + } else { + return error ToolCallError( + string `Tool call failed: unexpected result type '${(typeof result).toString()}' received.` + ); + } + } + + # Closes the session and disconnects from the server. + # + # + return - A ClientError if closure fails, or nil on success. + isolated remote function close() returns ClientError? { + lock { + StreamableHttpClientTransport? currentTransport = self.transport; + if currentTransport is () { + return error UninitializedTransportError( + "Closure failed: client transport is not initialized. Call initialize() first." + ); + } + + do { + check currentTransport.terminateSession(); + lock { + self.transport = (); + self.serverCapabilities = (); + self.serverInfo = (); + } + return; + } on fail error e { + return error ClientError(string `Failed to disconnect from server: ${e.message()}`, e); + } + } + } + + # Sends a request message to the server and returns the server's response. + # + # + request - The request object to send. + # + return - ServerResult, a stream of results, or a ClientError. + private isolated function sendRequestMessage(Request request) returns ServerResult|ClientError { + lock { + StreamableHttpClientTransport? currentTransport = self.transport; + if currentTransport is () { + return error UninitializedTransportError( + "Cannot send request: client transport is not initialized. Call initialize() first." + ); + } + + lock { + self.requestId += 1; + + JsonRpcRequest jsonRpcRequest = { + ...request.cloneReadOnly(), + jsonrpc: JSONRPC_VERSION, + id: self.requestId + }; + + JsonRpcMessage|stream|StreamableHttpTransportError? response = + currentTransport.sendMessage(jsonRpcRequest); + return processServerResponse(response).cloneReadOnly(); + } + } + } + + # Sends a notification message to the server. + # + # + notification - The notification object to send. + # + return - A ClientError if sending fails, or nil on success. + private isolated function sendNotificationMessage(Notification notification) returns ClientError? { + lock { + StreamableHttpClientTransport? currentTransport = self.transport; + if currentTransport is () { + return error UninitializedTransportError( + "Cannot send notification: client transport is not initialized. Call initialize() first." + ); + } + + JsonRpcNotification jsonRpcNotification = { + ...notification.cloneReadOnly(), + jsonrpc: JSONRPC_VERSION + }; + + _ = check currentTransport.sendMessage(jsonRpcNotification); + } + } +} diff --git a/ballerina/main.bal b/ballerina/constants.bal similarity index 71% rename from ballerina/main.bal rename to ballerina/constants.bal index 1a6315e..5c1dd34 100644 --- a/ballerina/main.bal +++ b/ballerina/constants.bal @@ -14,7 +14,9 @@ // specific language governing permissions and limitations // under the License. -import ballerina/io; -public function main() { - io:println("This is MCP Library"); -} +// Transport related constants (headers) +const SESSION_ID_HEADER = "mcp-session-id"; +const ACCEPT_HEADER = "accept"; +const CONTENT_TYPE_HEADER = "content-type"; +const CONTENT_TYPE_JSON = "application/json"; +const CONTENT_TYPE_SSE = "text/event-stream"; diff --git a/ballerina/error.bal b/ballerina/error.bal new file mode 100644 index 0000000..3d83bcb --- /dev/null +++ b/ballerina/error.bal @@ -0,0 +1,84 @@ +// Copyright (c) 2025 WSO2 LLC (http://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +# Defines the common base error type for this module. +public type Error distinct error; + +# Error for failures during streaming operations. +public type StreamError distinct Error & ClientError; + +# Error for failures during transport operations. +public type TransportError distinct Error; + +# Error for invalid or unexpected responses from the server. +public type ServerResponseError distinct Error & ClientError; + +# Error for failures occurring within client operations. +public type ClientError distinct Error; + +# Error for failures while processing SSE event streams. +public type SseEventStreamError distinct StreamError; + +# Error for JSON-RPC message transformation failures during streaming. +public type JsonRpcMessageTransformationError distinct StreamError; + +# Error when required data is missing from an SSE event. +public type MissingSseDataError distinct JsonRpcMessageTransformationError; + +# Error for JSON parsing failures within SSE event data. +public type JsonParsingError distinct JsonRpcMessageTransformationError; + +# Error for failures converting JSON to JsonRpcMessage. +public type TypeConversionError distinct JsonRpcMessageTransformationError; + +# Error when an invalid message type is received from the server. +public type InvalidMessageTypeError distinct ServerResponseError; + +# Error when the server response is malformed or unexpected. +public type MalformedResponseError distinct ServerResponseError; + +# Error for failures during HTTP transport operations. +public type StreamableHttpTransportError distinct TransportError & ClientError; + +# Error for failures during HTTP client operations. +public type HttpClientError distinct StreamableHttpTransportError; + +# Error for unsupported content types in HTTP responses. +public type UnsupportedContentTypeError distinct StreamableHttpTransportError; + +# Error for failures during session operations. +public type SessionOperationError distinct StreamableHttpTransportError; + +# Error for failures while parsing HTTP response content. +public type ResponseParsingError distinct StreamableHttpTransportError; + +# Error for failures during SSE stream establishment. +public type SseStreamEstablishmentError distinct StreamableHttpTransportError; + +# Error for operations attempted before transport initialization. +public type UninitializedTransportError distinct ClientError; + +# Error for failures during client initialization. +public type ClientInitializationError distinct ClientError; + +# Error for protocol version negotiation failures. +public type ProtocolVersionError distinct ClientInitializationError; + +# Error for failures during tool listing operations. +public type ListToolsError distinct ClientError; + +# Error for failures during tool execution operations. +public type ToolCallError distinct ClientError; diff --git a/ballerina/init.bal b/ballerina/init.bal new file mode 100644 index 0000000..fb3da35 --- /dev/null +++ b/ballerina/init.bal @@ -0,0 +1,25 @@ +// Copyright (c) 2025 WSO2 LLC. (http://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import ballerina/jballerina.java; + +isolated function init() { + setModule(); +} + +isolated function setModule() = @java:Method { + 'class: "io.ballerina.stdlib.mcp.ModuleUtils" +} external; diff --git a/ballerina/json_rpc_message_stream_transformer.bal b/ballerina/json_rpc_message_stream_transformer.bal new file mode 100644 index 0000000..2a30d2d --- /dev/null +++ b/ballerina/json_rpc_message_stream_transformer.bal @@ -0,0 +1,111 @@ +// Copyright (c) 2025 WSO2 LLC (http://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import ballerina/http; +import ballerina/jballerina.java; + +# Transforms a stream of SSE events into a stream of JsonRpcMessages. +isolated class JsonRpcMessageStreamTransformer { + + # Initializes the transformer with an SSE event stream. + # + # + sseEventStream - The SSE event stream to use as input. + public isolated function init(stream sseEventStream) { + self.attachSseStream(sseEventStream); + } + + # Retrieves the next JsonRpcMessage from the SSE event stream. + # + # + return - A record with the next JsonRpcMessage, a StreamError, or nil if the stream is complete. + public isolated function next() returns record {|JsonRpcMessage value;|}|StreamError? { + record {|http:SseEvent value;|}|error? sseEventRecord = self.getNextSseEvent(); + + if sseEventRecord is () { + return; // End of stream. + } + + if sseEventRecord is error { + return error SseEventStreamError( + string `Failed to retrieve SSE event: ${sseEventRecord.message()}` + ); + } + + string? eventData = sseEventRecord.value.data; + JsonRpcMessage|JsonRpcMessageTransformationError jsonRpcMessage = self.convertSseDataToJsonRpcMessage(eventData); + + if jsonRpcMessage is JsonRpcMessageTransformationError { + return jsonRpcMessage; + } + + return { + value: jsonRpcMessage + }; + } + + # Closes the underlying SSE event stream. + # + # + return - A StreamError if closing fails, or nil if successful. + public isolated function close() returns StreamError? { + error? closeError = self.closeSseEventStream(); + if closeError is error { + return error SseEventStreamError(string `Failed to close SSE event stream: ${closeError.message()}`); + } + return; + } + + # Attaches the SSE event stream to this transformer instance. + # + # + sseEventStream - The stream of SSE events to bind. + private isolated function attachSseStream(stream sseEventStream) = @java:Method { + 'class: "io.ballerina.stdlib.mcp.SseEventStreamHelper" + } external; + + # Retrieves the next event from the SSE stream. + # + # + return - Record containing the next SSE event, error, or nil if the stream is complete. + private isolated function getNextSseEvent() returns record {|http:SseEvent value;|}?|error? = @java:Method { + 'class: "io.ballerina.stdlib.mcp.SseEventStreamHelper" + } external; + + # Closes the attached SSE event stream. + # + # + return - Error if closing fails, or nil if successful. + private isolated function closeSseEventStream() returns error? = @java:Method { + 'class: "io.ballerina.stdlib.mcp.SseEventStreamHelper" + } external; + + # Converts SSE event data to a JsonRpcMessage. + # + # + eventData - The `data` field from an SSE event. + # + return - A JsonRpcMessage or a JsonRpcMessageTransformationError. + private isolated function convertSseDataToJsonRpcMessage(string? eventData) returns JsonRpcMessage|JsonRpcMessageTransformationError { + if eventData is () { + return error MissingSseDataError("SSE event is missing the required 'data' field."); + } + + json|error jsonData = eventData.fromJsonString(); + if jsonData is error { + return error JsonParsingError(string `Failed to parse SSE event data as JSON: ${jsonData.message()}`); + } + + JsonRpcMessage|error message = jsonData.cloneWithType(JsonRpcMessage); + if message is error { + return error TypeConversionError(string `Failed to convert JSON data to JsonRpcMessage: ${message.message()}`); + } + + return message; + } +} diff --git a/ballerina/protocol.bal b/ballerina/protocol.bal new file mode 100644 index 0000000..b4ea5c4 --- /dev/null +++ b/ballerina/protocol.bal @@ -0,0 +1,28 @@ +// Copyright (c) 2025 WSO2 LLC (http://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +# Additional initialization options. +# +# + enforceStrictCapabilities - Whether to restrict emitted requests to only those that the remote +# side has indicated that they can handle, through their advertised +# capabilities. Note that this DOES NOT affect checking of _local_ +# side capabilities, as it is considered a logic error to mis-specify +# those. Currently this defaults to false, for backwards compatibility +# with SDK versions that did not advertise capabilities correctly. In +# future, this will default to true. +public type ProtocolOptions record {| + boolean enforceStrictCapabilities?; +|}; diff --git a/ballerina/streamable_http.bal b/ballerina/streamable_http.bal new file mode 100644 index 0000000..7f3e8e4 --- /dev/null +++ b/ballerina/streamable_http.bal @@ -0,0 +1,185 @@ +// Copyright (c) 2025 WSO2 LLC (http://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import ballerina/http; + +# Configuration options for the Streamable HTTP client transport. +# +# + sessionId - Optional session identifier for continued interactions. +type StreamableHttpClientTransportConfig record {| + string? sessionId = (); +|}; + +# Provides HTTP-based client transport with support for streaming. +isolated class StreamableHttpClientTransport { + private final string serverUrl; + private final http:Client httpClient; + private string? sessionId; + + # Initializes the HTTP client transport with the provided server URL. + # + # + serverUrl - The URL of the server endpoint. + # + config - Optional configuration, such as session ID. + # + return - A StreamableHttpTransportError if initialization fails; otherwise, nil. + isolated function init(string serverUrl, StreamableHttpClientTransportConfig? config = ()) returns StreamableHttpTransportError? { + self.serverUrl = serverUrl; + do { + self.httpClient = check new (serverUrl); + } on fail error e { + return error HttpClientError(string `Unable to initialize HTTP client for '${serverUrl}': ${e.message()}`); + } + self.sessionId = config?.sessionId; + } + + # Sends a JSON-RPC message to the server and returns the response. + # + # + message - The JSON-RPC message to send. + # + return - A JSON-RPC response message, a stream of messages, or a transport error. + isolated function sendMessage(JsonRpcMessage message) returns JsonRpcMessage|stream|StreamableHttpTransportError? { + map headers = self.prepareRequestHeaders(); + headers[CONTENT_TYPE_HEADER] = CONTENT_TYPE_JSON; + headers[ACCEPT_HEADER] = string `${CONTENT_TYPE_JSON}, ${CONTENT_TYPE_SSE}`; + + do { + http:Response response = check self.httpClient->/.post(message, headers = headers); + + // Handle session ID in the initialization response. + string|error sessionIdHeader = response.getHeader(SESSION_ID_HEADER); + if sessionIdHeader is string { + lock { + self.sessionId = sessionIdHeader; + } + } + + // If response is 202 Accepted, there is no content to process. + if response.statusCode == 202 { + return; + } + + // Dispatch response based on content type. + string contentType = response.getContentType(); + if contentType.includes(CONTENT_TYPE_SSE) { + return self.processServerSentEvents(response); + } else if contentType.includes(CONTENT_TYPE_JSON) { + return self.processJsonResponse(response); + } else { + return error UnsupportedContentTypeError( + string `Server returned unsupported content type '${contentType}'.` + ); + } + } on fail error e { + return error HttpClientError(string `Failed to send message to server: ${e.message()}`); + } + } + + # Establishes a Server-Sent Events (SSE) stream with the server. + # + # + return - A stream of JsonRpcMessages, or a StreamableHttpTransportError. + isolated function establishEventStream() returns stream|StreamableHttpTransportError { + map headers = self.prepareRequestHeaders(); + headers[ACCEPT_HEADER] = CONTENT_TYPE_SSE; + + do { + stream sseEventStream = check self.httpClient->get("/", headers = headers); + + JsonRpcMessageStreamTransformer streamTransformer = new (sseEventStream); + return new stream(streamTransformer); + } on fail error e { + return error SseStreamEstablishmentError( + string `Failed to establish SSE connection with server: ${e.message()}` + ); + } + } + + # Terminates the current session with the server. + # + # + return - A StreamableHttpTransportError if termination fails; otherwise, nil. + isolated function terminateSession() returns StreamableHttpTransportError? { + lock { + if self.sessionId is () { + return; + } + + map headers = self.prepareRequestHeaders(); + headers[CONTENT_TYPE_HEADER] = CONTENT_TYPE_JSON; + + do { + http:Response response = check self.httpClient->delete("/", headers = headers); + + if response.statusCode == 405 { + return error SessionOperationError("Server does not support session termination."); + } + + self.sessionId = (); + return; + } on fail error e { + return error SessionOperationError( + string `Failed to terminate session: ${e.message()}` + ); + } + } + } + + # Returns the current session ID, or nil if no session is active. + # + # + return - The current session ID as a string, or nil if not set. + isolated function getSessionId() returns string? { + lock { + return self.sessionId; + } + } + + # Prepares common HTTP headers for requests, including the session ID if present. + # + # + return - Map of common headers to include in each request. + private isolated function prepareRequestHeaders() returns map { + lock { + string? currentSessionId = self.sessionId; + return currentSessionId is string ? {[SESSION_ID_HEADER]: currentSessionId} : {}; + } + } + + # Processes a Server-Sent Events HTTP response into a stream of JsonRpcMessages. + # + # + response - The HTTP response containing SSE data. + # + return - A stream of JsonRpcMessages, or a StreamableHttpTransportError. + private isolated function processServerSentEvents(http:Response response) returns stream|StreamableHttpTransportError { + do { + stream sseEventStream = check response.getSseEventStream(); + JsonRpcMessageStreamTransformer streamTransformer = new (sseEventStream); + return new stream(streamTransformer); + } on fail error e { + return error ResponseParsingError( + string `Unable to process SSE response: ${e.message()}` + ); + } + } + + # Processes a JSON HTTP response into a JsonRpcMessage. + # + # + response - The HTTP response containing JSON data. + # + return - A JsonRpcMessage, or a StreamableHttpTransportError. + private isolated function processJsonResponse(http:Response response) returns JsonRpcMessage|StreamableHttpTransportError { + do { + json payload = check response.getJsonPayload(); + return check payload.cloneWithType(); + } on fail error e { + return error ResponseParsingError( + string `Unable to parse JSON response: ${e.message()}` + ); + } + } +} diff --git a/ballerina/types.bal b/ballerina/types.bal new file mode 100644 index 0000000..e53a57c --- /dev/null +++ b/ballerina/types.bal @@ -0,0 +1,519 @@ +// Copyright (c) 2025 WSO2 LLC (http://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +# Refers to any valid JSON-RPC object that can be decoded off the wire, or encoded to be sent. +public type JsonRpcMessage JsonRpcRequest|JsonRpcNotification|JsonRpcResponse|JsonRpcError; + +public const LATEST_PROTOCOL_VERSION = "2025-03-26"; +public const SUPPORTED_PROTOCOL_VERSIONS = [ + LATEST_PROTOCOL_VERSION, + "2024-11-05", + "2024-10-07" +]; + +public const JSONRPC_VERSION = "2.0"; + +# A progress token, used to associate progress notifications with the original request. +public type ProgressToken string|int; + +# An opaque token used to represent a cursor for pagination. +public type Cursor string; + +# Represents a generic request in the protocol +public type Request record {| + # The method name for the request + string method; + # Optional parameters for the request + record { + record {| + # If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). + # The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications. + ProgressToken progressToken?; + |} _meta?; + } params?; +|}; + +# Represents a notification. +public type Notification record {| + # The method name of the notification + string method; + # Additional parameters for the notification + record { + record {} _meta?; + } params?; +|}; + +# Base result type with common fields. +public type Result record { + # This result property is reserved by the protocol to allow clients and servers + # to attach additional metadata to their responses. + record {} _meta?; +}; + +# A uniquely identifying ID for a request in JSON-RPC. +public type RequestId string|int; + +# A request that expects a response. +public type JsonRpcRequest record { + *Request; + # The JSON-RPC protocol version + JSONRPC_VERSION jsonrpc; + # Identifier established by the client that should be returned in the response + RequestId id; +}; + +# A notification which does not expect a response. +public type JsonRpcNotification record { + *Notification; + # The JSON-RPC protocol version + JSONRPC_VERSION jsonrpc; +}; + +# A successful (non-error) response to a request. +public type JsonRpcResponse record {| + # The JSON-RPC protocol version + JSONRPC_VERSION jsonrpc; + # Identifier of the request + RequestId id; + # The result of the request + ServerResult result; +|}; + +// Standard JSON-RPC error codes +public const PARSE_ERROR = -32700; +public const INVALID_REQUEST = -32600; +public const METHOD_NOT_FOUND = -32601; +public const INVALID_PARAMS = -32602; +public const INTERNAL_ERROR = -32603; + +# A response to a request that indicates an error occurred. +public type JsonRpcError record { + # The JSON-RPC protocol version + JSONRPC_VERSION jsonrpc; + # Identifier of the request + RequestId id; + # The error information + record { + # The error type that occurred + int code; + # A short description of the error. The message SHOULD be limited to a concise single sentence. + string message; + # Additional information about the error. The value of this member is defined by the sender (e.g. detailed error information, nested errors etc.). + anydata data?; + } 'error; +}; + +# A response that indicates success but carries no data. +public type EmptyResult Result; + +# This notification can be sent by either side to indicate that it is cancelling a previously-issued request. +public type CancelledNotification record {| + *Notification; + # The method name for this notification + "notifications/cancelled" method; + # The parameters for the cancellation notification + record {| + # The ID of the request to cancel. + # This MUST correspond to the ID of a request previously issued in the same direction. + RequestId requestId; + # An optional string describing the reason for the cancellation. This MAY be logged or presented to the user. + string? reason = (); + |} params; +|}; + +# This request is sent from the client to the server when it first connects, asking it to begin initialization. +type InitializeRequest record {| + *Request; + # Method name for the request + "initialize" method; + # Parameters for the initialize request + record {| + # The latest version of the Model Context Protocol that the client supports. + # The client MAY decide to support older versions as well. + string protocolVersion; + # Capabilities supported by the client + ClientCapabilities capabilities; + # Information about the client implementation + Implementation clientInfo; + |} params; +|}; + +# After receiving an initialize request from the client, the server sends this response. +public type InitializeResult record {| + *Result; + # The version of the Model Context Protocol that the server wants to use. + # This may not match the version that the client requested. + # If the client cannot support this version, it MUST disconnect. + string protocolVersion; + # The capabilities of the server. + ServerCapabilities capabilities; + # Information about the server implementation + Implementation serverInfo; + # Instructions describing how to use the server and its features. + # This can be used by clients to improve the LLM's understanding of available tools, resources, etc. + # It can be thought of like a "hint" to the model. + # For example, this information MAY be added to the system prompt. + string instructions?; +|}; + +# This notification is sent from the client to the server after initialization has finished. +public type InitializedNotification record {| + *Notification; + # The method identifier for the notification, must be "notifications/initialized" + "notifications/initialized" method; +|}; + +# Capabilities a client may support. Known capabilities are defined here, in this schema, +# but this is not a closed set: any client can define its own, additional capabilities. +public type ClientCapabilities record { + # Experimental, non-standard capabilities that the client supports. + record {|record {}...;|} experimental?; + # Present if the client supports listing roots. + record {| + # Whether the client supports notifications for changes to the roots list. + boolean listChanged?; + |} roots?; + # Present if the client supports sampling from an LLM. + record {} sampling?; +}; + +# Capabilities that a server may support. Known capabilities are defined here, in this schema, +# but this is not a closed set: any server can define its own, additional capabilities. +public type ServerCapabilities record { + # Experimental, non-standard capabilities that the server supports. + record {|record {}...;|} experimental?; + # Present if the server supports sending log messages to the client. + record {} logging?; + # Present if the server supports argument autocompletion suggestions. + record {} completions?; + # Present if the server offers any prompt templates. + record {| + # Whether this server supports notifications for changes to the prompt list. + boolean listChanged?; + |} prompts?; + # Present if the server offers any resources to read. + record {| + # Whether this server supports subscribing to resource updates. + boolean subscribe?; + # Whether this server supports notifications for changes to the resource list. + boolean listChanged?; + |} resources?; + # Present if the server offers any tools to call. + record {| + # Whether this server supports notifications for changes to the tool list. + boolean listChanged?; + |} tools?; +}; + +# Describes the name and version of an MCP implementation. +public type Implementation record {| + # The name of the implementation + string name; + # The version of the implementation + string version; +|}; + +# A ping, issued by either the server or the client, to check that +# the other party is still alive. The receiver must promptly respond, +# or else may be disconnected. +public type PingRequest record {| + *Request; + # The method name + "ping" method; +|}; + +# An out-of-band notification used to inform the receiver of a progress update for a long-running request. +public type ProgressNotification record {| + *Notification; + # The method name for the notification + "notifications/progress" method; + # The parameters for the progress notification + record { + # The progress token which was given in the initial request, + # used to associate this notification with the request that is proceeding. + ProgressToken progressToken; + # The progress thus far. This should increase every time progress is made, + # even if the total is unknown. + int progress; + # Total number of items to process (or total progress required), if known. + int total?; + # An optional message describing the current progress. + string message?; + record {} _meta?; + } params; +|}; + +# Represents a paginated request with optional cursor-based pagination. +public type PaginatedRequest record {| + # Optional pagination parameters + record {| + # An opaque token representing the current pagination position. + # If provided, the server should return results starting after this cursor. + Cursor cursor?; + |} params?; +|}; + +# Result that supports pagination +public type PaginatedResult record {| + *Result; + # An opaque token representing the pagination position after the last returned result. + # If present, there may be more results available. + Cursor nextCursor?; +|}; + +# An optional notification from the server to the client, informing it that the list of resources it can read from has changed. +public type ResourceListChangedNotification record {| + *Notification; + # The JSON-RPC method name for resource list changed notifications + "notifications/resources/list_changed" method; +|}; + +# A notification from the server to the client, informing it that a resource has changed and may need to be read again. +public type ResourceUpdatedNotification record {| + *Notification; + # The JSON-RPC method name for resource updated notifications + "notifications/resources/updated" method; + # The parameters for the resource updated notification + record { + # The URI of the resource that has been updated. This might be a sub-resource of the one + # that the client actually subscribed to. + string uri; + record {} _meta?; + } params; +|}; + +# The contents of a specific resource or sub-resource. +public type ResourceContents record {| + # The URI of this resource. + string uri; + # The MIME type of this resource, if known. + string mimeType?; +|}; + +# Text resource contents +public type TextResourceContents record {| + *ResourceContents; + # The text of the item. This must only be set if the item can actually be represented as text (not binary data). + string text; +|}; + +# Binary resource contents +public type BlobResourceContents record {| + *ResourceContents; + # A base64-encoded string representing the binary data of the item. + string blob; +|}; + +# The sender or recipient of messages and data in a conversation. +public type Role "user"|"assistant"; + +# The contents of a resource, embedded into a prompt or tool call result. +public type EmbeddedResource record {| + # The type of content + "resource" 'type; + # The resource content + TextResourceContents|BlobResourceContents 'resource; + # Optional annotations for the client + Annotations annotations?; +|}; + +# An optional notification from the server to the client, informing it that +# the list of prompts it offers has changed. +public type PromptListChangedNotification record {| + *Notification; + # The JSON-RPC method name for prompt list changed notifications + "notifications/prompts/list_changed" method; +|}; + +# Sent from the client to request a list of tools the server has. +public type ListToolsRequest record {| + *PaginatedRequest; + # The method identifier for this request + "tools/list" method; +|}; + +# The server's response to a tools/list request from the client. +public type ListToolsResult record {| + *PaginatedResult; + # A list of tools available on the server. + Tool[] tools; +|}; + +# The server's response to a tool call. +public type CallToolResult record {| + # The content of the tool call result + (TextContent|ImageContent|AudioContent|EmbeddedResource)[] content; + # Whether the tool call ended in an error. + # If not set, this is assumed to be false (the call was successful). + boolean isError?; +|}; + +# Used by the client to invoke a tool provided by the server. +public type CallToolRequest record {| + # The JSON-RPC method name + "tools/call" method; + # The parameters for the tool call + CallToolParams params; +|}; + +# Parameters for the tools/call request +public type CallToolParams record {| + # The name of the tool to invoke + string name; + # Optional arguments to pass to the tool + record {} arguments?; +|}; + +# An optional notification from the server to the client, informing it that the list of tools +# it offers has changed. +public type ToolListChangedNotification record {| + *Notification; + # The JSON-RPC method name for tool list changed notifications + "notifications/tools/list_changed" method; +|}; + +# Additional properties describing a Tool to clients. +# NOTE: all properties in ToolAnnotations are **hints**. +public type ToolAnnotations record {| + # A human-readable title for the tool. + string title?; + # If true, the tool does not modify its environment. + # Default: false + boolean readOnlyHint?; + # If true, the tool may perform destructive updates to its environment. + # If false, the tool performs only additive updates. + # (This property is meaningful only when `readOnlyHint == false`) + # Default: true + boolean destructiveHint?; + # If true, calling the tool repeatedly with the same arguments + # will have no additional effect on the its environment. + # (This property is meaningful only when `readOnlyHint == false`) + # Default: false + boolean idempotentHint?; + # If true, this tool may interact with an "open world" of external + # entities. If false, the tool's domain of interaction is closed. + # For example, the world of a web search tool is open, whereas that + # of a memory tool is not. + # Default: true + boolean openWorldHint?; +|}; + +# Definition for a tool the client can call. +public type Tool record {| + # The name of the tool + string name; + # A human-readable description of the tool + # This can be used by clients to improve the LLM's understanding of available tools. + string description?; + # A JSON Schema object defining the expected parameters for the tool. + record { + "object" 'type; + record {|record {}...;|} properties?; + string[] required?; + } inputSchema; + # Optional additional tool information. + ToolAnnotations annotations?; +|}; + +# Notification of a log message passed from server to client. If no logging/setLevel request has been +# sent from the client, the server MAY decide which messages to send automatically. +public type LoggingMessageNotification record {| + *Notification; + # The method name for the notification + "notifications/message" method; + # The parameters for the logging message notification + record { + # The severity of this log message. + LoggingLevel level; + # An optional name of the logger issuing this message. + string logger?; + # The data to be logged, such as a string message or an object. Any JSON serializable type is allowed here. + anydata data; + record {} _meta?; + } params; +|}; + +# The severity of a log message. +public type LoggingLevel "debug"|"info"|"notice"|"warning"|"error"|"critical"|"alert"|"emergency"; + +# Optional annotations for the client. The client can use annotations to inform how objects are used or displayed +public type Annotations record {| + # Describes who the intended customer of this object or data is. + # This can include multiple entries to indicate content useful for multiple audiences (e.g., `["user", "assistant"]`). + Role[] audience?; + # Describes how important this data is for operating the server. + # A value of 1 means "most important," and indicates that the data is effectively required, + # while 0 means "least important," and indicates that the data is entirely optional. + decimal priority?; +|}; + +# Text provided to or from an LLM. +public type TextContent record {| + # The type of content + "text" 'type; + # The text content of the message + string text; + # Optional annotations for the client + Annotations annotations?; +|}; + +# An image provided to or from an LLM. +public type ImageContent record {| + # The type of content + "image" 'type; + # The base64-encoded image data + string data; + # The MIME type of the image. Different providers may support different image types. + string mimeType; + # Optional annotations for the client + Annotations annotations?; +|}; + +# Audio provided to or from an LLM. +public type AudioContent record {| + # The type of content + "audio" 'type; + # The base64-encoded audio data + string data; + # The MIME type of the audio. Different providers may support different audio types. + string mimeType; + # Optional annotations for the client + Annotations annotations?; +|}; + +# Represents a request sent from the client to the server. +public type ClientRequest PingRequest|InitializeRequest|CallToolRequest|ListToolsRequest; + +# Represents a notification sent from the client to the server. +public type ClientNotification CancelledNotification|ProgressNotification|InitializedNotification; + +# Represents a result sent from the client to the server. +public type ClientResult EmptyResult; + +# Represents a response sent from the server to the client. +public type ServerRequest PingRequest; + +# Represents a notification sent from the server to the client. +public type ServerNotification CancelledNotification + |ProgressNotification + |LoggingMessageNotification + |ResourceUpdatedNotification + |ResourceListChangedNotification + |ToolListChangedNotification + |PromptListChangedNotification; + +# Represents a result sent from the server to the client. +public type ServerResult InitializeResult|CallToolResult|ListToolsResult|EmptyResult; diff --git a/ballerina/utils.bal b/ballerina/utils.bal new file mode 100644 index 0000000..a25a94d --- /dev/null +++ b/ballerina/utils.bal @@ -0,0 +1,74 @@ +// Copyright (c) 2025 WSO2 LLC (http://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +# Processes a server response and extracts the result. +# +# + serverResponse - The response from the server, which may be a single JsonRpcMessage, a stream, or a transport error. +# + return - Extracted ServerResult, ServerResponseError, or StreamError. +isolated function processServerResponse(JsonRpcMessage|stream|StreamableHttpTransportError? serverResponse) + returns ServerResult|ServerResponseError|StreamError { + + if serverResponse is stream { + return extractResultFromMessageStream(serverResponse); + } + + if serverResponse is JsonRpcMessage { + return extractResultFromMessage(serverResponse); + } + + if serverResponse is () { + return error MalformedResponseError("Received null response from server."); + } + + return error ServerResponseError( + string `Transport error connecting to server: ${serverResponse.message()}` + ); +} + +# Extracts the first valid result from a stream of JsonRpcMessages. +# +# + messageStream - The stream of JsonRpcMessages to process. +# + return - The first valid ServerResult, a specific ServerResponseError, or StreamError. +isolated function extractResultFromMessageStream(stream messageStream) + returns ServerResult|ServerResponseError|StreamError { + + record {|JsonRpcMessage value;|}|StreamError? streamItem = messageStream.next(); + // Iterate until a valid result or an error is found. + while streamItem !is () { + if streamItem is StreamError { + return streamItem; + } + + JsonRpcMessage message = streamItem.value; + if message is JsonRpcResponse { + return message.result; + } + streamItem = messageStream.next(); + } + + return error InvalidMessageTypeError("No valid messages found in server message stream."); +} + +# Extracts the result from a JsonRpcMessage and converts it to a ServerResult. +# +# + message - The JsonRpcMessage to convert. +# + return - The extracted ServerResult, or an InvalidMessageTypeError. +isolated function extractResultFromMessage(JsonRpcMessage message) returns ServerResult|ServerResponseError { + if message is JsonRpcResponse { + return message.result; + } + return error InvalidMessageTypeError("Received message from server is not a valid JsonRpcResponse."); +} diff --git a/build-config/checkstyle/build.gradle b/build-config/checkstyle/build.gradle new file mode 100644 index 0000000..dee55ba --- /dev/null +++ b/build-config/checkstyle/build.gradle @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025, WSO2 LLC. (http://www.wso2.com). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +plugins { + id "de.undercouch.download" +} + +apply plugin: 'java' + +task downloadCheckstyleRuleFiles(type: Download) { + src([ + 'https://raw.githubusercontent.com/wso2/code-quality-tools/v1.4/checkstyle/jdk-17/checkstyle.xml', + 'https://raw.githubusercontent.com/wso2/code-quality-tools/v1.4/checkstyle/jdk-17/suppressions.xml' + ]) + overwrite false + onlyIfNewer true + dest buildDir +} + +jar { + enabled = false +} + +clean { + enabled = false +} + +artifacts.add('default', file("$project.buildDir/checkstyle.xml")) { + builtBy('downloadCheckstyleRuleFiles') +} + +artifacts.add('default', file("$project.buildDir/suppressions.xml")) { + builtBy('downloadCheckstyleRuleFiles') +} diff --git a/build-config/resources/Ballerina.toml b/build-config/resources/Ballerina.toml index 01d4c88..8e2fae7 100644 --- a/build-config/resources/Ballerina.toml +++ b/build-config/resources/Ballerina.toml @@ -11,3 +11,9 @@ distribution = "2201.12.0" [platform.java21] graalvmCompatible = true + +[[platform.java21.dependency]] +groupId = "io.ballerina.stdlib." +artifactId = "mcp-native" +version = "@toml.version@" +path = "../native/build/libs/mcp-native-@project.version@.jar" diff --git a/build.gradle b/build.gradle index 1e36124..caecffa 100644 --- a/build.gradle +++ b/build.gradle @@ -16,6 +16,7 @@ */ plugins { + id "com.github.spotbugs-base" id "com.github.johnrengelman.shadow" id "de.undercouch.download" id "net.researchgate.release" @@ -70,7 +71,28 @@ subprojects { } /* Standard libraries */ + ballerinaStdLibs "io.ballerina.stdlib:auth-ballerina:${stdlibAuthVersion}" + ballerinaStdLibs "io.ballerina.stdlib:cache-ballerina:${stdlibCacheVersion}" + ballerinaStdLibs "io.ballerina.stdlib:constraint-ballerina:${stdlibConstraintVersion}" + ballerinaStdLibs "io.ballerina.stdlib:crypto-ballerina:${stdlibCryptoVersion}" + ballerinaStdLibs "io.ballerina.stdlib:file-ballerina:${stdlibFileVersion}" + ballerinaStdLibs "io.ballerina.stdlib:http-ballerina:${stdlibHttpVersion}" ballerinaStdLibs "io.ballerina.stdlib:io-ballerina:${stdlibIoVersion}" + ballerinaStdLibs "io.ballerina.stdlib:jwt-ballerina:${stdlibJwtVersion}" + ballerinaStdLibs "io.ballerina.stdlib:log-ballerina:${stdlibLogVersion}" + ballerinaStdLibs "io.ballerina.stdlib:mime-ballerina:${stdlibMimeVersion}" + ballerinaStdLibs "io.ballerina.stdlib:oauth2-ballerina:${stdlibOAuth2Version}" + ballerinaStdLibs "io.ballerina.stdlib:os-ballerina:${stdlibOsVersion}" + ballerinaStdLibs "io.ballerina.stdlib:task-ballerina:${stdlibTaskVersion}" + ballerinaStdLibs "io.ballerina.stdlib:time-ballerina:${stdlibTimeVersion}" + ballerinaStdLibs "io.ballerina.stdlib:url-ballerina:${stdlibUrlVersion}" + ballerinaStdLibs "io.ballerina.stdlib:yaml-ballerina:${stdlibYamlVersion}" + ballerinaStdLibs "io.ballerina.stdlib:xmldata-ballerina:${stdlibXmldataVersion}" + ballerinaStdLibs "io.ballerina.stdlib:uuid-ballerina:${stdlibUuidVersion}" + + ballerinaStdLibs "io.ballerina.stdlib:observe-ballerina:${observeVersion}" + ballerinaStdLibs "io.ballerina:observe-ballerina:${observeInternalVersion}" + ballerinaStdLibs "io.ballerina.lib:data.jsondata-ballerina:${stdlibDataJsonDataVersion}" } } @@ -91,5 +113,6 @@ release { } task build { - dependsOn('mcp-ballerina:build') + dependsOn(':mcp-ballerina:build') + dependsOn(':mcp-native:build') } diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..e570120 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,11 @@ +fixes: + - "io/ballerina/stdlib/mcp/plugin/" + +coverage: + precision: 2 + round: down + range: "60...80" + status: + project: + default: + target: 80 diff --git a/gradle.properties b/gradle.properties index 04a74b3..5494bd1 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,12 +1,48 @@ org.gradle.caching=true group=io.ballerina.stdlib -version=0.1.0-SNAPSHOT +version=0.4.0-SNAPSHOT ballerinaLangVersion=2201.12.0 +ballerinaGradlePluginVersion=3.0.0 + +checkstylePluginVersion=10.12.0 +spotbugsPluginVersion=6.0.18 + shadowJarPluginVersion=8.1.1 downloadPluginVersion=5.4.0 releasePluginVersion=2.8.0 -ballerinaGradlePluginVersion=2.3.0 -# Dependencies + +# Ballerina Library Dependencies +# Level 01 stdlibIoVersion=1.8.0 +stdlibTimeVersion=2.7.0 +stdlibUrlVersion=2.6.0 +stdlibXmldataVersion=2.9.0 + +# Level 02 +stdlibConstraintVersion=1.7.0 +stdlibCryptoVersion=2.9.0 +stdlibLogVersion=2.12.0 +stdlibOsVersion=1.10.0 +stdlibTaskVersion=2.10.0 + +# Level 03 +stdlibCacheVersion=3.10.0 +stdlibFileVersion=1.12.0 +stdlibMimeVersion=2.12.0 +stdlibUuidVersion=1.10.0 + +# Level 04 +stdlibAuthVersion=2.14.0 +stdlibDataJsonDataVersion=1.1.0 +stdlibJwtVersion=2.15.0 +stdlibOAuth2Version=2.14.0 +stdlibYamlVersion=0.8.0 + +# Level 05 +stdlibHttpVersion=2.14.1 + +# Ballerinax Observer +observeVersion=1.5.0 +observeInternalVersion=1.5.0 diff --git a/native/build.gradle b/native/build.gradle new file mode 100644 index 0000000..f7249ac --- /dev/null +++ b/native/build.gradle @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2025, WSO2 LLC. (http://www.wso2.com). + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +plugins { + id 'java' + id 'com.github.spotbugs' + id 'checkstyle' +} + +description = 'Ballerina - MCP package Java Utils' + +dependencies { + implementation group: 'org.ballerinalang', name: 'ballerina-lang', version: "${ballerinaLangVersion}" + implementation group: 'org.ballerinalang', name: 'ballerina-runtime', version: "${ballerinaLangVersion}" + checkstyle project(":checkstyle") + checkstyle "com.puppycrawl.tools:checkstyle:${checkstylePluginVersion}" +} + +spotbugsMain { + def classLoader = plugins["com.github.spotbugs"].class.classLoader + def SpotBugsConfidence = classLoader.findLoadedClass("com.github.spotbugs.snom.Confidence") + def SpotBugsEffort = classLoader.findLoadedClass("com.github.spotbugs.snom.Effort") + ignoreFailures = true + effort = SpotBugsEffort.MAX + reportLevel = SpotBugsConfidence.LOW + reportsDir = file("$project.buildDir/reports/spotbugs") + def excludeFile = file("${rootDir}/build-config/spotbugs-exclude.xml") + if (excludeFile.exists()) { + it.excludeFilter = excludeFile + } + reports { + text.enabled = true + } +} + +spotbugsTest { + enabled = false +} + +task validateSpotbugs() { + doLast { + if (spotbugsMain.reports.size() > 0 && + spotbugsMain.reports[0].destination.exists() && + spotbugsMain.reports[0].destination.text.readLines().size() > 0) { + spotbugsMain.reports[0].destination?.eachLine { + println 'Failure: ' + it + } + throw new GradleException("Spotbugs rule violations were found."); + } + } +} + +checkstyle { + toolVersion '7.8.2' + configFile file("${rootDir}/build-config/checkstyle/build/checkstyle.xml") + configProperties = ["suppressionFile": file("${rootDir}/build-config/checkstyle/build/suppressions.xml")] +} + +tasks.withType(Checkstyle) { + exclude '**/module-info.java' +} + +spotbugsMain.finalizedBy validateSpotbugs +checkstyleMain.dependsOn(":checkstyle:downloadCheckstyleRuleFiles") + +compileJava { + doFirst { + options.compilerArgs = [ + '--module-path', classpath.asPath, + ] + classpath = files() + } +} diff --git a/native/src/main/java/io/ballerina/stdlib/mcp/ModuleUtils.java b/native/src/main/java/io/ballerina/stdlib/mcp/ModuleUtils.java new file mode 100644 index 0000000..ba09fe7 --- /dev/null +++ b/native/src/main/java/io/ballerina/stdlib/mcp/ModuleUtils.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package io.ballerina.stdlib.mcp; + +import io.ballerina.runtime.api.Environment; +import io.ballerina.runtime.api.Module; + +public final class ModuleUtils { + private static Module module; + + private ModuleUtils() { + } + + @SuppressWarnings("unused") + public static Module getModule() { + return module; + } + + @SuppressWarnings("unused") + public static void setModule(Environment env) { + module = env.getCurrentModule(); + } +} diff --git a/native/src/main/java/io/ballerina/stdlib/mcp/SseEventStreamHelper.java b/native/src/main/java/io/ballerina/stdlib/mcp/SseEventStreamHelper.java new file mode 100644 index 0000000..b733df1 --- /dev/null +++ b/native/src/main/java/io/ballerina/stdlib/mcp/SseEventStreamHelper.java @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2025, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package io.ballerina.stdlib.mcp; + +import io.ballerina.runtime.api.Environment; +import io.ballerina.runtime.api.creators.ErrorCreator; +import io.ballerina.runtime.api.values.BObject; +import io.ballerina.runtime.api.values.BStream; +import io.ballerina.runtime.api.values.BString; + +import static io.ballerina.runtime.api.utils.StringUtils.fromString; + +/** + * Utility class for handling Server-Sent Events (SSE) streams in Ballerina via Java interop. + *

+ * Provides static helper methods to: + *

    + *
  • Attach an SSE stream to a Ballerina object as native data.
  • + *
  • Retrieve the next event from an SSE stream.
  • + *
  • Close the SSE stream and release resources.
  • + *
+ * This class is not instantiable. + */ +public final class SseEventStreamHelper { + + /** Native data key used to store the SSE stream in the Ballerina object. */ + private static final String SSE_STREAM_NATIVE_KEY = "sseStream"; + + // Private constructor to prevent instantiation. + private SseEventStreamHelper() {} + + /** + * Attaches the provided SSE {@link BStream} as native data to the specified Ballerina object. + * + * @param object The Ballerina object that will hold the SSE stream (native data). + * @param sseStream The SSE stream instance to attach. + */ + public static void attachSseStream(BObject object, BStream sseStream) { + object.addNativeData(SSE_STREAM_NATIVE_KEY, sseStream); + } + + /** + * Retrieves the next event from the SSE stream attached to the given Ballerina object. + *

+ * Invokes the "next" method on the stream's iterator object using the Ballerina runtime. + * + * @param env The Ballerina runtime environment. + * @param object The Ballerina object holding the SSE stream as native data. + * @return The next SSE event record, null if the stream is exhausted, + * or a Ballerina error object if unavailable. + */ + public static Object getNextSseEvent(Environment env, BObject object) { + BStream sseStream = (BStream) object.getNativeData(SSE_STREAM_NATIVE_KEY); + if (sseStream == null) { + BString errorMessage = fromString("Unable to obtain elements from stream. SSE stream not found."); + return ErrorCreator.createError(errorMessage); + } + BObject iteratorObject = sseStream.getIteratorObj(); + // Use the Ballerina runtime to call the "next" method on the iterator and fetch the next event. + return env.getRuntime().callMethod(iteratorObject, "next", null); + } + + /** + * Closes the SSE stream attached to the given Ballerina object. + *

+ * Invokes the "close" method on the stream's iterator object using the Ballerina runtime. + * + * @param env The Ballerina runtime environment. + * @param object The Ballerina object holding the SSE stream as native data. + * @return The result of the close operation (could be null or a Ballerina error object). + */ + public static Object closeSseEventStream(Environment env, BObject object) { + BStream sseStream = (BStream) object.getNativeData(SSE_STREAM_NATIVE_KEY); + if (sseStream == null) { + BString errorMessage = fromString("Unable to obtain elements from stream. SSE stream not found."); + return ErrorCreator.createError(errorMessage); + } + BObject iteratorObject = sseStream.getIteratorObj(); + // Use the Ballerina runtime to call the "close" method on the iterator and release resources. + return env.getRuntime().callMethod(iteratorObject, "close", null); + } +} diff --git a/native/src/main/java/module-info.java b/native/src/main/java/module-info.java new file mode 100644 index 0000000..5f904c8 --- /dev/null +++ b/native/src/main/java/module-info.java @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +module io.ballerina.stdlib.mcp { + requires io.ballerina.runtime; + requires io.ballerina.lang; + exports io.ballerina.stdlib.mcp; +} diff --git a/pull_request_template.md b/pull_request_template.md new file mode 100644 index 0000000..4ad062f --- /dev/null +++ b/pull_request_template.md @@ -0,0 +1,14 @@ +## Purpose + +Fixes: + +## Examples + +## Checklist +- [ ] Linked to an issue +- [ ] Updated the changelog +- [ ] Added tests +- [ ] Updated the spec +- [ ] Checked native-image compatibility +- [ ] No `commons` package changes (if there are any, please update the GraphQL version in [GraphQL tools](https://github.com/ballerina-platform/graphql-tools) and [Ballerina dev tools](https://github.com/ballerina-platform/ballerina-dev-tools)) +- [ ] No `compiler` package changes (if there are any, please update the GraphQL version in [Ballerina dev tools](https://github.com/ballerina-platform/ballerina-dev-tools)) diff --git a/settings.gradle b/settings.gradle index 1303d56..a00d7fb 100644 --- a/settings.gradle +++ b/settings.gradle @@ -9,6 +9,7 @@ pluginManagement { plugins { + id "com.github.spotbugs-base" version "${spotbugsPluginVersion}" id "com.github.johnrengelman.shadow" version "${shadowJarPluginVersion}" id "de.undercouch.download" version "${downloadPluginVersion}" id "net.researchgate.release" version "${releasePluginVersion}" @@ -33,9 +34,13 @@ plugins { rootProject.name = 'mcp' +include ':checkstyle' include ':mcp-ballerina' +include ':mcp-native' +project(':checkstyle').projectDir = file("build-config${File.separator}checkstyle") project(':mcp-ballerina').projectDir = file('ballerina') +project(':mcp-native').projectDir = file('native') gradleEnterprise { buildScan { @@ -43,5 +48,3 @@ gradleEnterprise { termsOfServiceAgree = 'yes' } } - - diff --git a/spotbugs-exclude.xml b/spotbugs-exclude.xml new file mode 100644 index 0000000..58da414 --- /dev/null +++ b/spotbugs-exclude.xml @@ -0,0 +1,19 @@ + + +