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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +156 -0
- data/CODE_OF_CONDUCT.md +67 -0
- data/CONTRIBUTING.md +82 -0
- data/MIT-LICENSE +21 -0
- data/README.md +483 -0
- data/Rakefile +11 -0
- data/app/assets/javascripts/solid_loop/chart.umd.min.js +14 -0
- data/app/assets/javascripts/solid_loop/chartjs-adapter-date-fns.bundle.min.js +7 -0
- data/app/assets/javascripts/solid_loop/chartkick.min.js +2 -0
- data/app/assets/stylesheets/solid_loop/application.css +15 -0
- data/app/controllers/solid_loop/application_controller.rb +29 -0
- data/app/controllers/solid_loop/dashboard_controller.rb +104 -0
- data/app/controllers/solid_loop/events_controller.rb +12 -0
- data/app/controllers/solid_loop/loops_controller.rb +77 -0
- data/app/controllers/solid_loop/mcp_inbound_sessions_controller.rb +13 -0
- data/app/controllers/solid_loop/mcp_sessions_controller.rb +68 -0
- data/app/controllers/solid_loop/mcp_tools_controller.rb +16 -0
- data/app/controllers/solid_loop/messages_controller.rb +12 -0
- data/app/controllers/solid_loop/tool_calls_controller.rb +12 -0
- data/app/helpers/solid_loop/application_helper.rb +125 -0
- data/app/jobs/solid_loop/application_job.rb +13 -0
- data/app/jobs/solid_loop/llm_completion_job.rb +139 -0
- data/app/jobs/solid_loop/observe_broadcast_job.rb +15 -0
- data/app/jobs/solid_loop/reaper_job.rb +20 -0
- data/app/jobs/solid_loop/tool_execution_job.rb +200 -0
- data/app/models/solid_loop/application_record.rb +5 -0
- data/app/models/solid_loop/base.rb +190 -0
- data/app/models/solid_loop/event.rb +7 -0
- data/app/models/solid_loop/loop.rb +225 -0
- data/app/models/solid_loop/mcp_inbound_session.rb +25 -0
- data/app/models/solid_loop/mcp_session.rb +12 -0
- data/app/models/solid_loop/mcp_tool.rb +26 -0
- data/app/models/solid_loop/message.rb +69 -0
- data/app/models/solid_loop/tool_call.rb +42 -0
- data/app/queries/solid_loop/admin/base_query.rb +23 -0
- data/app/queries/solid_loop/admin/events_query.rb +31 -0
- data/app/queries/solid_loop/admin/loops_query.rb +38 -0
- data/app/queries/solid_loop/admin/mcp_inbound_sessions_query.rb +31 -0
- data/app/queries/solid_loop/admin/mcp_sessions_query.rb +26 -0
- data/app/queries/solid_loop/admin/mcp_tools_query.rb +26 -0
- data/app/queries/solid_loop/admin/messages_query.rb +37 -0
- data/app/queries/solid_loop/admin/tool_calls_query.rb +34 -0
- data/app/services/solid_loop/adapters/native.rb +170 -0
- data/app/services/solid_loop/dialects/anthropic.rb +160 -0
- data/app/services/solid_loop/dialects/gemini.rb +122 -0
- data/app/services/solid_loop/dialects/open_ai.rb +62 -0
- data/app/services/solid_loop/dialects/reasoning_packer.rb +37 -0
- data/app/services/solid_loop/llm_usage_parser/anthropic.rb +22 -0
- data/app/services/solid_loop/llm_usage_parser/gemini.rb +18 -0
- data/app/services/solid_loop/llm_usage_parser/llama.rb +15 -0
- data/app/services/solid_loop/llm_usage_parser/openai.rb +18 -0
- data/app/services/solid_loop/llm_usage_parser.rb +19 -0
- data/app/services/solid_loop/mcp_session_initializer.rb +205 -0
- data/app/services/solid_loop/mcp_tool_execution_service.rb +153 -0
- data/app/services/solid_loop/middlewares/agent_initialization.rb +37 -0
- data/app/services/solid_loop/middlewares/error_handling.rb +107 -0
- data/app/services/solid_loop/middlewares/event_logging.rb +100 -0
- data/app/services/solid_loop/middlewares/message_building.rb +92 -0
- data/app/services/solid_loop/middlewares/network_calling.rb +84 -0
- data/app/services/solid_loop/middlewares/response_parsing.rb +348 -0
- data/app/services/solid_loop/middlewares/tool_call_xml_parser.rb +117 -0
- data/app/services/solid_loop/sse_stream_aggregator.rb +105 -0
- data/app/services/solid_loop/tool_failure_reconciler.rb +122 -0
- data/app/services/solid_loop/tool_middlewares/agent_initialization.rb +17 -0
- data/app/services/solid_loop/tool_middlewares/error_handling.rb +66 -0
- data/app/services/solid_loop/tool_middlewares/event_logging.rb +27 -0
- data/app/services/solid_loop/tool_middlewares/response_creation.rb +110 -0
- data/app/services/solid_loop/tool_middlewares/tool_execution.rb +206 -0
- data/app/views/layouts/solid_loop/admin.html.erb +416 -0
- data/app/views/layouts/solid_loop/application.html.erb +17 -0
- data/app/views/solid_loop/dashboard/index.html.erb +184 -0
- data/app/views/solid_loop/events/index.html.erb +47 -0
- data/app/views/solid_loop/events/show.html.erb +76 -0
- data/app/views/solid_loop/loops/index.html.erb +83 -0
- data/app/views/solid_loop/loops/show.html.erb +148 -0
- data/app/views/solid_loop/mcp_inbound_sessions/index.html.erb +53 -0
- data/app/views/solid_loop/mcp_inbound_sessions/show.html.erb +78 -0
- data/app/views/solid_loop/mcp_sessions/_tool_output.html.erb +11 -0
- data/app/views/solid_loop/mcp_sessions/index.html.erb +46 -0
- data/app/views/solid_loop/mcp_sessions/inspector.html.erb +94 -0
- data/app/views/solid_loop/mcp_sessions/show.html.erb +142 -0
- data/app/views/solid_loop/mcp_tools/index.html.erb +44 -0
- data/app/views/solid_loop/mcp_tools/show.html.erb +69 -0
- data/app/views/solid_loop/messages/_message.html.erb +75 -0
- data/app/views/solid_loop/messages/index.html.erb +80 -0
- data/app/views/solid_loop/messages/show.html.erb +121 -0
- data/app/views/solid_loop/shared/_pagination.html.erb +21 -0
- data/app/views/solid_loop/tool_calls/index.html.erb +53 -0
- data/app/views/solid_loop/tool_calls/show.html.erb +59 -0
- data/config/routes.rb +27 -0
- data/db/migrate/20260408000100_solid_loop_init.rb +136 -0
- data/db/migrate/20260709000100_solid_loop_create_mcp_inbound_sessions.rb +16 -0
- data/db/migrate/20260713000100_solid_loop_add_execution_guards.rb +6 -0
- data/db/migrate/20260714000100_solid_loop_add_message_steering.rb +17 -0
- data/db/migrate/20260714000200_solid_loop_add_message_conversation_order.rb +14 -0
- data/db/migrate/20260715000100_solid_loop_add_mcp_inbound_session_principal_key.rb +43 -0
- data/db/migrate/20260715000200_solid_loop_add_llm_lease.rb +40 -0
- data/db/migrate/20260715000300_solid_loop_add_tool_lease.rb +33 -0
- data/db/migrate/20260715000400_solid_loop_add_lease_running_check.rb +32 -0
- data/db/migrate/20260715000500_solid_loop_add_tool_lease_pair_check.rb +35 -0
- data/docs/contributing/coverage.md +64 -0
- data/docs/decisions/durable_attempt_lease.md +364 -0
- data/docs/decisions/mcp-only-tooling.md +135 -0
- data/docs/decisions/mcp-server.md +223 -0
- data/docs/decisions/reasoning_persistence.md +51 -0
- data/docs/decisions/ruby_llm_rejected.md +35 -0
- data/docs/guides/dialects.md +55 -0
- data/docs/guides/llm_middlewares.md +294 -0
- data/docs/guides/mcp_transports.md +374 -0
- data/docs/guides/tool_middlewares.md +148 -0
- data/lib/solid_loop/configuration.rb +175 -0
- data/lib/solid_loop/engine.rb +14 -0
- data/lib/solid_loop/lease_heartbeat.rb +94 -0
- data/lib/solid_loop/lease_renewer.rb +347 -0
- data/lib/solid_loop/llm_metrics.rb +16 -0
- data/lib/solid_loop/mcp/call_context.rb +19 -0
- data/lib/solid_loop/mcp/client_factory.rb +61 -0
- data/lib/solid_loop/mcp/http_transport.rb +52 -0
- data/lib/solid_loop/mcp/principal.rb +82 -0
- data/lib/solid_loop/mcp/result.rb +13 -0
- data/lib/solid_loop/mcp/server.rb +246 -0
- data/lib/solid_loop/mcp/stdio_transport.rb +224 -0
- data/lib/solid_loop/mcp/toolset.rb +347 -0
- data/lib/solid_loop/mcp/transport.rb +20 -0
- data/lib/solid_loop/mcp.rb +25 -0
- data/lib/solid_loop/mcp_client.rb +176 -0
- data/lib/solid_loop/pipeline/builder.rb +38 -0
- data/lib/solid_loop/pipeline/context.rb +61 -0
- data/lib/solid_loop/pipeline/tool_context.rb +53 -0
- data/lib/solid_loop/pipeline.rb +40 -0
- data/lib/solid_loop/reaper.rb +313 -0
- data/lib/solid_loop/version.rb +3 -0
- data/lib/solid_loop.rb +94 -0
- data/lib/tasks/coverage.rake +206 -0
- data/lib/tasks/solid_loop_tasks.rake +4 -0
- 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.
|