poetry-agent 0.0.2

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.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +3 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +51 -0
  5. data/app/javascript/poetry/agent/a2ui_surface_controller.js +141 -0
  6. data/app/javascript/poetry/agent/adapter.js +77 -0
  7. data/app/javascript/poetry/agent/agui_client_tool_controller.js +53 -0
  8. data/app/javascript/poetry/agent/index.js +41 -0
  9. data/app/javascript/poetry/agent/stream_actions.js +65 -0
  10. data/app/javascript/poetry/agent/webmcp_controller.js +248 -0
  11. data/app/javascript/poetry/agent/webmcp_form_controller.js +109 -0
  12. data/config/controllers_manifest.json +82 -0
  13. data/config/importmap.rb +10 -0
  14. data/exe/poetry-agent +28 -0
  15. data/lib/poetry/agent/a2ui/catalog.rb +289 -0
  16. data/lib/poetry/agent/a2ui/catalogs/basic.rb +460 -0
  17. data/lib/poetry/agent/a2ui/catalogs/native.rb +176 -0
  18. data/lib/poetry/agent/a2ui/checks.rb +45 -0
  19. data/lib/poetry/agent/a2ui/evaluator.rb +139 -0
  20. data/lib/poetry/agent/a2ui/expression.rb +175 -0
  21. data/lib/poetry/agent/a2ui/functions.rb +417 -0
  22. data/lib/poetry/agent/a2ui/markdown.rb +63 -0
  23. data/lib/poetry/agent/a2ui/pointer.rb +113 -0
  24. data/lib/poetry/agent/a2ui/protocol.rb +12 -0
  25. data/lib/poetry/agent/a2ui/renderer.rb +242 -0
  26. data/lib/poetry/agent/a2ui/session.rb +302 -0
  27. data/lib/poetry/agent/a2ui/streams.rb +82 -0
  28. data/lib/poetry/agent/a2ui/surface.rb +352 -0
  29. data/lib/poetry/agent/a2ui.rb +48 -0
  30. data/lib/poetry/agent/agui/client.rb +69 -0
  31. data/lib/poetry/agent/agui/json_patch.rb +137 -0
  32. data/lib/poetry/agent/agui/relay.rb +105 -0
  33. data/lib/poetry/agent/agui/run_input.rb +83 -0
  34. data/lib/poetry/agent/agui/sse.rb +97 -0
  35. data/lib/poetry/agent/agui/transcript.rb +540 -0
  36. data/lib/poetry/agent/agui/turbo_stream.rb +68 -0
  37. data/lib/poetry/agent/agui.rb +87 -0
  38. data/lib/poetry/agent/config.rb +49 -0
  39. data/lib/poetry/agent/engine.rb +37 -0
  40. data/lib/poetry/agent/mcp/bundled.rb +54 -0
  41. data/lib/poetry/agent/mcp/http.rb +89 -0
  42. data/lib/poetry/agent/mcp/server.rb +962 -0
  43. data/lib/poetry/agent/version.rb +8 -0
  44. data/lib/poetry/agent/webmcp/origin_trial.rb +49 -0
  45. data/lib/poetry/agent/webmcp.rb +37 -0
  46. data/lib/poetry/agent.rb +66 -0
  47. data/lib/poetry-agent.rb +4 -0
  48. metadata +117 -0
