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 +7 -0
- data/lib/terret/mcp/service.rb +231 -0
- data/lib/terret/mcp/translate.rb +60 -0
- data/lib/terret/mcp.rb +10 -0
- metadata +77 -0
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
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: []
|