nunki 0.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.
@@ -0,0 +1,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Nunki
6
+ module MCP
7
+ module Transports
8
+ class Stdio
9
+ MAX_MESSAGE_BYTES = 4_194_304
10
+
11
+ def initialize(command:, env: {})
12
+ @command = validate_command(command)
13
+ @env = validate_env(env)
14
+ @request_lock = Mutex.new
15
+ @write_lock = Mutex.new
16
+ @buffer = +""
17
+ @closed = false
18
+ end
19
+
20
+ def protocol_version=(_version); end
21
+
22
+ def request(message, timeout:)
23
+ @request_lock.synchronize do
24
+ ensure_open
25
+ write(message)
26
+ deadline = monotonic + timeout
27
+ loop do
28
+ response = read_message(deadline)
29
+ return response if response["id"] == message["id"]
30
+ reply_to_server(response) if response["method"] && response.key?("id")
31
+ end
32
+ end
33
+ end
34
+
35
+ def notify(message)
36
+ ensure_open
37
+ write(message)
38
+ nil
39
+ end
40
+
41
+ def close
42
+ return if @closed
43
+ @closed = true
44
+ @stdin&.close unless @stdin&.closed?
45
+ stop_process
46
+ [@stdout, @stderr].compact.each { |io| io.close unless io.closed? }
47
+ @stderr_thread&.join(0.2)
48
+ nil
49
+ end
50
+
51
+ private
52
+
53
+ def ensure_open
54
+ raise Error, "MCP transport is closed" if @closed
55
+ return if @waiter
56
+
57
+ @stdin, @stdout, @stderr, @waiter = Open3.popen3(@env, *@command)
58
+ @stdin.binmode
59
+ @stdout.binmode
60
+ @stderr_thread = Thread.new do
61
+ loop { break unless @stderr.read(16_384) }
62
+ rescue IOError
63
+ nil
64
+ end
65
+ rescue SystemCallError => error
66
+ raise Error, "failed to start MCP server: #{error.message}"
67
+ end
68
+
69
+ def write(message)
70
+ source = JSON.generate(Protocol.json(message))
71
+ raise ProtocolError, "MCP message exceeds #{MAX_MESSAGE_BYTES} bytes" if source.bytesize > MAX_MESSAGE_BYTES
72
+ @write_lock.synchronize do
73
+ @stdin.write(source, "\n")
74
+ @stdin.flush
75
+ end
76
+ end
77
+
78
+ def read_message(deadline)
79
+ loop do
80
+ if (newline = @buffer.index("\n"))
81
+ source = @buffer.slice!(0, newline + 1).delete_suffix("\n").delete_suffix("\r")
82
+ next if source.empty?
83
+ return Protocol.object(Protocol.parse(source, "MCP message"), "MCP message")
84
+ end
85
+ wait = deadline - monotonic
86
+ raise Timeout, "MCP request timed out" unless wait.positive? && IO.select([@stdout], nil, nil, wait)
87
+ chunk = @stdout.read_nonblock(16_384, exception: false)
88
+ raise Error, "MCP server closed its output" if chunk.nil?
89
+ next if chunk == :wait_readable
90
+ @buffer << chunk
91
+ raise ProtocolError, "MCP message exceeds #{MAX_MESSAGE_BYTES} bytes" if @buffer.bytesize > MAX_MESSAGE_BYTES
92
+ end
93
+ end
94
+
95
+ def reply_to_server(message)
96
+ result = message["method"] == "ping" ? {} : nil
97
+ reply = {"jsonrpc" => "2.0", "id" => message["id"]}
98
+ if result
99
+ reply["result"] = result
100
+ else
101
+ reply["error"] = {"code" => -32_601, "message" => "Method not found"}
102
+ end
103
+ write(reply)
104
+ end
105
+
106
+ def stop_process
107
+ return unless @waiter
108
+ return if @waiter.join(0.5)
109
+ ["TERM", "KILL"].each do |signal|
110
+ begin
111
+ signal_process(signal, @waiter.pid)
112
+ rescue Errno::EINVAL
113
+ next
114
+ end
115
+ return if @waiter.join(0.5)
116
+ end
117
+ rescue Errno::ESRCH, Errno::ECHILD
118
+ nil
119
+ end
120
+
121
+ def signal_process(signal, pid) = Process.kill(signal, pid)
122
+
123
+ def validate_command(command)
124
+ raise ProtocolError, "command must be a non-empty array" unless command.is_a?(Array) && command.any?
125
+ command.map { |part| Protocol.string(part, "command argument", empty: false, max: 32_768) }.freeze
126
+ end
127
+
128
+ def validate_env(env)
129
+ raise ProtocolError, "env must be an object" unless env.is_a?(Hash)
130
+ env.to_h do |key, value|
131
+ [Protocol.string(key, "environment name", empty: false, max: 1024),
132
+ Protocol.string(value, "environment value", max: 32_768)]
133
+ end.freeze
134
+ end
135
+
136
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
137
+ end
138
+
139
+ class HTTP
140
+ def initialize(url:, headers: {}, **options)
141
+ @client = Nunki::HTTP::Client.new(endpoint: url, headers: headers, **options)
142
+ @session_id = nil
143
+ @protocol_version = nil
144
+ end
145
+
146
+ def protocol_version=(version)
147
+ @protocol_version = version
148
+ end
149
+
150
+ def request(message, timeout:)
151
+ matched = nil
152
+ response = @client.post_json(message, headers: request_headers) do |event, response_headers|
153
+ @session_id ||= response_headers["mcp-session-id"]
154
+ matched ||= receive(parse_message(event.data), message["id"])
155
+ end
156
+ @session_id ||= response.headers["mcp-session-id"]
157
+ matched ||= receive(parse_message(response.body), message["id"]) unless response.body.empty?
158
+ matched ||
159
+ raise(ProtocolError, "MCP HTTP response did not contain the request id")
160
+ rescue Nunki::Timeout
161
+ raise
162
+ end
163
+
164
+ def notify(message)
165
+ @client.post_json(message, headers: request_headers)
166
+ nil
167
+ end
168
+
169
+ def close
170
+ begin
171
+ @client.delete(headers: request_headers) if @session_id
172
+ rescue HTTPError => error
173
+ raise unless error.status == 405
174
+ ensure
175
+ @client.cancel
176
+ end
177
+ nil
178
+ end
179
+
180
+ private
181
+
182
+ def request_headers
183
+ headers = {"Accept" => "application/json, text/event-stream"}
184
+ headers["MCP-Session-Id"] = @session_id if @session_id
185
+ headers["MCP-Protocol-Version"] = @protocol_version if @protocol_version
186
+ headers
187
+ end
188
+
189
+ def parse_message(source)
190
+ return nil if source.empty?
191
+ Protocol.object(Protocol.parse(source, "MCP HTTP response"), "MCP HTTP response")
192
+ end
193
+
194
+ def receive(message, request_id)
195
+ return unless message
196
+ raise ProtocolError, "invalid JSON-RPC version" unless message["jsonrpc"] == "2.0"
197
+ return message if !message.key?("method") && message["id"] == request_id
198
+ reply_to_server(message) if message["method"] && message.key?("id")
199
+ nil
200
+ end
201
+
202
+ def reply_to_server(message)
203
+ result = message["method"] == "ping" ? {} : nil
204
+ reply = {"jsonrpc" => "2.0", "id" => message["id"]}
205
+ if result
206
+ reply["result"] = result
207
+ else
208
+ reply["error"] = {"code" => -32_601, "message" => "Method not found"}
209
+ end
210
+ @client.post_json(reply, headers: request_headers)
211
+ end
212
+ end
213
+ end
214
+ end
215
+ end
data/lib/nunki/mcp.rb ADDED
@@ -0,0 +1,227 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nunki
4
+ module MCP
5
+ PROTOCOL_VERSION = "2025-11-25"
6
+
7
+ class RemoteError < Error
8
+ attr_reader :code, :data
9
+
10
+ def initialize(code, message, data = nil)
11
+ @code = code
12
+ @data = data
13
+ super(message)
14
+ end
15
+ end
16
+
17
+ class Client
18
+ def self.stdio(command:, env: {}, **options)
19
+ timeout = options.delete(:timeout) || 30
20
+ dispatch = options.delete(:dispatch) || ->(&block) { block.call }
21
+ raise ProtocolError, "unknown options: #{options.keys.join(", ")}" unless options.empty?
22
+ new(Transports::Stdio.new(command: command, env: env), timeout: timeout, dispatch: dispatch)
23
+ end
24
+
25
+ def self.http(url:, headers: {}, **options)
26
+ timeout = options.delete(:timeout) || 30
27
+ dispatch = options.delete(:dispatch) || ->(&block) { block.call }
28
+ options[:read_timeout] ||= timeout
29
+ options[:retries] = 0 unless options.key?(:retries)
30
+ new(Transports::HTTP.new(url: url, headers: headers, **options), timeout: timeout, dispatch: dispatch)
31
+ end
32
+
33
+ attr_reader :capabilities, :server_info
34
+
35
+ def initialize(transport, timeout:, dispatch:)
36
+ @transport = transport
37
+ @timeout = positive_timeout(timeout)
38
+ raise ProtocolError, "dispatch must respond to call" unless dispatch.respond_to?(:call)
39
+ @dispatch = dispatch
40
+ @sequence = 0
41
+ @request_lock = Mutex.new
42
+ @state_lock = Mutex.new
43
+ @state = :created
44
+ @capabilities = {}.freeze
45
+ @server_info = {}.freeze
46
+ end
47
+
48
+ def start
49
+ @dispatch.call do
50
+ begin_start
51
+ begin
52
+ result = request_now("initialize", {
53
+ protocolVersion: PROTOCOL_VERSION,
54
+ capabilities: {},
55
+ clientInfo: {name: "nunki", version: VERSION}
56
+ }, initializing: true)
57
+ validate_initialization(result)
58
+ @transport.protocol_version = PROTOCOL_VERSION
59
+ @transport.notify(notification("notifications/initialized"))
60
+ @state_lock.synchronize do
61
+ raise Error, "MCP client was closed during initialization" unless @state == :starting
62
+ @state = :started
63
+ end
64
+ result
65
+ rescue StandardError
66
+ close_after_failed_start
67
+ raise
68
+ end
69
+ end
70
+ end
71
+
72
+ def tools = schedule { list_all("tools/list", "tools", :tools) }
73
+ def resources = schedule { list_all("resources/list", "resources", :resources) }
74
+ def prompts = schedule { list_all("prompts/list", "prompts", :prompts) }
75
+
76
+ def call_tool(name, arguments)
77
+ schedule do
78
+ name = Protocol.string(name, "tool name", empty: false, max: 256)
79
+ arguments = Protocol.object(arguments, "tool arguments")
80
+ result = request_now("tools/call", {name: name, arguments: arguments})
81
+ validate_result_collection(result, "content")
82
+ end
83
+ end
84
+
85
+ def read_resource(uri)
86
+ schedule do
87
+ uri = Protocol.string(uri, "resource URI", empty: false, max: 4096)
88
+ result = request_now("resources/read", {uri: uri})
89
+ validate_result_collection(result, "contents")
90
+ end
91
+ end
92
+
93
+ def get_prompt(name, arguments = {})
94
+ schedule do
95
+ name = Protocol.string(name, "prompt name", empty: false, max: 256)
96
+ arguments = Protocol.object(arguments, "prompt arguments")
97
+ result = request_now("prompts/get", {name: name, arguments: arguments})
98
+ validate_result_collection(result, "messages")
99
+ end
100
+ end
101
+
102
+ def close
103
+ @dispatch.call do
104
+ should_close = @state_lock.synchronize do
105
+ next false if %i[closing closed].include?(@state)
106
+ @state = :closing
107
+ true
108
+ end
109
+ next nil unless should_close
110
+ begin
111
+ @transport.close
112
+ ensure
113
+ @state_lock.synchronize { @state = :closed }
114
+ end
115
+ nil
116
+ end
117
+ end
118
+
119
+ private
120
+
121
+ def schedule(&block)
122
+ @dispatch.call do
123
+ raise Error, "MCP client is not started" unless @state_lock.synchronize { @state == :started }
124
+ block.call
125
+ end
126
+ end
127
+
128
+ def begin_start
129
+ @state_lock.synchronize do
130
+ raise Error, "MCP client has already started" unless @state == :created
131
+ @state = :starting
132
+ end
133
+ end
134
+
135
+ def close_after_failed_start
136
+ @transport.close
137
+ rescue StandardError
138
+ nil
139
+ ensure
140
+ @capabilities = {}.freeze
141
+ @server_info = {}.freeze
142
+ @state_lock.synchronize { @state = :closed }
143
+ end
144
+
145
+ def request_now(method, params, initializing: false)
146
+ request_id = nil
147
+ @request_lock.synchronize do
148
+ request_id = @sequence += 1
149
+ message = {"jsonrpc" => "2.0", "id" => request_id, "method" => method, "params" => params}
150
+ response = Protocol.object(@transport.request(message, timeout: @timeout), "JSON-RPC response")
151
+ raise ProtocolError, "invalid JSON-RPC version" unless response["jsonrpc"] == "2.0"
152
+ raise ProtocolError, "mismatched JSON-RPC response id" unless response["id"] == request_id
153
+ raise_remote(response["error"]) if response["error"]
154
+ Protocol.deep_freeze(Protocol.object(response["result"], "JSON-RPC result").dup)
155
+ end
156
+ rescue Timeout
157
+ unless initializing
158
+ begin
159
+ @transport.notify(notification("notifications/cancelled", requestId: request_id, reason: "request timed out"))
160
+ rescue StandardError
161
+ nil
162
+ end
163
+ end
164
+ raise
165
+ end
166
+
167
+ def validate_initialization(result)
168
+ version = Protocol.string(result["protocolVersion"], "protocol version", empty: false, max: 64)
169
+ raise ProtocolError, "unsupported MCP protocol version: #{version}" unless version == PROTOCOL_VERSION
170
+ @capabilities = Protocol.deep_freeze(Protocol.object(result["capabilities"], "server capabilities").dup)
171
+ @server_info = Protocol.deep_freeze(Protocol.object(result.fetch("serverInfo", {}), "server info").dup)
172
+ end
173
+
174
+ def list_all(method, key, capability)
175
+ raise ProtocolError, "server does not advertise #{capability}" unless @capabilities.key?(capability.to_s)
176
+ items = []
177
+ cursor = nil
178
+ 100.times do
179
+ result = request_now(method, cursor ? {cursor: cursor} : {})
180
+ values = Protocol.collection(result[key], key)
181
+ values.each { |value| items << validate_descriptor(value, capability) }
182
+ raise ProtocolError, "#{key} list is too large" if items.length > Protocol::MAX_COLLECTION
183
+ cursor = result["nextCursor"]
184
+ break unless cursor
185
+ Protocol.string(cursor, "next cursor", empty: false, max: 4096)
186
+ end
187
+ raise ProtocolError, "#{key} pagination exceeds 100 pages" if cursor
188
+ Protocol.deep_freeze(items)
189
+ end
190
+
191
+ def validate_descriptor(value, capability)
192
+ value = Protocol.object(value, "#{capability} descriptor").dup
193
+ key = capability == :resources ? "uri" : "name"
194
+ Protocol.string(value[key], "#{capability} #{key}", empty: false, max: 4096)
195
+ Protocol.object(value["inputSchema"], "tool input schema") if capability == :tools
196
+ value
197
+ end
198
+
199
+ def validate_result_collection(result, key)
200
+ Protocol.collection(result[key], key).each { |value| Protocol.json(value) }
201
+ result
202
+ end
203
+
204
+ def raise_remote(error)
205
+ error = Protocol.object(error, "JSON-RPC error")
206
+ code = error["code"]
207
+ raise ProtocolError, "JSON-RPC error code must be an integer" unless code.is_a?(Integer)
208
+ message = Protocol.string(error["message"], "JSON-RPC error message", empty: false)
209
+ Protocol.json(error["data"]) if error.key?("data")
210
+ raise RemoteError.new(code, message, error["data"])
211
+ end
212
+
213
+ def notification(method, **params)
214
+ message = {"jsonrpc" => "2.0", "method" => method}
215
+ message["params"] = params unless params.empty?
216
+ message
217
+ end
218
+
219
+ def positive_timeout(value)
220
+ raise ProtocolError, "timeout must be positive" unless value.is_a?(Numeric) && value.positive?
221
+ value
222
+ end
223
+ end
224
+ end
225
+ end
226
+
227
+ require_relative "mcp/transports"
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Nunki
6
+ module Protocol
7
+ MAX_DEPTH = 32
8
+ MAX_COLLECTION = 10_000
9
+ MAX_STRING = 1_048_576
10
+
11
+ module_function
12
+
13
+ def json(value, depth = 0)
14
+ raise ProtocolError, "JSON nesting exceeds #{MAX_DEPTH}" if depth > MAX_DEPTH
15
+
16
+ case value
17
+ when nil, true, false, Integer
18
+ value
19
+ when Float
20
+ raise ProtocolError, "JSON number must be finite" unless value.finite?
21
+ when String
22
+ string(value, "JSON string")
23
+ when Array
24
+ collection(value, "JSON array").each { |item| json(item, depth + 1) }
25
+ when Hash
26
+ object(value, "JSON object").each do |key, item|
27
+ raise ProtocolError, "JSON object keys must be strings or symbols" unless key.is_a?(String) || key.is_a?(Symbol)
28
+ json(item, depth + 1)
29
+ end
30
+ else
31
+ raise ProtocolError, "unsupported JSON value: #{value.class}"
32
+ end
33
+ value
34
+ end
35
+
36
+ def parse(source, name = "response")
37
+ string(source, name, max: nil)
38
+ json(JSON.parse(source, max_nesting: MAX_DEPTH))
39
+ rescue JSON::ParserError => error
40
+ raise ProtocolError, "invalid #{name}: #{error.message}"
41
+ end
42
+
43
+ def object(value, name)
44
+ raise ProtocolError, "#{name} must be an object" unless value.is_a?(Hash)
45
+ raise ProtocolError, "#{name} has too many members" if value.length > MAX_COLLECTION
46
+ value
47
+ end
48
+
49
+ def collection(value, name)
50
+ raise ProtocolError, "#{name} must be an array" unless value.is_a?(Array)
51
+ raise ProtocolError, "#{name} has too many items" if value.length > MAX_COLLECTION
52
+ value
53
+ end
54
+
55
+ def string(value, name, empty: true, max: MAX_STRING)
56
+ raise ProtocolError, "#{name} must be a string" unless value.is_a?(String)
57
+ raise ProtocolError, "#{name} must not be empty" if !empty && value.empty?
58
+ raise ProtocolError, "#{name} is too large" if max && value.bytesize > max
59
+ encoded = value.encode(Encoding::UTF_8)
60
+ raise EncodingError unless encoded.valid_encoding?
61
+ encoded
62
+ rescue EncodingError
63
+ raise ProtocolError, "#{name} must be valid UTF-8"
64
+ end
65
+
66
+ def uint(value, name, positive: false)
67
+ minimum = positive ? 1 : 0
68
+ raise ProtocolError, "#{name} must be an integer >= #{minimum}" unless value.is_a?(Integer) && value >= minimum
69
+ value
70
+ end
71
+
72
+ def deep_freeze(value)
73
+ case value
74
+ when Array then value.each { |item| deep_freeze(item) }
75
+ when Hash then value.each { |key, item| deep_freeze(key); deep_freeze(item) }
76
+ end
77
+ value.freeze
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nunki
4
+ class Provider
5
+ HTTP_OPTIONS = %i[headers open_timeout read_timeout retries retry_base max_request_bytes max_response_bytes].freeze
6
+
7
+ def self.build(kind, endpoint:, api_key: nil, model:, **options)
8
+ kind = kind.to_s.tr("-", "_").to_sym
9
+ provider = case kind
10
+ when :openai, :openai_compatible, :local then Providers::OpenAI
11
+ when :anthropic then Providers::Anthropic
12
+ else raise ProtocolError, "unknown provider kind: #{kind}"
13
+ end
14
+
15
+ http_options = options.select { |key, _| HTTP_OPTIONS.include?(key) }
16
+ provider_options = options.reject { |key, _| HTTP_OPTIONS.include?(key) }
17
+ headers = http_options.delete(:headers) || {}
18
+ client = HTTP::Client.new(endpoint: endpoint, headers: headers.merge(provider.headers(api_key)), **http_options)
19
+ provider.new(client: client, model: model, **provider_options)
20
+ end
21
+
22
+ def initialize(client:, model:, dispatch: ->(&block) { block.call }, context_limit: nil)
23
+ @client = client
24
+ @model = Protocol.string(model, "model", empty: false, max: 256)
25
+ raise ProtocolError, "dispatch must respond to call" unless dispatch.respond_to?(:call)
26
+ @dispatch = dispatch
27
+ @context_limit = context_limit && Protocol.uint(context_limit, "context_limit", positive: true)
28
+ @call_lock = Mutex.new
29
+ end
30
+
31
+ def complete(messages, tools: [], system: nil, max_tokens: 1024, &stream)
32
+ @dispatch.call do
33
+ raise Error, "a completion is already in progress" unless @call_lock.try_lock
34
+ begin
35
+ perform_complete(prepare(messages, tools, system, max_tokens), &stream)
36
+ ensure
37
+ @call_lock.unlock
38
+ end
39
+ end
40
+ end
41
+
42
+ def cancel = @client.cancel
43
+
44
+ private
45
+
46
+ def prepare(messages, tools, system, max_tokens)
47
+ Context.validate_messages(Protocol.collection(messages, "messages"))
48
+ tools = validate_tools(Protocol.collection(tools, "tools"))
49
+ system = Protocol.string(system, "system") if system
50
+ max_tokens = Protocol.uint(max_tokens, "max_tokens", positive: true)
51
+ if @context_limit
52
+ raise ProtocolError, "context_limit must exceed max_tokens" unless @context_limit > max_tokens
53
+ messages = Context.truncate(messages, max_tokens: @context_limit - max_tokens)
54
+ end
55
+ [messages, tools, system, max_tokens]
56
+ end
57
+
58
+ def validate_tools(tools)
59
+ tools.each do |tool|
60
+ raise ProtocolError, "tool must be a Nunki::Tool" unless tool.is_a?(Tool)
61
+ Protocol.string(tool.name, "tool name", empty: false, max: 256)
62
+ Protocol.string(tool.description, "tool description", max: 16_384)
63
+ Protocol.json(Protocol.object(tool.input_schema, "tool input schema"))
64
+ end
65
+ tools
66
+ end
67
+
68
+ def parse_event(event)
69
+ Protocol.object(Protocol.parse(event.data, "SSE data"), "SSE data")
70
+ end
71
+
72
+ def response(parts, usage, finish_reason)
73
+ Response.new(
74
+ message: Message.new(role: :assistant, content: parts.freeze),
75
+ usage: Usage.new(input_tokens: usage[0], output_tokens: usage[1]),
76
+ finish_reason: finish_reason
77
+ )
78
+ end
79
+
80
+ def text_part(text)
81
+ Part.new(type: :text, text: text, tool_name: nil, tool_input: nil, tool_use_id: nil)
82
+ end
83
+
84
+ def tool_part(name, input, id)
85
+ name = Protocol.string(name, "tool name", empty: false, max: 256)
86
+ id = Protocol.string(id, "tool id", empty: false, max: 1024)
87
+ input = Protocol.object(input, "tool input")
88
+ Part.new(type: :tool_use, text: nil, tool_name: name, tool_input: input, tool_use_id: id)
89
+ end
90
+ end
91
+
92
+ module Providers; end
93
+ end