kairos-chain 3.14.1 → 3.15.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: baa3c9211947b4332c06aaa76869e817851ba5e314ada6045477c0bd8f87e7ff
4
- data.tar.gz: 4fa1dc54890125ffa1bf0be7d553f83f0deb536278776959f586d17dfcaff7d8
3
+ metadata.gz: de33a990a4ade8e91533b4a596e0f9e73fcd921c68af62cc9daa30173879234a
4
+ data.tar.gz: 29930e999d710a29626a4a22ed8f49f66dafe2b36f675be1e04bd308a76c75ba
5
5
  SHA512:
6
- metadata.gz: e82e3292dfeed48d2326e3e118d222f23b0b96547841f9ae5f937dae4ed74b61b012168e9b69a64a8f6870f5a04c70574fe36cbef8ac242a73318b36f4171ba8
7
- data.tar.gz: cb89a2cbe23dd57bf62b764dcf9bc382c4c1d84dc6bcc5e622673b50f77a2e3074b1f2a703a088f837fd06ce8a41f8234c21881e3935b94c6e5155c2c96751d4
6
+ metadata.gz: 3bb7aa9b8e4ac13aaa84b126277cdefb3ca479a0aee831e159da89881605061dd2f16aa2efdd11cc2c4be28736d295d90c65c6e74be88e41c7788ee000fc8341
7
+ data.tar.gz: b073833ea3eb15699e57078accac4eed96a3c30088382c9fa310cb1847f0bd6ea41392d1d9ea269ff9e1406d49ddd793b8f0b488bc6369144ce52b7c46812340
data/CHANGELOG.md CHANGED
@@ -4,6 +4,52 @@ All notable changes to the `kairos-chain` gem will be documented in this file.
4
4
 
