terret-mcp 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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c688ebfcf594669b85992b1b0c6a293c28c74141410866539a03367292be1cb8
4
+ data.tar.gz: 389073f6762a2d3558e1e8717c7292fdd22bbd73ff5c95fd3c1e3f2205645041
5
+ SHA512:
6
+ metadata.gz: eeba3604c890223502eda72ed686ce1bde508f0117bcc582b3baebe868ab0d46a284b88b30adc5c0a1d23459d6fb573aafd497d91e54fa61fd33ca54e44f3f80
7
+ data.tar.gz: 8b84eff300b7dca33c7771c43e737a7018d0c04d8b3a99ece0f246268f85043f281b7b4bc56ca87461b44df8243996f51f8a90ec2c281981f2c12af28953288b
@@ -0,0 +1,231 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "translate"
4
+
5
+ module Terret
6
+ module MCP
7
+ # ctx[:mcp] — mounts MCP servers as namespaced tool sources (docs/mcp.md).
8
+ # The client is injectable (client_factory config) so tests run on fakes;
9
+ # the default factory builds a manceps client, lazily required so nothing
10
+ # here needs manceps until something actually connects.
11
+ class Service < Hames::Service
12
+ service_key :mcp
13
+ inject :tools, :prompt
14
+ # client_factory: is an injectable seam (tests pass a factory), not YAML
15
+ # config, so it is absent from the schema. servers: is an open map of
16
+ # name => { url|command, args, env, bearer, approval, timeout }.
17
+ config_schema strict: { type: [TrueClass, FalseClass], default: false,
18
+ doc: "when true, a server that fails to mount fails the boot" },
19
+ servers: { type: Hash, default: {},
20
+ doc: "name => server config (url or command, args, env, bearer, " \
21
+ "approval, timeout)" }
22
+
23
+ DEFAULT_TIMEOUT = 30
24
+
25
+ def start(ctx)
26
+ @ctx = ctx
27
+ @strict = !!config[:strict]
28
+ @factory = config[:client_factory] || method(:default_client)
29
+ @servers = {}
30
+ @mounted = {} # name => { client:, disposers:, tool_names: }
31
+ (config[:servers] || {}).each do |name, cfg|
32
+ name = Translate.assert_server_name!(name)
33
+ unless cfg[:url].nil? ^ cfg[:command].nil?
34
+ raise ArgumentError, "server #{name}: exactly one of url:/command: required"
35
+ end
36
+
37
+ @servers[name] = cfg
38
+ end
39
+ end
40
+
41
+ def stop(_ctx) = @mounted.keys.each { |n| unmount!(n) }
42
+
43
+ def mounted = @mounted.keys
44
+
45
+ def mount!(*names)
46
+ names = @servers.keys if names.empty?
47
+ names.each { |n| mount_one(n.to_s) }
48
+ end
49
+
50
+ # Reverses every registration the server contributed and disconnects.
51
+ def unmount!(name)
52
+ entry = @mounted.delete(name.to_s) or return
53
+ entry[:listener]&.stop
54
+ entry[:resource_disposers].reverse_each(&:call)
55
+ entry[:disposers].reverse_each(&:call)
56
+ begin
57
+ entry[:client].disconnect
58
+ rescue StandardError => e
59
+ warn "terret-mcp: #{name}: disconnect failed: #{e.class}: #{e.message}"
60
+ end
61
+ end
62
+
63
+ # Reads the resource once and registers its text as a prompt section
64
+ # (docs/mcp.md); live refresh on resources/updated is deferred until a
65
+ # consumer needs it. Returns the section's disposer.
66
+ def register_resource_section(server, uri, name:, priority: 100)
67
+ entry = @mounted.fetch(server.to_s) { raise ArgumentError, "server #{server.inspect} is not mounted" }
68
+ body = entry[:client].read_resource(uri).text.to_s
69
+ disposer = @ctx.with_owner("mcp:#{server}") do
70
+ @ctx[:prompt].register_section(name, priority: priority) { body }
71
+ end
72
+ entry[:resource_disposers] << disposer
73
+ disposer
74
+ end
75
+
76
+ private
77
+
78
+ def mount_one(name)
79
+ cfg = @servers[name] or
80
+ raise ArgumentError, @strict ? "strict mode: server #{name.inspect} is not in this row's config" :
81
+ "unknown server #{name.inspect}"
82
+ return if @mounted.key?(name)
83
+
84
+ client = @factory.call(name, cfg)
85
+ client.connect
86
+ entry = { client: client, disposers: [], tool_names: [],
87
+ lock: (cfg[:command] ? Mutex.new : nil), resource_disposers: [] }
88
+ begin
89
+ sync_tools(name, entry, cfg)
90
+ rescue StandardError
91
+ entry[:disposers].reverse_each(&:call)
92
+ begin
93
+ client.disconnect
94
+ rescue StandardError => e
95
+ warn "terret-mcp: #{name}: disconnect after failed mount: #{e.class}: #{e.message}"
96
+ end
97
+ raise
98
+ end
99
+ @mounted[name] = entry
100
+ entry[:client].on("notifications/tools/list_changed") do |_params|
101
+ # a straggler notification after unmount (or from a superseded
102
+ # mount) must not resurrect tools from a dead entry
103
+ next unless @mounted[name].equal?(entry)
104
+
105
+ begin
106
+ sync_tools(name, entry, cfg)
107
+ rescue StandardError => e
108
+ # a transient relist failure must not kill the listener or take
109
+ # down the roster; sync_tools already left it in place (fetch
110
+ # happens before disposal) — just retry on the next notification
111
+ warn "terret-mcp: #{name}: reconcile failed: #{e.class}: #{e.message}"
112
+ end
113
+ end
114
+ start_listener(entry)
115
+ entry
116
+ end
117
+
118
+ # manceps' listen is a blocking dispatch loop; give it its own task
119
+ # when a reactor exists. Without one there is nothing to run it on —
120
+ # notifications are skipped (docs/mcp.md documents this).
121
+ def start_listener(entry)
122
+ task = defined?(Async) ? Async::Task.current? : nil
123
+ return unless task
124
+
125
+ entry[:listener] = task.async do
126
+ entry[:client].listen
127
+ rescue StandardError => e
128
+ warn "terret-mcp: listener died: #{e.class}: #{e.message}"
129
+ end
130
+ end
131
+
132
+ def sync_tools(name, entry, cfg)
133
+ approval = cfg[:approval] || :policy
134
+ timeout = cfg[:timeout] || DEFAULT_TIMEOUT
135
+ # fetch before disposing anything: a relist failure (network blip,
136
+ # server hiccup) must leave the current roster registered, not tear
137
+ # it down and then have nothing to put back.
138
+ tools = entry[:client].tools
139
+
140
+ entry[:disposers].reverse_each(&:call)
141
+ entry[:disposers].clear
142
+ entry[:tool_names].clear
143
+
144
+ @ctx.with_owner("mcp:#{name}") do
145
+ tools.each do |tool|
146
+ # The name is remote input interpolated into the tool id and the log;
147
+ # one entry with an unsafe name is skipped rather than mounted, and
148
+ # the rest of the roster still comes up (Translate.valid_tool_name?).
149
+ unless Translate.valid_tool_name?(tool.name)
150
+ warn "terret-mcp: #{name}: skipping tool with an unsafe name #{tool.name.inspect}"
151
+ next
152
+ end
153
+
154
+ args = Translate.definition_args(server: name, tool: tool, approval: approval)
155
+ remote = tool.name
156
+ entry[:disposers] << @ctx[:tools].register(**args) do |**call_args|
157
+ call_remote(name, entry, remote, call_args, timeout)
158
+ end
159
+ entry[:tool_names] << args[:name]
160
+ end
161
+ end
162
+ end
163
+
164
+ def call_remote(name, entry, remote, call_args, timeout)
165
+ if (lock = entry[:lock])
166
+ # stdio replies correlate by order, not id: the whole
167
+ # heal+call sequence must serialize per server, so a waiter
168
+ # re-checks the poison flag after the loser's timeout lands.
169
+ lock.synchronize { locked_call(name, entry, remote, call_args, timeout) }
170
+ else
171
+ locked_call(name, entry, remote, call_args, timeout)
172
+ end
173
+ end
174
+
175
+ def locked_call(name, entry, remote, call_args, timeout)
176
+ if entry[:poisoned]
177
+ # claim the heal before reconnecting (reconnect! yields): a second
178
+ # fiber arriving mid-heal proceeds against the reconnecting client
179
+ # and at worst gets a transport error that re-poisons. A waiting
180
+ # latch would be race-free; that arrives with the M6 lifecycle work.
181
+ entry[:poisoned] = false
182
+ begin
183
+ entry[:client].reconnect!
184
+ rescue StandardError
185
+ entry[:poisoned] = true
186
+ raise Terret::Tools::Failure, "mcp #{name}: reconnect failed"
187
+ end
188
+ end
189
+ result = with_timeout(timeout) { entry[:client].call_tool(remote, **call_args) }
190
+ error = Translate.result_error(result)
191
+ raise Terret::Tools::Failure, error if error
192
+
193
+ Translate.result_content(result)
194
+ rescue *timeout_errors
195
+ entry[:poisoned] = true
196
+ raise Terret::Tools::Failure, "mcp timeout after #{timeout}s"
197
+ rescue *transport_errors => e
198
+ entry[:poisoned] = true
199
+ raise Terret::Tools::Failure, "mcp #{name}: #{e.class}: #{e.message}"
200
+ end
201
+
202
+ def with_timeout(seconds, &block)
203
+ task = defined?(Async) ? Async::Task.current? : nil
204
+ return yield unless task
205
+
206
+ task.with_timeout(seconds) { block.call }
207
+ end
208
+
209
+ # Async is optional for terret-mcp; a rescue clause must not name a
210
+ # constant the host may never load. Evaluated at exception time, so a
211
+ # late `require "async"` still matches.
212
+ def timeout_errors
213
+ defined?(Async::TimeoutError) ? [Async::TimeoutError] : []
214
+ end
215
+
216
+ def transport_errors
217
+ defined?(Manceps::Error) ? [Manceps::Error] : [IOError]
218
+ end
219
+
220
+ def default_client(name, cfg)
221
+ require "manceps"
222
+ if cfg[:url]
223
+ auth = cfg[:bearer] ? Manceps::Auth::Bearer.new(cfg[:bearer]) : Manceps::Auth::None.new
224
+ Manceps::Client.new(cfg[:url], auth: auth)
225
+ else
226
+ Manceps::Client.new(cfg[:command], args: cfg[:args] || [], env: cfg[:env])
227
+ end
228
+ end
229
+ end
230
+ end
231
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Terret
4
+ module MCP
5
+ # Pure translation between MCP shapes and terret shapes (docs/mcp.md).
6
+ # Duck-typed against manceps' value objects so it needs no manceps at
7
+ # test time and no network ever.
8
+ module Translate
9
+ NAME_RE = /\A[a-z0-9_-]+\z/
10
+
11
+ # A tool name comes from the server's tools/list — remote input, not the
12
+ # operator's config — and is interpolated into the `mcp__server__name` tool
13
+ # id and into every log line that names the call. A control-char or
14
+ # whitespace name would poison both, so a tool is held to a safe identifier
15
+ # charset before it is mounted (Service#sync_tools skips one that fails).
16
+ # It is broader than the operator-chosen server NAME_RE on purpose — real
17
+ # MCP tools are named in mixed case and sometimes carry dots — but it still
18
+ # admits nothing that is not a plain, log-safe identifier character.
19
+ TOOL_NAME_RE = /\A[A-Za-z0-9_.-]+\z/
20
+
21
+ module_function
22
+
23
+ def assert_server_name!(name)
24
+ name = name.to_s
25
+ raise ArgumentError, "server name must match #{NAME_RE.inspect}, got #{name.inspect}" unless name.match?(NAME_RE)
26
+
27
+ name
28
+ end
29
+
30
+ def valid_tool_name?(name) = name.is_a?(String) && name.match?(TOOL_NAME_RE)
31
+
32
+ def tool_name(server, tool) = "mcp__#{server}__#{tool}"
33
+
34
+ # Keyword args for Registry#register. Remote tools default to
35
+ # mutating: we cannot see their effects, so policy assumes the worst.
36
+ def definition_args(server:, tool:, approval:)
37
+ { name: tool_name(server, tool.name), description: tool.description.to_s,
38
+ params: tool.input_schema || {}, mutating: true, approval: approval }
39
+ end
40
+
41
+ # structured_content wins (already primitives); else text items join,
42
+ # binary/resource items degrade to a typed placeholder. nil for errors.
43
+ def result_content(result)
44
+ return nil if result.error?
45
+ return result.structured_content if result.structured?
46
+
47
+ result.content.map do |item|
48
+ item.type == "text" ? item.text : "[#{item.type} #{item.uri || item.mime_type || item.type}]"
49
+ end.join("\n")
50
+ end
51
+
52
+ def result_error(result)
53
+ return nil unless result.error?
54
+
55
+ text = result.text.to_s
56
+ text.empty? ? "tool failed with no message" : text
57
+ end
58
+ end
59
+ end
60
+ end
data/lib/terret/mcp.rb ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "terret"
5
+ rescue LoadError
6
+ require_relative "../../../terret-core/lib/terret" # monorepo path source
7
+ end
8
+
9
+ require_relative "mcp/translate"
10
+ require_relative "mcp/service"
metadata ADDED
@@ -0,0 +1,77 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: terret-mcp
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.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: terret-core
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: manceps
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: Mounts Model Context Protocol servers (stdio and streamable HTTP, via
41
+ the manceps client) as namespaced tool sources behind ctx.tools, with per-server
42
+ approval policy, per-call timeouts, and live tool-list reconciliation.
43
+ email:
44
+ - obiefernandez@gmail.com
45
+ executables: []
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - lib/terret/mcp.rb
50
+ - lib/terret/mcp/service.rb
51
+ - lib/terret/mcp/translate.rb
52
+ homepage: https://terret.org
53
+ licenses:
54
+ - MIT
55
+ metadata:
56
+ homepage_uri: https://terret.org
57
+ source_code_uri: https://github.com/terret-org/terret
58
+ bug_tracker_uri: https://github.com/terret-org/terret/issues
59
+ rubygems_mfa_required: 'true'
60
+ rdoc_options: []
61
+ require_paths:
62
+ - lib
63
+ required_ruby_version: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '4.0'
68
+ required_rubygems_version: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - ">="
71
+ - !ruby/object:Gem::Version
72
+ version: '0'
73
+ requirements: []
74
+ rubygems_version: 4.0.16
75
+ specification_version: 4
76
+ summary: MCP client plugin for the Terret agent harness
77
+ test_files: []