solid_loop 0.0.4

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 (137) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +156 -0
  3. data/CODE_OF_CONDUCT.md +67 -0
  4. data/CONTRIBUTING.md +82 -0
  5. data/MIT-LICENSE +21 -0
  6. data/README.md +483 -0
  7. data/Rakefile +11 -0
  8. data/app/assets/javascripts/solid_loop/chart.umd.min.js +14 -0
  9. data/app/assets/javascripts/solid_loop/chartjs-adapter-date-fns.bundle.min.js +7 -0
  10. data/app/assets/javascripts/solid_loop/chartkick.min.js +2 -0
  11. data/app/assets/stylesheets/solid_loop/application.css +15 -0
  12. data/app/controllers/solid_loop/application_controller.rb +29 -0
  13. data/app/controllers/solid_loop/dashboard_controller.rb +104 -0
  14. data/app/controllers/solid_loop/events_controller.rb +12 -0
  15. data/app/controllers/solid_loop/loops_controller.rb +77 -0
  16. data/app/controllers/solid_loop/mcp_inbound_sessions_controller.rb +13 -0
  17. data/app/controllers/solid_loop/mcp_sessions_controller.rb +68 -0
  18. data/app/controllers/solid_loop/mcp_tools_controller.rb +16 -0
  19. data/app/controllers/solid_loop/messages_controller.rb +12 -0
  20. data/app/controllers/solid_loop/tool_calls_controller.rb +12 -0
  21. data/app/helpers/solid_loop/application_helper.rb +125 -0
  22. data/app/jobs/solid_loop/application_job.rb +13 -0
  23. data/app/jobs/solid_loop/llm_completion_job.rb +139 -0
  24. data/app/jobs/solid_loop/observe_broadcast_job.rb +15 -0
  25. data/app/jobs/solid_loop/reaper_job.rb +20 -0
  26. data/app/jobs/solid_loop/tool_execution_job.rb +200 -0
  27. data/app/models/solid_loop/application_record.rb +5 -0
  28. data/app/models/solid_loop/base.rb +190 -0
  29. data/app/models/solid_loop/event.rb +7 -0
  30. data/app/models/solid_loop/loop.rb +225 -0
  31. data/app/models/solid_loop/mcp_inbound_session.rb +25 -0
  32. data/app/models/solid_loop/mcp_session.rb +12 -0
  33. data/app/models/solid_loop/mcp_tool.rb +26 -0
  34. data/app/models/solid_loop/message.rb +69 -0
  35. data/app/models/solid_loop/tool_call.rb +42 -0
  36. data/app/queries/solid_loop/admin/base_query.rb +23 -0
  37. data/app/queries/solid_loop/admin/events_query.rb +31 -0
  38. data/app/queries/solid_loop/admin/loops_query.rb +38 -0
  39. data/app/queries/solid_loop/admin/mcp_inbound_sessions_query.rb +31 -0
  40. data/app/queries/solid_loop/admin/mcp_sessions_query.rb +26 -0
  41. data/app/queries/solid_loop/admin/mcp_tools_query.rb +26 -0
  42. data/app/queries/solid_loop/admin/messages_query.rb +37 -0
  43. data/app/queries/solid_loop/admin/tool_calls_query.rb +34 -0
  44. data/app/services/solid_loop/adapters/native.rb +170 -0
  45. data/app/services/solid_loop/dialects/anthropic.rb +160 -0
  46. data/app/services/solid_loop/dialects/gemini.rb +122 -0
  47. data/app/services/solid_loop/dialects/open_ai.rb +62 -0
  48. data/app/services/solid_loop/dialects/reasoning_packer.rb +37 -0
  49. data/app/services/solid_loop/llm_usage_parser/anthropic.rb +22 -0
  50. data/app/services/solid_loop/llm_usage_parser/gemini.rb +18 -0
  51. data/app/services/solid_loop/llm_usage_parser/llama.rb +15 -0
  52. data/app/services/solid_loop/llm_usage_parser/openai.rb +18 -0
  53. data/app/services/solid_loop/llm_usage_parser.rb +19 -0
  54. data/app/services/solid_loop/mcp_session_initializer.rb +205 -0
  55. data/app/services/solid_loop/mcp_tool_execution_service.rb +153 -0
  56. data/app/services/solid_loop/middlewares/agent_initialization.rb +37 -0
  57. data/app/services/solid_loop/middlewares/error_handling.rb +107 -0
  58. data/app/services/solid_loop/middlewares/event_logging.rb +100 -0
  59. data/app/services/solid_loop/middlewares/message_building.rb +92 -0
  60. data/app/services/solid_loop/middlewares/network_calling.rb +84 -0
  61. data/app/services/solid_loop/middlewares/response_parsing.rb +348 -0
  62. data/app/services/solid_loop/middlewares/tool_call_xml_parser.rb +117 -0
  63. data/app/services/solid_loop/sse_stream_aggregator.rb +105 -0
  64. data/app/services/solid_loop/tool_failure_reconciler.rb +122 -0
  65. data/app/services/solid_loop/tool_middlewares/agent_initialization.rb +17 -0
  66. data/app/services/solid_loop/tool_middlewares/error_handling.rb +66 -0
  67. data/app/services/solid_loop/tool_middlewares/event_logging.rb +27 -0
  68. data/app/services/solid_loop/tool_middlewares/response_creation.rb +110 -0
  69. data/app/services/solid_loop/tool_middlewares/tool_execution.rb +206 -0
  70. data/app/views/layouts/solid_loop/admin.html.erb +416 -0
  71. data/app/views/layouts/solid_loop/application.html.erb +17 -0
  72. data/app/views/solid_loop/dashboard/index.html.erb +184 -0
  73. data/app/views/solid_loop/events/index.html.erb +47 -0
  74. data/app/views/solid_loop/events/show.html.erb +76 -0
  75. data/app/views/solid_loop/loops/index.html.erb +83 -0
  76. data/app/views/solid_loop/loops/show.html.erb +148 -0
  77. data/app/views/solid_loop/mcp_inbound_sessions/index.html.erb +53 -0
  78. data/app/views/solid_loop/mcp_inbound_sessions/show.html.erb +78 -0
  79. data/app/views/solid_loop/mcp_sessions/_tool_output.html.erb +11 -0
  80. data/app/views/solid_loop/mcp_sessions/index.html.erb +46 -0
  81. data/app/views/solid_loop/mcp_sessions/inspector.html.erb +94 -0
  82. data/app/views/solid_loop/mcp_sessions/show.html.erb +142 -0
  83. data/app/views/solid_loop/mcp_tools/index.html.erb +44 -0
  84. data/app/views/solid_loop/mcp_tools/show.html.erb +69 -0
  85. data/app/views/solid_loop/messages/_message.html.erb +75 -0
  86. data/app/views/solid_loop/messages/index.html.erb +80 -0
  87. data/app/views/solid_loop/messages/show.html.erb +121 -0
  88. data/app/views/solid_loop/shared/_pagination.html.erb +21 -0
  89. data/app/views/solid_loop/tool_calls/index.html.erb +53 -0
  90. data/app/views/solid_loop/tool_calls/show.html.erb +59 -0
  91. data/config/routes.rb +27 -0
  92. data/db/migrate/20260408000100_solid_loop_init.rb +136 -0
  93. data/db/migrate/20260709000100_solid_loop_create_mcp_inbound_sessions.rb +16 -0
  94. data/db/migrate/20260713000100_solid_loop_add_execution_guards.rb +6 -0
  95. data/db/migrate/20260714000100_solid_loop_add_message_steering.rb +17 -0
  96. data/db/migrate/20260714000200_solid_loop_add_message_conversation_order.rb +14 -0
  97. data/db/migrate/20260715000100_solid_loop_add_mcp_inbound_session_principal_key.rb +43 -0
  98. data/db/migrate/20260715000200_solid_loop_add_llm_lease.rb +40 -0
  99. data/db/migrate/20260715000300_solid_loop_add_tool_lease.rb +33 -0
  100. data/db/migrate/20260715000400_solid_loop_add_lease_running_check.rb +32 -0
  101. data/db/migrate/20260715000500_solid_loop_add_tool_lease_pair_check.rb +35 -0
  102. data/docs/contributing/coverage.md +64 -0
  103. data/docs/decisions/durable_attempt_lease.md +364 -0
  104. data/docs/decisions/mcp-only-tooling.md +135 -0
  105. data/docs/decisions/mcp-server.md +223 -0
  106. data/docs/decisions/reasoning_persistence.md +51 -0
  107. data/docs/decisions/ruby_llm_rejected.md +35 -0
  108. data/docs/guides/dialects.md +55 -0
  109. data/docs/guides/llm_middlewares.md +294 -0
  110. data/docs/guides/mcp_transports.md +374 -0
  111. data/docs/guides/tool_middlewares.md +148 -0
  112. data/lib/solid_loop/configuration.rb +175 -0
  113. data/lib/solid_loop/engine.rb +14 -0
  114. data/lib/solid_loop/lease_heartbeat.rb +94 -0
  115. data/lib/solid_loop/lease_renewer.rb +347 -0
  116. data/lib/solid_loop/llm_metrics.rb +16 -0
  117. data/lib/solid_loop/mcp/call_context.rb +19 -0
  118. data/lib/solid_loop/mcp/client_factory.rb +61 -0
  119. data/lib/solid_loop/mcp/http_transport.rb +52 -0
  120. data/lib/solid_loop/mcp/principal.rb +82 -0
  121. data/lib/solid_loop/mcp/result.rb +13 -0
  122. data/lib/solid_loop/mcp/server.rb +246 -0
  123. data/lib/solid_loop/mcp/stdio_transport.rb +224 -0
  124. data/lib/solid_loop/mcp/toolset.rb +347 -0
  125. data/lib/solid_loop/mcp/transport.rb +20 -0
  126. data/lib/solid_loop/mcp.rb +25 -0
  127. data/lib/solid_loop/mcp_client.rb +176 -0
  128. data/lib/solid_loop/pipeline/builder.rb +38 -0
  129. data/lib/solid_loop/pipeline/context.rb +61 -0
  130. data/lib/solid_loop/pipeline/tool_context.rb +53 -0
  131. data/lib/solid_loop/pipeline.rb +40 -0
  132. data/lib/solid_loop/reaper.rb +313 -0
  133. data/lib/solid_loop/version.rb +3 -0
  134. data/lib/solid_loop.rb +94 -0
  135. data/lib/tasks/coverage.rake +206 -0
  136. data/lib/tasks/solid_loop_tasks.rake +4 -0
  137. metadata +228 -0
