manceps 1.0.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,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Manceps
4
+ # An MCP tool definition.
5
+ class Tool
6
+ attr_reader :name, :description, :input_schema, :output_schema, :annotations, :title
7
+
8
+ def initialize(data)
9
+ @name = data['name']
10
+ @description = data['description']
11
+ @title = data['title']
12
+ @input_schema = data['inputSchema']
13
+ @output_schema = data['outputSchema']
14
+ @annotations = data['annotations']
15
+ end
16
+
17
+ def to_h
18
+ h = { 'name' => name, 'description' => description, 'inputSchema' => input_schema }
19
+ h['title'] = title if title
20
+ h['outputSchema'] = output_schema if output_schema
21
+ h
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Manceps
4
+ # Result of a tool invocation.
5
+ class ToolResult
6
+ attr_reader :content, :is_error, :structured_content
7
+
8
+ def initialize(data)
9
+ @content = (data['content'] || []).map { |c| Content.new(c) }
10
+ @is_error = data['isError'] || false
11
+ @structured_content = data['structuredContent']
12
+ end
13
+
14
+ def error?
15
+ is_error
16
+ end
17
+
18
+ def text
19
+ content.select(&:text?).map(&:text).join("\n")
20
+ end
21
+
22
+ def structured?
23
+ !structured_content.nil?
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Manceps
4
+ module Transport
5
+ # Abstract base class for MCP transports.
6
+ class Base
7
+ def request(body)
8
+ raise NotImplementedError
9
+ end
10
+
11
+ def request_streaming(body, &)
12
+ raise NotImplementedError
13
+ end
14
+
15
+ def notify(body)
16
+ raise NotImplementedError
17
+ end
18
+
19
+ def terminate_session(session_id)
20
+ raise NotImplementedError
21
+ end
22
+
23
+ def close
24
+ raise NotImplementedError
25
+ end
26
+
27
+ def listen(&)
28
+ raise NotImplementedError
29
+ end
30
+
31
+ def on_notification(&block)
32
+ @notification_callback = block
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'json'
5
+
6
+ module Manceps
7
+ module Transport
8
+ # Stdio transport: communicates with a local subprocess via stdin/stdout.
9
+ class Stdio < Base
10
+ def initialize(command, args: [], env: {})
11
+ super()
12
+ @command = command
13
+ @args = args
14
+ @env = env
15
+ @stdin = nil
16
+ @stdout = nil
17
+ @stderr = nil
18
+ @wait_thread = nil
19
+ @mutex = Mutex.new
20
+ @notification_callback = nil
21
+ end
22
+
23
+ def open
24
+ close if @wait_thread # Clean up any existing process
25
+
26
+ @stdin, @stdout, @stderr, @wait_thread = Open3.popen3(@env, @command, *@args)
27
+
28
+ at_exit { close }
29
+
30
+ self
31
+ end
32
+
33
+ def request(body)
34
+ @mutex.synchronize do
35
+ write_message(body)
36
+ read_response
37
+ end
38
+ end
39
+
40
+ def notify(body)
41
+ @mutex.synchronize do
42
+ write_message(body)
43
+ end
44
+ end
45
+
46
+ def terminate_session(_session_id)
47
+ # No-op for stdio -- session ends when the process exits
48
+ end
49
+
50
+ def listen(&block)
51
+ raise ConnectionError, 'Stdio transport not open' unless @stdout
52
+
53
+ loop do
54
+ line = @stdout.gets
55
+ break if line.nil?
56
+
57
+ parsed = begin
58
+ JSON.parse(line)
59
+ rescue StandardError
60
+ next
61
+ end
62
+ block.call(parsed) if parsed['method']
63
+ end
64
+ end
65
+
66
+ def close
67
+ return unless @wait_thread
68
+
69
+ begin
70
+ @stdin&.close
71
+ rescue StandardError
72
+ nil
73
+ end
74
+
75
+ if @wait_thread.alive?
76
+ begin
77
+ Process.kill('TERM', @wait_thread.pid)
78
+ rescue StandardError
79
+ nil
80
+ end
81
+
82
+ unless @wait_thread.join(5)
83
+ begin
84
+ Process.kill('KILL', @wait_thread.pid)
85
+ rescue StandardError
86
+ nil
87
+ end
88
+ begin
89
+ @wait_thread.join(1)
90
+ rescue StandardError
91
+ nil
92
+ end
93
+ end
94
+ end
95
+
96
+ begin
97
+ @stdout&.close
98
+ rescue StandardError
99
+ nil
100
+ end
101
+ begin
102
+ @stderr&.close
103
+ rescue StandardError
104
+ nil
105
+ end
106
+ @stdin = nil
107
+ @stdout = nil
108
+ @stderr = nil
109
+ @wait_thread = nil
110
+ end
111
+
112
+ private
113
+
114
+ def write_message(body)
115
+ raise ConnectionError, 'Stdio transport not open' unless @stdin
116
+
117
+ json = JSON.generate(body)
118
+ @stdin.write("#{json}\n")
119
+ @stdin.flush
120
+ rescue Errno::EPIPE
121
+ raise ConnectionError, 'Process exited unexpectedly'
122
+ end
123
+
124
+ def read_response
125
+ raise ConnectionError, 'Stdio transport not open' unless @stdout
126
+
127
+ loop do
128
+ line = @stdout.gets
129
+ raise ConnectionError, 'Process exited unexpectedly' if line.nil?
130
+
131
+ parsed = JSON.parse(line)
132
+
133
+ return parsed unless parsed['method'] && !parsed.key?('id')
134
+
135
+ # This is a server-initiated notification; dispatch and keep reading
136
+ @notification_callback&.call(parsed)
137
+ end
138
+ rescue JSON::ParserError => e
139
+ raise ProtocolError, "Invalid JSON from process: #{e.message}"
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'httpx'
4
+ require 'json'
5
+
6
+ module Manceps
7
+ module Transport
8
+ # Streamable HTTP transport with persistent connections and SSE support.
9
+ class StreamableHTTP < Base
10
+ attr_reader :session_id
11
+ attr_writer :protocol_version
12
+
13
+ def initialize(url, auth:, timeout: nil)
14
+ super()
15
+ @url = url
16
+ @auth = auth
17
+ @session_id = nil
18
+ @last_event_id = nil
19
+ @protocol_version = nil
20
+
21
+ timeout_opts = timeout || {
22
+ connect_timeout: Manceps.configuration.connect_timeout,
23
+ request_timeout: Manceps.configuration.request_timeout
24
+ }
25
+
26
+ # httpx maintains persistent connections by default —
27
+ # critical because MCP servers bind Mcp-Session-Id to the TCP connection
28
+ @http = HTTPX.with(timeout: timeout_opts)
29
+ end
30
+
31
+ def request(body)
32
+ response = @http.post(@url, headers: base_headers, body: JSON.generate(body))
33
+ handle_connection_error(response)
34
+ handle_error_response(response)
35
+ capture_session_id(response)
36
+ result = parse_response(response)
37
+ track_event_ids_from_response(response)
38
+ result
39
+ rescue Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EPIPE, Errno::EHOSTUNREACH => e
40
+ raise ConnectionError, e.message
41
+ end
42
+
43
+ def request_streaming(body, &block)
44
+ response = @http.post(@url, headers: base_headers, body: JSON.generate(body))
45
+ handle_connection_error(response)
46
+ handle_error_response(response)
47
+ capture_session_id(response)
48
+
49
+ content_type = response.content_type&.mime_type.to_s
50
+
51
+ if content_type.include?('text/event-stream')
52
+ events = SSEParser.parse_events(response.body.to_s)
53
+ track_event_ids(events)
54
+ final_result = nil
55
+ events.each do |event|
56
+ parsed = begin
57
+ JSON.parse(event[:data])
58
+ rescue StandardError
59
+ next
60
+ end
61
+ if parsed['result'] || parsed['error']
62
+ final_result = parsed
63
+ elsif block
64
+ block.call(parsed)
65
+ end
66
+ end
67
+ final_result || parse_response(response)
68
+ else
69
+ parse_response(response)
70
+ end
71
+ end
72
+
73
+ def notify(body)
74
+ response = @http.post(@url, headers: base_headers, body: JSON.generate(body))
75
+ handle_connection_error(response)
76
+ handle_error_response(response) unless response.status == 202
77
+ rescue Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EPIPE, Errno::EHOSTUNREACH => e
78
+ raise ConnectionError, e.message
79
+ end
80
+
81
+ def terminate_session(session_id)
82
+ headers = {}
83
+ headers['mcp-session-id'] = session_id
84
+ @auth.apply(headers)
85
+ begin
86
+ @http.delete(@url, headers: headers)
87
+ rescue StandardError
88
+ nil
89
+ end
90
+ end
91
+
92
+ def listen(&block)
93
+ headers = base_headers.dup
94
+ headers.delete('content-type')
95
+ headers['accept'] = 'text/event-stream'
96
+
97
+ response = @http.get(@url, headers: headers)
98
+ handle_error_response(response)
99
+
100
+ content_type = response.content_type&.mime_type.to_s
101
+ return unless content_type.include?('text/event-stream')
102
+
103
+ events = SSEParser.parse_events(response.body.to_s)
104
+ events.each do |event|
105
+ parsed = begin
106
+ JSON.parse(event[:data])
107
+ rescue StandardError
108
+ next
109
+ end
110
+ block.call(parsed) if parsed['method']
111
+ end
112
+ end
113
+
114
+ def close
115
+ @http.close if @http.respond_to?(:close)
116
+ @session_id = nil
117
+ end
118
+
119
+ private
120
+
121
+ def base_headers
122
+ headers = {
123
+ 'content-type' => 'application/json',
124
+ 'accept' => 'application/json, text/event-stream'
125
+ }
126
+ headers['mcp-session-id'] = @session_id if @session_id
127
+ headers['mcp-protocol-version'] = @protocol_version if @protocol_version
128
+ headers['last-event-id'] = @last_event_id if @last_event_id
129
+ @auth.apply(headers)
130
+ headers
131
+ end
132
+
133
+ def parse_response(response)
134
+ body = response.body.to_s
135
+ content_type = response.content_type&.mime_type.to_s
136
+
137
+ if content_type.include?('text/event-stream')
138
+ SSEParser.extract_json(body)
139
+ else
140
+ JSON.parse(body)
141
+ end
142
+ rescue JSON::ParserError => e
143
+ raise ProtocolError, "Invalid JSON in response: #{e.message}"
144
+ end
145
+
146
+ def capture_session_id(response)
147
+ sid = response.headers['mcp-session-id']
148
+ @session_id = sid if sid
149
+ end
150
+
151
+ def track_event_ids(events)
152
+ last = events.select { |e| e[:id] }.last
153
+ @last_event_id = last[:id] if last
154
+ end
155
+
156
+ def track_event_ids_from_response(response)
157
+ content_type = response.content_type&.mime_type.to_s
158
+ return unless content_type.include?('text/event-stream')
159
+
160
+ events = SSEParser.parse_events(response.body.to_s)
161
+ track_event_ids(events)
162
+ end
163
+
164
+ def handle_connection_error(response)
165
+ return unless response.is_a?(HTTPX::ErrorResponse)
166
+
167
+ error = response.error
168
+ case error
169
+ when HTTPX::TimeoutError
170
+ raise TimeoutError, error.message
171
+ else
172
+ raise ConnectionError, error.message
173
+ end
174
+ end
175
+
176
+ def handle_error_response(response)
177
+ return if response.status < 400
178
+
179
+ case response.status
180
+ when 401
181
+ raise AuthenticationError, "Server returned 401: #{response.body}"
182
+ when 404
183
+ raise SessionExpiredError, 'Session expired (404)'
184
+ else
185
+ raise ConnectionError, "HTTP #{response.status}: #{response.body}"
186
+ end
187
+ end
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Manceps
4
+ VERSION = '1.0.0'
5
+ end
data/lib/manceps.rb ADDED
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'uri'
5
+ require 'securerandom'
6
+
7
+ require 'httpx'
8
+
9
+ require_relative 'manceps/version'
10
+ require_relative 'manceps/errors'
11
+ require_relative 'manceps/json_rpc'
12
+ require_relative 'manceps/sse_parser'
13
+ require_relative 'manceps/content'
14
+ require_relative 'manceps/tool'
15
+ require_relative 'manceps/tool_result'
16
+ require_relative 'manceps/prompt'
17
+ require_relative 'manceps/prompt_result'
18
+ require_relative 'manceps/resource'
19
+ require_relative 'manceps/resource_template'
20
+ require_relative 'manceps/resource_contents'
21
+ require_relative 'manceps/elicitation'
22
+ require_relative 'manceps/backoff'
23
+ require_relative 'manceps/session'
24
+ require_relative 'manceps/auth/none'
25
+ require_relative 'manceps/auth/bearer'
26
+ require_relative 'manceps/auth/api_key_header'
27
+ require_relative 'manceps/auth/oauth'
28
+ require_relative 'manceps/transport/base'
29
+ require_relative 'manceps/transport/streamable_http'
30
+ require_relative 'manceps/transport/stdio'
31
+ require_relative 'manceps/task'
32
+ require_relative 'manceps/client'
33
+
34
+ # Ruby client for the Model Context Protocol (MCP).
35
+ module Manceps
36
+ Configuration = Struct.new(
37
+ :client_name,
38
+ :client_version,
39
+ :client_description,
40
+ :protocol_version,
41
+ :supported_versions,
42
+ :request_timeout,
43
+ :connect_timeout,
44
+ keyword_init: true
45
+ ) do
46
+ def initialize(**)
47
+ super
48
+ self.client_name ||= 'Manceps'
49
+ self.client_version ||= Manceps::VERSION
50
+ self.protocol_version ||= '2025-11-25'
51
+ self.supported_versions ||= %w[2025-11-25 2025-06-18 2025-03-26]
52
+ self.request_timeout ||= 30
53
+ self.connect_timeout ||= 10
54
+ end
55
+ end
56
+
57
+ class << self
58
+ def configuration
59
+ @configuration ||= Configuration.new
60
+ end
61
+
62
+ def configure
63
+ yield(configuration)
64
+ end
65
+ end
66
+ end
metadata ADDED
@@ -0,0 +1,102 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: manceps
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Obie Fernandez
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: base64
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.2'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.2'
26
+ - !ruby/object:Gem::Dependency
27
+ name: httpx
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '1.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1.0'
40
+ description: A production-grade MCP client with first-class auth support. Connect
41
+ to MCP servers over Streamable HTTP or stdio, discover and invoke tools, read resources,
42
+ and get prompts.
43
+ email:
44
+ - obie@fernandez.net
45
+ executables: []
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - CHANGELOG.md
50
+ - LICENSE
51
+ - README.md
52
+ - lib/manceps.rb
53
+ - lib/manceps/auth/api_key_header.rb
54
+ - lib/manceps/auth/bearer.rb
55
+ - lib/manceps/auth/none.rb
56
+ - lib/manceps/auth/oauth.rb
57
+ - lib/manceps/backoff.rb
58
+ - lib/manceps/client.rb
59
+ - lib/manceps/content.rb
60
+ - lib/manceps/elicitation.rb
61
+ - lib/manceps/errors.rb
62
+ - lib/manceps/json_rpc.rb
63
+ - lib/manceps/prompt.rb
64
+ - lib/manceps/prompt_result.rb
65
+ - lib/manceps/resource.rb
66
+ - lib/manceps/resource_contents.rb
67
+ - lib/manceps/resource_template.rb
68
+ - lib/manceps/session.rb
69
+ - lib/manceps/sse_parser.rb
70
+ - lib/manceps/task.rb
71
+ - lib/manceps/tool.rb
72
+ - lib/manceps/tool_result.rb
73
+ - lib/manceps/transport/base.rb
74
+ - lib/manceps/transport/stdio.rb
75
+ - lib/manceps/transport/streamable_http.rb
76
+ - lib/manceps/version.rb
77
+ homepage: https://github.com/zarpay/manceps
78
+ licenses:
79
+ - MIT
80
+ metadata:
81
+ homepage_uri: https://github.com/zarpay/manceps
82
+ source_code_uri: https://github.com/zarpay/manceps
83
+ changelog_uri: https://github.com/zarpay/manceps/blob/main/CHANGELOG.md
84
+ rubygems_mfa_required: 'true'
85
+ rdoc_options: []
86
+ require_paths:
87
+ - lib
88
+ required_ruby_version: !ruby/object:Gem::Requirement
89
+ requirements:
90
+ - - ">="
91
+ - !ruby/object:Gem::Version
92
+ version: 3.4.0
93
+ required_rubygems_version: !ruby/object:Gem::Requirement
94
+ requirements:
95
+ - - ">="
96
+ - !ruby/object:Gem::Version
97
+ version: '0'
98
+ requirements: []
99
+ rubygems_version: 3.6.9
100
+ specification_version: 4
101
+ summary: Ruby client for the Model Context Protocol (MCP)
102
+ test_files: []