@@ -0,0 +1,962 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Poetry
6
+ module Agent
7
+ # The MCP server projecting the component contract
8
+ # over Model Context Protocol so an agent in Claude Code / Cursor queries
9
+ # the LIVE registry and runs the linter as a tool. Thin - it projects the
10
+ # surfaces already built (Registry + LlmsText + Check), never a second
11
+ # source. Read-only, progressive-disclosure (brief|detailed|full), and
12
+ # verdict-returning (check returns per-finding pass/fail).
13
+ #
14
+ # Two transports, one server: newline-delimited JSON-RPC 2.0 over stdio
15
+ # (the `poetry-agent` exe; own the supply chain - no MCP SDK dependency)
16
+ # and POST JSON-RPC over HTTP ({HTTP}, for the same-origin `/mcp` mount
17
+ # in-page bridges read). {Server#handle} is a pure request->response
18
+ # function (testable without either transport); {Server#serve} is the
19
+ # stdio loop; {Bundled} is the one assembly both transports share.
20
+ #
21
+ # v1 is the read/verify surface. The heavier roadmap - verify_screen
22
+ # running the eval gate array, component:// artifact resources, tag
23
+ # browsing, SSE streaming - is maturity-gated and NOT in this cut.
24
+ module MCP
25
+ # The MCP protocol revision this server negotiates.
26
+ PROTOCOL_VERSION = "2025-06-18"
27
+ # The serverInfo payload returned by the initialize handshake.
28
+ SERVER_INFO = { "name" => "poetry-agent", "version" => Poetry::Agent::VERSION }.freeze
29
+
30
+ # The tool roster the server advertises (tools/list): MCP Tool-shaped
31
+ # definitions, read-only by construction.
32
+ TOOLS = [
33
+ {
34
+ "name" => "compose",
35
+ "description" => "CALL THIS FIRST for every brief, before writing any ERB. Give it the task " \
36
+ "text; it routes to the right starting point: a matching vetted block " \
37
+ "(returns the full source to adapt in place - the known winning path for " \
38
+ "pages) or the matching components for single-component work. Composing a " \
39
+ "page from scratch when a block matched is the known losing path.",
40
+ "inputSchema" => {
41
+ "type" => "object",
42
+ "properties" => {
43
+ "brief" => { "type" => "string",
44
+ "description" => "the task text, verbatim (a sentence is enough)" }
45
+ },
46
+ "required" => ["brief"]
47
+ },
48
+ "annotations" => { "readOnlyHint" => true }
49
+ },
50
+ {
51
+ "name" => "build_page",
52
+ "description" => "The GUIDED build for a whole SCREEN/page/dashboard - use this over " \
53
+ "compose when the job is a full page. Give the intent verbatim; it runs a " \
54
+ "five-step workflow and each call returns ONE step plus the exact next call: " \
55
+ "probe (host setup) -> plan (page architecture: section order, states, edge " \
56
+ "cases) -> direct (theme-derived creative direction) -> snippets (the block/" \
57
+ "components to start from) -> verify (the executable check gate). The workflow " \
58
+ "is DONE only on a PASS from check - a real verdict, not a claim. Omit step to " \
59
+ "start; the entry routes by your verb (review/harden stay read-only). " \
60
+ "Out-of-order steps are answered, never refused.",
61
+ "inputSchema" => {
62
+ "type" => "object",
63
+ "properties" => {
64
+ "intent" => { "type" => "string",
65
+ "description" => "the page you are building, verbatim (a sentence is enough)" },
66
+ "step" => { "type" => "string", "enum" => %w[probe plan direct snippets verify],
67
+ "description" => "the workflow step to run; omit to start at the routed entry" },
68
+ "source" => { "type" => "string",
69
+ "description" => "verify step only: the ERB you built, to run through check" }
70
+ },
71
+ "required" => ["intent"]
72
+ },
73
+ "annotations" => { "readOnlyHint" => true }
74
+ },
75
+ {
76
+ "name" => "list_components",
77
+ "description" => "List every poetry component (name, helper, one-line surface). Start here " \
78
+ "AFTER compose has routed the brief; then describe_component for the full " \
79
+ "contract.",
80
+ "inputSchema" => { "type" => "object", "properties" => {} },
81
+ "annotations" => { "readOnlyHint" => true }
82
+ },
83
+ {
84
+ "name" => "describe_component",
85
+ "description" => "The contract for one component. detail: brief (summary), detailed " \
86
+ "(+ options/variants/slots), full (+ Stimulus wiring + agent rules).",
87
+ "inputSchema" => {
88
+ "type" => "object",
89
+ "properties" => {
90
+ "name" => { "type" => "string", "description" => "component name, e.g. button or command_dialog" },
91
+ "detail" => { "type" => "string", "enum" => %w[brief detailed full], "default" => "detailed" }
92
+ },
93
+ "required" => ["name"]
94
+ },
95
+ "annotations" => { "readOnlyHint" => true }
96
+ },
97
+ {
98
+ "name" => "check",
99
+ "description" => "Lint ERB source against the poetry contracts WITHOUT rendering - unknown " \
100
+ "components/options/variants/wiring, raw and arbitrary colors (inline styles too), the " \
101
+ "! modifier, fake buttons (an inert element with onclick or role=button), icon names, " \
102
+ "enum values, " \
103
+ "typed-slot props, helper/setter arity, yield-less blocks, setter keywords, " \
104
+ "required content blocks, required slots a call never set, and any-of " \
105
+ "contracts (Button's visible content, Command's accessible name). Returns a " \
106
+ "verdict and findings. Run this as the LAST action after the final edit - " \
107
+ "an edit after your last check is unverified.",
108
+ "inputSchema" => {
109
+ "type" => "object",
110
+ "properties" => { "source" => { "type" => "string", "description" => "the ERB template source" } },
111
+ "required" => ["source"]
112
+ },
113
+ "annotations" => { "readOnlyHint" => true }
114
+ },
115
+ {
116
+ "name" => "list_blocks",
117
+ "description" => "List the vetted composed-screen blocks (name, title, composed components). " \
118
+ "compose(brief) routes to the right one automatically; browse here when " \
119
+ "you want the full catalog, then describe_block for source.",
120
+ "inputSchema" => { "type" => "object", "properties" => {} },
121
+ "annotations" => { "readOnlyHint" => true }
122
+ },
123
+ {
124
+ "name" => "describe_block",
125
+ "description" => "One block's contract AND its full ERB source, ready to adapt - the " \
126
+ "boot-free equivalent of `bin/rails g poetry:block <name>`.",
127
+ "inputSchema" => {
128
+ "type" => "object",
129
+ "properties" => {
130
+ "name" => { "type" => "string", "description" => "block name, e.g. data-index or app-shell" }
131
+ },
132
+ "required" => ["name"]
133
+ },
134
+ "annotations" => { "readOnlyHint" => true }
135
+ },
136
+ {
137
+ "name" => "list_recipes",
138
+ "description" => "List the installable recipes - multi-file payloads beyond components " \
139
+ "(skill bundles, scaffold template sets, screen slices) served by the " \
140
+ "poetry registry. Install one with `bin/rails g poetry:add <name>` or " \
141
+ "any shadcn-compatible client.",
142
+ "inputSchema" => { "type" => "object", "properties" => {} },
143
+ "annotations" => { "readOnlyHint" => true }
144
+ },
145
+ {
146
+ "name" => "get_skill",
147
+ "description" => "A poetry Claude Code skill served at runtime - for hosts where " \
148
+ "the installed .claude/skills files are absent (hosted agents, sessions " \
149
+ "that never ran the generator). name: poetry (component usage, generated " \
150
+ "from this registry), poetry-design (page-composition taste), or " \
151
+ "poetry-component (authoring app-owned components). Returns " \
152
+ "SKILL.md plus the file index; pass file: to fetch one reference " \
153
+ "(e.g. references/forms.md). The installed skills are the same text - " \
154
+ "when .claude/skills/poetry exists, read it there instead.",
155
+ "inputSchema" => {
156
+ "type" => "object",
157
+ "properties" => {
158
+ "name" => { "type" => "string", "enum" => %w[poetry poetry-design poetry-component],
159
+ "default" => "poetry" },
160
+ "file" => { "type" => "string",
161
+ "description" => "one skill file, e.g. references/deciding.md " \
162
+ "(omit for SKILL.md + the file index)" }
163
+ }
164
+ },
165
+ "annotations" => { "readOnlyHint" => true }
166
+ },
167
+ {
168
+ "name" => "guidance",
169
+ "description" => "Curated composition guidance, per topic: 'deciding' = the " \
170
+ "which-component decision tree (interaction model first). The same " \
171
+ "text the installed usage skill carries - reach it here when the " \
172
+ "skill is not installed.",
173
+ "inputSchema" => {
174
+ "type" => "object",
175
+ "properties" => {
176
+ "topic" => { "type" => "string", "description" => "guidance topic; currently: deciding" }
177
+ },
178
+ "required" => ["topic"]
179
+ },
180
+ "annotations" => { "readOnlyHint" => true }
181
+ }
182
+ ].freeze
183
+
184
+ # The server: constructed with the registry root (and the host's helper
185
+ # names, so check knows the group/provider helpers - though the
186
+ # registry's own "helpers" section now carries those boot-free).
187
+ # icon_names: the active icon set's names, so the check tool validates
188
+ # icon values by membership, not just shape. Everything read is the
189
+ # live committed registry.
190
+ #
191
+ # @example Serve the committed registry over stdio
192
+ # Poetry::Agent::MCP::Server.from_registry("registry").serve
193
+ class Server
194
+ # skills: skill name => a zero-arg callable returning the skill's
195
+ # {relative path => content} file map (the get_skill tool).
196
+ # Lazy because the usage skill is generated from the registry on
197
+ # first fetch - server boot stays instant.
198
+ def self.from_registry(root, helpers: nil, icon_names: nil, skills: {}, app_root: nil, recipes: [])
199
+ committed = Poetry::Core::Registry.committed(root)
200
+ catalog = Poetry::Core::Check::Catalog.new(committed.entries, helpers: helpers,
201
+ helper_entries: committed.helpers,
202
+ icon_names: icon_names,
203
+ helper_args: committed.helper_args)
204
+ new(entries: committed.entries, catalog: catalog, blocks: committed.blocks || {},
205
+ root: root, skills: skills, app_root: app_root, recipes: recipes)
206
+ end
207
+
208
+ # app_root: the HOST app directory (where `bundle exec poetry-agent`
209
+ # runs, i.e. Dir.pwd), so build_page's probe/direct steps can read
210
+ # the app's config/theme. nil => the host is not inspected and those
211
+ # steps degrade gracefully; the registry read (root) is unaffected.
212
+ # recipes: registry-item SUMMARIES (content-free) from the owning
213
+ # gem's RecipeItems projection - the exe passes them so this class
214
+ # stays poetry-ui-free.
215
+ def initialize(entries:, catalog:, blocks: {}, root: nil, skills: {}, app_root: nil, recipes: []) # rubocop:disable Metrics/ParameterLists
216
+ @entries = entries
217
+ @catalog = catalog
218
+ @blocks = blocks
219
+ @root = root
220
+ @skills = skills
221
+ @skill_files = {}
222
+ @app_root = app_root
223
+ @recipes = recipes
224
+ end
225
+
226
+ # compose routing: the strong-match threshold, and the words
227
+ # too generic to signal a block - connectives, task verbs, and
228
+ # component-anatomy words (title/description/action) every brief
229
+ # uses regardless of scale.
230
+ STRONG_MATCH = 4
231
+ # build_page: the guided workflow's five steps, the themes
232
+ # its probe/direct steps sniff for, and the plan step's match floor
233
+ # (one curated archetype keyword = 2, so 2 is the weakest real hit).
234
+ STEPS = %w[probe plan direct snippets verify].freeze
235
+ # The theme roster build_page's direct step reads.
236
+ THEMES = %w[default vega nova mira rhea maia luma lyra sera].freeze
237
+ # The brief-word -> page-architecture routing table.
238
+ ARCHETYPE_MATCH = 2
239
+ # Tokens compose ignores when scoring a brief.
240
+ STOPWORDS = %w[
241
+ the a an and or with for of to in on at from into by over under this that it its as is are
242
+ be has have should must can will each per when where build create make add show include
243
+ using use page screen view ui app real realistic data
244
+ title titles description descriptions action actions
245
+ ].to_set.freeze
246
+
247
+ # A JSON-RPC 2.0 request hash -> a response hash (or nil for a
248
+ # notification, which gets no reply).
249
+ #
250
+ # @param request [Hash] one parsed JSON-RPC 2.0 request
251
+ # @return [Hash, nil] the response hash, or nil for a notification
252
+ def handle(request)
253
+ id = request["id"]
254
+ case request["method"]
255
+ when "initialize" then result(id, initialize_result)
256
+ when "tools/list" then result(id, { "tools" => TOOLS })
257
+ when "tools/call" then result(id, call_tool(request["params"] || {}))
258
+ when %r{\Anotifications/} then nil
259
+ else error(id, -32_601, "method not found: #{request["method"]}")
260
+ end
261
+ rescue StandardError => e
262
+ error(id, -32_603, e.message)
263
+ end
264
+
265
+ # The thin stdio loop: newline-delimited JSON-RPC in, replies out.
266
+ # A malformed line yields a parse error, never a crashed server.
267
+ def serve(input: $stdin, output: $stdout)
268
+ input.each_line do |line|
269
+ line = line.strip
270
+ next if line.empty?
271
+
272
+ response =
273
+ begin
274
+ handle(JSON.parse(line))
275
+ rescue JSON::ParserError => e
276
+ error(nil, -32_700, "parse error: #{e.message}")
277
+ end
278
+ next unless response
279
+
280
+ output.puts(JSON.generate(response))
281
+ output.flush
282
+ end
283
+ end
284
+
285
+ private
286
+
287
+ def initialize_result
288
+ {
289
+ "protocolVersion" => PROTOCOL_VERSION,
290
+ "serverInfo" => SERVER_INFO,
291
+ "capabilities" => { "tools" => {} }
292
+ }
293
+ end
294
+
295
+ def call_tool(params)
296
+ name = params["name"]
297
+ arguments = params["arguments"] || {}
298
+ text =
299
+ case name
300
+ when "compose" then compose(arguments)
301
+ when "build_page" then build_page(arguments)
302
+ when "list_components" then list_components
303
+ when "describe_component" then describe_component(arguments)
304
+ when "check" then check(arguments)
305
+ when "list_blocks" then list_blocks
306
+ when "describe_block" then describe_block(arguments)
307
+ when "list_recipes" then list_recipes
308
+ when "get_skill" then get_skill(arguments)
309
+ when "guidance" then guidance(arguments)
310
+ else return tool_content("unknown tool: #{name}", error: true)
311
+ end
312
+ tool_content(text)
313
+ end
314
+
315
+ # --- the tools (project the built surfaces) ---
316
+
317
+ def list_components
318
+ @entries.map do |path, entry|
319
+ interactive = entry["controllers"]&.any? ? " [interactive]" : ""
320
+ "- #{title(path)} (`#{helper(path)}`)#{interactive}: #{summary(entry)}"
321
+ end.join("\n")
322
+ end
323
+
324
+ def describe_component(arguments)
325
+ path = resolve(arguments["name"])
326
+ return "no such component: #{arguments["name"].inspect} - call list_components" unless path
327
+
328
+ entry = @entries.fetch(path)
329
+ detail = arguments.fetch("detail", "detailed")
330
+ lines = ["# #{title(path)} (`#{helper(path)}`)", summary(entry)]
331
+ lines.concat(surface_lines(entry)) if %w[detailed full].include?(detail)
332
+ lines.concat(full_lines(entry)) if detail == "full"
333
+ # The block back-reference: a screen containing this
334
+ # component should start from the vetted composition.
335
+ blocks = @blocks.select { |_name, block| block["components"].include?(title(path)) }.keys
336
+ if blocks.any?
337
+ lines << "- in blocks: #{blocks.join(", ")} - for a screen, start there (describe_block), " \
338
+ "not from scratch"
339
+ end
340
+ lines.join("\n")
341
+ end
342
+
343
+ def check(arguments)
344
+ findings = Poetry::Core::Check.lint(arguments["source"].to_s, catalog: @catalog)
345
+ errors = findings.count { |finding| finding.severity == :error }
346
+ verdict = errors.zero? ? "PASS" : "FAIL"
347
+ report = findings.map do |finding|
348
+ hint = finding.suggestion ? " (did you mean #{finding.suggestion}?)" : ""
349
+ "- [#{finding.severity}] line #{finding.line}: #{finding.message}#{hint}"
350
+ end
351
+ (["#{verdict} - #{errors} error(s), #{findings.length - errors} warning(s)"] + report).join("\n")
352
+ end
353
+
354
+ # The compose router: the unconditional first move. Measured runs
355
+ # proved advisory prose does not move behavior - the design skill
356
+ # fired in 24/31 arms and composition did not move, while the
357
+ # blocks surface (the one measured composition win, 21-9) sat
358
+ # untouched in 28/31. compose makes blocks the DEFAULT path: one
359
+ # call routes any brief either into a vetted block (source inline,
360
+ # nothing left to decide) or to the matching components. Scoring
361
+ # is deterministic lexical overlap - curated block keywords count
362
+ # double, title/description tokens count once, all over distinct
363
+ # stems (calibrated against the benchmark brief set: page-scale
364
+ # briefs route to their block, single-component briefs stay on the
365
+ # component path).
366
+ def compose(arguments)
367
+ brief = arguments["brief"].to_s.strip
368
+ return "compose needs the brief text - pass the task description verbatim.\n#{list_blocks}" if brief.empty?
369
+
370
+ tokens = brief_tokens(brief)
371
+ scored = @blocks.map { |name, entry| [name, entry, block_score(entry, tokens)] }
372
+ .sort_by { |name, _entry, score| [-score, name] }
373
+ components = matched_components(tokens)
374
+ best = scored.first
375
+ if best && best[2] >= STRONG_MATCH
376
+ block_route(scored, components)
377
+ else
378
+ component_route(scored, components)
379
+ end
380
+ end
381
+
382
+ # Distinct stems (crude: trailing-s stripped), so "settings" and
383
+ # "setting" are ONE hit, not a double-counted pair.
384
+ def brief_tokens(brief)
385
+ stems = brief.downcase.scan(/[a-z0-9][a-z0-9_-]+/).map { |token| token.delete_suffix("s") }
386
+ stems.uniq.reject { |stem| stem.length < 3 || STOPWORDS.include?(stem) }.to_set
387
+ end
388
+
389
+ # Curated keywords count double; title/description tokens count
390
+ # once. Component names stay OUT of the corpus - a lone-button
391
+ # brief must not route to a block that happens to compose buttons.
392
+ # Distinct stem hits only - repetition is not signal.
393
+ def block_score(entry, tokens)
394
+ keywords = (entry["keywords"] || []).to_set { |word| word.delete_suffix("s") }
395
+ corpus = "#{entry["title"]} #{entry["description"]}"
396
+ corpus_tokens = corpus.downcase.scan(/[a-z0-9][a-z0-9_-]+/)
397
+ .to_set { |token| token.delete_suffix("s") }
398
+ tokens.sum do |token|
399
+ if keywords.include?(token) then 2
400
+ elsif corpus_tokens.include?(token) then 1
401
+ else 0
402
+ end
403
+ end
404
+ end
405
+
406
+ # Component titles fully present in the brief (every underscore part
407
+ # matched: date_picker needs both "date" and "picker").
408
+ def matched_components(tokens)
409
+ @entries.keys.map { |path| title(path) }.select do |name|
410
+ name.split("_").all? { |part| tokens.include?(part.delete_suffix("s")) }
411
+ end.sort.first(8)
412
+ end
413
+
414
+ def block_route(scored, components)
415
+ name, entry, score = scored.first
416
+ runners = scored.drop(1).select { |_n, _e, s| s.positive? }.first(2)
417
+ lines = ["STRONG BLOCK MATCH (score #{score}): start from `#{name}` and adapt it in place.", "",
418
+ "# #{entry["title"]} (`#{name}`)", entry["description"],
419
+ "Composes: #{entry["components"].join(", ")}.",
420
+ "In an app: `bin/rails g poetry:block #{name}` copies it into app/views/blocks/.",
421
+ "", "Source (adapt in place - replace the sample content, keep the structure):", "",
422
+ block_source(entry)]
423
+ if runners.any?
424
+ described = runners.map { |n, e, s| "`#{n}` (#{e["title"]}, score #{s})" }.join(", ")
425
+ lines << ""
426
+ lines << "Runners-up: #{described} - describe_block returns their source."
427
+ end
428
+ matched = components.any? ? " - matched here: #{components.join(", ")}" : ""
429
+ lines << "Fill the gaps with components (describe_component)#{matched}."
430
+ lines << "Finish with the check tool as the LAST action after your final edit."
431
+ lines.join("\n")
432
+ end
433
+
434
+ def component_route(scored, components)
435
+ lines = ["No block covers this brief - component-scale work."]
436
+ lines << if components.any?
437
+ "Matched components: #{components.map { |name| "#{name} (`poetry_#{name}`)" }.join(", ")} - " \
438
+ "describe_component for the contracts."
439
+ else
440
+ "No component name matched either - list_components for the catalog."
441
+ end
442
+ if scored.any?
443
+ lines << ""
444
+ lines << "If this grows into a SCREEN, start from a block (describe_block for source):"
445
+ scored.each { |name, entry, _score| lines << "- #{name}: #{entry["title"]} - #{entry["description"]}" }
446
+ end
447
+ lines << "Finish with the check tool as the LAST action after your final edit."
448
+ lines.join("\n")
449
+ end
450
+
451
+ def block_source(entry)
452
+ Pathname.new(@root).join(entry.fetch("template")).read
453
+ .sub(/\A<%#\s*poetry:block[^%]*%>\n?/, "").rstrip
454
+ rescue StandardError => e
455
+ "block source unavailable: #{e.message} - use `bin/rails g poetry:block` instead"
456
+ end
457
+
458
+ # --- build_page: the guided page workflow ---
459
+ #
460
+ # A stateless state machine: the STEP is carried in the arguments,
461
+ # so #handle stays a pure function and the server stays boot-free -
462
+ # no session storage. Each step response ends with the exact next
463
+ # call. With no step, the entry routes on the intent's VERB:
464
+ # review/harden stay read-only so an audit never becomes an edit;
465
+ # shape plans without probing; implement runs the full sequence.
466
+ def build_page(arguments)
467
+ intent = arguments["intent"].to_s.strip
468
+ return "build_page needs the intent - the page you are building, in a sentence." if intent.empty?
469
+
470
+ step = arguments["step"].to_s.strip
471
+ return route_entry(intent) if step.empty?
472
+
473
+ run_step(step, intent, arguments)
474
+ end
475
+
476
+ # The request-mode router: resolve the mode from the verb BEFORE
477
+ # acting. Only a clear review/harden/shape
478
+ # verb diverts; a page description with no verb is an implement.
479
+ def request_mode(intent)
480
+ text = intent.downcase
481
+ return :review if text.match?(/\b(review|audit|assess|evaluate|inspect|critique)\b/)
482
+ return :harden if text.match?(/\b(harden|a11y|accessib\w*|tighten|secure|polish)\b/)
483
+ if text.match?(/\b(plan|shape|architect|outline|sketch|wireframe)\b/) &&
484
+ !text.match?(/\b(build|create|make|implement|generate|scaffold|add)\b/)
485
+ return :shape
486
+ end
487
+
488
+ :implement
489
+ end
490
+
491
+ def route_entry(intent)
492
+ case request_mode(intent)
493
+ when :review then review_route(intent)
494
+ when :harden then harden_route
495
+ when :shape then shape_entry(intent)
496
+ else implement_entry(intent)
497
+ end
498
+ end
499
+
500
+ def implement_entry(intent)
501
+ banner = ["GUIDED BUILD - mode: implement.",
502
+ "Steps: probe -> plan -> direct -> snippets -> verify; each ends with the next call.",
503
+ "DONE means a PASS from the check tool (an executable verdict), never a claim.", "", ""]
504
+ banner.join("\n") + run_step("probe", intent, {})
505
+ end
506
+
507
+ def shape_entry(intent)
508
+ banner = ["GUIDED BUILD - mode: shape (planning only, no host changes).",
509
+ "You asked to shape, not build - here is the architecture; call step: \"snippets\" " \
510
+ "when you want source.", "", ""]
511
+ banner.join("\n") + run_step("plan", intent, {})
512
+ end
513
+
514
+ def review_route(intent)
515
+ ["REQUEST MODE: review - staying read-only (an audit does not become an edit).",
516
+ "I will not enter the build sequence to review. To assess existing markup:",
517
+ "- run the `check` tool with the ERB source (verdict + findings), or `bin/rails poetry:check`;",
518
+ "- the visual/a11y critique: get_skill(name: \"poetry-design\", file: \"references/audit.md\");",
519
+ "- wrong-component calls: guidance(topic: \"deciding\").", "",
520
+ %(To BUILD instead, re-call with a build verb, e.g. "build #{intent}".)].join("\n")
521
+ end
522
+
523
+ def harden_route
524
+ ["REQUEST MODE: harden - read-only recon, no edits from here.",
525
+ "Hardening runs the same executable gate, not a rewrite:",
526
+ "- `check` the current ERB for contract violations (verdict + findings);",
527
+ "- get_skill(name: \"poetry-design\", file: \"references/audit.md\") for the a11y + slop pass;",
528
+ "- fix through tokens/variants/DESIGN.md, never per-instance CSS; re-run check as the LAST action.",
529
+ "", "To build a NEW page instead, re-call with a build verb."].join("\n")
530
+ end
531
+
532
+ def run_step(step, intent, arguments)
533
+ case step
534
+ when "probe" then framed(1, "PROBE (host setup)", probe_body, "plan")
535
+ when "plan" then framed(2, "PLAN (page architecture)", plan_body(intent), "direct")
536
+ when "direct" then framed(3, "DIRECT (creative direction)", direct_body, "snippets")
537
+ when "snippets" then framed(4, "SNIPPETS (start-from source)", snippets_body(intent), "verify")
538
+ when "verify" then verify_body(arguments)
539
+ else redirect_step(step)
540
+ end
541
+ end
542
+
543
+ def framed(number, label, body, nxt)
544
+ ["STEP #{number}/5 - #{label}", "", body, "",
545
+ %(NEXT -> call build_page again with step: "#{nxt}" (same intent).)].join("\n")
546
+ end
547
+
548
+ def redirect_step(step)
549
+ "no such step: #{step.inspect} - the workflow is #{STEPS.join(" -> ")}. " \
550
+ "Omit step for the guided entry, or pass one of those."
551
+ end
552
+
553
+ # Step 1: host doctor. Reads what it can from @app_root and degrades
554
+ # gracefully (the plan step never depends on it). Also the JS-channel
555
+ # fail-fast: a bundler present without the controllers channel
556
+ # leaves interactive components inert.
557
+ def probe_body
558
+ root = @app_root
559
+ return probe_no_host unless root && File.directory?(root)
560
+
561
+ lines = [host_config_line(root), theme_line(detect_theme(root)), css_mode_line(root)]
562
+ lines.concat(js_pipeline_lines(root))
563
+ lines.join("\n")
564
+ end
565
+
566
+ def probe_no_host
567
+ ["Host app not visible (run `bundle exec poetry-agent` from the app dir for setup checks).",
568
+ "Probe would read: installed components + declared overrides (config/poetry_components.yml),",
569
+ "the installed theme, css_mode, icon set, and importmap-vs-bundler. Assume a standard",
570
+ "poetry install and continue - the plan step does not depend on this."].join("\n")
571
+ end
572
+
573
+ def host_config_line(root)
574
+ cfg = read_host_yaml(File.join(root, "config", "poetry_components.yml"))
575
+ return "poetry config: config/poetry_components.yml not found - run `bin/rails g poetry:install`." unless cfg
576
+
577
+ installed = (cfg["components"] || {}).size
578
+ overrides = (cfg["overrides"] || {}).size
579
+ "poetry config: #{installed} component(s) configured, #{overrides} declared cn-* override(s)."
580
+ end
581
+
582
+ def theme_line(theme)
583
+ return "theme: not detected - the direct step covers picking one of the nine." unless theme
584
+
585
+ "theme: #{theme} (its tokens ARE your creative direction - step 3)."
586
+ end
587
+
588
+ def css_mode_line(root)
589
+ entry = ["app/assets/tailwind/application.css", "tailwind.config.js", "config/tailwind.config.js",
590
+ "app/assets/stylesheets/application.tailwind.css"].any? { |rel| host_exist?(root, rel) }
591
+ if entry
592
+ "css_mode: Tailwind entry found - poetry emits Tailwind classes."
593
+ else
594
+ "css_mode: no Tailwind entry detected - poetry can emit raw BEM when css_mode is set."
595
+ end
596
+ end
597
+
598
+ def js_pipeline_lines(root)
599
+ importmap = host_exist?(root, "config/importmap.rb")
600
+ bundler = %w[vite.config.js vite.config.ts config/vite.json package.json].any? do |rel|
601
+ host_exist?(root, rel)
602
+ end
603
+ if importmap && !bundler
604
+ ["js: importmap - pin poetry's Stimulus controllers so interactive components wire up."]
605
+ elsif bundler
606
+ ["js: a JS bundler is present - poetry's controllers need the @poetry/controllers npm channel",
607
+ "wired, or the interactive components stay inert. Verify the import."]
608
+ else
609
+ ["js: no importmap or bundler detected - poetry's interactive components need one wired."]
610
+ end
611
+ end
612
+
613
+ # Step 2: the intent-matched page architecture (the new IP). Same
614
+ # stem tokenization + weighting as the block router, so plan and
615
+ # compose rank an intent the same way.
616
+ def plan_body(intent)
617
+ tokens = brief_tokens(intent)
618
+ scored = Poetry::Core::PageArchitectures.scored(tokens)
619
+ best, best_score = scored.first
620
+ return no_archetype(scored) if best.nil? || best_score < ARCHETYPE_MATCH
621
+
622
+ [render_archetype(best, best_score), runners_note(scored)].reject(&:empty?).join("\n")
623
+ end
624
+
625
+ def render_archetype(entry, score)
626
+ lines = ["MATCH: #{entry["title"]} (`#{entry["name"]}`) - score #{score}.", entry["purpose"], "",
627
+ start_from(entry), "", "Section order:"]
628
+ entry["sections"].each { |section| lines << "- #{section}" }
629
+ lines << ""
630
+ lines << "States a real screen handles (not just the happy path):"
631
+ entry["states"].each { |state| lines << "- #{state}" }
632
+ lines << ""
633
+ lines << "Edge cases that bite:"
634
+ entry["edge_cases"].each { |edge| lines << "- #{edge}" }
635
+ lines << ""
636
+ lines << "Components: #{entry["components"].join(", ")} (describe_component for the contracts)."
637
+ lines.join("\n")
638
+ end
639
+
640
+ def start_from(entry)
641
+ if entry["block"]
642
+ "Start from block `#{entry["block"]}` - describe_block returns its source; adapt in place " \
643
+ "(the known winning path). Fill gaps with the components below."
644
+ else
645
+ "No single vetted block covers this yet - compose from the components below, applying the " \
646
+ "section order and the five mechanics (get_skill poetry-design references/compose.md)."
647
+ end
648
+ end
649
+
650
+ def runners_note(scored)
651
+ runners = scored.drop(1).select { |_entry, score| score.positive? }.first(2)
652
+ return "" if runners.empty?
653
+
654
+ described = runners.map { |entry, score| "`#{entry["name"]}` (#{entry["title"]}, score #{score})" }
655
+ "\nNearby archetypes: #{described.join(", ")}."
656
+ end
657
+
658
+ def no_archetype(scored)
659
+ near = scored.first(3).map { |entry, _score| entry["name"] }.join(", ")
660
+ ["No archetype strongly matched this intent. The catalog is a SEED " \
661
+ "(#{Poetry::Core::PageArchitectures.all.size} archetypes; it grows toward the ~50 target).",
662
+ "Fall back to `compose` for block/component routing, and the five composition mechanics " \
663
+ "(get_skill poetry-design references/compose.md).",
664
+ "Nearest archetypes if one fits: #{near}."].join("\n")
665
+ end
666
+
667
+ # Step 3: theme-derived creative direction. poetry's direction is
668
+ # design-system-constrained by construction - it delegates
669
+ # to the authored per-theme vocabulary rather than restating it.
670
+ def direct_body
671
+ theme = @app_root && detect_theme(@app_root)
672
+ lines = ["poetry's creative direction is THEME-DERIVED, not a freeform trend pick: the installed",
673
+ "theme's typography, radius, motion, and color vocabulary IS the direction. Coherence is",
674
+ "the product; range comes from the nine themes + a brand DESIGN.md, not per-page CSS.", ""]
675
+ lines << if theme
676
+ "Installed theme: `#{theme}`. Build inside its vocabulary - its tokens carry the look."
677
+ else
678
+ "No theme detected here. Pick one of the nine at install (`--theme`); each is distinct."
679
+ end
680
+ lines << "Per-theme vocabulary: get_skill(name: \"poetry-design\", file: \"references/theme.md\")."
681
+ lines << if @app_root && design_md_present?(@app_root)
682
+ "A DESIGN.md is present - it is the brand-override door on top of the theme; honor it."
683
+ else
684
+ "No DESIGN.md here - study a brand into one (poetry-design `study`) to override the theme."
685
+ end
686
+ lines << "Do NOT add gradients, shadows, or arbitrary colors for 'personality' - that is drift."
687
+ lines.join("\n")
688
+ end
689
+
690
+ # Step 4: the concrete source, routed exactly as the compose tool
691
+ # (block match inline, or the matching components). compose already
692
+ # closes with "check as the LAST action", which dovetails into verify.
693
+ def snippets_body(intent)
694
+ ["The source to start from (routed like the compose tool):", "", compose("brief" => intent)].join("\n")
695
+ end
696
+
697
+ # Step 5: the executable gate. DONE requires a PASS from the same
698
+ # check tool - not an attestation. This is the structural edge over
699
+ # a workflow whose completion is an LLM's word.
700
+ def verify_body(arguments)
701
+ source = arguments["source"].to_s
702
+ if source.strip.empty?
703
+ return ["STEP 5/5 - VERIFY (the executable gate)", "",
704
+ "Pass your built ERB as `source` to run check here, or run `bin/rails poetry:check`.",
705
+ "The workflow is DONE only on a PASS - an edit after your last check is unverified.",
706
+ %(When ready: build_page(intent: "...", step: "verify", source: "<your ERB>").)].join("\n")
707
+ end
708
+
709
+ report = check("source" => source)
710
+ header = if report.start_with?("PASS")
711
+ "STEP 5/5 - VERIFY: PASS - the guided build is DONE."
712
+ else
713
+ "STEP 5/5 - VERIFY: FAIL - not done. Fix the findings and re-run verify."
714
+ end
715
+ [header, "", report].join("\n")
716
+ end
717
+
718
+ # --- host-file probes (best-effort, always graceful) ---
719
+
720
+ def read_host_yaml(path)
721
+ return nil unless File.file?(path)
722
+
723
+ require "yaml"
724
+ YAML.safe_load_file(path, permitted_classes: [Symbol], aliases: true)
725
+ rescue StandardError
726
+ nil
727
+ end
728
+
729
+ # Header sniff for the installed theme: the `.style-<name>` scope the
730
+ # docs switcher uses, or a `poetry theme: <name>` marker. First 4KB
731
+ # is enough; any read error just means "not detected".
732
+ def detect_theme(root)
733
+ %w[app/assets/stylesheets/poetry.css app/assets/stylesheets/application.css
734
+ app/assets/tailwind/application.css app/assets/builds/poetry.css].each do |rel|
735
+ path = File.join(root, rel)
736
+ next unless File.file?(path)
737
+
738
+ head = File.read(path, 4096).to_s
739
+ match = head.match(/\bstyle-(#{THEMES.join("|")})\b/) || head.match(/poetry theme:\s*(\w+)/i)
740
+ return match[1] if match
741
+ rescue StandardError
742
+ next
743
+ end
744
+ nil
745
+ end
746
+
747
+ def design_md_present?(root)
748
+ %w[DESIGN.md config/DESIGN.md app/assets/DESIGN.md].any? { |rel| host_exist?(root, rel) }
749
+ end
750
+
751
+ def host_exist?(root, rel)
752
+ File.exist?(File.join(root, rel))
753
+ end
754
+
755
+ def list_blocks
756
+ return "no blocks in this registry" if @blocks.empty?
757
+
758
+ @blocks.map do |name, entry|
759
+ "- #{name}: #{entry["title"]} - #{entry["description"]} " \
760
+ "[composes: #{entry["components"].join(", ")}]"
761
+ end.join("\n")
762
+ end
763
+
764
+ def list_recipes
765
+ return "no recipes in this registry" if @recipes.empty?
766
+
767
+ @recipes.map do |recipe|
768
+ targets = recipe["files"].map { |file| file["target"] }.join(", ")
769
+ "- #{recipe["name"]}: #{recipe["title"]} - #{recipe["description"]} [installs: #{targets}]"
770
+ end.join("\n")
771
+ end
772
+
773
+ # Runtime skill delivery: the SAME
774
+ # files `rails g poetry:skill` installs, served over MCP for hosts
775
+ # that cannot write files. SKILL.md alone first - the skill's own
776
+ # progressive-disclosure design; references load one at a time.
777
+ def get_skill(arguments)
778
+ name = arguments["name"] || "poetry"
779
+ loader = @skills[name]
780
+ unless loader
781
+ available = @skills.keys.sort.join(", ")
782
+ listing = available.empty? ? "this host serves none" : "available here: #{available}"
783
+ return "no skill #{name.inspect} - #{listing}. In an app, " \
784
+ "`bin/rails g poetry:skill` installs them as files instead."
785
+ end
786
+
787
+ files = (@skill_files[name] ||= loader.call)
788
+ file = arguments["file"]
789
+ return skill_menu(name, files) unless file
790
+
791
+ files.fetch(file) do
792
+ "no file #{file.inspect} in the #{name} skill - files: #{files.keys.sort.join(", ")}"
793
+ end
794
+ end
795
+
796
+ def skill_menu(name, files)
797
+ references = files.keys.reject { |path| path == "SKILL.md" }.sort
798
+ menu = ["", "---", "Files in this skill - fetch one with get_skill(name: #{name.inspect}, " \
799
+ "file: \"...\"):"]
800
+ references.each { |path| menu << "- #{path}" }
801
+ files.fetch("SKILL.md") + menu.join("\n")
802
+ end
803
+
804
+ # Curated guidance topics: the same text the installed
805
+ # usage skill carries, reachable when only the MCP is connected.
806
+ def guidance(arguments)
807
+ topic = arguments["topic"].to_s
808
+ topics = { "deciding" => -> { Poetry::Core::SkillText.deciding_reference } }
809
+ entry = topics[topic]
810
+
811
+ return entry.call if entry
812
+
813
+ "no such topic: #{topic.inspect} - topics: #{topics.keys.join(", ")}"
814
+ end
815
+
816
+ def describe_block(arguments)
817
+ name = arguments["name"].to_s.tr("_", "-")
818
+ entry = @blocks[name]
819
+ return "no such block: #{arguments["name"].inspect} - call list_blocks" unless entry
820
+
821
+ source = Pathname.new(@root).join(entry.fetch("template")).read
822
+ .sub(/\A<%#\s*poetry:block[^%]*%>\n?/, "").rstrip
823
+ ["# #{entry["title"]} (`#{name}`)", entry["description"],
824
+ "Composes: #{entry["components"].join(", ")}.",
825
+ "In an app: `bin/rails g poetry:block #{name}` copies this into app/views/blocks/.",
826
+ "", "Source (adapt freely - the sample content is meant to be replaced):", "", source].join("\n")
827
+ rescue StandardError => e
828
+ "block source unavailable: #{e.message}"
829
+ end
830
+
831
+ # --- shared projections ---
832
+
833
+ def surface_lines(entry)
834
+ lines = []
835
+ lines << "- content block REQUIRED (#{entry["requires_content"]})" if entry["requires_content"]
836
+ (entry["required_slots"] || {}).each do |setter, hint|
837
+ lines << "- slot REQUIRED: with_#{setter} (#{hint}) - a call without it raises"
838
+ end
839
+ (entry["requires_any"] || []).each do |group|
840
+ parts = []
841
+ parts << "a content block" if group["content"]
842
+ parts.concat((group["slots"] || []).map { |name| "with_#{name}" })
843
+ parts.concat((group["options"] || []).map { |key| "#{key}:" })
844
+ lines << "- REQUIRED - one of #{parts.join(" / ")} (#{group["hint"]})"
845
+ end
846
+ (entry["styles"] + entry["options"]).each do |prop|
847
+ facets = []
848
+ facets << prop["variants"].join("|") if prop["variants"]
849
+ facets << "required" if prop["required"] && !prop.key?("default")
850
+ facets << "format: #{prop["format"]}" if prop["format"]
851
+ suffix = facets.any? ? " (#{facets.join("; ")})" : ""
852
+ description = prop["description"] ? " - #{prop["description"]}" : ""
853
+ lines << "- #{prop["name"]}: #{prop["type"]}#{suffix}#{description}"
854
+ end
855
+ slots = (entry["slots"] || []).map do |slot|
856
+ facets = []
857
+ facets << slot["description"] if slot["description"]
858
+ if slot["types"]
859
+ args = slot["setter_args"]
860
+ convention = args && slot["types"].all? { |type| args[type]&.zero? } ? " - options as keywords" : ""
861
+ facets << "types #{slot["types"].join("|")}#{convention}"
862
+ end
863
+ facets << "takes #{helper(slot["component"])} props, not a block" if slot["component"]
864
+ # The render-crash seams, stated where agents read them.
865
+ if (yieldless = slot["yieldless"])
866
+ setters = yieldless.map { |name| "with_#{name}" }.join("/")
867
+ facets << "#{setters} #{yieldless.size == 1 ? "yields" : "yield"} NOTHING to the block - no |param|"
868
+ end
869
+ (slot["setter_kwargs"] || {}).each do |setter, keywords|
870
+ facets << "with_#{setter} keywords: #{keywords.map { |keyword| "#{keyword}:" }.join(", ")} ONLY"
871
+ end
872
+ (slot["required_content"] || {}).each do |setter, hint|
873
+ facets << "with_#{setter} REQUIRES a content block (#{hint})"
874
+ end
875
+ (slot["builders"] || {}).each do |setter, surface|
876
+ (surface["required_slots"] || {}).each do |required, hint|
877
+ facets << "each with_#{setter} REQUIRES with_#{required} inside its block (#{hint})"
878
+ end
879
+ end
880
+ "#{slot["name"]}#{" (#{facets.join("; ")})" if facets.any?}"
881
+ end
882
+ lines << "- slots: #{slots.join(", ")}" if slots.any?
883
+ lines
884
+ end
885
+
886
+ def full_lines(entry)
887
+ lines = (entry["controllers"] || []).map do |controller|
888
+ "- wiring #{controller["identifier"]}: actions #{(controller["actions"] || []).join(", ")}"
889
+ end
890
+ # The styling contract: every data-slot part with its
891
+ # state attributes and var seams, DOM-verified by the
892
+ # part-contract tier - restyle via [data-slot=...], never by
893
+ # guessing at internal markup.
894
+ (entry["parts"] || []).each { |part| lines << part_line(part) }
895
+ # The operate surface: the tools an in-page agent may invoke on a
896
+ # rendered instance once the call opts in (webmcp: "name").
897
+ (entry["tools"] || []).each { |tool| lines << tool_line(tool) }
898
+ (entry["agent_rules"] || []).each { |rule| lines << "- RULE: #{rule}" }
899
+ lines
900
+ end
901
+
902
+ def tool_line(tool)
903
+ schema = tool["inputSchema"] || {}
904
+ required = schema["required"] || []
905
+ params = (schema["properties"] || {}).map do |name, spec|
906
+ facets = [spec["type"]]
907
+ facets << "required" if required.include?(name)
908
+ facets << "one of #{spec["enum"].join("|")}" if spec["enum"]
909
+ "#{name} (#{facets.join(", ")})"
910
+ end
911
+ hint = tool.dig("annotations", "readOnlyHint") ? "read-only" : "mutating"
912
+ facets = [hint]
913
+ facets << "params: #{params.join(", ")}" if params.any?
914
+ "- tool #{tool["name"]} (#{facets.join("; ")}) - #{tool["description"]} " \
915
+ "[opt in with webmcp: \"name\" on the call; dispatches #{tool["executes"]}]"
916
+ end
917
+
918
+ def part_line(part)
919
+ facets = []
920
+ states = (part["states"] || []).map do |state|
921
+ values = state["values"] ? "=#{state["values"].join("|")}" : ""
922
+ "#{state["attr"]}#{values} (#{state["condition"]})"
923
+ end
924
+ facets << "states: #{states.join("; ")}" if states.any?
925
+ vars = (part["vars"] || []).map { |var| "#{var["name"]} (#{var["description"]})" }
926
+ facets << "vars: #{vars.join("; ")}" if vars.any?
927
+ "- part [data-slot=#{part["name"]}] - #{part["description"]}" \
928
+ "#{" | #{facets.join(" | ")}" if facets.any?}"
929
+ end
930
+
931
+ def summary(entry)
932
+ return entry["description"] if entry["description"]
933
+
934
+ styles = entry["styles"].map { |style| style["variants"] ? style["variants"].join("|") : style["name"] }
935
+ styles.empty? ? "no style attributes" : styles.join("; ")
936
+ end
937
+
938
+ # name may be the title (button, command_dialog) or the full path.
939
+ def resolve(name)
940
+ return name if @entries.key?(name)
941
+
942
+ @entries.keys.find { |path| title(path) == name }
943
+ end
944
+
945
+ def title(path) = path.split("/").drop(2).join("_")
946
+ def helper(path) = "poetry_#{title(path)}"
947
+
948
+ def result(id, value) = { "jsonrpc" => "2.0", "id" => id, "result" => value }
949
+
950
+ def error(id, code,
951
+ message)
952
+ { "jsonrpc" => "2.0", "id" => id, "error" => { "code" => code, "message" => message } }
953
+ end
954
+
955
+ def tool_content(text,
956
+ error: false)
957
+ { "content" => [{ "type" => "text", "text" => text }], "isError" => error }
958
+ end
959
+ end
960
+ end
961
+ end
962
+ end