Skip to content

Commit 6da8bfa

Browse files
committed
Internal Release 0.5.2
Added - Added stdio transport functionality. Changed - Added stdio details to the README.
1 parent d2b6a33 commit 6da8bfa

File tree

8 files changed

+289
-3
lines changed

8 files changed

+289
-3
lines changed

Gemfile.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
PATH
22
remote: .
33
specs:
4-
model_context_protocol (0.5.1)
4+
model_context_protocol (0.5.2)
55
json_rpc_handler (~> 0.1)
66

77
GEM

README.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ It implements the Model Context Protocol specification, handling model context r
3636

3737
### Usage
3838

39+
#### Rails Controller
3940
Implement an `ApplicationController` which calls the `Server#handle` method, eg
4041

4142
```ruby
@@ -76,6 +77,59 @@ module ModelContextProtocol
7677
end
7778
```
7879

80+
#### Stdio Transport
81+
82+
If you want to build a simple command-line application, you can use the stdio transport:
83+
84+
```ruby
85+
#!/usr/bin/env ruby
86+
require "model_context_protocol"
87+
require "model_context_protocol/transports/stdio"
88+
89+
# Create a simple tool
90+
class ExampleTool < ModelContextProtocol::Tool
91+
description "A simple example tool that echoes back its arguments"
92+
input_schema type: "object",
93+
properties: {
94+
message: { type: "string" },
95+
},
96+
required: ["message"]
97+
98+
class << self
99+
def call(message:, context:)
100+
ModelContextProtocol::Tool::Response.new([{
101+
type: "text",
102+
text: "Hello from example tool! Message: #{message}",
103+
}])
104+
end
105+
end
106+
end
107+
108+
# Set up the server
109+
server = ModelContextProtocol::Server.new(
110+
name: "example_server",
111+
tools: [ExampleTool],
112+
)
113+
114+
# Create and start the transport
115+
transport = ModelContextProtocol::Transports::StdioTransport.new(server)
116+
transport.open
117+
```
118+
119+
You can run this script and then type in requests to the server at the command line.
120+
121+
```
122+
$ ./stdio_server.rb
123+
{"jsonrpc":"2.0","id":"1","result":"pong"}
124+
{"jsonrpc":"2.0","id":"2","result":["ExampleTool"]}
125+
{"jsonrpc":"2.0","id":"3","result":["ExampleTool"]}
126+
```
127+
128+
129+
130+
131+
#### Testing without a transport
132+
79133
To see sample responses without setting up a client to hit the server, you can simply run
80134

