Implement StreamableHTTP transport
Co-authored-by: Topher Bullock <topher.bullock@shopify.com>
This commit is contained in:
parent
04710ac472
commit
3e03a2af66
5 changed files with 1807 additions and 6 deletions
174
examples/README.md
Normal file
174
examples/README.md
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
# MCP Ruby Examples
|
||||
|
||||
This directory contains examples of how to use the Model Context Protocol (MCP) Ruby library.
|
||||
|
||||
## Available Examples
|
||||
|
||||
### 1. STDIO Server (`stdio_server.rb`)
|
||||
A simple server that communicates over standard input/output. This is useful for desktop applications and command-line tools.
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
ruby examples/stdio_server.rb
|
||||
{"jsonrpc":"2.0","id":0,"method":"tools/list"}
|
||||
```
|
||||
|
||||
### 2. HTTP Server (`http_server.rb`)
|
||||
A standalone HTTP server built with Rack that implements the MCP StreamableHTTP transport protocol. This demonstrates how to create a web-based MCP server with session management and Server-Sent Events (SSE) support.
|
||||
|
||||
**Features:**
|
||||
- HTTP transport with Server-Sent Events (SSE) for streaming
|
||||
- Session management with unique session IDs
|
||||
- Example tools, prompts, and resources
|
||||
- JSON-RPC 2.0 protocol implementation
|
||||
- Full MCP protocol compliance
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
ruby examples/http_server.rb
|
||||
```
|
||||
|
||||
The server will start on `http://localhost:9292` and provide:
|
||||
- **Tools**:
|
||||
- `ExampleTool` - adds two numbers
|
||||
- `echo` - echoes back messages
|
||||
- **Prompts**: `ExamplePrompt` - echoes back arguments as a prompt
|
||||
- **Resources**: `test_resource` - returns example content
|
||||
|
||||
### 3. HTTP Client Example (`http_client.rb`)
|
||||
A client that demonstrates how to interact with the HTTP server using all MCP protocol methods.
|
||||
|
||||
**Usage:**
|
||||
1. Start the HTTP server in one terminal:
|
||||
```bash
|
||||
ruby examples/http_server.rb
|
||||
```
|
||||
|
||||
2. Run the client example in another terminal:
|
||||
```bash
|
||||
ruby examples/http_client.rb
|
||||
```
|
||||
|
||||
The client will demonstrate:
|
||||
- Session initialization
|
||||
- Ping requests
|
||||
- Listing and calling tools
|
||||
- Listing and getting prompts
|
||||
- Listing and reading resources
|
||||
- Session cleanup
|
||||
|
||||
### 4. SSE Test Server (`sse_test_server.rb`)
|
||||
A specialized HTTP server designed to test and demonstrate Server-Sent Events (SSE) functionality in the MCP protocol.
|
||||
|
||||
**Features:**
|
||||
- Tools specifically designed to trigger SSE notifications
|
||||
- Real-time progress updates and notifications
|
||||
- Detailed SSE-specific logging
|
||||
|
||||
**Available Tools:**
|
||||
- `NotificationTool` - Send custom SSE notifications with optional delays
|
||||
- `echo` - Simple echo tool for basic testing
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
ruby examples/sse_test_server.rb
|
||||
```
|
||||
|
||||
The server will start on `http://localhost:9393` and provide detailed instructions for testing SSE functionality.
|
||||
|
||||
### 5. SSE Test Client (`sse_test_client.rb`)
|
||||
An interactive client that connects to the SSE stream and provides a menu-driven interface for testing SSE functionality.
|
||||
|
||||
**Features:**
|
||||
- Automatic SSE stream connection
|
||||
- Interactive menu for triggering various SSE events
|
||||
- Real-time display of received SSE notifications
|
||||
- Session management
|
||||
|
||||
**Usage:**
|
||||
1. Start the SSE test server in one terminal:
|
||||
```bash
|
||||
ruby examples/sse_test_server.rb
|
||||
```
|
||||
|
||||
2. Run the SSE test client in another terminal:
|
||||
```bash
|
||||
ruby examples/sse_test_client.rb
|
||||
```
|
||||
|
||||
The client will:
|
||||
- Initialize a session automatically
|
||||
- Connect to the SSE stream
|
||||
- Provide an interactive menu to trigger notifications
|
||||
- Display all received SSE events in real-time
|
||||
|
||||
### Testing SSE with cURL
|
||||
|
||||
You can also test SSE functionality manually using cURL:
|
||||
|
||||
1. Initialize a session:
|
||||
```bash
|
||||
SESSION_ID=$(curl -D - -s -o /dev/null -X POST http://localhost:9393 \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"curl-test","version":"1.0"}}}' |grep -i "Mcp-Session-Id:" | cut -d' ' -f2- | tr -d '\r')
|
||||
```
|
||||
|
||||
2. Connect to SSE stream (in one terminal):
|
||||
```bash
|
||||
curl -N -H "Mcp-Session-Id: $SESSION_ID" http://localhost:9393
|
||||
```
|
||||
|
||||
3. Trigger notifications (in another terminal):
|
||||
```bash
|
||||
# Send immediate notification
|
||||
curl -X POST http://localhost:9393 \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Mcp-Session-Id: $SESSION_ID" \
|
||||
-d '{"jsonrpc":"2.0","method":"tools/call","id":2,"params":{"name":"notification_tool","arguments":{"message":"Hello from cURL!"}}}'
|
||||
```
|
||||
|
||||
## StreamableHTTP Transport Details
|
||||
|
||||
### Protocol Flow
|
||||
|
||||
The HTTP server implements the MCP StreamableHTTP transport protocol:
|
||||
|
||||
1. **Initialize Session**:
|
||||
- Client sends POST request with `initialize` method
|
||||
- Server responds with session ID in `Mcp-Session-Id` header
|
||||
|
||||
2. **Establish SSE Connection** (optional):
|
||||
- Client sends GET request with `Mcp-Session-Id` header
|
||||
- Server establishes Server-Sent Events stream for notifications
|
||||
|
||||
3. **Send Requests**:
|
||||
- Client sends POST requests with JSON-RPC 2.0 format
|
||||
- Server processes and responds with results
|
||||
|
||||
4. **Close Session**:
|
||||
- Client sends DELETE request with `Mcp-Session-Id` header
|
||||
|
||||
### Example cURL Commands
|
||||
|
||||
Initialize a session:
|
||||
```bash
|
||||
curl -X POST http://localhost:9292 \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}'
|
||||
```
|
||||
|
||||
List tools (using the session ID from initialization):
|
||||
```bash
|
||||
curl -X POST http://localhost:9292 \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Mcp-Session-Id: YOUR_SESSION_ID" \
|
||||
-d '{"jsonrpc":"2.0","method":"tools/list","id":2}'
|
||||
```
|
||||
|
||||
Call a tool:
|
||||
```bash
|
||||
curl -X POST http://localhost:9292 \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Mcp-Session-Id: YOUR_SESSION_ID" \
|
||||
-d '{"jsonrpc":"2.0","method":"tools/call","id":3,"params":{"name":"ExampleTool","arguments":{"a":5,"b":3}}}'
|
||||
```
|
||||
185
examples/http_client.rb
Normal file
185
examples/http_client.rb
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
#!/usr/bin/env ruby
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "net/http"
|
||||
require "json"
|
||||
require "uri"
|
||||
|
||||
# Simple HTTP client example for interacting with the MCP HTTP server
|
||||
class MCPHTTPClient
|
||||
def initialize(base_url = "http://localhost:9292")
|
||||
@base_url = base_url
|
||||
@session_id = nil
|
||||
end
|
||||
|
||||
def send_request(method, params = nil, id = nil)
|
||||
uri = URI(@base_url)
|
||||
http = Net::HTTP.new(uri.host, uri.port)
|
||||
|
||||
request = Net::HTTP::Post.new(uri.path.empty? ? "/" : uri.path)
|
||||
request["Content-Type"] = "application/json"
|
||||
request["Mcp-Session-Id"] = @session_id if @session_id
|
||||
|
||||
body = {
|
||||
jsonrpc: "2.0",
|
||||
method: method,
|
||||
params: params,
|
||||
id: id || rand(10000),
|
||||
}.compact
|
||||
|
||||
request.body = body.to_json
|
||||
|
||||
response = http.request(request)
|
||||
|
||||
# Store session ID if provided
|
||||
if response["Mcp-Session-Id"]
|
||||
@session_id = response["Mcp-Session-Id"]
|
||||
puts "Session ID: #{@session_id}"
|
||||
end
|
||||
|
||||
JSON.parse(response.body)
|
||||
end
|
||||
|
||||
def initialize_session
|
||||
puts "=== Initializing session ==="
|
||||
result = send_request("initialize", {
|
||||
protocolVersion: "2024-11-05",
|
||||
capabilities: {},
|
||||
clientInfo: {
|
||||
name: "example_client",
|
||||
version: "1.0",
|
||||
},
|
||||
})
|
||||
puts "Response: #{JSON.pretty_generate(result)}"
|
||||
puts
|
||||
result
|
||||
end
|
||||
|
||||
def ping
|
||||
puts "=== Sending ping ==="
|
||||
result = send_request("ping")
|
||||
puts "Response: #{JSON.pretty_generate(result)}"
|
||||
puts
|
||||
result
|
||||
end
|
||||
|
||||
def list_tools
|
||||
puts "=== Listing tools ==="
|
||||
result = send_request("tools/list")
|
||||
puts "Response: #{JSON.pretty_generate(result)}"
|
||||
puts
|
||||
result
|
||||
end
|
||||
|
||||
def call_tool(name, arguments)
|
||||
puts "=== Calling tool: #{name} ==="
|
||||
result = send_request("tools/call", {
|
||||
name: name,
|
||||
arguments: arguments,
|
||||
})
|
||||
puts "Response: #{JSON.pretty_generate(result)}"
|
||||
puts
|
||||
result
|
||||
end
|
||||
|
||||
def list_prompts
|
||||
puts "=== Listing prompts ==="
|
||||
result = send_request("prompts/list")
|
||||
puts "Response: #{JSON.pretty_generate(result)}"
|
||||
puts
|
||||
result
|
||||
end
|
||||
|
||||
def get_prompt(name, arguments)
|
||||
puts "=== Getting prompt: #{name} ==="
|
||||
result = send_request("prompts/get", {
|
||||
name: name,
|
||||
arguments: arguments,
|
||||
})
|
||||
puts "Response: #{JSON.pretty_generate(result)}"
|
||||
puts
|
||||
result
|
||||
end
|
||||
|
||||
def list_resources
|
||||
puts "=== Listing resources ==="
|
||||
result = send_request("resources/list")
|
||||
puts "Response: #{JSON.pretty_generate(result)}"
|
||||
puts
|
||||
result
|
||||
end
|
||||
|
||||
def read_resource(uri)
|
||||
puts "=== Reading resource: #{uri} ==="
|
||||
result = send_request("resources/read", {
|
||||
uri: uri,
|
||||
})
|
||||
puts "Response: #{JSON.pretty_generate(result)}"
|
||||
puts
|
||||
result
|
||||
end
|
||||
|
||||
def close_session
|
||||
return unless @session_id
|
||||
|
||||
puts "=== Closing session ==="
|
||||
uri = URI(@base_url)
|
||||
http = Net::HTTP.new(uri.host, uri.port)
|
||||
|
||||
request = Net::HTTP::Delete.new(uri.path.empty? ? "/" : uri.path)
|
||||
request["Mcp-Session-Id"] = @session_id
|
||||
|
||||
response = http.request(request)
|
||||
result = JSON.parse(response.body)
|
||||
puts "Response: #{JSON.pretty_generate(result)}"
|
||||
puts
|
||||
|
||||
@session_id = nil
|
||||
result
|
||||
end
|
||||
end
|
||||
|
||||
# Main script
|
||||
if __FILE__ == $PROGRAM_NAME
|
||||
puts "MCP HTTP Client Example"
|
||||
puts "Make sure the HTTP server is running (ruby examples/http_server.rb)"
|
||||
puts "=" * 50
|
||||
puts
|
||||
|
||||
client = MCPHTTPClient.new
|
||||
|
||||
begin
|
||||
# Initialize session
|
||||
client.initialize_session
|
||||
|
||||
# Test ping
|
||||
client.ping
|
||||
|
||||
# List available tools
|
||||
client.list_tools
|
||||
|
||||
# Call the example_tool (note: snake_case name)
|
||||
client.call_tool("example_tool", { a: 5, b: 3 })
|
||||
|
||||
# Call the echo tool
|
||||
client.call_tool("echo", { message: "Hello from client!" })
|
||||
|
||||
# List prompts
|
||||
client.list_prompts
|
||||
|
||||
# Get a prompt (note: snake_case name)
|
||||
client.get_prompt("example_prompt", { message: "This is a test message" })
|
||||
|
||||
# List resources
|
||||
client.list_resources
|
||||
|
||||
# Read a resource
|
||||
client.read_resource("test_resource")
|
||||
rescue => e
|
||||
puts "Error: #{e.message}"
|
||||
puts e.backtrace
|
||||
ensure
|
||||
# Clean up session
|
||||
client.close_session
|
||||
end
|
||||
end
|
||||
169
examples/http_server.rb
Normal file
169
examples/http_server.rb
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
#!/usr/bin/env ruby
|
||||
# frozen_string_literal: true
|
||||
|
||||
$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
|
||||
require "mcp"
|
||||
require "mcp/transports/http"
|
||||
require "rack"
|
||||
require "rackup"
|
||||
require "json"
|
||||
require "logger"
|
||||
|
||||
# Create a simple tool
|
||||
class ExampleTool < MCP::Tool
|
||||
description "A simple example tool that adds two numbers"
|
||||
input_schema(
|
||||
properties: {
|
||||
a: { type: "number" },
|
||||
b: { type: "number" },
|
||||
},
|
||||
required: ["a", "b"],
|
||||
)
|
||||
|
||||
class << self
|
||||
def call(a:, b:)
|
||||
MCP::Tool::Response.new([{
|
||||
type: "text",
|
||||
text: "The sum of #{a} and #{b} is #{a + b}",
|
||||
}])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Create a simple prompt
|
||||
class ExamplePrompt < MCP::Prompt
|
||||
description "A simple example prompt that echoes back its arguments"
|
||||
arguments [
|
||||
MCP::Prompt::Argument.new(
|
||||
name: "message",
|
||||
description: "The message to echo back",
|
||||
required: true,
|
||||
),
|
||||
]
|
||||
|
||||
class << self
|
||||
def template(args, server_context:)
|
||||
MCP::Prompt::Result.new(
|
||||
messages: [
|
||||
MCP::Prompt::Message.new(
|
||||
role: "user",
|
||||
content: MCP::Content::Text.new(args[:message]),
|
||||
),
|
||||
],
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Set up the server
|
||||
server = MCP::Server.new(
|
||||
name: "example_http_server",
|
||||
tools: [ExampleTool],
|
||||
prompts: [ExamplePrompt],
|
||||
resources: [
|
||||
MCP::Resource.new(
|
||||
uri: "test_resource",
|
||||
name: "Test resource",
|
||||
description: "Test resource that echoes back the uri as its content",
|
||||
mime_type: "text/plain",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
server.define_tool(
|
||||
name: "echo",
|
||||
description: "A simple example tool that echoes back its arguments",
|
||||
input_schema: { properties: { message: { type: "string" } }, required: ["message"] },
|
||||
) do |message:|
|
||||
MCP::Tool::Response.new(
|
||||
[
|
||||
{
|
||||
type: "text",
|
||||
text: "Hello from echo tool! Message: #{message}",
|
||||
},
|
||||
],
|
||||
)
|
||||
end
|
||||
|
||||
server.resources_read_handler do |params|
|
||||
[{
|
||||
uri: params[:uri],
|
||||
mimeType: "text/plain",
|
||||
text: "Hello from HTTP server resource!",
|
||||
}]
|
||||
end
|
||||
|
||||
# Create the StreamableHTTP transport
|
||||
server.transport = MCP::Transports::HTTP.new(server)
|
||||
|
||||
# Create a logger for MCP-specific logging
|
||||
mcp_logger = Logger.new($stdout)
|
||||
mcp_logger.formatter = proc do |_severity, _datetime, _progname, msg|
|
||||
"[MCP] #{msg}\n"
|
||||
end
|
||||
|
||||
# Create a Rack application with logging
|
||||
app = proc do |env|
|
||||
request = Rack::Request.new(env)
|
||||
|
||||
# Log MCP-specific details for POST requests
|
||||
if request.post?
|
||||
body = request.body.read
|
||||
request.body.rewind
|
||||
begin
|
||||
parsed_body = JSON.parse(body)
|
||||
mcp_logger.info("Request: #{parsed_body["method"]} (id: #{parsed_body["id"]})")
|
||||
mcp_logger.debug("Request body: #{JSON.pretty_generate(parsed_body)}")
|
||||
rescue JSON::ParserError
|
||||
mcp_logger.warn("Request body (raw): #{body}")
|
||||
end
|
||||
end
|
||||
|
||||
# Handle the request
|
||||
puts "request: #{request.inspect}"
|
||||
response = server.handle_rack_request(request)
|
||||
|
||||
# Log the MCP response details
|
||||
_, _, body = response
|
||||
if body.is_a?(Array) && !body.empty?
|
||||
begin
|
||||
parsed_response = JSON.parse(body.first)
|
||||
if parsed_response["error"]
|
||||
mcp_logger.error("Response error: #{parsed_response["error"]["message"]}")
|
||||
else
|
||||
mcp_logger.info("Response: #{parsed_response["result"] ? "success" : "empty"} (id: #{parsed_response["id"]})")
|
||||
end
|
||||
mcp_logger.debug("Response body: #{JSON.pretty_generate(parsed_response)}")
|
||||
rescue JSON::ParserError
|
||||
mcp_logger.warn("Response body (raw): #{body}")
|
||||
end
|
||||
end
|
||||
|
||||
response
|
||||
end
|
||||
|
||||
# Wrap the app with Rack middleware
|
||||
rack_app = Rack::Builder.new do
|
||||
# Use CommonLogger for standard HTTP request logging
|
||||
use(Rack::CommonLogger, Logger.new($stdout))
|
||||
|
||||
# Add other useful middleware
|
||||
use(Rack::ShowExceptions)
|
||||
|
||||
run(app)
|
||||
end
|
||||
|
||||
# Start the server
|
||||
puts "Starting MCP HTTP server on http://localhost:9292"
|
||||
puts "Use POST requests to initialize and send JSON-RPC commands"
|
||||
puts "Example initialization:"
|
||||
puts ' curl -X POST http://localhost:9292 -H "Content-Type: application/json" -d \'{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}\''
|
||||
puts ""
|
||||
puts "The server will return a session ID in the Mcp-Session-Id header."
|
||||
puts "Use this session ID for subsequent requests."
|
||||
puts ""
|
||||
puts "Press Ctrl+C to stop the server"
|
||||
|
||||
# Run the server
|
||||
# Use Rackup to run the server
|
||||
Rackup::Handler.get("puma").run(rack_app, Port: 9292, Host: "localhost")
|
||||
207
examples/sse_test_client.rb
Normal file
207
examples/sse_test_client.rb
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
#!/usr/bin/env ruby
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "net/http"
|
||||
require "uri"
|
||||
require "json"
|
||||
require "logger"
|
||||
|
||||
# Logger for client operations
|
||||
logger = Logger.new($stdout)
|
||||
logger.formatter = proc do |severity, datetime, _progname, msg|
|
||||
"[CLIENT] #{severity} #{datetime.strftime("%H:%M:%S.%L")} - #{msg}\n"
|
||||
end
|
||||
|
||||
# Server configuration
|
||||
SERVER_URL = "http://localhost:9393/mcp"
|
||||
PROTOCOL_VERSION = "2024-11-05"
|
||||
|
||||
# Helper method to make JSON-RPC requests
|
||||
def make_request(session_id, method, params = {}, id = nil)
|
||||
uri = URI(SERVER_URL)
|
||||
http = Net::HTTP.new(uri.host, uri.port)
|
||||
|
||||
request = Net::HTTP::Post.new(uri)
|
||||
request["Content-Type"] = "application/json"
|
||||
request["Mcp-Session-Id"] = session_id if session_id
|
||||
|
||||
body = {
|
||||
jsonrpc: "2.0",
|
||||
method: method,
|
||||
params: params,
|
||||
id: id || SecureRandom.uuid,
|
||||
}
|
||||
|
||||
request.body = body.to_json
|
||||
response = http.request(request)
|
||||
|
||||
{
|
||||
status: response.code,
|
||||
headers: response.to_hash,
|
||||
body: JSON.parse(response.body),
|
||||
}
|
||||
rescue => e
|
||||
{ error: e.message }
|
||||
end
|
||||
|
||||
# Connect to SSE stream
|
||||
def connect_sse(session_id, logger)
|
||||
uri = URI(SERVER_URL)
|
||||
|
||||
logger.info("Connecting to SSE stream...")
|
||||
|
||||
Net::HTTP.start(uri.host, uri.port) do |http|
|
||||
request = Net::HTTP::Get.new(uri)
|
||||
request["Mcp-Session-Id"] = session_id
|
||||
request["Accept"] = "text/event-stream"
|
||||
request["Cache-Control"] = "no-cache"
|
||||
|
||||
http.request(request) do |response|
|
||||
if response.code == "200"
|
||||
logger.info("SSE stream connected successfully")
|
||||
|
||||
response.read_body do |chunk|
|
||||
chunk.split("\n").each do |line|
|
||||
if line.start_with?("data: ")
|
||||
data = line[6..-1]
|
||||
begin
|
||||
logger.info("SSE data: #{data}")
|
||||
rescue JSON::ParserError
|
||||
logger.debug("Non-JSON SSE data: #{data}")
|
||||
end
|
||||
elsif line.start_with?(": ")
|
||||
logger.debug("SSE keepalive received: #{line}")
|
||||
end
|
||||
end
|
||||
end
|
||||
else
|
||||
logger.error("Failed to connect to SSE: #{response.code} #{response.message}")
|
||||
end
|
||||
end
|
||||
end
|
||||
rescue Interrupt
|
||||
logger.info("SSE connection interrupted by user")
|
||||
rescue => e
|
||||
logger.error("SSE connection error: #{e.message}")
|
||||
end
|
||||
|
||||
# Main client flow
|
||||
def main
|
||||
logger = Logger.new($stdout)
|
||||
logger.formatter = proc do |severity, datetime, _progname, msg|
|
||||
"[CLIENT] #{severity} #{datetime.strftime("%H:%M:%S.%L")} - #{msg}\n"
|
||||
end
|
||||
|
||||
puts "=== MCP SSE Test Client ==="
|
||||
puts ""
|
||||
|
||||
# Step 1: Initialize session
|
||||
logger.info("Initializing session...")
|
||||
|
||||
init_response = make_request(
|
||||
nil,
|
||||
"initialize",
|
||||
{
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
capabilities: {},
|
||||
clientInfo: {
|
||||
name: "sse-test-client",
|
||||
version: "1.0",
|
||||
},
|
||||
},
|
||||
"init-1",
|
||||
)
|
||||
|
||||
if init_response[:error]
|
||||
logger.error("Failed to initialize: #{init_response[:error]}")
|
||||
exit(1)
|
||||
end
|
||||
|
||||
session_id = init_response[:headers]["mcp-session-id"]&.first
|
||||
|
||||
if session_id.nil?
|
||||
logger.error("No session ID received")
|
||||
exit(1)
|
||||
end
|
||||
|
||||
logger.info("Session initialized: #{session_id}")
|
||||
logger.info("Server info: #{init_response[:body]["result"]["serverInfo"]}")
|
||||
|
||||
# Step 2: Start SSE connection in a separate thread
|
||||
sse_thread = Thread.new { connect_sse(session_id, logger) }
|
||||
|
||||
# Give SSE time to connect
|
||||
sleep(1)
|
||||
|
||||
# Step 3: Interactive menu
|
||||
loop do
|
||||
puts "\n=== Available Actions ==="
|
||||
puts "1. Send custom notification"
|
||||
puts "2. Test echo"
|
||||
puts "3. List tools"
|
||||
puts "0. Exit"
|
||||
puts ""
|
||||
print("Choose an action: ")
|
||||
|
||||
choice = gets.chomp
|
||||
|
||||
case choice
|
||||
when "1"
|
||||
print("Enter notification message: ")
|
||||
message = gets.chomp
|
||||
print("Enter delay in seconds (0 for immediate): ")
|
||||
delay = gets.chomp.to_f
|
||||
|
||||
response = make_request(
|
||||
session_id,
|
||||
"tools/call",
|
||||
{
|
||||
name: "notification_tool",
|
||||
arguments: {
|
||||
message: message,
|
||||
delay: delay,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
if response[:body]["result"]
|
||||
logger.info("Notification tool response: #{response[:body]["result"]["content"]}")
|
||||
else
|
||||
logger.error("Error: #{response[:body]["error"]}")
|
||||
end
|
||||
|
||||
when "2"
|
||||
print("Enter message to echo: ")
|
||||
message = gets.chomp
|
||||
make_request(session_id, "tools/call", { name: "echo", arguments: { message: message } })
|
||||
|
||||
when "3"
|
||||
make_request(session_id, "tools/list")
|
||||
|
||||
when "0"
|
||||
logger.info("Exiting...")
|
||||
break
|
||||
|
||||
else
|
||||
puts "Invalid choice"
|
||||
end
|
||||
end
|
||||
|
||||
# Clean up
|
||||
sse_thread.kill if sse_thread.alive?
|
||||
|
||||
# Close session
|
||||
logger.info("Closing session...")
|
||||
make_request(session_id, "close")
|
||||
logger.info("Session closed")
|
||||
rescue Interrupt
|
||||
logger.info("Client interrupted by user")
|
||||
rescue => e
|
||||
logger.error("Client error: #{e.message}")
|
||||
logger.error(e.backtrace.join("\n"))
|
||||
end
|
||||
|
||||
# Run the client
|
||||
if __FILE__ == $PROGRAM_NAME
|
||||
main
|
||||
end
|
||||
172
examples/sse_test_server.rb
Normal file
172
examples/sse_test_server.rb
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
#!/usr/bin/env ruby
|
||||
# frozen_string_literal: true
|
||||
|
||||
$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
|
||||
require "mcp"
|
||||
require "mcp/transports/http"
|
||||
require "rack"
|
||||
require "rackup"
|
||||
require "json"
|
||||
require "logger"
|
||||
|
||||
# Create a logger for SSE-specific logging
|
||||
sse_logger = Logger.new($stdout)
|
||||
sse_logger.formatter = proc do |severity, datetime, _progname, msg|
|
||||
"[SSE] #{severity} #{datetime.strftime("%H:%M:%S.%L")} - #{msg}\n"
|
||||
end
|
||||
|
||||
# Tool that returns a response that will be sent via SSE if a stream is active
|
||||
class NotificationTool < MCP::Tool
|
||||
tool_name "notification_tool"
|
||||
description "Returns a notification message that will be sent via SSE if stream is active"
|
||||
input_schema(
|
||||
properties: {
|
||||
message: { type: "string", description: "Message to send via SSE" },
|
||||
delay: { type: "number", description: "Delay in seconds before returning (optional)" },
|
||||
},
|
||||
required: ["message"],
|
||||
)
|
||||
|
||||
class << self
|
||||
attr_accessor :logger
|
||||
|
||||
def call(message:, delay: 0)
|
||||
sleep(delay) if delay > 0
|
||||
|
||||
logger&.info("Returning notification message: #{message}")
|
||||
|
||||
MCP::Tool::Response.new([{
|
||||
type: "text",
|
||||
text: "Notification: #{message} (timestamp: #{Time.now.iso8601})",
|
||||
}])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Create the server
|
||||
server = MCP::Server.new(
|
||||
name: "sse_test_server",
|
||||
tools: [NotificationTool],
|
||||
prompts: [],
|
||||
resources: [],
|
||||
)
|
||||
|
||||
# Set logger for tools
|
||||
NotificationTool.logger = sse_logger
|
||||
|
||||
# Add a simple echo tool for basic testing
|
||||
server.define_tool(
|
||||
name: "echo",
|
||||
description: "Simple echo tool",
|
||||
input_schema: { properties: { message: { type: "string" } }, required: ["message"] },
|
||||
) do |message:|
|
||||
MCP::Tool::Response.new([{ type: "text", text: "Echo: #{message}" }])
|
||||
end
|
||||
|
||||
# Create the StreamableHTTP transport
|
||||
server.transport = MCP::Transports::HTTP.new(server)
|
||||
|
||||
# Create a logger for MCP request/response logging
|
||||
mcp_logger = Logger.new($stdout)
|
||||
mcp_logger.formatter = proc do |_severity, _datetime, _progname, msg|
|
||||
"[MCP] #{msg}\n"
|
||||
end
|
||||
|
||||
# Create the Rack application
|
||||
app = proc do |env|
|
||||
request = Rack::Request.new(env)
|
||||
|
||||
# Log request details
|
||||
if request.post?
|
||||
body = request.body.read
|
||||
request.body.rewind
|
||||
begin
|
||||
parsed_body = JSON.parse(body)
|
||||
mcp_logger.info("Request: #{parsed_body["method"]} (id: #{parsed_body["id"]})")
|
||||
|
||||
# Log SSE-specific setup
|
||||
if parsed_body["method"] == "initialize"
|
||||
sse_logger.info("New client initializing session")
|
||||
end
|
||||
rescue JSON::ParserError
|
||||
mcp_logger.warn("Invalid JSON in request")
|
||||
end
|
||||
elsif request.get?
|
||||
session_id = request.env["HTTP_MCP_SESSION_ID"] ||
|
||||
Rack::Utils.parse_query(request.env["QUERY_STRING"])["sessionId"]
|
||||
sse_logger.info("SSE connection request for session: #{session_id}")
|
||||
end
|
||||
|
||||
# Handle the request
|
||||
response = server.handle_rack_request(request)
|
||||
|
||||
# Log response details
|
||||
status, headers, body = response
|
||||
if body.is_a?(Array) && !body.empty? && request.post?
|
||||
begin
|
||||
parsed_response = JSON.parse(body.first)
|
||||
if parsed_response["error"]
|
||||
mcp_logger.error("Response error: #{parsed_response["error"]["message"]}")
|
||||
elsif parsed_response["accepted"]
|
||||
# Response was sent via SSE
|
||||
sse_logger.info("Response sent via SSE stream")
|
||||
else
|
||||
mcp_logger.info("Response: success (id: #{parsed_response["id"]})")
|
||||
|
||||
# Log session ID for initialization
|
||||
if headers["Mcp-Session-Id"]
|
||||
sse_logger.info("Session created: #{headers["Mcp-Session-Id"]}")
|
||||
end
|
||||
end
|
||||
rescue JSON::ParserError
|
||||
mcp_logger.warn("Invalid JSON in response")
|
||||
end
|
||||
elsif request.get? && status == 200
|
||||
sse_logger.info("SSE stream established")
|
||||
end
|
||||
|
||||
response
|
||||
end
|
||||
|
||||
# Build the Rack application with middleware
|
||||
rack_app = Rack::Builder.new do
|
||||
use(Rack::CommonLogger, Logger.new($stdout))
|
||||
use(Rack::ShowExceptions)
|
||||
run(app)
|
||||
end
|
||||
|
||||
# Print usage instructions
|
||||
puts "=== MCP SSE Test Server ==="
|
||||
puts ""
|
||||
puts "Starting server on http://localhost:9393"
|
||||
puts ""
|
||||
puts "Available Tools:"
|
||||
puts "1. NotificationTool - Returns messages that are sent via SSE when stream is active"
|
||||
puts "2. echo - Simple echo tool"
|
||||
puts ""
|
||||
puts "Testing SSE:"
|
||||
puts ""
|
||||
puts "1. Initialize session:"
|
||||
puts ' curl -X POST http://localhost:9393 -H "Content-Type: application/json" \\'
|
||||
puts ' -d \'{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"sse-test","version":"1.0"}}}\''
|
||||
puts ""
|
||||
puts "2. Connect SSE stream (use the session ID from step 1):"
|
||||
puts ' curl -N -H "Mcp-Session-Id: YOUR_SESSION_ID" http://localhost:9393'
|
||||
puts ""
|
||||
puts "3. In another terminal, test tools (responses will be sent via SSE if stream is active):"
|
||||
puts ""
|
||||
puts " Echo tool:"
|
||||
puts ' curl -X POST http://localhost:9393 -H "Content-Type: application/json" -H "Mcp-Session-Id: YOUR_SESSION_ID" \\'
|
||||
puts ' -d \'{"jsonrpc":"2.0","method":"tools/call","id":2,"params":{"name":"echo","arguments":{"message":"Hello SSE!"}}}\''
|
||||
puts ""
|
||||
puts " Notification tool (with 2 second delay):"
|
||||
puts ' curl -X POST http://localhost:9393 -H "Content-Type: application/json" -H "Mcp-Session-Id: YOUR_SESSION_ID" \\'
|
||||
puts ' -d \'{"jsonrpc":"2.0","method":"tools/call","id":3,"params":{"name":"notification_tool","arguments":{"message":"Hello SSE!", "delay": 2}}}\''
|
||||
puts ""
|
||||
puts "Note: When an SSE stream is active, tool responses will appear in the SSE stream and the POST request will return {\"accepted\": true}"
|
||||
puts ""
|
||||
puts "Press Ctrl+C to stop the server"
|
||||
puts ""
|
||||
|
||||
# Start the server
|
||||
Rackup::Handler.get("puma").run(rack_app, Port: 9393, Host: "localhost")
|
||||
Loading…
Reference in a new issue