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,374 @@
1
+ # MCP Transports — port, adapters, local toolset, server endpoint
2
+
3
+ **Companions:** [mcp-only-tooling.md](../decisions/mcp-only-tooling.md) — the decision and
4
+ migration guide; [mcp-server.md](../decisions/mcp-server.md) — serving toolsets to
5
+ external clients.
6
+
7
+ Since 0.0.3 every tool in SolidLoop is an MCP tool. MCP is the *interface*;
8
+ where and how the server runs is a *transport* concern. This guide documents
9
+ the transport port, the built-in adapters, and the in-process toolset.
10
+
11
+ ## The port: `SolidLoop::Mcp::Transport`
12
+
13
+ A transport is any object that can deliver a JSON-RPC envelope to an MCP server
14
+ and return the response:
15
+
16
+ ```ruby
17
+ module SolidLoop
18
+ module Mcp
19
+ module Transport
20
+ # payload — raw JSON-RPC request Hash ({jsonrpc:, id:, method:, params:})
21
+ # session_id — nil for `initialize`; afterwards whatever initialize returned
22
+ # context — SolidLoop::Mcp::CallContext (agent:, loop:), may be nil.
23
+ # HTTP transports typically ignore it (auth travels in
24
+ # headers); in-process transports may use it to reach
25
+ # domain state without a network hop.
26
+ # returns — SolidLoop::Mcp::Result
27
+ def deliver(payload, session_id: nil, context: nil)
28
+ raise NotImplementedError
29
+ end
30
+ end
31
+
32
+ Result = Struct.new(
33
+ :body, # JSON-RPC response Hash (result or error envelope)
34
+ :session_id, # set by `initialize`; echoed back otherwise; nil => stateless
35
+ :raw_request, # exact envelope sent — recorded to wire logs / Events
36
+ :raw_response, # exact envelope received — recorded to wire logs / Events
37
+ :status, # transport status code (HTTP status or synthetic 200/500)
38
+ :stderr, # optional transport diagnostics for the wire log (stdio stderr)
39
+ keyword_init: true
40
+ )
41
+ end
42
+ end
43
+ ```
44
+
45
+ Contract rules:
46
+
47
+ 1. **Session identity is explicit.** `initialize` may return a `session_id`
48
+ (`Result#session_id`); every subsequent request receives it as a keyword.
49
+ A transport that returns `nil` is *stateless* — SolidLoop synthesizes a
50
+ session id for the `McpSession` row (as the current `stateless: true` client
51
+ mode does) and the transport must not rely on server-side continuity.
52
+ 2. **Errors follow MCP semantics.** Tool-level failures are a *successful*
53
+ `tools/call` response with `isError: true` content — the model sees them and
54
+ can self-correct. Protocol-level failures are a JSON-RPC error envelope in
55
+ `Result#body`. Transport-level failures (process died, connection refused,
56
+ timeout) raise `SolidLoop::McpError`. Custom transports must not leak raw
57
+ exceptions for the first two categories.
58
+ 3. **Transports are stateless and thread-safe.** Concurrent `ToolExecutionJob`s
59
+ share the transport instance; all per-session state must be keyed by
60
+ `session_id`, never held in transport ivars.
61
+ 4. **Constructible anywhere.** The admin MCP Inspector calls tools from the web
62
+ process; a transport must be buildable from the agent's `mcps` config alone,
63
+ with no job-runtime state.
64
+ 5. **Wire logs are mandatory.** `raw_request` / `raw_response` feed
65
+ `env.log_buffer`, `Event` records and the admin UI identically for every
66
+ transport. An adapter that returns empty raw payloads forfeits the main
67
+ reason SolidLoop owns its transport layer.
68
+
69
+ `McpClient` keeps its public API (`initialize_session`, `list_tools`,
70
+ `call_tool`) and becomes a thin protocol layer that builds envelopes and
71
+ delegates I/O to the transport.
72
+
73
+ ## Agent configuration
74
+
75
+ `Base#mcps` accepts three entry forms; `name:` is required in all of them.
76
+
77
+ ```ruby
78
+ def mcps
79
+ [
80
+ # 1. HTTP sugar (today's form) → Mcp::HttpTransport
81
+ { name: :main,
82
+ url: ENV["MCP_URL"],
83
+ api_token: ENV["MCP_TOKEN"],
84
+ custom_headers: { "X-Workspace-Id" => WORKSPACE_ID },
85
+ tools: %w[list_files read_file], # optional whitelist (unchanged)
86
+ required_tools: %w[list_files] }, # optional validation (unchanged)
87
+
88
+ # 2. In-process toolset → the toolset class IS the server; local transport
89
+ { name: :search, toolset: SearchTools },
90
+
91
+ # 3. General form — any object implementing Mcp::Transport
92
+ { name: :fs,
93
+ transport: SolidLoop::Mcp::StdioTransport.new(
94
+ command: ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/data"],
95
+ timeout: 30
96
+ ),
97
+ prefix: "fs" }
98
+ ]
99
+ end
100
+ ```
101
+
102
+ ### Session recovery: `session_recovery: :auto | :fail`
103
+
104
+ On session loss (`McpSessionInvalidError`) the default `:auto` re-initializes
105
+ the session once, transparently, and retries the tool call — today's behavior.
106
+ `:fail` is for servers where the session is expensive server-side state (e.g.
107
+ a sandbox container per session): a silent re-initialize would destroy that
108
+ state mid-loop, so the tool call fails loudly instead and recreation stays
109
+ explicit.
110
+
111
+ ### Tool-name collisions and `prefix:`
112
+
113
+ Tool resolution is `loop.mcp_tools.find_by(name:)` across *all* servers of the
114
+ loop, so names must be loop-unique. With several domains this is no longer
115
+ guaranteed:
116
+
117
+ - `prefix: "search"` advertises tools to the LLM as `search__fetch_url` and
118
+ strips the prefix before issuing `tools/call` to the server.
119
+ - Without prefixes, a name collision across servers **raises at session
120
+ initialization** with a message naming both servers. Collisions never resolve
121
+ silently by insertion order.
122
+
123
+ ## Built-in adapters
124
+
125
+ ### `Mcp::HttpTransport` (reference adapter)
126
+
127
+ Today's Faraday implementation extracted verbatim: JSON request/response,
128
+ `Authorization: Bearer`, `Mcp-Session-Id` header mapped to the port's
129
+ `session_id`, custom headers, timeout. Everything downstream (session rows,
130
+ tool sync, wire logs, events) is transport-agnostic and unchanged.
131
+
132
+ ### `Mcp::Toolset` (in-process server — the home of former native tools)
133
+
134
+ A toolset *is* both the server and its transport: it implements `deliver` by
135
+ dispatching `initialize` / `tools/list` / `tools/call` in-process, no HTTP.
136
+
137
+ ```ruby
138
+ class SearchTools < SolidLoop::Mcp::Toolset
139
+ server_name "search"
140
+
141
+ tool "web_search",
142
+ description: "Search the web and return the top results",
143
+ input_schema: {
144
+ type: "object",
145
+ properties: { query: { type: "string" } },
146
+ required: %w[query]
147
+ } do |args, ctx|
148
+ Search::Web.call(loop: ctx.loop, **args.symbolize_keys)
149
+ end
150
+
151
+ tool "fetch_url",
152
+ description: "Fetch a URL and return a text summary",
153
+ input_schema: {
154
+ type: "object",
155
+ properties: { url: { type: "string" } },
156
+ required: %w[url]
157
+ } do |args, ctx|
158
+ Search::Fetch.call(loop: ctx.loop, **args.symbolize_keys).to_json
159
+ end
160
+ end
161
+ ```
162
+
163
+ Semantics:
164
+
165
+ - `tools/list` is generated from the declarations; `to_tool_spec` conversion,
166
+ whitelists, `required_tools`, prefixes and the admin Inspector all work as
167
+ for any MCP server.
168
+ - The block receives parsed `arguments` (string keys, exactly as they would
169
+ arrive over HTTP — the JSON boundary is preserved so local and remote
170
+ behavior cannot drift) and a `CallContext` (`ctx.agent`, `ctx.loop`,
171
+ `ctx.principal`). **Do not assume `ctx.agent`/`ctx.loop` are present**: for
172
+ a toolset served externally via `SolidLoop::Mcp.server` they are `nil` and
173
+ `ctx.principal` carries the caller's identity instead (see the server
174
+ section below). A handler that needs loop state should guard
175
+ (`ctx.loop or raise`) — or its toolset should simply never be mounted.
176
+ - Return value: a String becomes text content; a Hash is serialized to JSON
177
+ text content **and** exposed as `structuredContent` (MCP spec rev 2025-06);
178
+ raising inside the block produces an `isError: true` tool result with the
179
+ exception message — the standard self-correction path for the model.
180
+ **Sanitize those messages when the toolset is mounted**: they go to the
181
+ external client verbatim, so a raw `ActiveRecord::RecordNotFound` (or any
182
+ driver error) can leak scoping clauses and schema details. Rescue expected
183
+ failures and re-raise an opaque message (`raise "Generation not found"`).
184
+ - Toolsets are stateless by default (`session_id` = nil → synthetic). A toolset
185
+ may override `on_initialize(params, context)` to mint and later receive a
186
+ real session id.
187
+ - **Transactions:** the block runs on the caller's thread and shares the job's
188
+ ActiveRecord connection. That makes a tool atomic with SolidLoop's own writes
189
+ — and makes `transaction do ... end` inside a tool a *nested* transaction.
190
+ Both are by design; the boundary is the toolset author's responsibility.
191
+ - **Ordering & concurrency:** by default SolidLoop enqueues every tool call from
192
+ one assistant turn at once, so they run **in parallel** across workers with no
193
+ order guarantee. An agent that returns `sequential_tool_calls? == true` runs
194
+ them one at a time in model order instead. Either way different loops run
195
+ concurrently, so a toolset guarding shared invariants (running counters,
196
+ sequences, append-only timelines) must serialize per resource itself
197
+ (`record.with_lock`, advisory lock) — and under the parallel default that also
198
+ applies to two calls from the *same* turn touching the same resource.
199
+ - **Principal:** in-process calls receive `ctx.principal` from
200
+ `Base#mcp_principal`, which defaults to the loop subject and can be overridden
201
+ when the subject is not the actor. Mounted calls continue to use the
202
+ authorizer's return value.
203
+ - **Idempotency:** every tool invocation carries a stable per-`ToolCall` key.
204
+ In-process handlers read it from `ctx.idempotency_key`; on the wire it is sent
205
+ as MCP `_meta` on the `tools/call` params:
206
+
207
+ ```json
208
+ { "method": "tools/call",
209
+ "params": { "name": "web_search", "arguments": { "...": "..." },
210
+ "_meta": { "solidloop/idempotencyKey": "solid_loop:tool_call:42" } } }
211
+ ```
212
+
213
+ SolidLoop's `executed_at` checkpoint stops it from re-invoking a tool whose
214
+ result committed, but a crash between a remote side effect and that commit
215
+ re-delivers the invocation with the **same** key. **Irreversible tools
216
+ (sends, posts, deletes) should dedupe on this key** — e.g. a unique index on
217
+ it next to the side effect. SolidLoop does not (and cannot) make external
218
+ effects exactly-once itself; it only guarantees the key is stable per tool
219
+ call across retries. (A toolset mounted via `Mcp::Server` also surfaces an
220
+ inbound client's `_meta` key as `ctx.idempotency_key`.)
221
+ - `refresh_mcp_tools?` note: toolset schemas live in code, so discovery is free;
222
+ agents using toolsets should refresh on every loop start (otherwise schemas
223
+ persisted in `mcp_tools` go stale across deploys).
224
+
225
+ #### Prompts
226
+
227
+ A toolset can also declare prompts — reusable, parameterized message templates
228
+ served via `prompts/list` / `prompts/get`. The `prompts` capability is
229
+ advertised only when prompts are declared.
230
+
231
+ ```ruby
232
+ class SearchTools < SolidLoop::Mcp::Toolset
233
+ prompt "research_brief",
234
+ description: "Skeleton for a topic research brief",
235
+ arguments: [ { name: "topic", description: "e.g. renewable energy", required: true } ] do |args, ctx|
236
+ "Research the #{args['topic']} topic and summarize the key findings."
237
+ end
238
+ end
239
+ ```
240
+
241
+ The block returns a String (wrapped into a single user message) or a
242
+ ready-made messages array. Missing required arguments and unknown prompt names
243
+ are `-32602` protocol errors; unlike `tools/call` there is no `isError` shape
244
+ for prompts, so handler exceptions become `-32603` instead of propagating.
245
+
246
+ ### `Mcp::StdioTransport` (restricted contract)
247
+
248
+ Runs a child-process MCP server over stdin/stdout. Because SolidLoop executes
249
+ each tool call in an independent job (any worker, any machine), there is no
250
+ process that could own a long-lived child across rounds. Therefore:
251
+
252
+ - **Lifecycle is spawn-per-call:** spawn → `initialize` handshake → request →
253
+ graceful shutdown (kill on timeout), all inside one `deliver`.
254
+ - **Stateless servers only.** `session_id` is synthetic and implies no
255
+ server-side continuity. Stateful stdio servers (a running shell, a browser
256
+ session, a REPL sandbox) are unsupported — their in-memory state would be
257
+ lost at every job boundary. This is a deliberate consequence of the
258
+ durable-async core, not a transport bug.
259
+ - Options: `command:` (argv array, never a shell string), `env:`, `cwd:`,
260
+ `timeout:` (per call, includes spawn+handshake). stderr is captured into the
261
+ wire log.
262
+ - Cost note: spawn+handshake per call is typically 50–500 ms for Node/Python
263
+ servers. Prefer HTTP or a toolset for hot paths.
264
+
265
+ ## Custom transports & conformance
266
+
267
+ Any object implementing the port can be passed via `transport:`. The gem ships
268
+ RSpec shared examples so adapter authors can verify conformance:
269
+
270
+ ```ruby
271
+ RSpec.describe MyTransport do
272
+ it_behaves_like "a solid_loop mcp transport" do
273
+ let(:transport) { described_class.new(...) }
274
+ end
275
+ end
276
+ ```
277
+
278
+ The shared examples cover: initialize returns/omits `session_id` coherently,
279
+ session id round-trip, `tools/list` shape, `isError` tool failures vs JSON-RPC
280
+ protocol errors vs raised `McpError`, raw request/response presence, and
281
+ thread-safety of concurrent `deliver` calls.
282
+
283
+ ## Serving toolsets over HTTP — `SolidLoop::Mcp.server`
284
+
285
+ The mirror image of `HttpTransport` over the same port: a mountable Rack
286
+ endpoint that serves **one toolset** to external MCP clients (Claude Code,
287
+ other apps) over Streamable HTTP. The POST body goes straight into
288
+ `Toolset#deliver` — the same method the in-process agent path calls — so a
289
+ toolset defined once is consumed both locally and remotely with shared
290
+ schemas, wire logs and admin. Decision doc: [mcp-server.md](../decisions/mcp-server.md).
291
+
292
+ ```ruby
293
+ # config/routes.rb (host app)
294
+ mount SolidLoop::Mcp.server(RpcTools, auth: McpAuth.new) => "/rpc"
295
+ mount SolidLoop::Mcp.server(ReportTools, auth: ReportAuth.new) => "/tools"
296
+ # AdminTools is simply not mounted — it stays in-process only.
297
+ ```
298
+
299
+ ### Auth (fail-closed)
300
+
301
+ `auth:` is a **required argument**; the endpoint cannot be constructed without
302
+ it. The authorizer is any object responding to
303
+ `call(token, request) -> principal | nil` — a lambda for trivial cases, a host
304
+ class when the logic deserves a home:
305
+
306
+ ```ruby
307
+ class McpAuth
308
+ def call(token, request)
309
+ client = ApiClient.active.find_by(token: token)
310
+ return nil unless client
311
+ return nil unless client.ip_allowed?(request.remote_ip)
312
+
313
+ client
314
+ end
315
+ end
316
+ ```
317
+
318
+ It receives the raw bearer token from `Authorization: Bearer ...` (`nil` if
319
+ absent or a different scheme) and the `ActionDispatch::Request`. Returning
320
+ `nil` → `401` with an empty body. There is no anonymous mode and no
321
+ development bypass; serving without real auth must be written explicitly
322
+ (`auth: ->(_token, _request) { :anonymous }`). The gem stores no tokens —
323
+ bearer lists, DB lookups and IP allowlists live in the host's authorizer.
324
+
325
+ Any other return value becomes the **principal**: handlers receive
326
+ `CallContext(agent: nil, loop: nil, principal: <that value>)` and can use it
327
+ for authorization decisions and tenant scoping.
328
+
329
+ ### Sessions & protocol scope
330
+
331
+ - `initialize` creates a `McpInboundSession` row (honoring `on_initialize`
332
+ minting, else a UUID) and returns it in the `Mcp-Session-Id` header. Every
333
+ other request must carry that header: `400` without it, `404` for an
334
+ unknown or terminated session (the client's signal to re-initialize).
335
+ - `DELETE` terminates the session but keeps the row — a client must not be
336
+ able to erase its own audit trail.
337
+ - Notifications (requests without an `id`) → `202`, not dispatched. Batch
338
+ bodies → `-32600`. Malformed JSON → `-32700`. **No SSE:** `GET` → `405`
339
+ (the spec's no-stream option); POST always answers with a single
340
+ `application/json` response.
341
+ - Every request that reaches a session writes a `SolidLoop::Event`
342
+ (`mcp_inbound_*`, loop-less, eventable = the inbound session) with the
343
+ JSON-RPC envelopes and a wire log. The admin UI lists inbound sessions
344
+ under **Inbound MCP** with per-session event history.
345
+
346
+ ## Client breadth: prompts & resources
347
+
348
+ `McpClient` consumes more than tools:
349
+
350
+ - `list_prompts(session_id)` / `get_prompt(session_id, name, arguments = {})`
351
+ - `list_resources(session_id)` / `read_resource(session_id, uri)`
352
+ - `server_capabilities` — the capabilities object from the last `initialize`.
353
+
354
+ At session initialization SolidLoop snapshots the server's capabilities into
355
+ `McpSession#metadata`, and — when the server advertises `prompts` /
356
+ `resources` — the discovered lists as well (best-effort: a failing
357
+ prompts/resources endpoint does not take tool availability down with it). The
358
+ admin session page shows the discovered prompts and resources; the fetches
359
+ leave `mcp_list_prompts` / `mcp_list_resources` events.
360
+
361
+ ## Persistence & admin
362
+
363
+ - `McpSession` rows are created per loop per server for **every** transport,
364
+ with `transport_kind` (`http` / `local` / `stdio` / `custom`) shown in the
365
+ admin UI. Stateless transports get a synthetic `session_id`.
366
+ - `McpTool` rows, wire logs, `Event` records, the Inspector (including
367
+ `run_tool` and replay) behave identically across transports.
368
+ - `McpInboundSession` rows are created per external client connection by
369
+ `SolidLoop::Mcp.server` (server name, principal label, last-used and
370
+ terminated timestamps) and listed under **Inbound MCP** in the admin UI;
371
+ inbound `Event` records hang off them with `loop_id` nil.
372
+ - Schema change: `transport_kind` (`string`, default `"http"`, `null: false`)
373
+ on `solid_loop_mcp_sessions` — added to the init migration (nothing is
374
+ published yet, so it is still editable in place).
@@ -0,0 +1,148 @@
1
+ # Tools Middlewares Architecture in SolidLoop
2
+
3
+ ## 1. Middleware Principles
4
+
5
+ The execution of agent-side tools (`ToolExecutionJob`) in `SolidLoop` also utilizes the **Chain of Responsibility (Middleware)** pattern to encapsulate functionality and provide extensibility.
6
+
7
+ **Why is this needed?**
8
+ Monolithic background jobs are notoriously difficult to maintain:
9
+ 1. Every tool uses the MCP interface, either through an in-process `Toolset` or another MCP transport. Middlewares separate policy, telemetry, execution and response creation.
10
+ 2. The logic for saving telemetry Events, calculating elapsed duration, and creating the new `tool` message in the loop must be reusable and isolated.
11
+
12
+ **How it works:**
13
+ - Execution begins by initializing a shared `SolidLoop::Pipeline::ToolContext` containing the `tool_call_id`.
14
+ - Middlewares are executed sequentially through an array (`Pipeline.new(middlewares).call(context)`).
15
+ - The stack guarantees that even if a tool throws an error within Ruby code or an MCP server is unreachable (Connection Timeout), the system gracefully logs the error, updates `is_success: false`, and transitions the conversational loop to the `failed` state without crashing the ActiveJob thread.
16
+
17
+ **Extensibility (Custom Middlewares):**
18
+ Unlike the global LLM initializer, tool execution configuration is delegated to the **specific agent instance** handling the loop.
19
+ An agent can implement `configure_tool_middlewares(builder)` and explicitly insert custom layers into the framework's baseline stack using the `Pipeline::Builder` DSL (`insert_before`, `insert_after`, `use`, `replace`, `delete`).
20
+
21
+ For example, if you need to trigger your own business logic immediately after a tool finishes executing:
22
+
23
+ ```ruby
24
+ # app/agents/benchmark_agent.rb
25
+ class BenchmarkAgent < SolidLoop::Base
26
+ def configure_tool_middlewares(builder)
27
+ builder.insert_after(SolidLoop::ToolMiddlewares::ToolExecution, BenchmarkEvaluatorMiddleware)
28
+ end
29
+ end
30
+
31
+ # app/services/benchmark_evaluator_middleware.rb
32
+ class BenchmarkEvaluatorMiddleware
33
+ def initialize(app)
34
+ @app = app
35
+ end
36
+
37
+ def call(env)
38
+ @app.call(env) # Pass control downstream (the tool is logged and actually executed)
39
+
40
+ # RESPONSE PHASE: After the tool acts, we run our custom evaluation:
41
+ BenchmarkToolEvaluator.call(env.tool_call)
42
+ end
43
+ end
44
+ ```
45
+
46
+ ---
47
+
48
+ ## 2. Default Tool Middlewares Stack
49
+
50
+ During a `ToolExecutionJob` (which is typically enqueued by `LlmCompletionJob` when `tool_calls` are present), the layers execute in the following order:
51
+
52
+ ### 1. `ErrorHandling`
53
+ * **What it does:** A global catch-all error handler for exceptional situations. Wraps all nested layers.
54
+ * **Request Phase:** Passes control down the pipeline.
55
+ * **Response Phase:** If an unhandled exception (`StandardError`) propagates up from inner layers, it is logged to the Rails console with a backtrace. The `SolidLoop::ToolCall` record is updated with `is_success: false` and the `error_message`. The parent `SolidLoop::Loop` gracefully assumes a `failed` status. Exception: `CancellationError`, which safely aborts execution.
56
+
57
+ ### 2. `AgentInitialization`
58
+ * **What it does:** Instantiates the AI agent class.
59
+ * **Request Phase:**
60
+ 1. Relies on the pre-fetched ActiveRecord context (`env.loop`) populated by the worker.
61
+ 2. Initializes an instance of the required AI Agent class (e.g., `BenchmarkAgent` or whatever is defined in `loop.agent_class_name`).
62
+ 3. Stores the agent back into the state object (`env.agent`).
63
+
64
+ ### 3. `EventLogging`
65
+ * **What it does:** Responsible for logging telemetry (requests and responses to external tools) into the `SolidLoop::Event` table.
66
+ * **Request Phase:** Passes control down the chain.
67
+ * **Response Phase:** On the way back up, the layer inspects `Context.metadata`. If lower layers (like `ToolExecution`) executed an MCP server call and populated network parameters (TCP/IP logs, `mcp_request_data`, `mcp_response_data`), `EventLogging` will save or update a record of this interaction in the database for the debug event log.
68
+
69
+ ### 4. `ToolExecution`
70
+ * **What it does:** Resolves the persisted call against the loop's MCP tools,
71
+ invokes it and commits a terminal `executed_at` checkpoint with the result.
72
+ * Duplicate jobs serialize on the `ToolCall` row. Once `executed_at` is set,
73
+ later jobs reuse the persisted result and do not invoke the tool again.
74
+ * MCP `isError` results are persisted with `is_success: false`; they remain
75
+ normal tool responses so the model can self-correct.
76
+ * A policy middleware can call
77
+ `env.skip_execution!(result:, success: false)` and continue downstream. The
78
+ supplied result is checkpointed and emitted without invoking MCP. Returning
79
+ without calling downstream still holds the step for approval.
80
+ * The layer always invokes `@app.call(env)`, allowing `ResponseCreation` to use
81
+ the same path for executed, rejected and replayed results.
82
+
83
+ ### 5. `ResponseCreation`
84
+ * **What it does:** The layer responsible for advancing the LLM dialogue. It converts the tool's output into a format suitable for the neural network.
85
+ * **Response Phase:**
86
+ 1. Creates a new `SolidLoop::Message` with the role `"tool"`, linking it via `tool_call_id` to the tool we just executed, and assigns the final result (output payload) to the message's content.
87
+ 2. Creates the response idempotently; replaying the same job cannot create a
88
+ duplicate `tool` message.
89
+ 3. Advances the turn according to the agent's execution mode (see below). Only
90
+ after *all* results exist does it conditionally queue the next LLM turn, and
91
+ that completion gate is fenced by the execution token so exactly one job
92
+ queues the next turn.
93
+
94
+ ## 2b. Parallel vs. sequential tool execution
95
+
96
+ An assistant turn can return several tool calls. `SolidLoop::Base#sequential_tool_calls?`
97
+ selects how they run; the single flag gates both emission and ordering:
98
+
99
+ | | `sequential_tool_calls? == false` (default) | `sequential_tool_calls? == true` |
100
+ |---|---|---|
101
+ | **Emission** (`ResponseParsing`) | enqueues **every** call at once → they run in parallel across workers | enqueues **only the first** call |
102
+ | **Ordering guard** (`ToolExecutionJob`) | not applied; any call may run first | rejects any job that is not the first unresolved call |
103
+ | **Continuation** (`ResponseCreation`) | does not chain; each job only fires the completion gate once every result exists | chains the next unresolved call in model order after each result |
104
+ | **Completion gate** | identical in both modes — the job that commits the *last* result queues the next LLM turn |
105
+
106
+ Safety is orthogonal to ordering and holds in both modes: execution-token
107
+ fencing rejects stale-turn jobs, and the `executed_at` checkpoint makes a
108
+ re-delivered job replay its persisted result instead of re-invoking the tool. In
109
+ parallel mode the N jobs serialize their response creation on the assistant
110
+ message row, so exactly one of them observes "all resolved" and queues the next
111
+ turn.
112
+
113
+ `resume!` mirrors emission: it re-enters every unresolved call for a parallel
114
+ agent (so a half-finished multi-call turn cannot stall) and only the first for a
115
+ sequential agent.
116
+
117
+ ## 3. Pause, resume and HITL
118
+
119
+ `Base#resume!` inspects the latest assistant tool turn. If a tool result is
120
+ missing, it re-enqueues that exact step; it starts a new LLM turn only when no
121
+ tool step is unresolved. This is the same re-entry primitive an approval action
122
+ uses after changing its host-owned decision to approved.
123
+
124
+ Loop transitions are conditional and fenced by an execution token. A worker
125
+ from before a pause/resume cycle cannot complete or fail the new generation.
126
+ Pausing between tool execution and response creation is safe: resume replays the
127
+ persisted checkpoint without repeating the tool.
128
+
129
+ `executed_at` prevents duplicates after a result is committed. It cannot provide
130
+ exactly-once semantics if a process dies after an external side effect but before
131
+ the database checkpoint commits. For that window SolidLoop supplies a stable
132
+ per-`ToolCall` idempotency key with every invocation — `ctx.idempotency_key` for
133
+ in-process handlers, `_meta["solidloop/idempotencyKey"]` on the wire (see
134
+ `docs/guides/mcp_transports.md`). Irreversible remote tools should dedupe on it.
135
+
136
+ Approval records, approvers, reasons and deadlines remain host concerns. A
137
+ generic pause TTL is intentionally not provided because `paused` also represents
138
+ manual pauses and safety limits. Hosts should put `expires_at` on the approval,
139
+ resolve approval/timeout races under a lock and use `skip_execution!` to emit an
140
+ expired or rejected result.
141
+
142
+ **HITL is cleanest under `sequential_tool_calls? == true`.** With sequential
143
+ execution there is a single in-flight tool at a time, so an approval gate has one
144
+ unambiguous step to hold, approve or reject before the next call is dispatched.
145
+ `skip_execution!` still works in either mode, but the parallel default puts every
146
+ call from the turn in flight simultaneously, so per-tool approval must gate each
147
+ of the concurrent jobs independently. Agents that gate tools on human approval
148
+ should opt into sequential execution.