@@ -0,0 +1,347 @@
1
+ module SolidLoop
2
+ module Mcp
3
+ # In-process MCP server: a toolset IS both the server and its transport.
4
+ # `deliver` dispatches initialize / tools/list / tools/call without HTTP,
5
+ # preserving the JSON boundary (string keys) so local and remote behavior
6
+ # cannot drift. See docs/guides/mcp_transports.md.
7
+ #
8
+ # class SearchTools < SolidLoop::Mcp::Toolset
9
+ # server_name "search"
10
+ #
11
+ # tool "fetch_url",
12
+ # description: "Fetch a URL and return a text summary",
13
+ # input_schema: { type: "object", properties: {} } do |args, ctx|
14
+ # Search::Fetch.call(loop: ctx.loop).to_json
15
+ # end
16
+ # end
17
+ class Toolset
18
+ include Transport
19
+
20
+ # Raised inside a tool handler to return an intentional, user-facing
21
+ # message to the caller (passed through verbatim in the isError result).
22
+ # Any OTHER StandardError is treated as an internal fault: it is logged
23
+ # server-side and the caller gets an opaque message, so SQL/paths/tenant
24
+ # detail never leak across the public boundary. See handle_tools_call.
25
+ class PublicToolError < StandardError; end
26
+
27
+ # Opaque text returned for an unexpected internal exception.
28
+ OPAQUE_TOOL_ERROR = "Internal error executing tool".freeze
29
+
30
+ ToolDefinition = Struct.new(:name, :description, :input_schema, :handler, keyword_init: true)
31
+ PromptDefinition = Struct.new(:name, :description, :arguments, :handler, keyword_init: true)
32
+
33
+ # A focused, dependency-free validator for the subset of JSON Schema the
34
+ # DSL actually uses: object `required` and top-level `properties` types
35
+ # (plus `additionalProperties: false` to reject unknown fields). It is not
36
+ # a full JSON-Schema implementation — the aim is to keep obviously wrong
37
+ # calls out of handlers, not to validate deep structures.
38
+ module SchemaValidator
39
+ # JSON Schema type name => a predicate on the (string-keyed, JSON-parsed)
40
+ # argument value.
41
+ TYPE_CHECKS = {
42
+ "string" => ->(v) { v.is_a?(String) },
43
+ "integer" => ->(v) { v.is_a?(Integer) },
44
+ "number" => ->(v) { v.is_a?(Numeric) && !v.is_a?(TrueClass) && !v.is_a?(FalseClass) },
45
+ "boolean" => ->(v) { v == true || v == false },
46
+ "array" => ->(v) { v.is_a?(Array) },
47
+ "object" => ->(v) { v.is_a?(Hash) },
48
+ "null" => ->(v) { v.nil? }
49
+ }.freeze
50
+
51
+ # Returns a human-readable violation string, or nil when arguments
52
+ # satisfy the schema. A non-object schema (or a schema whose type is not
53
+ # "object") is treated as "no constraints" — validation is best-effort.
54
+ def self.violation(input_schema, arguments)
55
+ schema = normalize(input_schema)
56
+ return nil unless schema.is_a?(Hash)
57
+
58
+ type = schema["type"]
59
+ return nil if type && type != "object"
60
+ return "arguments must be an object" unless arguments.is_a?(Hash)
61
+
62
+ required_violation(schema, arguments) ||
63
+ additional_violation(schema, arguments) ||
64
+ property_type_violation(schema, arguments)
65
+ end
66
+
67
+ def self.required_violation(schema, arguments)
68
+ missing = Array(schema["required"]).reject { |name| arguments.key?(name.to_s) }
69
+ "missing required argument(s): #{missing.join(', ')}" if missing.any?
70
+ end
71
+
72
+ def self.additional_violation(schema, arguments)
73
+ return nil unless schema["additionalProperties"] == false
74
+
75
+ allowed = (schema["properties"] || {}).keys
76
+ extra = arguments.keys - allowed
77
+ "unexpected argument(s): #{extra.join(', ')}" if extra.any?
78
+ end
79
+
80
+ def self.property_type_violation(schema, arguments)
81
+ properties = schema["properties"]
82
+ return nil unless properties.is_a?(Hash)
83
+
84
+ properties.each do |name, spec|
85
+ next unless arguments.key?(name)
86
+
87
+ expected = spec.is_a?(Hash) ? spec["type"] : nil
88
+ next unless expected
89
+
90
+ checks = Array(expected).map { |t| TYPE_CHECKS[t] }
91
+ # If any declared type is unknown to us, don't enforce this property
92
+ # (best-effort — we can't judge what we don't understand).
93
+ next if checks.any?(&:nil?)
94
+
95
+ ok = checks.any? { |check| check.call(arguments[name]) }
96
+ return "argument '#{name}' must be #{Array(expected).join(' or ')}" unless ok
97
+ end
98
+ nil
99
+ end
100
+
101
+ # Schemas are declared with symbol keys in the DSL but arrive JSON-parsed
102
+ # (string keys) over the wire; normalize to string keys so both match.
103
+ def self.normalize(value)
104
+ return value unless value.is_a?(Hash) || value.is_a?(Array)
105
+
106
+ JSON.parse(JSON.generate(value))
107
+ end
108
+ end
109
+
110
+ class << self
111
+ def server_name(value = nil)
112
+ @server_name = value.to_s if value
113
+
114
+ klass = self
115
+ while klass.respond_to?(:own_tools)
116
+ explicit = klass.instance_variable_get(:@server_name)
117
+ return explicit if explicit
118
+ klass = klass.superclass
119
+ end
120
+
121
+ name.to_s.demodulize.underscore
122
+ end
123
+
124
+ def tool(tool_name, description:, input_schema:, &block)
125
+ raise ArgumentError, "tool '#{tool_name}' requires a block" unless block
126
+
127
+ own_tools[tool_name.to_s] = ToolDefinition.new(
128
+ name: tool_name.to_s,
129
+ description: description,
130
+ input_schema: input_schema,
131
+ handler: block
132
+ )
133
+ end
134
+
135
+ # A reusable, parameterized message template served via prompts/get.
136
+ # The block returns a String (wrapped into a single user message) or a
137
+ # ready-made messages array.
138
+ #
139
+ # prompt "audit_report",
140
+ # description: "Skeleton for a period audit",
141
+ # arguments: [ { name: "period", description: "e.g. 2026-Q1", required: true } ] do |args, ctx|
142
+ # "Audit the #{args['period']} period and list anomalies."
143
+ # end
144
+ def prompt(prompt_name, description:, arguments: [], &block)
145
+ raise ArgumentError, "prompt '#{prompt_name}' requires a block" unless block
146
+
147
+ own_prompts[prompt_name.to_s] = PromptDefinition.new(
148
+ name: prompt_name.to_s,
149
+ description: description,
150
+ arguments: arguments,
151
+ handler: block
152
+ )
153
+ end
154
+
155
+ # Declared tools, including those inherited from parent toolsets.
156
+ def tools
157
+ parent = superclass.respond_to?(:tools) ? superclass.tools : {}
158
+ parent.merge(own_tools)
159
+ end
160
+
161
+ def own_tools
162
+ @own_tools ||= {}
163
+ end
164
+
165
+ def prompts
166
+ parent = superclass.respond_to?(:prompts) ? superclass.prompts : {}
167
+ parent.merge(own_prompts)
168
+ end
169
+
170
+ def own_prompts
171
+ @own_prompts ||= {}
172
+ end
173
+ end
174
+
175
+ def deliver(payload, session_id: nil, context: nil)
176
+ request = json_boundary(payload)
177
+
178
+ body =
179
+ case request["method"]
180
+ when "initialize" then handle_initialize(request, context) { |sid| session_id = sid }
181
+ when "tools/list" then success_envelope(request, "tools" => tool_specs)
182
+ when "tools/call" then handle_tools_call(request, context)
183
+ when "prompts/list" then success_envelope(request, "prompts" => prompt_specs)
184
+ when "prompts/get" then handle_prompts_get(request, context)
185
+ else error_envelope(request, -32601, "Method not found: #{request['method']}")
186
+ end
187
+
188
+ Result.new(
189
+ body: body,
190
+ session_id: session_id,
191
+ raw_request: payload,
192
+ raw_response: body,
193
+ status: 200
194
+ )
195
+ end
196
+
197
+ # Toolsets are stateless by default (nil => SolidLoop synthesizes a
198
+ # session id). Override to mint a real session id and receive it back
199
+ # on subsequent calls.
200
+ def on_initialize(_params, _context)
201
+ nil
202
+ end
203
+
204
+ private
205
+
206
+ def handle_initialize(request, context)
207
+ yield on_initialize(request["params"] || {}, context)
208
+
209
+ success_envelope(request,
210
+ "protocolVersion" => "2024-11-05",
211
+ "capabilities" => capabilities,
212
+ "serverInfo" => { "name" => self.class.server_name, "version" => SolidLoop::VERSION })
213
+ end
214
+
215
+ # prompts is advertised only when prompts are actually declared.
216
+ def capabilities
217
+ caps = { "tools" => {} }
218
+ caps["prompts"] = {} if self.class.prompts.any?
219
+ caps
220
+ end
221
+
222
+ def handle_tools_call(request, context)
223
+ params = request["params"] || {}
224
+ definition = self.class.tools[params["name"].to_s]
225
+ return error_envelope(request, -32602, "Unknown tool: #{params['name']}") unless definition
226
+
227
+ arguments = params["arguments"] || {}
228
+
229
+ # Enforce the advertised inputSchema before dispatch: missing/wrong-typed
230
+ # arguments are a caller error (-32602), not something the handler should
231
+ # ever see. Returned as a protocol error, distinct from an isError result.
232
+ violation = SchemaValidator.violation(definition.input_schema, arguments)
233
+ return error_envelope(request, -32602, "Invalid params: #{violation}") if violation
234
+
235
+ # Surface the wire-level idempotency key uniformly: handlers reached
236
+ # through Mcp::Server (external client) get it from `_meta` just like
237
+ # in-process handlers get it from McpToolExecutionService.
238
+ if context && context.idempotency_key.nil?
239
+ context.idempotency_key = params.dig("_meta", "solidloop/idempotencyKey")
240
+ end
241
+
242
+ value = definition.handler.call(arguments, context)
243
+ success_envelope(request, tool_result(value))
244
+ rescue PublicToolError => e
245
+ # Intentional, user-facing message — passed through verbatim.
246
+ success_envelope(request,
247
+ "content" => [ { "type" => "text", "text" => e.message } ],
248
+ "isError" => true)
249
+ rescue StandardError => e
250
+ # Unexpected internal fault: log the detail, return an opaque message so
251
+ # SQL/paths/tenant/credential detail never leak to the public boundary.
252
+ log_tool_error(definition.name, e)
253
+ success_envelope(request,
254
+ "content" => [ { "type" => "text", "text" => OPAQUE_TOOL_ERROR } ],
255
+ "isError" => true)
256
+ end
257
+
258
+ def log_tool_error(tool_name, error)
259
+ return unless defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
260
+
261
+ Rails.logger.error(
262
+ "SolidLoop::Mcp::Toolset tool '#{tool_name}' raised " \
263
+ "#{error.class}: #{error.message}\n#{Array(error.backtrace).take(10).join("\n")}"
264
+ )
265
+ end
266
+
267
+ # Unlike tools/call there is no isError shape for prompts, so handler
268
+ # exceptions become a -32603 protocol error instead of propagating.
269
+ def handle_prompts_get(request, context)
270
+ params = request["params"] || {}
271
+ definition = self.class.prompts[params["name"].to_s]
272
+ return error_envelope(request, -32602, "Unknown prompt: #{params['name']}") unless definition
273
+
274
+ arguments = params["arguments"] || {}
275
+ required = json_boundary(definition.arguments).select { |a| a["required"] }.map { |a| a["name"] }
276
+ missing = required.reject { |name| arguments.key?(name) }
277
+ if missing.any?
278
+ return error_envelope(request, -32602, "Missing required prompt arguments: #{missing.join(', ')}")
279
+ end
280
+
281
+ value = definition.handler.call(arguments, context)
282
+ success_envelope(request,
283
+ "description" => definition.description,
284
+ "messages" => prompt_messages(value))
285
+ rescue PublicToolError => e
286
+ # Intentional, user-facing message — passed through verbatim.
287
+ error_envelope(request, -32603, e.message)
288
+ rescue StandardError => e
289
+ # Prompts have no isError shape, so an internal fault becomes a -32603
290
+ # protocol error — but with an OPAQUE message (same leak boundary as
291
+ # tools/call), logging the detail server-side.
292
+ log_tool_error("prompt:#{params['name']}", e)
293
+ error_envelope(request, -32603, OPAQUE_TOOL_ERROR)
294
+ end
295
+
296
+ def prompt_messages(value)
297
+ case value
298
+ when Array then json_boundary(value)
299
+ else [ { "role" => "user", "content" => { "type" => "text", "text" => value.to_s } } ]
300
+ end
301
+ end
302
+
303
+ def prompt_specs
304
+ self.class.prompts.values.map do |p|
305
+ spec = { "name" => p.name, "description" => p.description }
306
+ spec["arguments"] = json_boundary(p.arguments) if p.arguments.any?
307
+ spec
308
+ end
309
+ end
310
+
311
+ def tool_specs
312
+ self.class.tools.values.map do |t|
313
+ {
314
+ "name" => t.name,
315
+ "description" => t.description,
316
+ "inputSchema" => json_boundary(t.input_schema)
317
+ }
318
+ end
319
+ end
320
+
321
+ def tool_result(value)
322
+ case value
323
+ when Hash
324
+ structured = json_boundary(value)
325
+ {
326
+ "content" => [ { "type" => "text", "text" => JSON.generate(structured) } ],
327
+ "structuredContent" => structured
328
+ }
329
+ else
330
+ { "content" => [ { "type" => "text", "text" => value.to_s } ] }
331
+ end
332
+ end
333
+
334
+ def success_envelope(request, result)
335
+ { "jsonrpc" => "2.0", "id" => request["id"], "result" => result }
336
+ end
337
+
338
+ def error_envelope(request, code, message)
339
+ { "jsonrpc" => "2.0", "id" => request["id"], "error" => { "code" => code, "message" => message } }
340
+ end
341
+
342
+ def json_boundary(value)
343
+ JSON.parse(JSON.generate(value))
344
+ end
345
+ end
346
+ end
347
+ end
@@ -0,0 +1,20 @@
1
+ module SolidLoop
2
+ module Mcp
3
+ # The transport port: any object that can deliver a JSON-RPC envelope to an
4
+ # MCP server and return the response. See docs/guides/mcp_transports.md for
5
+ # the full contract (session identity, error semantics, thread-safety,
6
+ # mandatory raw request/response for wire logs).
7
+ module Transport
8
+ # payload — raw JSON-RPC request Hash ({jsonrpc:, id:, method:, params:})
9
+ # session_id — nil for `initialize`; afterwards whatever initialize returned
10
+ # context — SolidLoop::Mcp::CallContext (agent:, loop:), may be nil.
11
+ # HTTP transports typically ignore it (auth travels in
12
+ # headers); in-process transports may use it to reach
13
+ # domain state without a network hop.
14
+ # returns — SolidLoop::Mcp::Result
15
+ def deliver(payload, session_id: nil, context: nil)
16
+ raise NotImplementedError
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,25 @@
1
+ require "solid_loop/mcp/transport"
2
+ require "solid_loop/mcp/result"
3
+ require "solid_loop/mcp/call_context"
4
+ require "solid_loop/mcp/principal"
5
+ require "solid_loop/mcp/http_transport"
6
+ require "solid_loop/mcp/stdio_transport"
7
+ require "solid_loop/mcp/toolset"
8
+ require "solid_loop/mcp/client_factory"
9
+ require "solid_loop/mcp/server"
10
+
11
+ module SolidLoop
12
+ # MCP transport port and built-in adapters. See docs/guides/mcp_transports.md.
13
+ module Mcp
14
+ # Builds a mountable Rack endpoint serving one toolset to external MCP
15
+ # clients. See Mcp::Server and docs/decisions/mcp-server.md.
16
+ #
17
+ # mount: a stable identity for THIS mount point. `server_name` comes from
18
+ # the toolset class, so two mounts of the same class would share one session
19
+ # namespace; pass a distinct `mount:` to keep them isolated. Defaults to the
20
+ # toolset's server_name.
21
+ def self.server(toolset, auth:, mount: nil)
22
+ Server.new(toolset, auth: auth, mount: mount)
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,176 @@
1
+ require "solid_loop/mcp"
2
+
3
+ module SolidLoop
4
+ class McpError < StandardError; end
5
+ class McpSessionInvalidError < McpError; end
6
+ class McpToolError < McpError; end
7
+
8
+ # Thin MCP protocol layer: builds JSON-RPC envelopes and delegates I/O to a
9
+ # transport implementing SolidLoop::Mcp::Transport. Defaults to
10
+ # Mcp::HttpTransport built from the url/token/headers keywords.
11
+ class McpClient
12
+ attr_reader :last_result, :transport
13
+
14
+ def initialize(url = nil, token: nil, timeout: 60, headers: {}, initialize_params: {}, stateless: false, transport: nil, context: nil)
15
+ @initialize_params = initialize_params
16
+ @stateless = stateless
17
+ @transport = transport || Mcp::HttpTransport.new(url: url, token: token, timeout: timeout, headers: headers)
18
+ @context = context
19
+ @last_result = nil
20
+ end
21
+
22
+ def wire_log
23
+ return "No response captured" unless last_result
24
+
25
+ lines = [
26
+ "request: #{pretty_json(last_result.raw_request)}",
27
+ "status: #{last_result.status}",
28
+ "response: #{pretty_json(last_result.raw_response)}"
29
+ ]
30
+ lines << "stderr: #{last_result.stderr}" if last_result.stderr.present?
31
+ lines.join("\n")
32
+ end
33
+
34
+ def initialize_session
35
+ base_params = {
36
+ protocolVersion: "2024-11-05",
37
+ capabilities: {},
38
+ clientInfo: { name: "solid_loop", version: SolidLoop::VERSION }
39
+ }
40
+
41
+ deliver(envelope("initialize", base_params.deep_merge(@initialize_params)))
42
+
43
+ unless success?
44
+ raise McpError, "Failed to initialize MCP session: #{last_result.status} #{body_string}"
45
+ end
46
+ if (error = protocol_error)
47
+ raise McpError, "Failed to initialize MCP session: #{error.to_json}"
48
+ end
49
+
50
+ last_result.session_id || stateless_session_id
51
+ end
52
+
53
+ def list_tools(session_id)
54
+ deliver(envelope("tools/list", {}), session_id: session_id)
55
+ validate_result!(session_id)
56
+
57
+ body_hash.dig("result", "tools") || []
58
+ end
59
+
60
+ # meta: optional MCP `_meta` hash for the tools/call params (e.g. the
61
+ # per-ToolCall idempotency key under "solidloop/idempotencyKey").
62
+ def call_tool(session_id, name, arguments, meta: nil)
63
+ params = { name: name, arguments: arguments }
64
+ params[:_meta] = meta if meta.present?
65
+
66
+ deliver(envelope("tools/call", params), session_id: session_id)
67
+ validate_result!(session_id)
68
+
69
+ process_tool_result(last_result.body)
70
+ end
71
+
72
+ # Capabilities advertised by the server in the last initialize response.
73
+ # Meaningful right after initialize_session.
74
+ def server_capabilities
75
+ body_hash.dig("result", "capabilities") || {}
76
+ end
77
+
78
+ def last_tool_error?
79
+ body_hash.dig("result", "isError") == true
80
+ end
81
+
82
+ def list_prompts(session_id)
83
+ deliver(envelope("prompts/list", {}), session_id: session_id)
84
+ validate_result!(session_id)
85
+
86
+ body_hash.dig("result", "prompts") || []
87
+ end
88
+
89
+ # Returns the MCP prompt result: { "description" => ..., "messages" => [...] }.
90
+ def get_prompt(session_id, name, arguments = {})
91
+ deliver(envelope("prompts/get", { name: name, arguments: arguments }), session_id: session_id)
92
+ validate_result!(session_id)
93
+
94
+ body_hash["result"] || {}
95
+ end
96
+
97
+ def list_resources(session_id)
98
+ deliver(envelope("resources/list", {}), session_id: session_id)
99
+ validate_result!(session_id)
100
+
101
+ body_hash.dig("result", "resources") || []
102
+ end
103
+
104
+ # Returns the MCP resource contents array (text and/or blob entries).
105
+ def read_resource(session_id, uri)
106
+ deliver(envelope("resources/read", { uri: uri }), session_id: session_id)
107
+ validate_result!(session_id)
108
+
109
+ body_hash.dig("result", "contents") || []
110
+ end
111
+
112
+ private
113
+
114
+ # Resets last_result first so a transport-level raise never leaves the
115
+ # previous call's result behind — Events would record the wrong envelope.
116
+ def deliver(payload, session_id: nil)
117
+ @last_result = nil
118
+ @last_result = transport.deliver(payload, session_id: session_id, context: @context)
119
+ end
120
+
121
+ def envelope(method, params)
122
+ { jsonrpc: "2.0", id: SecureRandom.uuid, method: method, params: params }
123
+ end
124
+
125
+ def success?
126
+ (200..299).cover?(last_result.status)
127
+ end
128
+
129
+ def validate_result!(session_id)
130
+ unless success?
131
+ if [ 401, 404 ].include?(last_result.status)
132
+ raise McpSessionInvalidError, "Session #{session_id} is invalid"
133
+ end
134
+
135
+ raise McpError, "MCP Error (HTTP #{last_result.status}): #{body_string}"
136
+ end
137
+
138
+ if (error = protocol_error)
139
+ raise McpError, "MCP protocol error #{error['code']}: #{error['message']}"
140
+ end
141
+ end
142
+
143
+ def protocol_error
144
+ error = body_hash["error"]
145
+ error.is_a?(Hash) ? error : nil
146
+ end
147
+
148
+ def body_hash
149
+ last_result.body.is_a?(Hash) ? last_result.body : {}
150
+ end
151
+
152
+ def body_string
153
+ body = last_result.body
154
+ body.is_a?(Hash) ? body.to_json : body.to_s
155
+ end
156
+
157
+ def stateless_session_id
158
+ raise McpError, "Mcp-Session-Id header missing in response" unless @stateless
159
+ SecureRandom.uuid
160
+ end
161
+
162
+ def pretty_json(payload)
163
+ payload.is_a?(String) ? payload : JSON.pretty_generate(payload)
164
+ end
165
+
166
+ def process_tool_result(body)
167
+ content = body.is_a?(Hash) ? body.dig("result", "content") : nil
168
+ if content.is_a?(Array)
169
+ texts = content.select { |item| item["type"] == "text" }.map { |item| item["text"] }
170
+ texts.join("\n")
171
+ else
172
+ body.is_a?(Hash) ? JSON.pretty_generate(body) : body.to_s
173
+ end
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,38 @@
1
+ module SolidLoop
2
+ class Pipeline
3
+ class Builder
4
+ attr_reader :middlewares
5
+
6
+ def initialize(middlewares = [])
7
+ @middlewares = middlewares.dup
8
+ end
9
+
10
+ def use(middleware)
11
+ @middlewares << middleware
12
+ end
13
+
14
+ def insert_before(target, middleware)
15
+ index = @middlewares.index(target) || 0
16
+ @middlewares.insert(index, middleware)
17
+ end
18
+
19
+ def insert_after(target, middleware)
20
+ index = @middlewares.index(target) || @middlewares.size - 1
21
+ @middlewares.insert(index + 1, middleware)
22
+ end
23
+
24
+ def replace(target, middleware)
25
+ index = @middlewares.index(target)
26
+ if index
27
+ @middlewares[index] = middleware
28
+ else
29
+ @middlewares << middleware
30
+ end
31
+ end
32
+
33
+ def delete(target)
34
+ @middlewares.delete(target)
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,61 @@
1
+ module SolidLoop
2
+ class Pipeline
3
+ class Context
4
+ # Inputs
5
+ attr_accessor :loop, :agent, :model_config
6
+ attr_accessor :messages # ActiveRecord relation
7
+ attr_accessor :payload # The hash that will be sent to the LLM (messages, tools, model, etc)
8
+ attr_accessor :triggering_message
9
+
10
+ # Working state
11
+ attr_accessor :messages_text_for_token_estimation
12
+ attr_accessor :event
13
+ attr_accessor :assistant_message
14
+ attr_accessor :request_started_at, :first_token_at, :content_started_at, :last_ui_update_at
15
+ attr_accessor :aggregator
16
+ attr_accessor :log_buffer, :debug_output
17
+ attr_accessor :timings
18
+
19
+ # Results
20
+ attr_accessor :duration
21
+ attr_accessor :request_url
22
+ attr_accessor :response # Faraday response object
23
+ # Captured (non-2xx) failing-response envelope so the wire-log Event can
24
+ # persist status/headers/body even when the streaming adapter has already
25
+ # drained response.body via on_data.
26
+ attr_accessor :error_status, :error_headers, :error_body
27
+ attr_accessor :normalized_data # Data normalized by the dialect
28
+ attr_accessor :exception
29
+ attr_accessor :detected_reasoning_field
30
+ attr_accessor :metadata # Hash for middlewares to share custom state
31
+ attr_accessor :execution_token
32
+ # The background lease renewer for THIS turn. ResponseParsing
33
+ # (and any other canonical-write site) calls `heartbeat&.check!` before
34
+ # committing, so a worker whose lease was lost mid-stream raises
35
+ # SolidLoop::LostLease instead of writing a turn it no longer owns.
36
+ attr_accessor :heartbeat
37
+
38
+ def initialize(loop_id:, execution_token: nil)
39
+ @loop = SolidLoop::Loop.find(loop_id)
40
+ @execution_token = execution_token
41
+ @metadata = {}
42
+ @payload = {}
43
+ end
44
+
45
+ def loop_active?
46
+ # Fail closed: a context without a generation token never owns the loop.
47
+ @loop.running? && @execution_token.present? && @loop.execution_token == @execution_token
48
+ end
49
+
50
+ def loop_active_fresh?
51
+ @loop.reload
52
+ loop_active?
53
+ end
54
+
55
+ # Used by inner layers to record timing precisely
56
+ def mark_request_started!
57
+ @request_started_at = Time.current
58
+ end
59
+ end
60
+ end
61
+ end