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,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
|