MCP Open AI is a Kotlin/Spring Boot package for connecting a Realtime client to OpenAI Realtime sessions and dispatching Realtime function calls to server-side Kotlin tools.
Use it when you want to:
- Create short-lived OpenAI Realtime client secrets from your backend.
- Open a server-side sideband WebSocket for each Realtime call.
- Register Kotlin tools that OpenAI can call during a conversation.
- Return tool results back to the same Realtime conversation.
- Listen to Realtime lifecycle and conversation events in your application.
- Java 23
- Kotlin 2.3+
- Spring Boot 4.1+
- Spring WebFlux
- An OpenAI API key with Realtime API access
Add the package to your application.
dependencies {
implementation("io.github.kostack:mcp-openai:<version>")
}Add the dependency and let Spring Boot auto-configuration register the package.
Your application does not need to scan io.github.kostack.mcp_openai.
package com.example.demo
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
@SpringBootApplication
class DemoApplication
fun main(args: Array<String>) {
runApplication<DemoApplication>(*args)
}The package creates a default WebClient when your application does not
already provide one. Register your own WebClient bean only when you need
custom HTTP client settings.
package com.example.demo
import org.springframework.web.reactive.function.client.WebClient
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
@Configuration
class HttpConfiguration {
@Bean
fun webClient(): WebClient = WebClient.create()
}Configure the package with the kostack-mcp prefix.
kostack-mcp:
api-key: ${OPENAI_API_KEY}
model: gpt-realtime-mini
transcription-model: gpt-realtime-whisper
sideband-max-frame-payload-length: 1048576
enable-audio: falseAvailable properties:
| Property | Default | Description |
|---|---|---|
api-key |
empty | Your server-side OpenAI API key. Do not expose this to browsers. |
client-secrets-url |
https://api.openai.com/v1/realtime/client_secrets |
OpenAI endpoint used to create ephemeral Realtime client secrets. |
sideband-url |
wss://api.openai.com/v1/realtime |
OpenAI sideband WebSocket endpoint. |
sideband-prefix |
/api/realtime |
Prefix for the built-in Realtime HTTP routes. |
sideband-max-frame-payload-length |
1048576 |
Maximum inbound sideband WebSocket frame payload length in bytes. Increase this if large Realtime events exceed the default. |
model |
gpt-realtime-mini |
Realtime model used when creating a session. |
transcription-model |
gpt-realtime-whisper |
Audio transcription model used for input audio. |
enable-audio |
false |
Application-level flag you can use when deciding whether to allow audio requests. |
A namespace groups tools and routes. When a token is created for a namespace, only tools from that namespace are sent to OpenAI. When a tool call comes back, the dispatcher executes a tool with a matching namespace and name.
Use namespaces when one application has separate assistants, tenants, or feature areas.
A channel identifies a conversation history in ConversationStore. Use a stable
value for the same user conversation, such as a chat id, ticket id, or room id.
The browser receives a call id from OpenAI when it creates the Realtime WebRTC session. Send that call id to your backend so the package can open the matching server-side sideband connection.
Create a request data class for the tool arguments.
package com.example.demo.tools
import kotlinx.serialization.Serializable
import org.springframework.ai.tool.annotation.ToolParam
@Serializable
data class WeatherRequest(
@ToolParam(description = "City name, for example Athens or Berlin")
val city: String
)Create a result type.
package com.example.demo.tools
data class WeatherResult(
val city: String,
val summary: String,
val temperatureCelsius: Int
)Implement a Spring component that extends AbstractTool.
package com.example.demo.tools
import io.github.kostack.mcp_openai.dto.ToolContext
import io.github.kostack.mcp_openai.dto.ToolDefinition
import io.github.kostack.mcp_openai.dto.ToolResult
import io.github.kostack.mcp_openai.tool.AbstractTool
import io.github.kostack.mcp_openai.utils.ToolSchemaUtils
import org.springframework.stereotype.Component
@Component
class WeatherTool : AbstractTool() {
override val namespace: String = "support_assistant"
override val toolName: String = "get_weather"
override val description: String = "Gets the current weather for a city."
override fun getDefinition(): ToolDefinition =
ToolDefinition(
namespace = namespace,
name = toolName,
description = description,
parameters = ToolSchemaUtils.toParameters<WeatherRequest>()
)
override suspend fun execute(context: ToolContext): ToolResult {
val request = context.getRequest<WeatherRequest>()
val result =
WeatherResult(
city = request.city,
summary = "Clear",
temperatureCelsius = 27
)
return ToolResult(success = true, result = result)
}
}When OpenAI calls get_weather, the package decodes the JSON arguments into
WeatherRequest, runs execute, and sends the ToolResult back to OpenAI.
The package auto-configures a default SidebandConfigurationRouter. The
default routes use kostack-mcp.sideband-prefix, which is /api/realtime by
default:
POST /api/realtime/tokenPOST /api/realtime/connectPOST /api/realtime/disconnect
The request body carries namespace, channel, and language/audio settings.
The handler uses request.namespace to select tool definitions. The default
token instructions are intentionally restrictive; set application instructions
in a RealtimeEvents.TOKEN_PRE_CREATE listener.
Request:
POST /api/realtime/token
Content-Type: application/json
{
"namespace": "support_assistant",
"channel": "conversation-123",
"language": "en",
"audioEnabled": false
}Response:
{
"clientSecret": "ek_..."
}Use the returned clientSecret only in the browser session that requested it.
After the browser creates the Realtime WebRTC call, send the OpenAI call id and the client secret to your backend.
POST /api/realtime/connect
Content-Type: application/json
{
"callId": "call_abc123",
"clientSecret": "ek_...",
"namespace": "support_assistant",
"channel": "conversation-123",
"language": "en",
"audioEnabled": false
}Successful response:
204 No ContentPOST /api/realtime/disconnect
Content-Type: application/json
{
"callId": "call_abc123",
"namespace": "support_assistant",
"channel": "conversation-123"
}Successful response:
204 No ContentThe browser is responsible for connecting to OpenAI Realtime with WebRTC. The backend is responsible for creating the ephemeral client secret and sideband connection.
const channel = "conversation-123";
const namespace = "support_assistant";
const language = "en";
async function createToken() {
const response = await fetch("/api/realtime/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
namespace,
channel,
language,
audioEnabled: false
})
});
return response.json();
}
function extractCallId(locationHeader) {
const match = locationHeader.match(/\/v1\/realtime\/calls\/([^/?#]+)/);
if (!match) throw new Error("Could not read Realtime call id");
return match[1];
}
async function connectRealtime() {
const { clientSecret } = await createToken();
const pc = new RTCPeerConnection();
const dc = pc.createDataChannel("oai-events");
dc.onmessage = (event) => {
const realtimeEvent = JSON.parse(event.data);
console.log("Realtime event", realtimeEvent);
};
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
const sdpResponse = await fetch("https://api.openai.com/v1/realtime/calls", {
method: "POST",
headers: {
"Authorization": `Bearer ${clientSecret}`,
"Content-Type": "application/sdp"
},
body: offer.sdp
});
const answer = await sdpResponse.text();
await pc.setRemoteDescription({ type: "answer", sdp: answer });
const callId = extractCallId(sdpResponse.headers.get("Location"));
await fetch("/api/realtime/connect", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
callId,
clientSecret,
namespace,
channel,
language,
audioEnabled: false
})
});
return { pc, dc, callId };
}Send a text message through the Realtime data channel:
function sendText(dc, text) {
dc.send(JSON.stringify({
type: "conversation.item.create",
item: {
type: "message",
role: "user",
content: [
{
type: "input_text",
text
}
]
}
}));
dc.send(JSON.stringify({
type: "response.create",
response: {
output_modalities: ["text"]
}
}));
}Disconnect when the session ends:
async function disconnectRealtime(pc, callId) {
await fetch("/api/realtime/disconnect", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ callId, namespace, channel })
});
pc.close();
}The package publishes events through io.github.kostack:event-dispatcher.
Register suspend listeners with @SuspendListener.
package com.example.demo
import io.github.kostack.event_dispatcher.SuspendListener
import io.github.kostack.mcp_openai.RealtimeEvents
import io.github.kostack.mcp_openai.event.RealtimeConnectEvent
import io.github.kostack.mcp_openai.event.RealtimeHandlerEvent
import io.github.kostack.mcp_openai.event.RealtimeTokenPreCreateEvent
import org.springframework.stereotype.Component
@Component
class RealtimeListeners {
@SuspendListener(RealtimeEvents.TOKEN_PRE_CREATE)
suspend fun onTokenPreCreate(event: RealtimeTokenPreCreateEvent) {
event.instructions =
"""
You are a helpful support assistant.
Reply in the user's selected language.
Conversation history:
:history_items
""".trimIndent()
}
@SuspendListener(RealtimeEvents.CONNECT)
suspend fun onConnect(event: RealtimeConnectEvent) {
println("Connected call ${event.request.callId}")
}
@SuspendListener(RealtimeEvents.SESSION_START)
suspend fun onSessionStart(event: RealtimeConnectEvent) {
println("Sideband session started for ${event.request.callId}")
}
@SuspendListener(RealtimeEvents.RESPONSE_DONE)
suspend fun onResponseDone(event: RealtimeHandlerEvent) {
println("Response completed on ${event.request.channel}")
}
}Published event names:
| Event | When it is published |
|---|---|
kostack_mcp.token.pre_create |
Before an ephemeral token is created. |
kostack_mcp.connect |
Before the sideband connection is started. |
kostack_mcp.disconnect |
Before a sideband connection is cancelled. |
kostack_mcp.session.start |
After the sideband WebSocket session is available. |
kostack_mcp.conversation.item.done |
When a completed conversation item is received. |
kostack_mcp.conversation.item.input_audio_transcription.completed |
When input audio transcription completes. |
kostack_mcp.response.output.item.done |
When a response output item completes. |
kostack_mcp.response.done |
When a response completes. |
kostack_mcp.response.error |
When OpenAI sends an error event. |
kostack_mcp.session.updated |
When the Realtime session is updated. |
ConversationStore stores conversation history by channel in memory. The
included ConversationListener records user and assistant messages and replaces
:history_items in RealtimeTokenPreCreateEvent.instructions when a token is
created.
Example instruction:
You are a support assistant.
Use this conversation history when answering:
:history_items
For production applications, treat this as an in-memory default. If you need
durable history across restarts, persist your own history and update
RealtimeTokenPreCreateEvent.instructions in a token pre-create listener.
Set audioEnabled to true in token and sideband requests only when your
application supports microphone access and audio output. If enable-audio is
false in configuration, the handler forces request audio off.
When audio is enabled, session creation includes:
- Server VAD turn detection.
- Input transcription using
kostack-mcp.transcription-model. - Output voice set to
alloy.
If tool execution throws an exception, ToolDispatcher returns:
{
"success": false,
"result": "Error executing tool: tool_name, error: ..."
}For expected business failures, return a normal ToolResult instead:
return ToolResult(
success = false,
result = mapOf("message" to "No matching account was found")
)You can test tools without opening a Realtime session.
package com.example.demo.tools
import io.github.kostack.mcp_openai.dto.ToolContext
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertTrue
class WeatherToolTest {
@Test
fun `returns weather`() = runTest {
val tool = WeatherTool()
val result =
tool.execute(
ToolContext(
namespace = "support_assistant",
channel = "test-channel",
rawRequest = """{"city":"Athens"}"""
)
)
assertTrue(result.success)
}
}package com.example.demo
import io.github.kostack.event_dispatcher.SuspendListener
import io.github.kostack.mcp_openai.RealtimeEvents
import io.github.kostack.mcp_openai.event.RealtimeTokenPreCreateEvent
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.stereotype.Component
@SpringBootApplication
class DemoApplication
fun main(args: Array<String>) {
runApplication<DemoApplication>(*args)
}
@Component
class DemoRealtimeListeners {
@SuspendListener(RealtimeEvents.TOKEN_PRE_CREATE)
suspend fun onTokenPreCreate(event: RealtimeTokenPreCreateEvent) {
event.instructions = "You are a helpful assistant. Reply in English."
}
}- Keep
kostack-mcp.api-keyserver-side. - Send only ephemeral Realtime client secrets to the browser.
- Protect your token and sideband endpoints with your application auth.
- Validate that the current user is allowed to access the requested channel.
- Do not trust tool arguments just because they came from the model; validate them in your tool implementation.