5
5
  This project follows [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [3.15.0] - 2026-04-15
8
+
9
+ ### Fixed
10
+
11
+ - **PlaceClient browse drops tags** — `PlaceClient#browse` silently discarded the
12
+ `tags` parameter when building query params. Tag-filtered browse and federate
13
+ now work correctly.
14
+ - **Silent error swallowing in 5 MMP tools** — `meeting_browse`, `meeting_deposit`,
15
+ `meeting_get_skill_details`, `meeting_acquire_skill`, and `meeting_federate`
16
+ used raw `Net::HTTP` with `rescue StandardError; nil`, hiding connection errors,
17
+ auth failures, and server errors. All 5 tools now use `PlaceClient` with
18
+ structured error reporting.
19
+ - **Wrong auth token for /meeting/v1/* endpoints** — `meeting_get_skill_details`
20
+ and `meeting_acquire_skill` (peer path) sent the Place session token to
21
+ `/meeting/v1/*` endpoints instead of the meeting session token. Token routing
22
+ now matches endpoint prefix: `/place/v1/*` uses `session_token`,
23
+ `/meeting/v1/*` uses `meeting_session_token`.
24
+
25
+ ### Added
26
+
27
+ - **PlaceClient.reconnect** — class method for restoring client state from saved
28
+ connection without re-registration. Replaces `instance_variable_set` pattern.
29
+ - **PlaceClient new methods** — `deposit`, `get_skill_details`, `get_skill_content`,
30
+ `request_skill_content`, `place_info` (unauthenticated).
31
+ - **Response adapters** — A1 (Place skill content), A2 (peer skill content with
32
+ nested payload unwrap), A3 (skill details with metadata envelope unwrap and
33
+ error/not_found distinction).
34
+ - **Expanded network error handling** — `PlaceClient` now catches `SocketError`,
35
+ `Net::ReadTimeout`, `OpenSSL::SSL::SSLError`, `Errno::ECONNRESET`, `Errno::EPIPE`
36
+ in addition to `Errno::ECONNREFUSED` and `Net::OpenTimeout`.
37
+ - **PlaceClient.parse_response preserves server error details** — non-success HTTP
38
+ responses now retain full server body (`:reasons`, `:status`, etc.) instead of
39
+ wrapping into a generic `{ error: "HTTP 4xx" }`.
40
+
41
+ ### Removed
42
+
43
+ - **Dead code** — `get_details_relay` and `get_skill_from_relay` (unused relay
44
+ path methods) removed from `meeting_get_skill_details` and `meeting_acquire_skill`.
45
+
46
+ ### Review
47
+
48
+ - Design: 3 rounds × 3 LLMs (Claude Opus 4.6, Codex GPT-5.4, Cursor Composer-2)
49
+ - Implementation: 1 round × 3 LLMs
50
+ - Key findings: browse tags bug (3/3), response format contract (3/3),
51
+ token routing (Codex FAIL → resolved), adapter key mismatch (Claude HIGH → resolved)
52
+
7
53
  ## [3.14.1] - 2026-04-12
8
54
 
9
55
  ### Fixed
@@ -78,7 +78,7 @@ module KairosMcp
78
78
  description: frontmatter['description'],
79
79
  version: frontmatter['version'],
80
80
  layer: frontmatter['layer'],
81
- tags: frontmatter['tags'] || [],
81
+ tags: normalize_tags(frontmatter['tags']),
82
82
  content: body.strip,
83
83
  frontmatter: frontmatter,
84
84
  base_path: skill_dir,
@@ -204,6 +204,14 @@ module KairosMcp
204
204
 
205
205
  private
206
206
 
207
+ def normalize_tags(tags)
208
+ case tags
209
+ when Array then tags
210
+ when String then tags.split(',').map(&:strip).reject(&:empty?)
211
+ else []
212
+ end
213
+ end
214
+
207
215
  def find_md_file(skill_dir)
208
216
  # First try to find a file with the same name as the directory
209
217
  skill_name = File.basename(skill_dir)
@@ -1,4 +1,4 @@
1
1
  module KairosMcp
2
- VERSION = "3.14.1"
2
+ VERSION = "3.15.0"
3
3
  CHANGELOG_URL = "https://github.com/masaomi/KairosChain_2026/blob/main/CHANGELOG.md"
4
4
  end
@@ -11,6 +11,12 @@ module MMP
11
11
  DEFAULT_MAX_SESSION_MINUTES = 60
12
12
  DEFAULT_WARN_AFTER_INTERACTIONS = 50
13
13
 
14
+ NETWORK_ERRORS = [
15
+ Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EPIPE,
16
+ Net::OpenTimeout, Net::ReadTimeout,
17
+ SocketError, OpenSSL::SSL::SSLError
18
+ ].freeze
19
+
14
20
  def initialize(place_url:, identity:, timeout: 10, crypto: nil, keypair_path: nil, config: {})
15
21
  @place_url = place_url.chomp('/')
16
22
  @identity = identity
@@ -26,6 +32,15 @@ module MMP
26
32
  @peer_public_keys = {}
27
33
  end
28
34
 
35
+ # Reconnect using saved connection state (no re-registration)
36
+ def self.reconnect(place_url:, identity:, session_token:, agent_id: nil, timeout: 10, config: {})
37
+ client = new(place_url: place_url, identity: identity, timeout: timeout, config: config)
38
+ client.instance_variable_set(:@bearer_token, session_token)
39
+ client.instance_variable_set(:@agent_id, agent_id)
40
+ client.instance_variable_set(:@connected, true)
41
+ client
42
+ end
43
+
29
44
  def connect
30
45
  result = register
31
46
  if result && result[:agent_id]
@@ -88,10 +103,36 @@ module MMP
88
103
  end
89
104
 
90
105
  def browse(type: nil, search: nil, tags: nil, limit: nil)
91
- params = {}; params[:type] = type if type; params[:search] = search if search; params[:limit] = limit if limit
106
+ params = {}
107
+ params[:type] = type if type
108
+ params[:search] = search if search
109
+ params[:tags] = tags.is_a?(Array) ? tags.join(',') : tags if tags
110
+ params[:limit] = limit if limit
92
111
  get('/place/v1/board/browse', params)
93
112
  end
94
113
 
114
+ def deposit(skill)
115
+ post('/place/v1/deposit', skill)
116
+ end
117
+
118
+ def get_skill_details(skill_id:)
119
+ get('/meeting/v1/skill_details', { skill_id: skill_id })
120
+ end
121
+
122
+ def get_skill_content(skill_id:, owner: nil)
123
+ params = {}
124
+ params[:owner] = owner if owner
125
+ get("/place/v1/skill_content/#{URI.encode_www_form_component(skill_id)}", params)
126
+ end
127
+
128
+ def request_skill_content(skill_id:)
129
+ post('/meeting/v1/skill_content', { skill_id: skill_id })
130
+ end
131
+
132
+ def place_info
133
+ get_unauthenticated('/place/v1/info')
134
+ end
135
+
95
136
  def withdraw(skill_id:, reason:)
96
137
  delete("/place/v1/deposit/#{URI.encode_www_form_component(skill_id)}", { reason: reason })
97
138
  end
@@ -160,7 +201,20 @@ module MMP
160
201
  response = http.request(req)
161
202
  @interaction_count += 1
162
203
  parse_response(response)
163
- rescue Errno::ECONNREFUSED, Net::OpenTimeout => e
204
+ rescue *NETWORK_ERRORS => e
205
+ { error: "Connection failed: #{e.message}" }
206
+ end
207
+
208
+ def get_unauthenticated(path, params = {})
209
+ uri = URI.parse("#{@place_url}#{path}")
210
+ uri.query = URI.encode_www_form(params) unless params.empty?
211
+ http = Net::HTTP.new(uri.host, uri.port); http.open_timeout = @timeout; http.read_timeout = @timeout
212
+ http.use_ssl = (uri.scheme == 'https')
213
+ req = Net::HTTP::Get.new(uri)
214
+ response = http.request(req)
215
+ @interaction_count += 1
216
+ parse_response(response)
217
+ rescue *NETWORK_ERRORS => e
164
218
  { error: "Connection failed: #{e.message}" }
165
219
  end
166
220
 
@@ -186,15 +240,20 @@ module MMP
186
240
  req.body = JSON.generate(body) if body
187
241
  @interaction_count += 1
188
242
  parse_response(http.request(req))
189
- rescue Errno::ECONNREFUSED, Net::OpenTimeout => e
243
+ rescue *NETWORK_ERRORS => e
190
244
  { error: "Connection failed: #{e.message}" }
191
245
  end
192
246
 
193
247
  def parse_response(response)
194
248
  data = JSON.parse(response.body, symbolize_names: true)
195
- response.is_a?(Net::HTTPSuccess) ? data : { error: data[:error] || "HTTP #{response.code}" }
249
+ if response.is_a?(Net::HTTPSuccess)
250
+ data
251
+ else
252
+ data[:error] ||= "HTTP #{response.code}"
253
+ data
254
+ end
196
255
  rescue JSON::ParserError
197
- { error: "Invalid JSON response" }
256
+ { error: "Invalid JSON response (HTTP #{response.code})" }
198
257
  end
199
258
  end
200
259
  end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'net/http'
4
- require 'uri'
5
3
  require 'json'
6
4
  require 'yaml'
7
5
  require 'digest'
@@ -52,11 +50,6 @@ module KairosMcp
52
50
  owner_agent_id = arguments['owner_agent_id']
53
51
  save_layer = arguments['save_to_layer'] || 'L1'
54
52
 
55
- config = ::MMP.load_config
56
- unless config['enabled']
57
- return text_content(JSON.pretty_generate({ error: 'Meeting Protocol is disabled' }))
58
- end
59
-
60
53
  connection = load_connection_state
61
54
  unless connection
62
55
  return text_content(JSON.pretty_generate({ error: 'Not connected', hint: 'Use meeting_connect first' }))
@@ -64,22 +57,30 @@ module KairosMcp
64
57
 
65
58
  begin
66
59
  url = connection['url'] || connection[:url]
67
- token = connection['session_token'] || connection[:session_token]
68
60
 
69
61
  if peer_id
70
- # Existing flow: acquire from specific peer
62
+ # Peer direct path: /meeting/v1/skill_content
71
63
  peer = find_peer(connection, peer_id)
72
64
  unless peer
73
65
  return text_content(JSON.pretty_generate({ error: "Peer not found: #{peer_id}" }))
74
66
  end
75
67
  endpoint = peer['endpoint'] || peer[:endpoint]
76
68
  target = endpoint || url
77
- content_result = get_skill_direct(target, skill_id, bearer_token: token)
69
+
70
+ meeting_client = build_meeting_client(url_override: target)
71
+ return meeting_client if meeting_client.is_a?(Array)
72
+
73
+ raw = meeting_client.request_skill_content(skill_id: skill_id)
74
+ content_result = adapt_peer_skill_content(raw, skill_id)
78
75
  source_id = peer_id
79
76
  source_name = peer['name'] || peer[:name] || peer_id
80
77
  else
81
- # A案: acquire from Place deposits
82
- content_result = get_skill_from_place(url, skill_id, owner_agent_id: owner_agent_id, bearer_token: token)
78
+ # Place deposit path: /place/v1/skill_content/:id
79
+ place_client = build_place_client
80
+ return place_client if place_client.is_a?(Array)
81
+
82
+ raw = place_client.get_skill_content(skill_id: skill_id, owner: owner_agent_id)
83
+ content_result = adapt_place_skill_content(raw, skill_id)
83
84
  source_id = content_result[:depositor_id] || owner_agent_id || 'place'
84
85
  source_name = source_id
85
86
  end
@@ -155,10 +156,89 @@ module KairosMcp
155
156
  (connection['peers'] || connection[:peers] || []).find { |p| (p['agent_id'] || p[:agent_id]) == peer_id }
156
157
  end
157
158
 
159
+ # Build PlaceClient with session_token for /place/v1/* endpoints
160
+ def build_place_client(timeout: 10)
161
+ config = ::MMP.load_config
162
+ unless config['enabled']
163
+ return text_content(JSON.pretty_generate({ error: 'Meeting Protocol is disabled' }))
164
+ end
165
+ connection = load_connection_state
166
+ unless connection
167
+ return text_content(JSON.pretty_generate({ error: 'Not connected', hint: 'Use meeting_connect first' }))
168
+ end
169
+ url = connection['url'] || connection[:url]
170
+ token = connection['session_token'] || connection[:session_token]
171
+ agent_id = connection['agent_id'] || connection[:agent_id]
172
+ identity = ::MMP::Identity.new(config: config)
173
+ ::MMP::PlaceClient.reconnect(
174
+ place_url: url, identity: identity,
175
+ session_token: token, agent_id: agent_id, timeout: timeout
176
+ )
177
+ end
178
+
179
+ # Build PlaceClient with meeting_session_token for /meeting/v1/* endpoints
180
+ def build_meeting_client(url_override: nil, timeout: 10)
181
+ config = ::MMP.load_config
182
+ unless config['enabled']
183
+ return text_content(JSON.pretty_generate({ error: 'Meeting Protocol is disabled' }))
184
+ end
185
+ connection = load_connection_state
186
+ unless connection
187
+ return text_content(JSON.pretty_generate({ error: 'Not connected', hint: 'Use meeting_connect first' }))
188
+ end
189
+ url = url_override || connection['url'] || connection[:url]
190
+ token = connection['meeting_session_token'] || connection[:meeting_session_token]
191
+ unless token
192
+ return text_content(JSON.pretty_generate({
193
+ error: 'No meeting session token',
194
+ hint: 'meeting_connect may have failed the /meeting/v1/introduce handshake. Reconnect.'
195
+ }))
196
+ end
197
+ identity = ::MMP::Identity.new(config: config)
198
+ ::MMP::PlaceClient.reconnect(
199
+ place_url: url, identity: identity,
200
+ session_token: token, timeout: timeout
201
+ )
202
+ end
203
+
204
+ # Adapter A1: Place deposit skill content
205
+ def adapt_place_skill_content(raw, skill_id)
206
+ if raw[:error]
207
+ { success: false, error: raw[:error] }
208
+ else
209
+ {
210
+ success: true,
211
+ name: raw[:name] || skill_id,
212
+ skill_name: raw[:name] || skill_id,
213
+ format: raw[:format] || 'markdown',
214
+ content: raw[:content],
215
+ content_hash: raw[:content_hash],
216
+ size_bytes: raw[:content]&.bytesize || 0,
217
+ depositor_id: raw[:depositor_id],
218
+ trust_notice: raw[:trust_notice]
219
+ }
220
+ end
221
+ end
222
+
223
+ # Adapter A2: Peer direct skill content
224
+ def adapt_peer_skill_content(raw, skill_id)
225
+ if raw[:error]
226
+ { success: false, error: raw[:error] }
227
+ else
228
+ payload = raw.dig(:message, :payload) || raw[:payload] || raw
229
+ {
230
+ success: true,
231
+ skill_name: payload[:skill_name] || skill_id,
232
+ format: payload[:format] || 'markdown',
233
+ content: payload[:content],
234
+ content_hash: payload[:content_hash],
235
+ size_bytes: payload[:content]&.bytesize || 0
236
+ }
237
+ end
238
+ end
239
+
158
240
  MAX_TRUSTED_PEERS = 100
159
241
 
160
- # Save or update a trusted peer in L1 knowledge.
161
- # Returns true on success, false on failure.
162
242
  def save_trusted_peer(agent_id:, name:, place_url:, skill_acquired:)
163
243
  dir = File.join(KairosMcp.data_dir, 'knowledge', 'trusted_peers')
164
244
  FileUtils.mkdir_p(dir)
@@ -167,7 +247,6 @@ module KairosMcp
167
247
 
168
248
  peers = load_trusted_peers_data(filepath)
169
249
 
170
- # Find or create peer entry
171
250
  existing = peers.find { |p| p['agent_id'] == agent_id }
172
251
  if existing
173
252
  existing['name'] = name
@@ -189,7 +268,6 @@ module KairosMcp
189
268
  }
190
269
  end
191
270
 
192
- # LRU eviction: keep most recently interacted peers
193
271
  if peers.size > MAX_TRUSTED_PEERS
194
272
  peers = peers.sort_by { |p| p['last_interaction'] || '' }.last(MAX_TRUSTED_PEERS)
195
273
  end
@@ -222,68 +300,6 @@ module KairosMcp
222
300
  content = "---\n#{frontmatter.to_yaml}---\n\n# Trusted Peers\n\nPeers from whom skills were successfully acquired.\nAuto-managed by meeting_acquire_skill. Max #{MAX_TRUSTED_PEERS} entries (LRU).\n"
223
301
  File.write(filepath, content)
224
302
  end
225
-
226
- # Acquire from Place's deposited skills (A案)
227
- def get_skill_from_place(url, skill_id, owner_agent_id: nil, bearer_token: nil)
228
- path = "/place/v1/skill_content/#{URI.encode_www_form_component(skill_id)}"
229
- path += "?owner=#{URI.encode_www_form_component(owner_agent_id)}" if owner_agent_id
230
- uri = URI.parse("#{url}#{path}")
231
- http = Net::HTTP.new(uri.host, uri.port); http.use_ssl = (uri.scheme == 'https')
232
- http.open_timeout = 5; http.read_timeout = 10
233
- req = Net::HTTP::Get.new(uri)
234
- req['Authorization'] = "Bearer #{bearer_token}" if bearer_token
235
- response = http.request(req)
236
- if response.is_a?(Net::HTTPSuccess)
237
- data = JSON.parse(response.body, symbolize_names: true)
238
- {
239
- success: true,
240
- skill_name: data[:name] || skill_id,
241
- format: data[:format] || 'markdown',
242
- content: data[:content],
243
- content_hash: data[:content_hash],
244
- size_bytes: data[:content]&.bytesize || 0,
245
- depositor_id: data[:depositor_id],
246
- trust_notice: data[:trust_notice]
247
- }
248
- else
249
- { success: false, error: "HTTP #{response.code}: #{response.body}" }
250
- end
251
- rescue StandardError => e
252
- { success: false, error: e.message }
253
- end
254
-
255
- def get_skill_from_relay(url, skill_id)
256
- uri = URI.parse("#{url}/place/v1/skills/content/#{URI.encode_www_form_component(skill_id)}")
257
- response = Net::HTTP.get_response(uri)
258
- if response.is_a?(Net::HTTPSuccess)
259
- data = JSON.parse(response.body, symbolize_names: true)
260
- { success: true, skill_name: data[:name] || skill_id, format: data[:format] || 'markdown', content: data[:content], content_hash: data[:content_hash], size_bytes: data[:content]&.bytesize || 0 }
261
- else
262
- { success: false, error: "HTTP #{response.code}" }
263
- end
264
- rescue StandardError => e
265
- { success: false, error: e.message }
266
- end
267
-
268
- def get_skill_direct(endpoint, skill_id, bearer_token: nil)
269
- uri = URI.parse("#{endpoint}/meeting/v1/skill_content")
270
- http = Net::HTTP.new(uri.host, uri.port); http.use_ssl = (uri.scheme == 'https')
271
- http.open_timeout = 5; http.read_timeout = 10
272
- req = Net::HTTP::Post.new(uri.path)
273
- req['Content-Type'] = 'application/json'
274
- req['Authorization'] = "Bearer #{bearer_token}" if bearer_token
275
- req.body = JSON.generate({ skill_id: skill_id })
276
- response = http.request(req)
277
- if response.is_a?(Net::HTTPSuccess)
278
- data = JSON.parse(response.body, symbolize_names: true)
279
- payload = data.dig(:message, :payload) || data[:payload] || data
280
- { success: true, skill_name: payload[:skill_name] || skill_id, format: payload[:format] || 'markdown', content: payload[:content], content_hash: payload[:content_hash], size_bytes: payload[:content]&.bytesize || 0 }
281
- else
282
- { success: false, error: response.body }
283
- end
284
- rescue StandardError => e
285
- { success: false, error: e.message }
286
- end
287
303
  end
288
304
  end
289
305
  end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'net/http'
4
- require 'uri'
5
3
  require 'json'
6
4
 
7
5
  module KairosMcp
@@ -43,30 +41,21 @@ module KairosMcp
43
41
  end
44
42
 
45
43
  def call(arguments)
46
- config = ::MMP.load_config
47
- unless config['enabled']
48
- return text_content(JSON.pretty_generate({ error: 'Meeting Protocol is disabled' }))
49
- end
50
-
51
- connection = load_connection_state
52
- unless connection
53
- return text_content(JSON.pretty_generate({ error: 'Not connected', hint: 'Use meeting_connect first' }))
54
- end
55
-
56
- url = connection['url'] || connection[:url]
57
- token = connection['session_token'] || connection[:session_token]
44
+ client = build_place_client
45
+ return client if client.is_a?(Array)
58
46
 
59
47
  begin
60
48
  page_size = [[arguments['page_size'] || 20, 50].min, 1].max
61
- params = { 'limit' => page_size.to_s }
62
- params['tags'] = arguments['tags'].join(',') if arguments['tags'] && !arguments['tags'].empty?
63
- params['search'] = arguments['search'] if arguments['search']
64
- params['type'] = arguments['type'] if arguments['type']
65
49
 
66
- result = browse_place(url, token, params)
50
+ result = client.browse(
51
+ type: arguments['type'],
52
+ search: arguments['search'],
53
+ tags: arguments['tags'],
54
+ limit: page_size
55
+ )
67
56
 
68
- unless result
69
- return text_content(JSON.pretty_generate({ error: 'Failed to browse Meeting Place' }))
57
+ if result[:error]
58
+ return text_content(JSON.pretty_generate({ error: 'Failed to browse Meeting Place', message: result[:error] }))
70
59
  end
71
60
 
72
61
  entries = result[:entries] || []
@@ -79,7 +68,6 @@ module KairosMcp
79
68
  hint: entries.empty? ? 'No skills found. Try different filters or wait for agents to deposit skills.' : 'Use meeting_acquire_skill(skill_id: "...") to acquire a skill.'
80
69
  }
81
70
 
82
- # Pass through place-level trust info (factual metadata only)
83
71
  output[:place_trust] = result[:place_trust] if result[:place_trust]
84
72
 
85
73
  # Check for pending attestation nudge
@@ -100,25 +88,31 @@ module KairosMcp
100
88
 
101
89
  private
102
90
 
91
+ def build_place_client
92
+ config = ::MMP.load_config
93
+ unless config['enabled']
94
+ return text_content(JSON.pretty_generate({ error: 'Meeting Protocol is disabled' }))
95
+ end
96
+ connection = load_connection_state
97
+ unless connection
98
+ return text_content(JSON.pretty_generate({ error: 'Not connected', hint: 'Use meeting_connect first' }))
99
+ end
100
+ url = connection['url'] || connection[:url]
101
+ token = connection['session_token'] || connection[:session_token]
102
+ agent_id = connection['agent_id'] || connection[:agent_id]
103
+ identity = ::MMP::Identity.new(config: config)
104
+ ::MMP::PlaceClient.reconnect(
105
+ place_url: url, identity: identity,
106
+ session_token: token, agent_id: agent_id
107
+ )
108
+ end
109
+
103
110
  def load_connection_state
104
111
  f = File.join(KairosMcp.storage_dir, 'meeting_connection.json')
105
112
  File.exist?(f) ? JSON.parse(File.read(f)) : nil
106
113
  rescue StandardError; nil
107
114
  end
108
115
 
109
- def browse_place(url, token, params)
110
- query = URI.encode_www_form(params)
111
- uri = URI.parse("#{url}/place/v1/board/browse?#{query}")
112
- http = Net::HTTP.new(uri.host, uri.port); http.use_ssl = (uri.scheme == 'https')
113
- http.open_timeout = 5; http.read_timeout = 10
114
- req = Net::HTTP::Get.new(uri)
115
- req['Authorization'] = "Bearer #{token}" if token
116
- response = http.request(req)
117
- response.is_a?(Net::HTTPSuccess) ? JSON.parse(response.body, symbolize_names: true) : nil
118
- rescue StandardError
119
- nil
120
- end
121
-
122
116
  def format_entry(entry)
123
117
  base = {
124
118
  type: entry[:type] || entry[:format],
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'net/http'
4
- require 'uri'
5
3
  require 'json'
6
4
  require 'yaml'
7
5
  require 'digest'
@@ -42,20 +40,11 @@ module KairosMcp
42
40
  end
43
41
 
44
42
  def call(arguments)
45
- config = ::MMP.load_config
46
- unless config['enabled']
47
- return text_content(JSON.pretty_generate({ error: 'Meeting Protocol is disabled' }))
48
- end
49
-
50
- connection = load_connection_state
51
- unless connection
52
- return text_content(JSON.pretty_generate({ error: 'Not connected', hint: 'Use meeting_connect first' }))
53
- end
54
-
55
- url = connection['url'] || connection[:url]
56
- token = connection['session_token'] || connection[:session_token]
43
+ client = build_place_client(timeout: 15)
44
+ return client if client.is_a?(Array)
57
45
 
58
46
  begin
47
+ config = ::MMP.load_config
59
48
  identity = ::MMP::Identity.new(config: config)
60
49
  crypto = identity.crypto
61
50
 
@@ -79,7 +68,7 @@ module KairosMcp
79
68
  content_hash = Digest::SHA256.hexdigest(content)
80
69
  signature = crypto.has_keypair? ? crypto.sign(content) : nil
81
70
 
82
- result = deposit_to_place(url, token, {
71
+ result = client.deposit({
83
72
  skill_id: skill[:name],
84
73
  name: skill[:name],
85
74
  description: skill[:description],
@@ -117,6 +106,25 @@ module KairosMcp
117
106
 
118
107
  private
119
108
 
109
+ def build_place_client(timeout: 10)
110
+ config = ::MMP.load_config
111
+ unless config['enabled']
112
+ return text_content(JSON.pretty_generate({ error: 'Meeting Protocol is disabled' }))
113
+ end
114
+ connection = load_connection_state
115
+ unless connection
116
+ return text_content(JSON.pretty_generate({ error: 'Not connected', hint: 'Use meeting_connect first' }))
117
+ end
118
+ url = connection['url'] || connection[:url]
119
+ token = connection['session_token'] || connection[:session_token]
120
+ agent_id = connection['agent_id'] || connection[:agent_id]
121
+ identity = ::MMP::Identity.new(config: config)
122
+ ::MMP::PlaceClient.reconnect(
123
+ place_url: url, identity: identity,
124
+ session_token: token, agent_id: agent_id, timeout: timeout
125
+ )
126
+ end
127
+
120
128
  def load_connection_state
121
129
  f = File.join(KairosMcp.storage_dir, 'meeting_connection.json')
122
130
  File.exist?(f) ? JSON.parse(File.read(f)) : nil
@@ -170,20 +178,6 @@ module KairosMcp
170
178
  rescue StandardError
171
179
  0
172
180
  end
173
-
174
- def deposit_to_place(url, token, skill)
175
- uri = URI.parse("#{url}/place/v1/deposit")
176
- http = Net::HTTP.new(uri.host, uri.port); http.use_ssl = (uri.scheme == 'https')
177
- http.open_timeout = 5; http.read_timeout = 15
178
- req = Net::HTTP::Post.new(uri.path)
179
- req['Content-Type'] = 'application/json'
180
- req['Authorization'] = "Bearer #{token}" if token
181
- req.body = JSON.generate(skill)
182
- response = http.request(req)
183
- JSON.parse(response.body, symbolize_names: true)
184
- rescue StandardError => e
185
- { error: e.message }
186
- end
187
181
  end
188
182
  end
189
183
  end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'net/http'
4
- require 'uri'
5
3
  require 'json'
6
4
  require 'digest'
7
5
 
@@ -64,13 +62,29 @@ module KairosMcp
64
62
  filter_tags = arguments['tags']
65
63
 
66
64
  begin
67
- # Resolve Place IDs for provenance chain (prefer place_id, fallback to URL)
68
- source_place_id = resolve_place_id(source_url) || source_url
65
+ source_client = build_client_for(url: source_url, token: source_token)
66
+ target_client = build_client_for(url: target_url, token: target_token, timeout: 15)
67
+
68
+ # Resolve Place ID for provenance chain
69
+ info_result = source_client.place_info
70
+ source_place_id = info_result[:place_id] || source_url
69
71
 
70
72
  # Step 1: Browse source Place for deposited skills
71
- source_skills = browse_deposited_skills(source_url, source_token, tags: filter_tags)
72
- if source_skills.nil?
73
- return text_content(JSON.pretty_generate({ error: 'Failed to browse source Place' }))
73
+ source_result = source_client.browse(type: 'deposited_skill', tags: filter_tags, limit: 50)
74
+ if source_result[:error]
75
+ return text_content(JSON.pretty_generate({ error: 'Failed to browse source Place', message: source_result[:error] }))
76
+ end
77
+
78
+ source_skills = (source_result[:entries] || []).map do |e|
79
+ {
80
+ skill_id: e[:skill_id],
81
+ name: e[:name],
82
+ description: e[:description],
83
+ tags: e[:tags],
84
+ owner_agent_id: e[:agent_id],
85
+ deposited_at: e[:deposited_at],
86
+ trust_metadata: e[:trust_metadata]
87
+ }
74
88
  end
75
89
 
76
90
  # Filter by skill_ids if specified
@@ -91,11 +105,11 @@ module KairosMcp
91
105
 
92
106
  source_skills.each do |skill_meta|
93
107
  # GET full content from source
94
- content_result = get_skill_content(
95
- source_url, source_token,
96
- skill_meta[:skill_id],
97
- owner_agent_id: skill_meta[:owner_agent_id]
108
+ raw = source_client.get_skill_content(
109
+ skill_id: skill_meta[:skill_id],
110
+ owner: skill_meta[:owner_agent_id]
98
111
  )
112
+ content_result = adapt_place_skill_content(raw, skill_meta[:skill_id])
99
113
 
100
114
  unless content_result[:success]
101
115
  failed << { skill_id: skill_meta[:skill_id], error: content_result[:error] }
@@ -115,7 +129,7 @@ module KairosMcp
115
129
  signature = crypto.has_keypair? ? crypto.sign(content) : nil
116
130
 
117
131
  # POST to target Place with provenance
118
- deposit_result = deposit_to_target(target_url, target_token, {
132
+ deposit_result = target_client.deposit({
119
133
  skill_id: skill_meta[:skill_id],
120
134
  name: content_result[:name] || skill_meta[:name],
121
135
  description: skill_meta[:description],
@@ -163,83 +177,37 @@ module KairosMcp
163
177
 
164
178
  private
165
179
 
166
- # Browse source Place for deposited_skill entries only
167
- def browse_deposited_skills(url, token, tags: nil)
168
- params = { 'type' => 'deposited_skill', 'limit' => '50' }
169
- params['tags'] = tags.join(',') if tags && !tags.empty?
170
-
171
- query = URI.encode_www_form(params)
172
- uri = URI.parse("#{url}/place/v1/board/browse?#{query}")
173
- http = Net::HTTP.new(uri.host, uri.port); http.use_ssl = (uri.scheme == 'https')
174
- http.open_timeout = 5; http.read_timeout = 10
175
- req = Net::HTTP::Get.new(uri)
176
- req['Authorization'] = "Bearer #{token}"
177
- response = http.request(req)
178
- return nil unless response.is_a?(Net::HTTPSuccess)
179
-
180
- data = JSON.parse(response.body, symbolize_names: true)
181
- (data[:entries] || []).map do |e|
182
- {
183
- skill_id: e[:skill_id],
184
- name: e[:name],
185
- description: e[:description],
186
- tags: e[:tags],
187
- owner_agent_id: e[:agent_id],
188
- deposited_at: e[:deposited_at],
189
- trust_metadata: e[:trust_metadata]
190
- }
191
- end
192
- rescue StandardError
193
- nil
180
+ # Build PlaceClient for explicit URL/token (federate uses explicit args, not connection state)
181
+ def build_client_for(url:, token:, timeout: 10)
182
+ identity = ::MMP::Identity.new(config: ::MMP.load_config)
183
+ ::MMP::PlaceClient.reconnect(
184
+ place_url: url, identity: identity,
185
+ session_token: token, timeout: timeout
186
+ )
194
187
  end
195
188
 
196
- # GET skill content from source Place
197
- def get_skill_content(url, token, skill_id, owner_agent_id: nil)
198
- path = "/place/v1/skill_content/#{URI.encode_www_form_component(skill_id)}"
199
- path += "?owner=#{URI.encode_www_form_component(owner_agent_id)}" if owner_agent_id
200
- uri = URI.parse("#{url}#{path}")
201
- http = Net::HTTP.new(uri.host, uri.port); http.use_ssl = (uri.scheme == 'https')
202
- http.open_timeout = 5; http.read_timeout = 10
203
- req = Net::HTTP::Get.new(uri)
204
- req['Authorization'] = "Bearer #{token}"
205
- response = http.request(req)
206
- if response.is_a?(Net::HTTPSuccess)
207
- data = JSON.parse(response.body, symbolize_names: true)
189
+ # Adapter A1: Place deposit skill content
190
+ def adapt_place_skill_content(raw, skill_id)
191
+ if raw[:error]
192
+ { success: false, error: raw[:error] }
193
+ else
208
194
  {
209
195
  success: true,
210
- name: data[:name],
211
- content: data[:content],
212
- content_hash: data[:content_hash],
213
- format: data[:format],
214
- depositor_id: data[:depositor_id]
196
+ name: raw[:name] || skill_id,
197
+ skill_name: raw[:name] || skill_id,
198
+ format: raw[:format] || 'markdown',
199
+ content: raw[:content],
200
+ content_hash: raw[:content_hash],
201
+ size_bytes: raw[:content]&.bytesize || 0,
202
+ depositor_id: raw[:depositor_id],
203
+ trust_notice: raw[:trust_notice]
215
204
  }
216
- else
217
- { success: false, error: "HTTP #{response.code}" }
218
205
  end
219
- rescue StandardError => e
220
- { success: false, error: e.message }
221
- end
222
-
223
- # Resolve Place's instance ID from /place/v1/info (unauthenticated).
224
- # Returns nil on failure (caller should fallback to URL).
225
- def resolve_place_id(url)
226
- uri = URI.parse("#{url}/place/v1/info")
227
- http = Net::HTTP.new(uri.host, uri.port); http.use_ssl = (uri.scheme == 'https')
228
- http.open_timeout = 3; http.read_timeout = 5
229
- req = Net::HTTP::Get.new(uri)
230
- response = http.request(req)
231
- return nil unless response.is_a?(Net::HTTPSuccess)
232
- data = JSON.parse(response.body, symbolize_names: true)
233
- data[:place_id]
234
- rescue StandardError
235
- nil
236
206
  end
237
207
 
238
208
  # Build provenance for the federated deposit.
239
- # source_place_id: resolved Place instance ID (or URL as fallback).
240
209
  def build_federation_provenance(source_provenance, source_place_id, content_result, source_deposited_at)
241
210
  if source_provenance[:hop_count].to_i > 0
242
- # Already federated: increment hop, append source to via
243
211
  {
244
212
  origin_place_id: source_provenance[:origin_place_id],
245
213
  origin_agent_id: source_provenance[:origin_agent_id] || content_result[:depositor_id],
@@ -248,7 +216,6 @@ module KairosMcp
248
216
  deposited_at_origin: source_provenance[:deposited_at_origin]
249
217
  }
250
218
  else
251
- # First federation: source Place is the origin
252
219
  {
253
220
  origin_place_id: source_place_id,
254
221
  origin_agent_id: content_result[:depositor_id],
@@ -258,21 +225,6 @@ module KairosMcp
258
225
  }
259
226
  end
260
227
  end
261
-
262
- # POST skill to target Place with provenance
263
- def deposit_to_target(url, token, skill)
264
- uri = URI.parse("#{url}/place/v1/deposit")
265
- http = Net::HTTP.new(uri.host, uri.port); http.use_ssl = (uri.scheme == 'https')
266
- http.open_timeout = 5; http.read_timeout = 15
267
- req = Net::HTTP::Post.new(uri.path)
268
- req['Content-Type'] = 'application/json'
269
- req['Authorization'] = "Bearer #{token}"
270
- req.body = JSON.generate(skill)
271
- response = http.request(req)
272
- JSON.parse(response.body, symbolize_names: true)
273
- rescue StandardError => e
274
- { error: e.message }
275
- end
276
228
  end
277
229
  end
278
230
  end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'net/http'
4
- require 'uri'
5
3
  require 'json'
6
4
 
7
5
  module KairosMcp
@@ -34,8 +32,7 @@ module KairosMcp
34
32
  type: 'object',
35
33
  properties: {
36
34
  peer_id: { type: 'string', description: 'ID of the peer agent' },
37
- skill_id: { type: 'string', description: 'ID of the skill' },
38
- include_preview: { type: 'boolean', description: 'Include content preview (default: false)' }
35
+ skill_id: { type: 'string', description: 'ID of the skill' }
39
36
  },
40
37
  required: %w[peer_id skill_id]
41
38
  }
@@ -44,12 +41,6 @@ module KairosMcp
44
41
  def call(arguments)
45
42
  peer_id = arguments['peer_id']
46
43
  skill_id = arguments['skill_id']
47
- include_preview = arguments['include_preview'] || false
48
-
49
- config = ::MMP.load_config
50
- unless config['enabled']
51
- return text_content(JSON.pretty_generate({ error: 'Meeting Protocol is disabled' }))
52
- end
53
44
 
54
45
  connection = load_connection_state
55
46
  unless connection
@@ -62,31 +53,36 @@ module KairosMcp
62
53
  end
63
54
 
64
55
  begin
65
- relay_mode = connection['relay_mode'] || connection[:relay_mode]
66
- url = connection['url'] || connection[:url]
56
+ # Target: peer endpoint if available, otherwise Place URL
67
57
  endpoint = peer['endpoint'] || peer[:endpoint]
68
-
69
- # In relay mode, use the peer's endpoint if available,
70
- # otherwise fall back to the connection URL (Meeting Place itself)
58
+ url = connection['url'] || connection[:url]
71
59
  target = endpoint || url
72
- token = connection['session_token'] || connection[:session_token]
73
- details = get_details_direct(target, skill_id, bearer_token: token)
74
60
 
75
- unless details
61
+ client = build_meeting_client(url_override: target, timeout: 5)
62
+ return client if client.is_a?(Array)
63
+
64
+ raw = client.get_skill_details(skill_id: skill_id)
65
+ details = adapt_skill_details(raw)
66
+
67
+ if details.nil?
76
68
  return text_content(JSON.pretty_generate({ error: "Skill not found: #{skill_id}" }))
77
69
  end
78
70
 
71
+ if details[:error]
72
+ return text_content(JSON.pretty_generate({ error: details[:error], message: details[:message] }))
73
+ end
74
+
79
75
  result = {
80
76
  peer_id: peer_id,
81
77
  peer_name: peer['name'] || peer[:name],
82
78
  skill: {
83
79
  id: skill_id,
84
- name: details['name'] || details[:name],
85
- description: details['description'] || details[:description],
86
- version: details['version'] || details[:version] || '1.0.0',
87
- format: details['format'] || details[:format] || 'markdown',
88
- tags: details['tags'] || details[:tags] || [],
89
- size_bytes: details['size_bytes'] || details[:size_bytes]
80
+ name: details[:name],
81
+ description: details[:description],
82
+ version: details[:version],
83
+ format: details[:format],
84
+ tags: details[:tags],
85
+ size_bytes: details[:size_bytes]
90
86
  },
91
87
  hint: "To acquire: meeting_acquire_skill(peer_id: \"#{peer_id}\", skill_id: \"#{skill_id}\")"
92
88
  }
@@ -119,22 +115,52 @@ module KairosMcp
119
115
  (connection['peers'] || connection[:peers] || []).find { |p| (p['agent_id'] || p[:agent_id]) == peer_id }
120
116
  end
121
117
 
122
- def get_details_relay(url, skill_id)
123
- uri = URI.parse("#{url}/place/v1/skills/metadata/#{URI.encode_www_form_component(skill_id)}")
124
- response = Net::HTTP.get_response(uri)
125
- response.is_a?(Net::HTTPSuccess) ? JSON.parse(response.body, symbolize_names: true) : nil
126
- rescue StandardError; nil
118
+ # Build PlaceClient with meeting_session_token for /meeting/v1/* endpoints
119
+ def build_meeting_client(url_override: nil, timeout: 10)
120
+ config = ::MMP.load_config
121
+ unless config['enabled']
122
+ return text_content(JSON.pretty_generate({ error: 'Meeting Protocol is disabled' }))
123
+ end
124
+ connection = load_connection_state
125
+ unless connection
126
+ return text_content(JSON.pretty_generate({ error: 'Not connected', hint: 'Use meeting_connect first' }))
127
+ end
128
+ url = url_override || connection['url'] || connection[:url]
129
+ token = connection['meeting_session_token'] || connection[:meeting_session_token]
130
+ unless token
131
+ return text_content(JSON.pretty_generate({
132
+ error: 'No meeting session token',
133
+ hint: 'meeting_connect may have failed the /meeting/v1/introduce handshake. Reconnect.'
134
+ }))
135
+ end
136
+ identity = ::MMP::Identity.new(config: config)
137
+ ::MMP::PlaceClient.reconnect(
138
+ place_url: url, identity: identity,
139
+ session_token: token, timeout: timeout
140
+ )
127
141
  end
128
142
 
129
- def get_details_direct(endpoint, skill_id, bearer_token: nil)
130
- uri = URI.parse("#{endpoint}/meeting/v1/skill_details?skill_id=#{URI.encode_www_form_component(skill_id)}")
131
- http = Net::HTTP.new(uri.host, uri.port); http.use_ssl = (uri.scheme == 'https')
132
- http.open_timeout = 3; http.read_timeout = 5
133
- req = Net::HTTP::Get.new(uri)
134
- req['Authorization'] = "Bearer #{bearer_token}" if bearer_token
135
- response = http.request(req)
136
- response.is_a?(Net::HTTPSuccess) ? JSON.parse(response.body, symbolize_names: true) : nil
137
- rescue StandardError; nil
143
+ # Adapter A3: skill_details response normalization
144
+ # Distinguishes "not found" from auth/network errors (R3-1 fix)
145
+ def adapt_skill_details(raw)
146
+ if raw[:error]
147
+ # Distinguish not_found from other errors
148
+ if raw[:error].to_s.include?('not_found') || raw[:error].to_s.include?('404')
149
+ nil
150
+ else
151
+ { error: raw[:error], message: raw[:message] }
152
+ end
153
+ else
154
+ meta = raw[:metadata] || raw
155
+ {
156
+ name: meta[:name],
157
+ description: meta[:description] || meta[:summary],
158
+ version: meta[:version] || '1.0',
159
+ format: meta[:format] || 'markdown',
160
+ tags: meta[:tags] || [],
161
+ size_bytes: meta[:size_bytes]
162
+ }
163
+ end
138
164
  end
139
165
  end
140
166
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kairos-chain
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.14.1
4
+ version: 3.15.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Masaomi Hatakeyama
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-04-12 00:00:00.000000000 Z
11
+ date: 2026-04-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: minitest