asterism 0.7.0 → 0.8.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,272 @@
1
+ # `asterism mcp`: Asterism as tools of an MCP client (an AI assistant),
2
+ # over stdio, with the mcp gem (an optional dependency: `gem install mcp`;
3
+ # the rest of the command line does not need it).
4
+ #
5
+ # asterism mcp --router tcp/192.0.2.2:7447 --allow 'games/othello1.*'
6
+ #
7
+ # Tools:
8
+ # asterism_nodes the nodes, their applications (with their statuses)
9
+ # and objects (always; reads)
10
+ # asterism_objects the objects and their methods (meta) (always; reads)
11
+ # asterism_status the applications' statuses (always; reads)
12
+ # <node>.<app>.<object>.<method>
13
+ # one per method that an --allow pattern names, made
14
+ # from the object's meta: the arguments' names the
15
+ # meta tells (docs/protocol.md 5) are the inputs and
16
+ # its doc the description; without names, arity n
17
+ # gives arg1 .. argN (any JSON value), arity -1 an
18
+ # array "args"; "kwargs" (an object) is the keyword
19
+ # arguments. Nothing is callable without --allow.
20
+ #
21
+ # The call tools follow the network: when objects appear or go, the list is
22
+ # made again and the client is told (notifications/tools/list_changed).
23
+ require "mcp"
24
+
25
+ module Asterism
26
+ class CLI
27
+ class MCPBridge
28
+ REFRESH_S = 2.0
29
+ INSTRUCTIONS = <<~TEXT.freeze
30
+ These tools look at an Asterism network: Ruby programs and boards that expose objects to each other over
31
+ Zenoh. Paths are <node>/<app>/<object>. asterism_nodes lists the nodes and their applications (with the
32
+ status each application tells about itself, a JSON object of its own), asterism_objects the objects and
33
+ their methods, asterism_status the statuses. The other tools call one method each, on the object their
34
+ name says (<node>.<app>.<object>.<method>); only the methods this server was started to allow are there.
35
+ A call's answer is {"value": ...}; an error says its kind (raised, not_exposed, arguments, ...).
36
+ TEXT
37
+
38
+ def initialize(net, survey, allow:, log: $stderr)
39
+ @net = net
40
+ @survey = survey
41
+ @allow = Allow.new(allow)
42
+ @log = log
43
+ @call_tools = {} # tool name => [path, method, arity, meta entry]
44
+ @signature = nil
45
+ @lock = Mutex.new
46
+ end
47
+
48
+ def run
49
+ @server = ::MCP::Server.new(name: "asterism", version: ::Asterism::VERSION, instructions: INSTRUCTIONS,
50
+ tools: read_tools)
51
+ @transport = ::MCP::Server::Transports::StdioTransport.new(@server)
52
+ @server.transport = @transport
53
+ refresh
54
+ @log.puts "asterism mcp: #{@net.node_id}/#{@net.app}; #{@call_tools.size} call tools" \
55
+ "#{@allow.empty? ? ' (no --allow: read-only)' : ''}"
56
+ @refresher = Thread.new { refresh_loop }
57
+ @transport.open
58
+ ensure
59
+ @refresher&.kill
60
+ end
61
+
62
+ # ------------------------------------------------------------ the list
63
+
64
+ def refresh_loop
65
+ loop do
66
+ sleep REFRESH_S
67
+ refresh
68
+ rescue StandardError => e
69
+ @log.puts "asterism mcp: refresh: #{e.class}: #{e.message}"
70
+ end
71
+ end
72
+
73
+ # Makes the call tools again when the objects changed; tells the
74
+ # client when the list did.
75
+ def refresh
76
+ return if @allow.empty?
77
+ paths = @survey.objects.select { @allow.object?(_1) }
78
+ sig = paths.sort
79
+ return if sig == @signature
80
+ metas = @survey.metas(paths)
81
+ tools = {}
82
+ metas.sort.each do |path, methods|
83
+ methods.each do |m|
84
+ next unless @allow.method?(path, m["name"])
85
+ name = unique_name(tool_name(path, m["name"]), tools)
86
+ tools[name] = [path, m["name"], m["arity"], m]
87
+ end
88
+ end
89
+ # Keep trying the objects whose meta did not come.
90
+ @signature = (paths - metas.keys).empty? ? sig : nil
91
+ changed = @lock.synchronize do
92
+ next false if tools == @call_tools
93
+ @call_tools = tools
94
+ list = read_tools + tools.map { |name, (path, _method, _arity, m)| call_tool(name, path, m) }
95
+ @server.tools = list.to_h { [_1.name_value, _1] }
96
+ @server.instance_variable_set(:@tool_names, @server.tools.keys)
97
+ true
98
+ end
99
+ return unless changed
100
+ @log.puts "asterism mcp: call tools: #{tools.keys.join(', ')}"
101
+ @server.notify_tools_list_changed
102
+ end
103
+
104
+ def tool_name(path, method)
105
+ "#{path.tr('/', '.')}.#{method}".gsub(/[^A-Za-z0-9_.-]/, "_")[0, 120]
106
+ end
107
+
108
+ def unique_name(name, taken)
109
+ return name unless taken.key?(name)
110
+ n = 2
111
+ n += 1 while taken.key?("#{name}_#{n}")
112
+ "#{name}_#{n}"
113
+ end
114
+
115
+ # ------------------------------------------------------------ tools
116
+
117
+ def read_tools
118
+ sv = @survey
119
+ bridge = self
120
+ [
121
+ ::MCP::Tool.define(
122
+ name: "asterism_nodes",
123
+ description: "The nodes of the Asterism network, each with its profile (what runs it, a display name), " \
124
+ "its applications with their statuses, and the names of their objects.",
125
+ input_schema: { properties: {} },
126
+ annotations: { read_only_hint: true }
127
+ ) do |server_context:|
128
+ bridge.answer { sv.tree(meta: false) }
129
+ end,
130
+ ::MCP::Tool.define(
131
+ name: "asterism_objects",
132
+ description: "The exposed objects (<node>/<app>/<object>) and their methods with the number of " \
133
+ "arguments (-1: any), and the arguments' names and what each method does when the object " \
134
+ "tells them (args, doc). callable_as names the tool that calls a method, when this server " \
135
+ "allows it.",
136
+ input_schema: { properties: { pattern: { type: "string",
137
+ description: "node/app/object with * (one part) and ** " \
138
+ "(any parts); default **" } } },
139
+ annotations: { read_only_hint: true }
140
+ ) do |server_context:, pattern: "**"|
141
+ bridge.answer { bridge.objects(pattern) }
142
+ end,
143
+ ::MCP::Tool.define(
144
+ name: "asterism_status",
145
+ description: "The statuses the applications tell about themselves (asterism/<node>/<app>/@status): " \
146
+ "{\"<node>/<app>\": {...}}. An application without one is not listed.",
147
+ input_schema: { properties: { node: { type: "string" }, app: { type: "string" } } },
148
+ annotations: { read_only_hint: true }
149
+ ) do |server_context:, node: nil, app: nil|
150
+ bridge.answer do
151
+ sv.statuses.select do |k, _|
152
+ n, a = k.split("/")
153
+ (node.nil? || node == n) && (app.nil? || app == a)
154
+ end
155
+ end
156
+ end
157
+ ]
158
+ end
159
+
160
+ def objects(pattern)
161
+ paths = @survey.objects(pattern)
162
+ metas = @survey.metas(paths)
163
+ by = @lock.synchronize { @call_tools.to_h { |name, (path, m, _)| [[path, m], name] } }
164
+ paths.map do |p|
165
+ methods = metas[p]&.map do |m|
166
+ name = by[[p, m["name"]]]
167
+ name ? m.merge("callable_as" => name) : m
168
+ end
169
+ h = { "path" => p, "methods" => methods }
170
+ h["doc"] = @survey.object_doc(p) if @survey.object_doc(p)
171
+ h
172
+ end
173
+ end
174
+
175
+ def call_tool(name, path, meta)
176
+ method = meta["name"]
177
+ arity = meta["arity"]
178
+ names = input_names(meta)
179
+ props = {}
180
+ required = []
181
+ if names
182
+ names.each do |n|
183
+ props[n.to_sym] = { description: "the argument #{n}" }
184
+ required << n if arity >= 0
185
+ end
186
+ props[:args] = { type: "array", description: "more positional arguments, if any" } if arity.negative?
187
+ elsif arity.negative?
188
+ props[:args] = { type: "array", description: "the positional arguments (any number)" }
189
+ else
190
+ arity.times do |i|
191
+ props[:"arg#{i + 1}"] = { description: "argument #{i + 1} (any JSON value)" }
192
+ required << "arg#{i + 1}"
193
+ end
194
+ end
195
+ props[:kwargs] = { type: "object", description: "keyword arguments, if the method takes any" }
196
+ schema = { properties: props }
197
+ schema[:required] = required unless required.empty?
198
+ about = [meta["doc"], (d = @survey.object_doc(path)) && "(#{path}: #{d})"].compact
199
+ count = names ? "" : " (#{arity.negative? ? 'any number of' : arity} arguments)"
200
+ bridge = self
201
+ ::MCP::Tool.define(
202
+ name: name,
203
+ description: "#{about.empty? ? '' : "#{about.join(' ')}\n"}Calls #{method} on the Asterism object " \
204
+ "#{path}#{count} and returns {\"value\": ...}.",
205
+ input_schema: schema
206
+ ) do |server_context:, **args|
207
+ bridge.invoke(path, method, arity, args, names)
208
+ end
209
+ end
210
+
211
+ # The meta's argument names as inputs, or nil (none, or a name the
212
+ # inputs cannot take: "args", "kwargs", a repeated one).
213
+ def input_names(meta)
214
+ names = meta["args"]
215
+ return nil unless names.is_a?(Array) && !names.empty?
216
+ return nil if names.uniq.size != names.size || names.any? { %w[args kwargs].include?(_1) }
217
+ names
218
+ end
219
+
220
+ # A call tool's call.
221
+ def invoke(path, method, arity, args, names = nil)
222
+ pos = if names
223
+ names.map { args[_1.to_sym] } + (arity.negative? ? Array(args[:args]) : [])
224
+ elsif arity.negative?
225
+ Array(args[:args])
226
+ else
227
+ (1..arity).map { args[:"arg#{_1}"] }
228
+ end
229
+ if names && arity.negative?
230
+ # Optional named arguments: the trailing ones not given are left out.
231
+ pos.pop while !pos.empty? && pos.last.nil? && args[:args].nil?
232
+ end
233
+ kw = (args[:kwargs] || {}).to_h.transform_keys(&:to_sym)
234
+ unless @lock.synchronize { @call_tools.any? { |_, t| t[0] == path && t[1] == method } }
235
+ return error("#{path}.#{method} is not callable here (not allowed, or gone)")
236
+ end
237
+ v = ::Asterism[path, timeout: @survey.timeout].asterism_call(method, *pos, **kw)
238
+ text({ "value" => v })
239
+ rescue ::Asterism::RemoteError => e
240
+ error({ "error" => { "kind" => e.remote_kind, "class" => e.remote_class, "message" => e.remote_message } })
241
+ rescue ::Asterism::Error => e
242
+ error({ "error" => { "kind" => "asterism", "class" => e.class.name, "message" => e.message } })
243
+ end
244
+
245
+ # The value of the block as a JSON text; an Asterism error as an
246
+ # error result.
247
+ def answer
248
+ text(yield)
249
+ rescue ::Asterism::Error => e
250
+ error("#{e.class}: #{e.message}")
251
+ end
252
+
253
+ def text(v)
254
+ ::MCP::Tool::Response.new([{ type: "text", text: JSON.generate(json_safe(v)) }])
255
+ end
256
+
257
+ def error(v)
258
+ ::MCP::Tool::Response.new([{ type: "text", text: v.is_a?(String) ? v : JSON.generate(json_safe(v)) }],
259
+ error: true)
260
+ end
261
+
262
+ def json_safe(v)
263
+ case v
264
+ when Hash then v.to_h { |k, x| [k.to_s, json_safe(x)] }
265
+ when Array then v.map { json_safe(_1) }
266
+ when String then v.encoding == Encoding::BINARY && !v.ascii_only? ? "(#{v.bytesize} bytes)" : v
267
+ else v
268
+ end
269
+ end
270
+ end
271
+ end
272
+ end
@@ -0,0 +1,204 @@
1
+ # What `asterism nodes / objects / tree` and the MCP bridge read from the
2
+ # network, on a connection that is already open (Asterism.connect): the
3
+ # applications and objects from the liveliness tokens, then the meta of
4
+ # the objects, the nodes' profiles and the applications' statuses
5
+ # (docs/protocol.md 5, 7, 8) with one get for each node or application.
6
+ #
7
+ # Not one wildcard get for all (asterism/*/*/@status): behind a router's
8
+ # access control a query must fit the rules of every node it would reach
9
+ # (a game host may answer asterism/games/*/@status, not asterism/*/*/@status),
10
+ # so a wildcard over the nodes is refused where the plain keys pass. The
11
+ # gets go out at once and are read as their replies come (zenoh-c fills
12
+ # them on its own threads), so a listing costs one time limit at most,
13
+ # whatever the number of nodes. What a router refuses is simply not there,
14
+ # and the listing says so instead of calling a node gone.
15
+ module Asterism
16
+ class CLI
17
+ class Survey
18
+ # What watch subscribes to (puts are routed per subscriber, so the
19
+ # wildcards pass where the rules let them through).
20
+ PROFILES_ALL = "asterism/*/@profile"
21
+ STATUS_ALL = "asterism/*/*/@status"
22
+ STEP = 0.005
23
+
24
+ attr_reader :timeout
25
+
26
+ # own: this connection's "<node>/<app>", left out of the listings.
27
+ def initialize(timeout: 2.0, own: nil)
28
+ @timeout = timeout
29
+ @own = own || "#{::Asterism.node_id}/#{::Asterism.app}"
30
+ @object_docs = {}
31
+ end
32
+
33
+ # Waits for the tokens alive at connect to come in (they come in one
34
+ # burst from the router): until the applications stop changing for
35
+ # quiet seconds, at most max seconds.
36
+ def settle(max = 1.5, quiet: 0.3)
37
+ t0 = now
38
+ last = nil
39
+ since = now
40
+ loop do
41
+ cur = [::Asterism.apps, ::Asterism.each("**").size]
42
+ if cur != last
43
+ last = cur
44
+ since = now
45
+ end
46
+ break if now - since >= quiet || now - t0 >= max
47
+ sleep 0.02
48
+ end
49
+ end
50
+
51
+ # The applications alive, "<node>/<app>", sorted (this one left out).
52
+ def apps(pattern = "*/*")
53
+ (::Asterism.apps(pattern) - [@own]).sort
54
+ end
55
+
56
+ # The nodes that have an application or an object alive (sorted).
57
+ def nodes
58
+ (apps.map { _1.split("/")[0] } + objects.map { _1.split("/")[0] }).uniq.sort
59
+ end
60
+
61
+ # The objects alive, "<node>/<app>/<object>", sorted (this
62
+ # application's own left out).
63
+ def objects(pattern = "**")
64
+ ::Asterism.each(pattern).map(&:asterism_path).reject { _1.start_with?("#{@own}/") }.sort
65
+ end
66
+
67
+ # {path => [{"name", "arity"}, ...]} for the paths given (an object
68
+ # that does not answer its meta in time is left out). One get per
69
+ # application (asterism/<node>/<app>/*/meta), then one for each object
70
+ # it did not bring.
71
+ def metas(paths)
72
+ return {} if paths.empty?
73
+ out = {}
74
+ apps = paths.map { _1.split("/")[0, 2].join("/") }.uniq
75
+ gets(apps.map { "asterism/#{_1}/*/meta" }).each_value do |replies|
76
+ replies.each do |key, payload|
77
+ path = key.delete_prefix("asterism/").delete_suffix("/meta")
78
+ m = meta_list(payload, path)
79
+ out[path] = m if m && paths.include?(path)
80
+ end
81
+ end
82
+ missing = paths - out.keys
83
+ unless missing.empty?
84
+ gets(missing.map { "asterism/#{_1}/meta" }).each do |key, replies|
85
+ path = key.delete_prefix("asterism/").delete_suffix("/meta")
86
+ m = replies.first && meta_list(replies.first[1], path)
87
+ out[path] = m if m
88
+ end
89
+ end
90
+ out
91
+ end
92
+
93
+ # {node => profile without the icon} of the nodes that answered (one
94
+ # get per node, all at once).
95
+ def profiles(list = nodes)
96
+ out = {}
97
+ gets(list.map { "asterism/#{_1}/@profile" }, params: "icon=0").each do |key, replies|
98
+ pr = replies.first && ::Asterism::Profile.check(::Asterism::Codec.unpack(replies.first[1]))
99
+ out[key.split("/")[1]] = pr if pr
100
+ end
101
+ out
102
+ end
103
+
104
+ # {"<node>/<app>" => status} of the applications that answered (one
105
+ # get per application, all at once).
106
+ def statuses(list = apps)
107
+ out = {}
108
+ gets(list.map { "asterism/#{_1}/@status" }).each do |key, replies|
109
+ st = replies.first && ::Asterism::Status.check(::Asterism::Codec.unpack(replies.first[1]))
110
+ parts = key.split("/")
111
+ out["#{parts[1]}/#{parts[2]}"] = st if st
112
+ end
113
+ out
114
+ end
115
+
116
+ # The tree: [{"node", "profile", "apps" => [{"app", "status",
117
+ # "objects" => [{"name", "methods"}]}]}]. meta: false leaves the
118
+ # methods out (no meta gets).
119
+ def tree(meta: true)
120
+ objs = objects
121
+ app_list = apps
122
+ threads = []
123
+ node_list = (app_list.map { _1.split("/")[0] } + objs.map { _1.split("/")[0] }).uniq
124
+ threads << Thread.new { profiles(node_list) }
125
+ threads << Thread.new { statuses(app_list) }
126
+ threads << Thread.new { meta ? metas(objs) : {} }
127
+ prof, stat, met = threads.map(&:value)
128
+ by_node = Hash.new { |h, k| h[k] = {} }
129
+ app_list.each { |a| n, ap = a.split("/"); by_node[n][ap] ||= [] }
130
+ objs.each do |o|
131
+ n, ap, name = o.split("/")
132
+ (by_node[n][ap] ||= []) << name
133
+ end
134
+ by_node.keys.sort.map do |n|
135
+ apps = by_node[n].keys.sort.map do |ap|
136
+ h = { "app" => ap }
137
+ h["status"] = stat["#{n}/#{ap}"] if stat.key?("#{n}/#{ap}")
138
+ h["objects"] = by_node[n][ap].sort.map do |name|
139
+ e = { "name" => name }
140
+ e["methods"] = met["#{n}/#{ap}/#{name}"] if met.key?("#{n}/#{ap}/#{name}")
141
+ d = object_doc("#{n}/#{ap}/#{name}")
142
+ e["doc"] = d if d
143
+ e
144
+ end
145
+ h
146
+ end
147
+ h = { "node" => n }
148
+ h["profile"] = prof[n] if prof[n]
149
+ h["apps"] = apps
150
+ h
151
+ end
152
+ end
153
+
154
+ # Sends a get on each key at once and waits for all of them (at most
155
+ # the time limit): {key => [[reply key, payload], ...]}.
156
+ def gets(keys, params: nil)
157
+ s = ::Asterism.session
158
+ ms = (@timeout * 1000).round
159
+ pending = keys.to_h { |k| [k, s.get(k, timeout_ms: ms, params: params)] }
160
+ out = keys.to_h { [_1, []] }
161
+ until pending.empty?
162
+ pending.delete_if do |k, g|
163
+ ended = g.done?
164
+ out[k].concat(g.each_reply.map { |r| [r[0], r[1]] })
165
+ ended
166
+ end
167
+ sleep STEP unless pending.empty?
168
+ end
169
+ out
170
+ rescue ::Asterism::Zenoh::Error => e
171
+ raise ::Asterism::Disconnected, e.message
172
+ end
173
+
174
+ # A meta reply's methods: [{"name", "arity"} and, when the object
175
+ # tells them (docs/protocol.md 5), "args" (names) and "doc"]. Fields
176
+ # of another shape are left out, unknown ones ignored.
177
+ def meta_list(payload, path = nil)
178
+ m = ::Asterism::Codec.unpack(payload)
179
+ list = m.is_a?(Hash) ? m["methods"] : nil
180
+ return nil unless list.is_a?(Array)
181
+ @object_docs[path] = m["doc"] if path && m["doc"].is_a?(String)
182
+ list.filter_map do |e|
183
+ next unless e.is_a?(Hash) && e["name"]
184
+ h = { "name" => e["name"].to_s, "arity" => e["arity"].to_i }
185
+ args = e["args"]
186
+ h["args"] = args if args.is_a?(Array) && args.all? { _1.is_a?(String) } &&
187
+ (h["arity"].negative? || args.size == h["arity"])
188
+ h["doc"] = e["doc"] if e["doc"].is_a?(String)
189
+ h
190
+ end
191
+ end
192
+
193
+ # The text an object tells about itself in its meta (read by metas),
194
+ # or nil.
195
+ def object_doc(path)
196
+ @object_docs[path]
197
+ end
198
+
199
+ def now
200
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
201
+ end
202
+ end
203
+ end
204
+ end