ruby-utcp 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +11 -0
  3. data/LICENSE +22 -0
  4. data/Makefile +226 -0
  5. data/README.md +331 -0
  6. data/examples/basic.rb +33 -0
  7. data/examples/cli.rb +32 -0
  8. data/examples/generated/__init__.py +1 -0
  9. data/examples/generated/utcp_pb2.py +46 -0
  10. data/examples/generated/utcp_pb2_grpc.py +183 -0
  11. data/examples/graphql.rb +15 -0
  12. data/examples/grpc.rb +42 -0
  13. data/examples/grpc_python.py +52 -0
  14. data/examples/http.rb +17 -0
  15. data/examples/mcp.rb +28 -0
  16. data/examples/servers/graphql_server.rb +39 -0
  17. data/examples/servers/grpc_server.py +97 -0
  18. data/examples/servers/grpc_server.rb +62 -0
  19. data/examples/servers/http_helpers.rb +34 -0
  20. data/examples/servers/http_server.rb +28 -0
  21. data/examples/servers/mcp_stdio_server.rb +43 -0
  22. data/examples/servers/requirements-grpc.txt +2 -0
  23. data/examples/servers/sse_server.rb +36 -0
  24. data/examples/servers/streamable_http_server.rb +39 -0
  25. data/examples/servers/tcp_server.rb +58 -0
  26. data/examples/servers/udp_server.rb +33 -0
  27. data/examples/servers/webrtc_server.rb +78 -0
  28. data/examples/servers/websocket_server.rb +92 -0
  29. data/examples/sse.rb +16 -0
  30. data/examples/streamable_http.rb +17 -0
  31. data/examples/tcp.rb +20 -0
  32. data/examples/text.rb +23 -0
  33. data/examples/udp.rb +18 -0
  34. data/examples/webrtc.rb +19 -0
  35. data/examples/websocket.rb +17 -0
  36. data/lib/ruby-utcp.rb +4 -0
  37. data/lib/utcp/client.rb +217 -0
  38. data/lib/utcp/config.rb +79 -0
  39. data/lib/utcp/errors.rb +48 -0
  40. data/lib/utcp/migration.rb +88 -0
  41. data/lib/utcp/models.rb +794 -0
  42. data/lib/utcp/openapi_converter.rb +179 -0
  43. data/lib/utcp/protocols/base.rb +97 -0
  44. data/lib/utcp/protocols/cli.rb +186 -0
  45. data/lib/utcp/protocols/file.rb +52 -0
  46. data/lib/utcp/protocols/graphql.rb +277 -0
  47. data/lib/utcp/protocols/grpc.rb +207 -0
  48. data/lib/utcp/protocols/http.rb +340 -0
  49. data/lib/utcp/protocols/http_stream_support.rb +122 -0
  50. data/lib/utcp/protocols/mcp.rb +339 -0
  51. data/lib/utcp/protocols/socket_support.rb +51 -0
  52. data/lib/utcp/protocols/sse.rb +107 -0
  53. data/lib/utcp/protocols/streamable_http.rb +78 -0
  54. data/lib/utcp/protocols/tcp.rb +143 -0
  55. data/lib/utcp/protocols/text.rb +44 -0
  56. data/lib/utcp/protocols/udp.rb +61 -0
  57. data/lib/utcp/protocols/webrtc.rb +217 -0
  58. data/lib/utcp/protocols/websocket.rb +350 -0
  59. data/lib/utcp/registry.rb +67 -0
  60. data/lib/utcp/repository.rb +137 -0
  61. data/lib/utcp/serializer.rb +71 -0
  62. data/lib/utcp/utils.rb +118 -0
  63. data/lib/utcp/variables.rb +170 -0
  64. data/lib/utcp/version.rb +6 -0
  65. data/lib/utcp.rb +70 -0
  66. data/proto/utcp.proto +31 -0
  67. metadata +148 -0
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ $stdout.sync = true
6
+
7
+ ARGF.each_line do |line|
8
+ message = JSON.parse(line)
9
+ next unless message.key?("id")
10
+
11
+ result = case message["method"]
12
+ when "initialize"
13
+ {
14
+ protocolVersion: message.dig("params", "protocolVersion") || "2025-06-18",
15
+ capabilities: { tools: {}, resources: {} },
16
+ serverInfo: { name: "ruby-example", version: "1.0.0" }
17
+ }
18
+ when "tools/list"
19
+ {
20
+ tools: [{
21
+ name: "echo",
22
+ description: "Echo a message",
23
+ inputSchema: {
24
+ type: "object",
25
+ properties: { message: { type: "string" } },
26
+ required: ["message"]
27
+ }
28
+ }]
29
+ }
30
+ when "tools/call"
31
+ text = message.dig("params", "arguments", "message").to_s
32
+ { content: [{ type: "text", text: JSON.generate(echo: text) }] }
33
+ when "resources/list"
34
+ { resources: [{ name: "welcome", uri: "memory://welcome", description: "Welcome text" }] }
35
+ when "resources/read"
36
+ { contents: [{ uri: "memory://welcome", mimeType: "text/plain", text: "Hello from MCP" }] }
37
+ else
38
+ {}
39
+ end
40
+ puts JSON.generate(jsonrpc: "2.0", id: message["id"], result: result)
41
+ rescue JSON::ParserError => error
42
+ warn error.message
43
+ end
@@ -0,0 +1,2 @@
1
+ grpcio>=1.83.1,<2
2
+ grpcio-tools>=1.83.1,<2
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "http_helpers"
4
+
5
+ port = Integer(ENV.fetch("PORT", "8081"))
6
+ base = "http://localhost:#{port}"
7
+
8
+ ExampleHTTP.server(port) do |server|
9
+ server.mount_proc("/utcp") do |_request, response|
10
+ ExampleHTTP.json(response, {
11
+ utcp_version: "1.1.0",
12
+ tools: [{
13
+ name: "watch",
14
+ tool_call_template: {
15
+ call_template_type: "sse",
16
+ url: "#{base}/watch",
17
+ event_type: "update"
18
+ }
19
+ }]
20
+ })
21
+ end
22
+
23
+ server.mount_proc("/watch") do |request, response|
24
+ topic = request.query["topic"] || "events"
25
+ response.status = 200
26
+ response["Content-Type"] = "text/event-stream"
27
+ response["Cache-Control"] = "no-cache"
28
+ response.chunked = true
29
+ response.body = proc do |output|
30
+ 3.times do |index|
31
+ output.write("id: #{index + 1}\nevent: update\ndata: #{JSON.generate(topic: topic, number: index + 1)}\n\n")
32
+ sleep 0.2
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "http_helpers"
4
+
5
+ port = Integer(ENV.fetch("PORT", "8082"))
6
+ base = "http://localhost:#{port}"
7
+
8
+ ExampleHTTP.server(port) do |server|
9
+ server.mount_proc("/utcp") do |_request, response|
10
+ ExampleHTTP.json(response, {
11
+ utcp_version: "1.1.0",
12
+ tools: [{
13
+ name: "tokens",
14
+ tool_call_template: {
15
+ call_template_type: "streamable_http",
16
+ url: "#{base}/tokens",
17
+ http_method: "POST",
18
+ content_type: "application/json",
19
+ body_field: "body",
20
+ chunk_size: 32,
21
+ timeout: 60_000
22
+ }
23
+ }]
24
+ })
25
+ end
26
+
27
+ server.mount_proc("/tokens") do |request, response|
28
+ prompt = ExampleHTTP.request_json(request)["prompt"].to_s
29
+ response.status = 200
30
+ response["Content-Type"] = "application/x-ndjson"
31
+ response.chunked = true
32
+ response.body = proc do |output|
33
+ prompt.split.each_with_index do |token, index|
34
+ output.write(JSON.generate(index: index, token: token) + "\n")
35
+ sleep 0.15
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "socket"
5
+
6
+ port = Integer(ENV.fetch("PORT", "9000"))
7
+
8
+ def read_exact(socket, length)
9
+ value = +"".b
10
+ value << socket.readpartial(length - value.bytesize) while value.bytesize < length
11
+ value
12
+ end
13
+
14
+ def read_message(socket)
15
+ length = read_exact(socket, 4).unpack1("N")
16
+ read_exact(socket, length)
17
+ end
18
+
19
+ def write_message(socket, value)
20
+ bytes = value.to_s.b
21
+ socket.write([bytes.bytesize].pack("N") + bytes)
22
+ end
23
+
24
+ manual = {
25
+ utcp_version: "1.1.0",
26
+ tools: [{
27
+ name: "echo",
28
+ tool_call_template: {
29
+ call_template_type: "tcp",
30
+ host: "localhost",
31
+ port: port,
32
+ framing_strategy: "length_prefix",
33
+ length_prefix_bytes: 4,
34
+ length_prefix_endian: "big",
35
+ request_data_format: "json",
36
+ response_byte_format: "utf-8"
37
+ }
38
+ }]
39
+ }
40
+
41
+ server = TCPServer.new("127.0.0.1", port)
42
+ warn "Listening on tcp://127.0.0.1:#{port}"
43
+ loop do
44
+ socket = server.accept
45
+ Thread.new(socket) do |client|
46
+ request = JSON.parse(read_message(client))
47
+ response = if request["type"] == "utcp"
48
+ JSON.generate(manual)
49
+ else
50
+ "Hello over TCP: #{request["message"]}"
51
+ end
52
+ write_message(client, response)
53
+ rescue EOFError, IOError, SystemCallError, JSON::ParserError
54
+ nil
55
+ ensure
56
+ client.close unless client.closed?
57
+ end
58
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "socket"
5
+
6
+ port = Integer(ENV.fetch("PORT", "9001"))
7
+ socket = UDPSocket.new
8
+ socket.bind("127.0.0.1", port)
9
+ warn "Listening on udp://127.0.0.1:#{port}"
10
+
11
+ manual = {
12
+ utcp_version: "1.1.0",
13
+ tools: [{
14
+ name: "echo",
15
+ tool_call_template: {
16
+ call_template_type: "udp",
17
+ host: "localhost",
18
+ port: port,
19
+ number_of_response_datagrams: 1,
20
+ request_data_format: "json",
21
+ response_byte_format: "utf-8"
22
+ }
23
+ }]
24
+ }
25
+
26
+ loop do
27
+ bytes, sender = socket.recvfrom(65_535)
28
+ request = JSON.parse(bytes)
29
+ response = request["type"] == "utcp" ? JSON.generate(manual) : "Hello over UDP: #{request["message"]}"
30
+ socket.send(response, 0, sender[3], sender[1])
31
+ rescue JSON::ParserError => error
32
+ warn error.message
33
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "utcp"
5
+ gem "webrtc-ruby", ">= 1.0.0"
6
+ require "webrtc"
7
+ require_relative "http_helpers"
8
+
9
+ WebRTC.init
10
+ port = Integer(ENV.fetch("PORT", "8084"))
11
+ peers = {}
12
+ peers_mutex = Mutex.new
13
+
14
+ def wait_for_ice(peer, timeout = 5)
15
+ mutex = Mutex.new
16
+ condition = ConditionVariable.new
17
+ complete = peer.ice_gathering_state == :complete
18
+ peer.on_ice_gathering_state_change do
19
+ mutex.synchronize do
20
+ complete = peer.ice_gathering_state == :complete
21
+ condition.broadcast if complete
22
+ end
23
+ end
24
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
25
+ mutex.synchronize do
26
+ until complete
27
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
28
+ break unless remaining.positive?
29
+ condition.wait(mutex, remaining)
30
+ end
31
+ end
32
+ end
33
+
34
+ ExampleHTTP.server(port) do |server|
35
+ server.mount_proc("/connect") do |request, response|
36
+ payload = ExampleHTTP.request_json(request)
37
+ candidates = []
38
+ peer = WebRTC::RTCPeerConnection.new
39
+ peer.on_ice_candidate { |candidate| candidates << candidate if candidate }
40
+ peer.on_data_channel do |channel|
41
+ channel.on_message do |event|
42
+ envelope = JSON.parse(event.data)
43
+ result = envelope.dig("args", "message") if envelope["tool"] == "echo"
44
+ channel.send_text(JSON.generate(id: envelope["id"], result: result))
45
+ rescue JSON::ParserError
46
+ nil
47
+ end
48
+ end
49
+
50
+ offer = WebRTC::RTCSessionDescription.new(type: :offer, sdp: payload.fetch("sdp"))
51
+ peer.set_remote_description(offer).await
52
+ answer = peer.create_answer.await
53
+ peer.set_local_description(answer).await
54
+ wait_for_ice(peer)
55
+ peers_mutex.synchronize { peers[payload.fetch("peer_id")] = peer }
56
+ ExampleHTTP.json(response, {
57
+ sdp: peer.local_description.sdp,
58
+ candidates: candidates.map(&:to_h),
59
+ tools: [{ name: "echo", description: "Echo over a WebRTC DataChannel" }]
60
+ })
61
+ rescue StandardError => error
62
+ ExampleHTTP.json(response, { error: error.message }, status: 500)
63
+ end
64
+
65
+ server.mount_proc("/candidate") do |request, response|
66
+ payload = ExampleHTTP.request_json(request)
67
+ peer = peers_mutex.synchronize { peers[payload["peer_id"]] }
68
+ if peer
69
+ candidate = WebRTC::RTCIceCandidate.new(UTCP::Utils.symbolize_keys(payload.fetch("candidate")))
70
+ peer.add_ice_candidate(candidate).await
71
+ ExampleHTTP.json(response, { ok: true })
72
+ else
73
+ ExampleHTTP.json(response, { error: "unknown peer" }, status: 404)
74
+ end
75
+ rescue StandardError => error
76
+ ExampleHTTP.json(response, { error: error.message }, status: 400)
77
+ end
78
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "digest/sha1"
5
+ require "json"
6
+ require "socket"
7
+
8
+ GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
9
+ port = Integer(ENV.fetch("PORT", "8083"))
10
+ endpoint = "ws://localhost:#{port}/utcp"
11
+
12
+ def read_exact(socket, length)
13
+ value = +"".b
14
+ value << socket.readpartial(length - value.bytesize) while value.bytesize < length
15
+ value
16
+ end
17
+
18
+ def read_frame(socket)
19
+ first, second = read_exact(socket, 2).unpack("CC")
20
+ opcode = first & 0x0F
21
+ length = second & 0x7F
22
+ length = read_exact(socket, 2).unpack1("n") if length == 126
23
+ length = read_exact(socket, 8).unpack1("Q>") if length == 127
24
+ mask = (second & 0x80).zero? ? nil : read_exact(socket, 4)
25
+ payload = read_exact(socket, length)
26
+ if mask
27
+ payload = payload.bytes.each_with_index.map { |byte, index| byte ^ mask.getbyte(index % 4) }.pack("C*")
28
+ end
29
+ [opcode, payload]
30
+ end
31
+
32
+ def write_frame(socket, payload, opcode = 0x1)
33
+ bytes = payload.to_s.b
34
+ header = [0x80 | opcode].pack("C")
35
+ header << if bytes.bytesize < 126
36
+ [bytes.bytesize].pack("C")
37
+ elsif bytes.bytesize <= 65_535
38
+ [126, bytes.bytesize].pack("Cn")
39
+ else
40
+ [127, bytes.bytesize].pack("CQ>")
41
+ end
42
+ socket.write(header + bytes)
43
+ end
44
+
45
+ server = TCPServer.new("127.0.0.1", port)
46
+ warn "Listening on #{endpoint}"
47
+
48
+ loop do
49
+ socket = server.accept
50
+ Thread.new(socket) do |client|
51
+ header = +""
52
+ header << client.readpartial(1024) until header.include?("\r\n\r\n")
53
+ headers = header.split("\r\n").drop(1).each_with_object({}) do |line, values|
54
+ name, value = line.split(":", 2)
55
+ values[name.downcase] = value.to_s.strip
56
+ end
57
+ accept = Base64.strict_encode64(Digest::SHA1.digest(headers.fetch("sec-websocket-key") + GUID))
58
+ response = +"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: #{accept}\r\n"
59
+ response << "Sec-WebSocket-Protocol: #{headers["sec-websocket-protocol"]}\r\n" if headers["sec-websocket-protocol"]
60
+ client.write(response + "\r\n")
61
+
62
+ loop do
63
+ opcode, bytes = read_frame(client)
64
+ break if opcode == 0x8
65
+ if opcode == 0x9
66
+ write_frame(client, bytes, 0xA)
67
+ next
68
+ end
69
+ message = JSON.parse(bytes)
70
+ if message["type"] == "utcp"
71
+ write_frame(client, JSON.generate(
72
+ utcp_version: "1.1.0",
73
+ tools: [{
74
+ name: "echo",
75
+ tool_call_template: {
76
+ call_template_type: "websocket",
77
+ url: endpoint,
78
+ protocol: "utcp-v1",
79
+ response_format: "json"
80
+ }
81
+ }]
82
+ ))
83
+ else
84
+ write_frame(client, JSON.generate(echo: message["message"]))
85
+ end
86
+ end
87
+ rescue EOFError, IOError, SystemCallError, JSON::ParserError
88
+ nil
89
+ ensure
90
+ client.close unless client.closed?
91
+ end
92
+ end
data/examples/sse.rb ADDED
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "utcp"
4
+
5
+ # The discovery URL returns JSON; the discovered tool template points to an SSE endpoint.
6
+ client = UTCP::Client.create(config: {
7
+ manual_call_templates: [{
8
+ name: "events",
9
+ call_template_type: "sse",
10
+ url: ENV.fetch("UTCP_SSE_MANUAL", "http://localhost:8081/utcp")
11
+ }]
12
+ })
13
+
14
+ client.call_tool_streaming("events.watch", topic: "builds") do |event|
15
+ puts "event: #{event.inspect}"
16
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "utcp"
4
+
5
+ # Supports NDJSON, JSON Sequence, JSON, and arbitrary binary chunks.
6
+ client = UTCP::Client.create(config: {
7
+ manual_call_templates: [{
8
+ name: "generator",
9
+ call_template_type: "streamable_http",
10
+ url: ENV.fetch("UTCP_STREAM_MANUAL", "http://localhost:8082/utcp"),
11
+ http_method: "GET",
12
+ chunk_size: 4096,
13
+ timeout: 60_000
14
+ }]
15
+ })
16
+
17
+ client.call_tool_streaming("generator.tokens", body: { prompt: "Hello from Ruby" }) { |chunk| puts chunk.inspect }
data/examples/tcp.rb ADDED
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "utcp"
4
+
5
+ # The server answers the framed discovery message {"type":"utcp"} with a manual.
6
+ client = UTCP::Client.create(config: {
7
+ manual_call_templates: [{
8
+ name: "tcp_service",
9
+ call_template_type: "tcp",
10
+ host: ENV.fetch("UTCP_TCP_HOST", "localhost"),
11
+ port: Integer(ENV.fetch("UTCP_TCP_PORT", "9000")),
12
+ framing_strategy: "length_prefix",
13
+ length_prefix_bytes: 4,
14
+ length_prefix_endian: "big",
15
+ request_data_format: "json",
16
+ response_byte_format: "utf-8"
17
+ }]
18
+ })
19
+
20
+ puts client.call_tool("tcp_service.echo", message: "Hello over TCP")
data/examples/text.rb ADDED
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "utcp"
5
+
6
+ manual = {
7
+ utcp_version: "1.1.0",
8
+ manual_version: "1.0.0",
9
+ tools: [{
10
+ name: "motd",
11
+ description: "Return a static message",
12
+ tool_call_template: {
13
+ call_template_type: "text",
14
+ content: "Hello from a text UTCP tool"
15
+ }
16
+ }]
17
+ }
18
+
19
+ client = UTCP::Client.create(config: {
20
+ manual_call_templates: [{ name: "static", call_template_type: "text", content: JSON.generate(manual) }]
21
+ })
22
+
23
+ puts client.call_tool("static.motd")
data/examples/udp.rb ADDED
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "utcp"
4
+
5
+ # The server answers {"type":"utcp"} with one manual datagram.
6
+ client = UTCP::Client.create(config: {
7
+ manual_call_templates: [{
8
+ name: "udp_service",
9
+ call_template_type: "udp",
10
+ host: ENV.fetch("UTCP_UDP_HOST", "localhost"),
11
+ port: Integer(ENV.fetch("UTCP_UDP_PORT", "9001")),
12
+ number_of_response_datagrams: 1,
13
+ request_data_format: "json",
14
+ response_byte_format: "utf-8"
15
+ }]
16
+ })
17
+
18
+ puts client.call_tool("udp_service.echo", message: "Hello over UDP")
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "utcp"
4
+
5
+ # Requires Ruby 3.1+, `gem "webrtc-ruby"`, and libdatachannel.
6
+ # POST /connect exchanges SDP and returns {sdp, candidates, tools}; /candidate accepts ICE.
7
+ client = UTCP::Client.create(config: {
8
+ manual_call_templates: [{
9
+ name: "rtc",
10
+ call_template_type: "webrtc",
11
+ signaling_server: ENV.fetch("UTCP_WEBRTC_SIGNALING", "http://localhost:8084"),
12
+ peer_id: ENV.fetch("UTCP_WEBRTC_PEER_ID", "ruby-client"),
13
+ data_channel_name: "utcp",
14
+ ice_servers: [{ urls: "stun:stun.l.google.com:19302" }]
15
+ }]
16
+ })
17
+
18
+ puts client.call_tool("rtc.echo", message: "Hello over WebRTC").inspect
19
+ client.close
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "utcp"
4
+
5
+ # The server first answers {"type":"utcp"} with a manual, then handles tool messages.
6
+ client = UTCP::Client.create(config: {
7
+ manual_call_templates: [{
8
+ name: "realtime",
9
+ call_template_type: "websocket",
10
+ url: ENV.fetch("UTCP_WEBSOCKET_URL", "ws://localhost:8083/utcp"),
11
+ protocol: "utcp-v1",
12
+ keep_alive: true,
13
+ response_format: "json"
14
+ }]
15
+ })
16
+
17
+ puts client.call_tool("realtime.echo", message: "Hello over WebSocket").inspect
data/lib/ruby-utcp.rb ADDED
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "utcp"
4
+