81135
```ruby

examples/stdio_server.rb

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
#!/usr/bin/env ruby
2+
# frozen_string_literal: true
3+
4+
$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
5+
require "model_context_protocol"
6+
require "model_context_protocol/transports/stdio"
7+
8+
# Create a simple tool
9+
class ExampleTool < ModelContextProtocol::Tool
10+
description "A simple example tool that echoes back its arguments"
11+
input_schema type: "object",
12+
properties: {
13+
message: { type: "string" },
14+
},
15+
required: ["message"]
16+
17+
class << self
18+
def call(message:, context:)
19+
ModelContextProtocol::Tool::Response.new([{
20+
type: "text",
21+
text: "Hello from example tool! Message: #{message}",
22+
}])
23+
end
24+
end
25+
end
26+
27+
# Set up the server
28+
server = ModelContextProtocol::Server.new(
29+
name: "example_server",
30+
tools: [ExampleTool],
31+
)
32+
33+
# Create and start the transport
34+
transport = ModelContextProtocol::Transports::StdioTransport.new(server)
35+
transport.open

lib/model_context_protocol/server.rb

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ def initialize(name: "model_context_protocol", tools: [], prompts: [], resources
2828
@tools = tools.to_h { |t| [t.name_value, t] }
2929
@prompts = prompts.to_h { |p| [p.name_value, p] }
3030
@resources = resources
31-
@resource_index = resources.index_by(&:uri)
31+
@resource_index = index_resources_by_uri(resources)
3232
@context = context
3333
@configuration = ModelContextProtocol.configuration.merge(configuration)
3434
@handlers = {
@@ -205,5 +205,11 @@ def read_resource(request)
205205
def report_exception(exception, context = {})
206206
configuration.exception_reporter.call(exception, context)
207207
end
208+
209+
def index_resources_by_uri(resources)
210+
resources.each_with_object({}) do |resource, hash|
211+
hash[resource.uri] = resource
212+
end
213+
end
208214
end
209215
end
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# frozen_string_literal: true
2+
3+
module ModelContextProtocol
4+
class Transport
5+
def initialize(server)
6+
@server = server
7+
end
8+
9+
def send_response(response)
10+
raise NotImplementedError, "Subclasses must implement send_response"
11+
end
12+
13+
def open
14+
raise NotImplementedError, "Subclasses must implement open"
15+
end
16+
17+
def close
18+
raise NotImplementedError, "Subclasses must implement close"
19+
end
20+
21+
private
22+
23+
def handle_request(request)
24+
response = @server.handle(request)
25+
send_response(response) if response
26+
end
27+
28+
def handle_json_request(request)
29+
response = @server.handle_json(request)
30+
send_response(response) if response
31+
end
32+
end
33+
end
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# frozen_string_literal: true
2+
3+
require_relative "../transport"
4+
require "json"
5+
6+
module ModelContextProtocol
7+
module Transports
8+
class StdioTransport < Transport
9+
def initialize(server)
10+
@server = server
11+
@open = false
12+
super
13+
end
14+
15+
def open
16+
@open = true
17+
while @open && (line = $stdin.gets)
18+
handle_json_request(line.strip)
19+
end
20+
end
21+
22+
def close
23+
@open = false
24+
end
25+
26+
def send_response(message)
27+
json_message = message.is_a?(String) ? message : JSON.generate(message)
28+
$stdout.puts(json_message)
29+
$stdout.flush
30+
end
31+
end
32+
end
33+
end

lib/model_context_protocol/version.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# frozen_string_literal: true
22

33
module ModelContextProtocol
4-
VERSION = "0.5.1"
4+
VERSION = "0.5.2"
55
end
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
# frozen_string_literal: true
2+
3+
require "test_helper"
4+
require "model_context_protocol/transports/stdio"
5+
require "json"
6+
7+
module ModelContextProtocol
8+
module Transports
9+
class StdioTransportTest < ActiveSupport::TestCase
10+
include InstrumentationTestHelper
11+
12+
setup do
13+
configuration = ModelContextProtocol::Configuration.new
14+
configuration.instrumentation_callback = instrumentation_helper.callback
15+
@server = Server.new(name: "test_server", configuration: configuration)
16+
@transport = StdioTransport.new(@server)
17+
end
18+
19+
test "initializes with server and closed state" do
20+
server = @transport.instance_variable_get(:@server)
21+
assert_equal @server.object_id, server.object_id
22+
refute @transport.instance_variable_get(:@open)
23+
end
24+
25+
test "processes JSON-RPC requests from stdin and sends responses to stdout" do
26+
request = {
27+
jsonrpc: "2.0",
28+
method: "ping",
29+
id: "123",
30+
}
31+
input = StringIO.new(JSON.generate(request) + "\n")
32+
output = StringIO.new
33+
34+
original_stdin = $stdin
35+
original_stdout = $stdout
36+
37+
begin
38+
$stdin = input
39+
$stdout = output
40+
41+
thread = Thread.new { @transport.open }
42+
sleep(0.1)
43+
@transport.close
44+
thread.join
45+
46+
response = JSON.parse(output.string, symbolize_names: true)
47+
assert_equal("2.0", response[:jsonrpc])
48+
assert_equal("123", response[:id])
49+
assert_equal({}, response[:result])
50+
refute(@transport.instance_variable_get(:@open))
51+
ensure
52+
$stdin = original_stdin
53+
$stdout = original_stdout
54+
end
55+
end
56+
57+
test "sends string responses to stdout" do
58+
output = StringIO.new
59+
original_stdout = $stdout
60+
61+
begin
62+
$stdout = output
63+
@transport.send_response("test response")
64+
assert_equal("test response\n", output.string)
65+
ensure
66+
$stdout = original_stdout
67+
end
68+
end
69+
70+
test "sends JSON responses to stdout" do
71+
output = StringIO.new
72+
original_stdout = $stdout
73+
74+
begin
75+
$stdout = output
76+
response = { key: "value" }
77+
@transport.send_response(response)
78+
assert_equal(JSON.generate(response) + "\n", output.string)
79+
ensure
80+
$stdout = original_stdout
81+
end
82+
end
83+
84+
test "handles valid JSON-RPC requests" do
85+
request = {
86+
jsonrpc: "2.0",
87+
method: "ping",
88+
id: "123",
89+
}
90+
output = StringIO.new
91+
original_stdout = $stdout
92+
93+
begin
94+
$stdout = output
95+
@transport.send(:handle_request, JSON.generate(request))
96+
response = JSON.parse(output.string, symbolize_names: true)
97+
assert_equal("2.0", response[:jsonrpc])
98+
assert_nil(response[:id])
99+
assert_nil(response[:result])
100+
ensure
101+
$stdout = original_stdout
102+
end
103+
end
104+
105+
test "handles invalid JSON requests" do
106+
invalid_json = "invalid json"
107+
output = StringIO.new
108+
original_stdout = $stdout
109+
110+
begin
111+
$stdout = output
112+
@transport.send(:handle_request, invalid_json)
113+
response = JSON.parse(output.string, symbolize_names: true)
114+
assert_equal("2.0", response[:jsonrpc])
115+
assert_nil(response[:id])
116+
assert_equal(-32600, response[:error][:code])
117+
assert_equal("Invalid Request", response[:error][:message])
118+
assert_equal("Request must be an array or a hash", response[:error][:data])
119+
ensure
120+
$stdout = original_stdout
121+
end
122+
end
123+
end
124+
end
125+
end

0 commit comments

Comments
 (0)