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,61 @@
|
|
|
1
|
+
module SolidLoop
|
|
2
|
+
module Mcp
|
|
3
|
+
# Resolves an agent `mcps` entry into a transport and a protocol client.
|
|
4
|
+
# Three entry forms (name: is required in all):
|
|
5
|
+
# { name:, url:, api_token:, ... } — HTTP sugar -> Mcp::HttpTransport
|
|
6
|
+
# { name:, toolset: SearchTools } — in-process -> the toolset itself
|
|
7
|
+
# { name:, transport: object } — general form -> any Mcp::Transport
|
|
8
|
+
module ClientFactory
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def client_for(mcp_config, context: nil)
|
|
12
|
+
validate_session_recovery!(mcp_config)
|
|
13
|
+
|
|
14
|
+
SolidLoop::McpClient.new(
|
|
15
|
+
transport: transport_for(mcp_config),
|
|
16
|
+
context: context,
|
|
17
|
+
initialize_params: mcp_config[:initialize_params] || {},
|
|
18
|
+
stateless: mcp_config.fetch(:stateless, transport_kind(mcp_config) != "http")
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def transport_for(mcp_config)
|
|
23
|
+
if (toolset = mcp_config[:toolset])
|
|
24
|
+
toolset.is_a?(Class) ? toolset.new : toolset
|
|
25
|
+
elsif mcp_config[:transport]
|
|
26
|
+
mcp_config[:transport]
|
|
27
|
+
elsif mcp_config[:url]
|
|
28
|
+
HttpTransport.new(
|
|
29
|
+
url: mcp_config[:url],
|
|
30
|
+
token: mcp_config[:api_token] || ENV["MCP_API_TOKEN"],
|
|
31
|
+
timeout: mcp_config[:timeout] || 60,
|
|
32
|
+
headers: mcp_config[:custom_headers] || {}
|
|
33
|
+
)
|
|
34
|
+
else
|
|
35
|
+
raise SolidLoop::McpError,
|
|
36
|
+
"MCP config '#{mcp_config[:name]}' must provide url:, toolset:, or transport:"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Validated eagerly — a typo must surface at session init, not months
|
|
41
|
+
# later when a session actually expires.
|
|
42
|
+
def validate_session_recovery!(mcp_config)
|
|
43
|
+
value = mcp_config.fetch(:session_recovery, :auto).to_sym
|
|
44
|
+
return if %i[auto fail].include?(value)
|
|
45
|
+
|
|
46
|
+
raise SolidLoop::McpError,
|
|
47
|
+
"Invalid session_recovery #{value.inspect} for '#{mcp_config[:name]}' (use :auto or :fail)"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def transport_kind(mcp_config)
|
|
51
|
+
return "local" if mcp_config[:toolset]
|
|
52
|
+
|
|
53
|
+
if (transport = mcp_config[:transport])
|
|
54
|
+
return transport.is_a?(StdioTransport) ? "stdio" : "custom"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
"http"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
require "faraday"
|
|
2
|
+
|
|
3
|
+
module SolidLoop
|
|
4
|
+
module Mcp
|
|
5
|
+
# Reference adapter: Streamable-HTTP MCP over Faraday. JSON request/response,
|
|
6
|
+
# `Authorization: Bearer`, `Mcp-Session-Id` header mapped to the port's
|
|
7
|
+
# session_id, custom headers, timeout.
|
|
8
|
+
class HttpTransport
|
|
9
|
+
include Transport
|
|
10
|
+
|
|
11
|
+
def initialize(url:, token: nil, timeout: 60, headers: {})
|
|
12
|
+
@url = url
|
|
13
|
+
@token = token
|
|
14
|
+
@timeout = timeout
|
|
15
|
+
@extra_headers = headers
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def deliver(payload, session_id: nil, context: nil)
|
|
19
|
+
response = connection(session_id).post("", payload)
|
|
20
|
+
|
|
21
|
+
Result.new(
|
|
22
|
+
body: response.body,
|
|
23
|
+
session_id: response.headers["Mcp-Session-Id"],
|
|
24
|
+
raw_request: payload,
|
|
25
|
+
raw_response: response.body,
|
|
26
|
+
status: response.status
|
|
27
|
+
)
|
|
28
|
+
rescue Faraday::Error => e
|
|
29
|
+
raise SolidLoop::McpError, "MCP transport error: #{e.class}: #{e.message}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def connection(session_id = nil)
|
|
35
|
+
Faraday.new(url: @url) do |f|
|
|
36
|
+
f.request :json
|
|
37
|
+
f.response :json
|
|
38
|
+
f.options.timeout = @timeout
|
|
39
|
+
f.headers = default_headers(session_id)
|
|
40
|
+
f.adapter Faraday.default_adapter
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def default_headers(session_id = nil)
|
|
45
|
+
h = { "Content-Type" => "application/json" }
|
|
46
|
+
h["Authorization"] = "Bearer #{@token}" if @token.present?
|
|
47
|
+
h["Mcp-Session-Id"] = session_id if session_id
|
|
48
|
+
h.merge(@extra_headers)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
module SolidLoop
|
|
2
|
+
module Mcp
|
|
3
|
+
# The authenticated caller identity returned by an Mcp::Server authorizer.
|
|
4
|
+
#
|
|
5
|
+
# An authorizer responds to `call(token, request) -> principal | nil`. To
|
|
6
|
+
# bind sessions to the caller (so principal B cannot ride principal A's
|
|
7
|
+
# session_id) the server needs a STABLE identity key, not just a display
|
|
8
|
+
# label. This value object carries both:
|
|
9
|
+
#
|
|
10
|
+
# key — stable, opaque identity used for session ownership + audit. Two
|
|
11
|
+
# requests are the same principal iff their keys are equal.
|
|
12
|
+
# label — human-readable name for the admin UI (may collide / change).
|
|
13
|
+
#
|
|
14
|
+
# Authorizer contract: `key` MUST be stable across a principal's requests and
|
|
15
|
+
# unique per principal. Two callers whose keys collide (e.g. an authorizer
|
|
16
|
+
# returning "" or two records that stringify identically) would share a
|
|
17
|
+
# session namespace — the same trust boundary the authorizer already owns.
|
|
18
|
+
#
|
|
19
|
+
# Backward compatibility: authorizers may still return a bare value (String,
|
|
20
|
+
# Symbol, or any object) and `Principal.wrap` derives a STABLE key from it:
|
|
21
|
+
#
|
|
22
|
+
# * a Principal — used as-is
|
|
23
|
+
# * an object with #key AND #label — those are the key/label
|
|
24
|
+
# * an ActiveRecord-ish object (responds to #to_global_id) — the GID URI
|
|
25
|
+
# is the stable key; #to_s (or the GID) the label
|
|
26
|
+
# * anything else — `to_s` is BOTH key and label
|
|
27
|
+
#
|
|
28
|
+
# The GID branch matters because a bare object's default #to_s embeds the
|
|
29
|
+
# memory address (unstable per request); using it as the session key would
|
|
30
|
+
# mean a caller could never reuse their own session. `to_context` always
|
|
31
|
+
# returns the ORIGINAL value, so tool handlers keep receiving what the
|
|
32
|
+
# authorizer returned (a String, a User record, …) unchanged.
|
|
33
|
+
class Principal
|
|
34
|
+
attr_reader :key, :label
|
|
35
|
+
|
|
36
|
+
def self.wrap(value)
|
|
37
|
+
return value if value.is_a?(Principal)
|
|
38
|
+
|
|
39
|
+
if value.respond_to?(:key) && value.respond_to?(:label)
|
|
40
|
+
new(key: value.key.to_s, label: value.label.to_s, principal: value)
|
|
41
|
+
elsif (gid = stable_gid(value))
|
|
42
|
+
new(key: gid, label: label_for(value, gid), principal: value)
|
|
43
|
+
else
|
|
44
|
+
new(key: value.to_s, label: value.to_s, principal: value)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The GlobalID URI for a persisted record, or nil (unpersisted records
|
|
49
|
+
# raise; non-GID objects don't respond) — the caller then falls back to
|
|
50
|
+
# the bare to_s key.
|
|
51
|
+
def self.stable_gid(value)
|
|
52
|
+
return nil unless value.respond_to?(:to_global_id)
|
|
53
|
+
|
|
54
|
+
value.to_global_id.to_s
|
|
55
|
+
rescue StandardError
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Prefer a human-readable #to_s when the object overrides it (not the
|
|
60
|
+
# default "#<Klass:0x...>"); otherwise fall back to the GID.
|
|
61
|
+
def self.label_for(value, gid)
|
|
62
|
+
str = value.to_s
|
|
63
|
+
str.start_with?("#<") ? gid : str
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# `principal` is the raw object the authorizer returned — handlers receive
|
|
67
|
+
# THIS via CallContext#principal, so existing handlers that expect the raw
|
|
68
|
+
# value (a String, a User, …) keep working unchanged.
|
|
69
|
+
def initialize(key:, label:, principal:)
|
|
70
|
+
@key = key
|
|
71
|
+
@label = label
|
|
72
|
+
@principal = principal
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# What tool handlers see as ctx.principal. For a bare-value authorizer this
|
|
76
|
+
# is the original value (backward compatible); otherwise the wrapped object.
|
|
77
|
+
def to_context
|
|
78
|
+
@principal
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
module SolidLoop
|
|
2
|
+
module Mcp
|
|
3
|
+
Result = Struct.new(
|
|
4
|
+
:body, # JSON-RPC response Hash (result or error envelope)
|
|
5
|
+
:session_id, # set by `initialize`; echoed back otherwise; nil => stateless
|
|
6
|
+
:raw_request, # exact envelope sent — recorded to wire logs / Events
|
|
7
|
+
:raw_response, # exact envelope received — recorded to wire logs / Events
|
|
8
|
+
:status, # transport status code (HTTP status or synthetic 200/500)
|
|
9
|
+
:stderr, # optional transport diagnostics for the wire log (stdio stderr)
|
|
10
|
+
keyword_init: true
|
|
11
|
+
)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
module SolidLoop
|
|
2
|
+
module Mcp
|
|
3
|
+
# Mountable Rack endpoint serving ONE toolset to external MCP clients over
|
|
4
|
+
# Streamable HTTP — the mirror image of HttpTransport over the same port.
|
|
5
|
+
# The POST body goes straight into Toolset#deliver, the exact method the
|
|
6
|
+
# in-process agent path calls, so local and remote behavior cannot drift.
|
|
7
|
+
# See docs/decisions/mcp-server.md.
|
|
8
|
+
#
|
|
9
|
+
# # config/routes.rb (host app)
|
|
10
|
+
# mount SolidLoop::Mcp.server(RpcTools, auth: McpAuth.new) => "/rpc"
|
|
11
|
+
#
|
|
12
|
+
# Fail-closed: auth: is required at construction. The authorizer is any
|
|
13
|
+
# object responding to call(token, request) -> principal | nil; nil means
|
|
14
|
+
# 401 with an empty body. The returned principal is normalized via
|
|
15
|
+
# Mcp::Principal.wrap (a bare value becomes both key and label; an object
|
|
16
|
+
# with #key/#label separates identity from display) and the raw value
|
|
17
|
+
# becomes CallContext#principal for every tool handler.
|
|
18
|
+
#
|
|
19
|
+
# Principal binding: the session row records the principal's stable KEY and
|
|
20
|
+
# every in-session POST/DELETE lookup is scoped to (mount, session_id,
|
|
21
|
+
# principal_key). A different valid principal who obtains another caller's
|
|
22
|
+
# session_id therefore cannot use or terminate it (404, as for any unknown
|
|
23
|
+
# session). Two mounts of the same toolset class are separated by mount:.
|
|
24
|
+
#
|
|
25
|
+
# Sessions: every initialize creates a McpInboundSession row (honoring the
|
|
26
|
+
# toolset's on_initialize minting, else a UUID) returned in Mcp-Session-Id.
|
|
27
|
+
# Every other request must carry that header — 400 without it, 404 for an
|
|
28
|
+
# unknown or terminated session. DELETE terminates the session but keeps
|
|
29
|
+
# the row: a client must not be able to erase its own audit trail. Every
|
|
30
|
+
# request that reaches a session writes a SolidLoop::Event (loop-less,
|
|
31
|
+
# eventable = the session) with the JSON-RPC envelopes and a wire log.
|
|
32
|
+
#
|
|
33
|
+
# Protocol scope is deliberately narrow (see the decision doc): single
|
|
34
|
+
# JSON-RPC request per POST, notifications -> 202, no SSE (GET -> 405),
|
|
35
|
+
# no batching (-32600). Errors raised inside tool handlers become isError
|
|
36
|
+
# results in Toolset#deliver; anything else propagates — internal bugs
|
|
37
|
+
# must surface, not dissolve into JSON-RPC envelopes.
|
|
38
|
+
class Server
|
|
39
|
+
ALLOWED_METHODS = %w[POST DELETE].freeze
|
|
40
|
+
|
|
41
|
+
def initialize(toolset, auth:, mount: nil)
|
|
42
|
+
unless auth.respond_to?(:call)
|
|
43
|
+
raise ArgumentError,
|
|
44
|
+
"SolidLoop::Mcp.server requires auth: responding to " \
|
|
45
|
+
"call(token, request) -> principal | nil (see docs/decisions/mcp-server.md)"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
@toolset = toolset.is_a?(Class) ? toolset.new : toolset
|
|
49
|
+
@auth = auth
|
|
50
|
+
@mount = mount&.to_s
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def call(env)
|
|
54
|
+
request = ActionDispatch::Request.new(env)
|
|
55
|
+
|
|
56
|
+
# 405 before auth: it reveals nothing and keeps method probes away
|
|
57
|
+
# from the authorizer. Everything else authenticates first.
|
|
58
|
+
return method_not_allowed unless ALLOWED_METHODS.include?(request.request_method)
|
|
59
|
+
|
|
60
|
+
authorized = @auth.call(bearer_token(request), request)
|
|
61
|
+
# Deny on any falsey return, not just nil: the contract is truthy principal
|
|
62
|
+
# => allow. A contract-violating `false` must never authenticate (it would
|
|
63
|
+
# otherwise mint a "false"-keyed session shared by every such caller).
|
|
64
|
+
return unauthorized unless authorized
|
|
65
|
+
principal = Principal.wrap(authorized)
|
|
66
|
+
|
|
67
|
+
if request.request_method == "DELETE"
|
|
68
|
+
handle_delete(request, principal)
|
|
69
|
+
else
|
|
70
|
+
handle_post(request, principal)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def handle_post(request, principal)
|
|
77
|
+
payload = JSON.parse(request.body.read)
|
|
78
|
+
|
|
79
|
+
return rpc_error(-32600, "Batch requests are not supported") if payload.is_a?(Array)
|
|
80
|
+
return rpc_error(-32600, "Invalid JSON-RPC request") unless valid_jsonrpc?(payload)
|
|
81
|
+
return serve_initialize(request, payload, principal) if payload["method"] == "initialize"
|
|
82
|
+
|
|
83
|
+
session = resolve_session(request, principal)
|
|
84
|
+
return session unless session.is_a?(McpInboundSession)
|
|
85
|
+
|
|
86
|
+
serve_in_session(request, payload, principal, session)
|
|
87
|
+
rescue JSON::ParserError
|
|
88
|
+
rpc_error(-32700, "Parse error")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def serve_initialize(request, payload, principal)
|
|
92
|
+
started_at = Time.current
|
|
93
|
+
result = @toolset.deliver(payload, context: call_context(principal))
|
|
94
|
+
|
|
95
|
+
session = McpInboundSession.create!(
|
|
96
|
+
server_name: server_name,
|
|
97
|
+
mount_key: mount_key,
|
|
98
|
+
session_id: result.session_id || SecureRandom.uuid,
|
|
99
|
+
principal: principal.label,
|
|
100
|
+
principal_key: principal.key,
|
|
101
|
+
last_used_at: Time.current
|
|
102
|
+
)
|
|
103
|
+
record_event(session, request, payload, result, started_at, principal)
|
|
104
|
+
|
|
105
|
+
respond(result, session_id: session.session_id)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def serve_in_session(request, payload, principal, session)
|
|
109
|
+
started_at = Time.current
|
|
110
|
+
session.touch(:last_used_at)
|
|
111
|
+
|
|
112
|
+
# Notifications are acknowledged, logged, and not dispatched — the
|
|
113
|
+
# toolset protocol surface is request/response only.
|
|
114
|
+
unless payload.key?("id")
|
|
115
|
+
record_event(session, request, payload, nil, started_at, principal)
|
|
116
|
+
return accepted
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
result = @toolset.deliver(payload, session_id: session.session_id,
|
|
120
|
+
context: call_context(principal))
|
|
121
|
+
record_event(session, request, payload, result, started_at, principal)
|
|
122
|
+
|
|
123
|
+
respond(result)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def handle_delete(request, principal)
|
|
127
|
+
session = resolve_session(request, principal)
|
|
128
|
+
return session unless session.is_a?(McpInboundSession)
|
|
129
|
+
|
|
130
|
+
started_at = Time.current
|
|
131
|
+
session.update!(terminated_at: Time.current, last_used_at: Time.current)
|
|
132
|
+
record_event(session, request, {}, nil, started_at, principal)
|
|
133
|
+
|
|
134
|
+
no_content
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# McpInboundSession, or a Rack error response: 400 without the header,
|
|
138
|
+
# 404 for an unknown or terminated session (the client's signal to
|
|
139
|
+
# re-initialize).
|
|
140
|
+
#
|
|
141
|
+
# Lookup is scoped to (mount_key, session_id, principal_key): a valid
|
|
142
|
+
# principal B who obtains principal A's session_id cannot use or delete it
|
|
143
|
+
# — B's principal_key does not match A's row, so the row is invisible and
|
|
144
|
+
# B gets a 404 exactly as for any unknown session. The `principal` scope
|
|
145
|
+
# ALSO closes the mismatch where B's key differs from the session owner's.
|
|
146
|
+
def resolve_session(request, principal)
|
|
147
|
+
header = request.headers["Mcp-Session-Id"]
|
|
148
|
+
return bad_request if header.blank?
|
|
149
|
+
|
|
150
|
+
McpInboundSession.active
|
|
151
|
+
.where(mount_key: mount_key, session_id: header, principal_key: principal.key)
|
|
152
|
+
.first || not_found
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def call_context(principal)
|
|
156
|
+
CallContext.new(agent: nil, loop: nil, principal: principal.to_context)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def server_name
|
|
160
|
+
@toolset.class.server_name
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Stable identity for this mount: the explicit mount: option, else the
|
|
164
|
+
# toolset's server_name (so a single mount keeps its historical namespace).
|
|
165
|
+
def mount_key
|
|
166
|
+
@mount || server_name
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def record_event(session, request, payload, result, started_at, principal)
|
|
170
|
+
SolidLoop::Event.create!(
|
|
171
|
+
eventable: session,
|
|
172
|
+
name: event_name(request, payload),
|
|
173
|
+
http_method: request.request_method,
|
|
174
|
+
url: request.path,
|
|
175
|
+
principal_key: principal&.key,
|
|
176
|
+
request_data: payload,
|
|
177
|
+
response_data: result ? result.raw_response || {} : {},
|
|
178
|
+
duration: Time.current - started_at,
|
|
179
|
+
log: wire_log(payload, result)
|
|
180
|
+
)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def event_name(request, payload)
|
|
184
|
+
return "mcp_inbound_terminate" if request.request_method == "DELETE"
|
|
185
|
+
|
|
186
|
+
"mcp_inbound_#{payload['method'].to_s.tr('/', '_')}"
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def wire_log(payload, result)
|
|
190
|
+
lines = [ "request: #{JSON.pretty_generate(payload)}" ]
|
|
191
|
+
if result
|
|
192
|
+
lines << "status: #{result.status}"
|
|
193
|
+
lines << "response: #{JSON.pretty_generate(result.raw_response || {})}"
|
|
194
|
+
end
|
|
195
|
+
lines.join("\n")
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def valid_jsonrpc?(payload)
|
|
199
|
+
payload.is_a?(Hash) && payload["jsonrpc"] == "2.0" && payload["method"].is_a?(String)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def bearer_token(request)
|
|
203
|
+
scheme, token = request.authorization.to_s.split(" ", 2)
|
|
204
|
+
token if scheme&.casecmp?("bearer")
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def respond(result, session_id: nil)
|
|
208
|
+
headers = { "content-type" => "application/json" }
|
|
209
|
+
sid = session_id || result.session_id
|
|
210
|
+
headers["mcp-session-id"] = sid if sid
|
|
211
|
+
[result.status || 200, headers, [JSON.generate(result.body)]]
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Protocol errors ride HTTP 200 with a JSON-RPC error object; HTTP
|
|
215
|
+
# status codes are reserved for transport-level outcomes.
|
|
216
|
+
def rpc_error(code, message, id = nil)
|
|
217
|
+
body = { "jsonrpc" => "2.0", "id" => id, "error" => { "code" => code, "message" => message } }
|
|
218
|
+
[200, { "content-type" => "application/json" }, [JSON.generate(body)]]
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def unauthorized
|
|
222
|
+
[401, {}, []]
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def bad_request
|
|
226
|
+
[400, {}, []]
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def not_found
|
|
230
|
+
[404, {}, []]
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def accepted
|
|
234
|
+
[202, {}, []]
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def no_content
|
|
238
|
+
[204, {}, []]
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def method_not_allowed
|
|
242
|
+
[405, { "allow" => ALLOWED_METHODS.join(", ") }, []]
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
require "open3"
|
|
2
|
+
require "io/wait"
|
|
3
|
+
|
|
4
|
+
module SolidLoop
|
|
5
|
+
module Mcp
|
|
6
|
+
# Child-process MCP server over stdin/stdout — restricted contract.
|
|
7
|
+
#
|
|
8
|
+
# SolidLoop executes each tool call in an independent job (any worker, any
|
|
9
|
+
# machine), so no process could own a long-lived child across rounds.
|
|
10
|
+
# Therefore the lifecycle is spawn-per-call: spawn -> `initialize`
|
|
11
|
+
# handshake -> request -> graceful shutdown (kill on timeout), all inside
|
|
12
|
+
# one `deliver`. Stateless servers only: the session id is synthetic and
|
|
13
|
+
# implies no server-side continuity; stateful stdio servers (a running
|
|
14
|
+
# shell, a browser session, a REPL sandbox) are unsupported — their
|
|
15
|
+
# in-memory state would be lost at every job boundary. This is a deliberate
|
|
16
|
+
# consequence of the durable-async core, not a transport bug.
|
|
17
|
+
#
|
|
18
|
+
# Options: command: (argv array, never a shell string), env:, cwd:,
|
|
19
|
+
# timeout: (per call, includes spawn+handshake). stderr is captured into
|
|
20
|
+
# the wire log. Spawn+handshake is typically 50-500 ms for Node/Python
|
|
21
|
+
# servers — prefer HTTP or a toolset for hot paths.
|
|
22
|
+
class StdioTransport
|
|
23
|
+
include Transport
|
|
24
|
+
|
|
25
|
+
# Mutex-guarded string buffer: the stderr drain thread and the stdout
|
|
26
|
+
# reader append concurrently (safe under the GVL, undefined elsewhere).
|
|
27
|
+
class Diagnostics
|
|
28
|
+
def initialize
|
|
29
|
+
@mutex = Mutex.new
|
|
30
|
+
@buffer = +""
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def <<(text)
|
|
34
|
+
@mutex.synchronize { @buffer << text }
|
|
35
|
+
self
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def to_s
|
|
39
|
+
@mutex.synchronize { @buffer.dup }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def initialize(command:, env: {}, cwd: nil, timeout: 30)
|
|
44
|
+
unless command.is_a?(Array) && command.any? && command.all?(String)
|
|
45
|
+
raise ArgumentError, "command: must be a non-empty argv Array of Strings (never a shell string)"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
@command = command
|
|
49
|
+
@env = env.transform_keys(&:to_s).transform_values(&:to_s)
|
|
50
|
+
@cwd = cwd
|
|
51
|
+
@timeout = timeout
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def deliver(payload, session_id: nil, context: nil)
|
|
55
|
+
request = JSON.parse(JSON.generate(payload))
|
|
56
|
+
deadline = now + @timeout
|
|
57
|
+
diagnostics = Diagnostics.new
|
|
58
|
+
|
|
59
|
+
body = with_server(deadline, diagnostics) do |stdin, stdout|
|
|
60
|
+
handshake(stdin, stdout, deadline, diagnostics) unless request["method"] == "initialize"
|
|
61
|
+
write_message(stdin, request, deadline, diagnostics)
|
|
62
|
+
read_response(stdout, request["id"], deadline, diagnostics)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
Result.new(
|
|
66
|
+
body: body,
|
|
67
|
+
session_id: nil, # stateless-only: no server-side continuity between calls
|
|
68
|
+
raw_request: payload,
|
|
69
|
+
raw_response: body,
|
|
70
|
+
status: 200,
|
|
71
|
+
stderr: diagnostics.to_s.strip.presence
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def with_server(deadline, diagnostics)
|
|
78
|
+
stdin, stdout, stderr, wait_thr = spawn_server
|
|
79
|
+
|
|
80
|
+
drain = Thread.new do
|
|
81
|
+
stderr.each_line { |line| diagnostics << line }
|
|
82
|
+
rescue IOError
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
begin
|
|
87
|
+
result = yield(stdin, stdout)
|
|
88
|
+
safe_close(stdin)
|
|
89
|
+
wait_thr.join(remaining(deadline).clamp(0, 0.5))
|
|
90
|
+
result
|
|
91
|
+
ensure
|
|
92
|
+
terminate(wait_thr)
|
|
93
|
+
drain.join(0.5)
|
|
94
|
+
[ stdin, stdout, stderr ].each { |io| safe_close(io) }
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def spawn_server
|
|
99
|
+
options = @cwd ? { chdir: @cwd } : {}
|
|
100
|
+
Open3.popen3(@env, *@command, **options)
|
|
101
|
+
rescue SystemCallError => e
|
|
102
|
+
raise SolidLoop::McpError, "MCP stdio transport failed to spawn #{@command.first.inspect}: #{e.message}"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def handshake(stdin, stdout, deadline, diagnostics)
|
|
106
|
+
init = {
|
|
107
|
+
"jsonrpc" => "2.0",
|
|
108
|
+
"id" => "init-#{SecureRandom.uuid}",
|
|
109
|
+
"method" => "initialize",
|
|
110
|
+
"params" => {
|
|
111
|
+
"protocolVersion" => "2024-11-05",
|
|
112
|
+
"capabilities" => {},
|
|
113
|
+
"clientInfo" => { "name" => "solid_loop", "version" => SolidLoop::VERSION }
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
write_message(stdin, init, deadline, diagnostics)
|
|
118
|
+
response = read_response(stdout, init["id"], deadline, diagnostics)
|
|
119
|
+
if response["error"]
|
|
120
|
+
raise transport_error("MCP stdio initialize handshake failed: #{response['error']}", diagnostics)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
write_message(stdin, { "jsonrpc" => "2.0", "method" => "notifications/initialized" }, deadline, diagnostics)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Non-blocking writes under the same per-call deadline as reads: a child
|
|
127
|
+
# that never drains its stdin must not hang the job past `timeout:`.
|
|
128
|
+
def write_message(stdin, message, deadline, diagnostics)
|
|
129
|
+
data = JSON.generate(message) + "\n"
|
|
130
|
+
|
|
131
|
+
until data.empty?
|
|
132
|
+
wait = remaining(deadline)
|
|
133
|
+
raise timeout_error(diagnostics) if wait <= 0 || !stdin.wait_writable(wait)
|
|
134
|
+
|
|
135
|
+
begin
|
|
136
|
+
written = stdin.write_nonblock(data)
|
|
137
|
+
data = data.byteslice(written..)
|
|
138
|
+
rescue IO::WaitWritable
|
|
139
|
+
next
|
|
140
|
+
rescue Errno::EPIPE, IOError
|
|
141
|
+
raise transport_error("MCP stdio server closed its stdin before the request could be written", diagnostics)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def read_response(stdout, id, deadline, diagnostics)
|
|
147
|
+
buffer = +""
|
|
148
|
+
|
|
149
|
+
loop do
|
|
150
|
+
while (line = buffer.slice!(/\A[^\n]*\n/))
|
|
151
|
+
message = parse_line(line, diagnostics)
|
|
152
|
+
next unless message
|
|
153
|
+
return message if message["id"] == id
|
|
154
|
+
# Notifications and non-matching messages are skipped: the
|
|
155
|
+
# restricted contract serves exactly one request per process.
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
wait = remaining(deadline)
|
|
159
|
+
raise timeout_error(diagnostics) if wait <= 0 || !stdout.wait_readable(wait)
|
|
160
|
+
|
|
161
|
+
begin
|
|
162
|
+
buffer << stdout.readpartial(64 * 1024)
|
|
163
|
+
rescue EOFError
|
|
164
|
+
raise transport_error("MCP stdio server exited before responding", diagnostics)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# stdout is protocol-only per the MCP spec; anything that is not a
|
|
170
|
+
# JSON-RPC message object is kept as a diagnostic instead of failing
|
|
171
|
+
# the call.
|
|
172
|
+
def parse_line(line, diagnostics)
|
|
173
|
+
message = JSON.parse(line)
|
|
174
|
+
return message if message.is_a?(Hash)
|
|
175
|
+
|
|
176
|
+
diagnostics << "[stdout] #{line}"
|
|
177
|
+
nil
|
|
178
|
+
rescue JSON::ParserError
|
|
179
|
+
diagnostics << "[stdout] #{line}"
|
|
180
|
+
nil
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def terminate(wait_thr)
|
|
184
|
+
return unless wait_thr.alive?
|
|
185
|
+
|
|
186
|
+
kill("TERM", wait_thr)
|
|
187
|
+
return if wait_thr.join(0.5)
|
|
188
|
+
|
|
189
|
+
kill("KILL", wait_thr)
|
|
190
|
+
wait_thr.join(0.5)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def kill(signal, wait_thr)
|
|
194
|
+
Process.kill(signal, wait_thr.pid)
|
|
195
|
+
rescue Errno::ESRCH
|
|
196
|
+
nil
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def safe_close(io)
|
|
200
|
+
io.close unless io.closed?
|
|
201
|
+
rescue IOError
|
|
202
|
+
nil
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def timeout_error(diagnostics)
|
|
206
|
+
transport_error("MCP stdio call timed out after #{@timeout}s", diagnostics)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def transport_error(message, diagnostics)
|
|
210
|
+
excerpt = diagnostics.to_s.strip
|
|
211
|
+
message += " (stderr: #{excerpt.byteslice(0, 2000)})" if excerpt.present?
|
|
212
|
+
SolidLoop::McpError.new(message)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def remaining(deadline)
|
|
216
|
+
deadline - now
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def now
|
|
220
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|