solid_loop 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +156 -0
  3. data/CODE_OF_CONDUCT.md +67 -0
  4. data/CONTRIBUTING.md +82 -0
  5. data/MIT-LICENSE +21 -0
  6. data/README.md +483 -0
  7. data/Rakefile +11 -0
  8. data/app/assets/javascripts/solid_loop/chart.umd.min.js +14 -0
  9. data/app/assets/javascripts/solid_loop/chartjs-adapter-date-fns.bundle.min.js +7 -0
  10. data/app/assets/javascripts/solid_loop/chartkick.min.js +2 -0
  11. data/app/assets/stylesheets/solid_loop/application.css +15 -0
  12. data/app/controllers/solid_loop/application_controller.rb +29 -0
  13. data/app/controllers/solid_loop/dashboard_controller.rb +104 -0
  14. data/app/controllers/solid_loop/events_controller.rb +12 -0
  15. data/app/controllers/solid_loop/loops_controller.rb +77 -0
  16. data/app/controllers/solid_loop/mcp_inbound_sessions_controller.rb +13 -0
  17. data/app/controllers/solid_loop/mcp_sessions_controller.rb +68 -0
  18. data/app/controllers/solid_loop/mcp_tools_controller.rb +16 -0
  19. data/app/controllers/solid_loop/messages_controller.rb +12 -0
  20. data/app/controllers/solid_loop/tool_calls_controller.rb +12 -0
  21. data/app/helpers/solid_loop/application_helper.rb +125 -0
  22. data/app/jobs/solid_loop/application_job.rb +13 -0
  23. data/app/jobs/solid_loop/llm_completion_job.rb +139 -0
  24. data/app/jobs/solid_loop/observe_broadcast_job.rb +15 -0
  25. data/app/jobs/solid_loop/reaper_job.rb +20 -0
  26. data/app/jobs/solid_loop/tool_execution_job.rb +200 -0
  27. data/app/models/solid_loop/application_record.rb +5 -0
  28. data/app/models/solid_loop/base.rb +190 -0
  29. data/app/models/solid_loop/event.rb +7 -0
  30. data/app/models/solid_loop/loop.rb +225 -0
  31. data/app/models/solid_loop/mcp_inbound_session.rb +25 -0
  32. data/app/models/solid_loop/mcp_session.rb +12 -0
  33. data/app/models/solid_loop/mcp_tool.rb +26 -0
  34. data/app/models/solid_loop/message.rb +69 -0
  35. data/app/models/solid_loop/tool_call.rb +42 -0
  36. data/app/queries/solid_loop/admin/base_query.rb +23 -0
  37. data/app/queries/solid_loop/admin/events_query.rb +31 -0
  38. data/app/queries/solid_loop/admin/loops_query.rb +38 -0
  39. data/app/queries/solid_loop/admin/mcp_inbound_sessions_query.rb +31 -0
  40. data/app/queries/solid_loop/admin/mcp_sessions_query.rb +26 -0
  41. data/app/queries/solid_loop/admin/mcp_tools_query.rb +26 -0
  42. data/app/queries/solid_loop/admin/messages_query.rb +37 -0
  43. data/app/queries/solid_loop/admin/tool_calls_query.rb +34 -0
  44. data/app/services/solid_loop/adapters/native.rb +170 -0
  45. data/app/services/solid_loop/dialects/anthropic.rb +160 -0
  46. data/app/services/solid_loop/dialects/gemini.rb +122 -0
  47. data/app/services/solid_loop/dialects/open_ai.rb +62 -0
  48. data/app/services/solid_loop/dialects/reasoning_packer.rb +37 -0
  49. data/app/services/solid_loop/llm_usage_parser/anthropic.rb +22 -0
  50. data/app/services/solid_loop/llm_usage_parser/gemini.rb +18 -0
  51. data/app/services/solid_loop/llm_usage_parser/llama.rb +15 -0
  52. data/app/services/solid_loop/llm_usage_parser/openai.rb +18 -0
  53. data/app/services/solid_loop/llm_usage_parser.rb +19 -0
  54. data/app/services/solid_loop/mcp_session_initializer.rb +205 -0
  55. data/app/services/solid_loop/mcp_tool_execution_service.rb +153 -0
  56. data/app/services/solid_loop/middlewares/agent_initialization.rb +37 -0
  57. data/app/services/solid_loop/middlewares/error_handling.rb +107 -0
  58. data/app/services/solid_loop/middlewares/event_logging.rb +100 -0
  59. data/app/services/solid_loop/middlewares/message_building.rb +92 -0
  60. data/app/services/solid_loop/middlewares/network_calling.rb +84 -0
  61. data/app/services/solid_loop/middlewares/response_parsing.rb +348 -0
  62. data/app/services/solid_loop/middlewares/tool_call_xml_parser.rb +117 -0
  63. data/app/services/solid_loop/sse_stream_aggregator.rb +105 -0
  64. data/app/services/solid_loop/tool_failure_reconciler.rb +122 -0
  65. data/app/services/solid_loop/tool_middlewares/agent_initialization.rb +17 -0
  66. data/app/services/solid_loop/tool_middlewares/error_handling.rb +66 -0
  67. data/app/services/solid_loop/tool_middlewares/event_logging.rb +27 -0
  68. data/app/services/solid_loop/tool_middlewares/response_creation.rb +110 -0
  69. data/app/services/solid_loop/tool_middlewares/tool_execution.rb +206 -0
  70. data/app/views/layouts/solid_loop/admin.html.erb +416 -0
  71. data/app/views/layouts/solid_loop/application.html.erb +17 -0
  72. data/app/views/solid_loop/dashboard/index.html.erb +184 -0
  73. data/app/views/solid_loop/events/index.html.erb +47 -0
  74. data/app/views/solid_loop/events/show.html.erb +76 -0
  75. data/app/views/solid_loop/loops/index.html.erb +83 -0
  76. data/app/views/solid_loop/loops/show.html.erb +148 -0
  77. data/app/views/solid_loop/mcp_inbound_sessions/index.html.erb +53 -0
  78. data/app/views/solid_loop/mcp_inbound_sessions/show.html.erb +78 -0
  79. data/app/views/solid_loop/mcp_sessions/_tool_output.html.erb +11 -0
  80. data/app/views/solid_loop/mcp_sessions/index.html.erb +46 -0
  81. data/app/views/solid_loop/mcp_sessions/inspector.html.erb +94 -0
  82. data/app/views/solid_loop/mcp_sessions/show.html.erb +142 -0
  83. data/app/views/solid_loop/mcp_tools/index.html.erb +44 -0
  84. data/app/views/solid_loop/mcp_tools/show.html.erb +69 -0
  85. data/app/views/solid_loop/messages/_message.html.erb +75 -0
  86. data/app/views/solid_loop/messages/index.html.erb +80 -0
  87. data/app/views/solid_loop/messages/show.html.erb +121 -0
  88. data/app/views/solid_loop/shared/_pagination.html.erb +21 -0
  89. data/app/views/solid_loop/tool_calls/index.html.erb +53 -0
  90. data/app/views/solid_loop/tool_calls/show.html.erb +59 -0
  91. data/config/routes.rb +27 -0
  92. data/db/migrate/20260408000100_solid_loop_init.rb +136 -0
  93. data/db/migrate/20260709000100_solid_loop_create_mcp_inbound_sessions.rb +16 -0
  94. data/db/migrate/20260713000100_solid_loop_add_execution_guards.rb +6 -0
  95. data/db/migrate/20260714000100_solid_loop_add_message_steering.rb +17 -0
  96. data/db/migrate/20260714000200_solid_loop_add_message_conversation_order.rb +14 -0
  97. data/db/migrate/20260715000100_solid_loop_add_mcp_inbound_session_principal_key.rb +43 -0
  98. data/db/migrate/20260715000200_solid_loop_add_llm_lease.rb +40 -0
  99. data/db/migrate/20260715000300_solid_loop_add_tool_lease.rb +33 -0
  100. data/db/migrate/20260715000400_solid_loop_add_lease_running_check.rb +32 -0
  101. data/db/migrate/20260715000500_solid_loop_add_tool_lease_pair_check.rb +35 -0
  102. data/docs/contributing/coverage.md +64 -0
  103. data/docs/decisions/durable_attempt_lease.md +364 -0
  104. data/docs/decisions/mcp-only-tooling.md +135 -0
  105. data/docs/decisions/mcp-server.md +223 -0
  106. data/docs/decisions/reasoning_persistence.md +51 -0
  107. data/docs/decisions/ruby_llm_rejected.md +35 -0
  108. data/docs/guides/dialects.md +55 -0
  109. data/docs/guides/llm_middlewares.md +294 -0
  110. data/docs/guides/mcp_transports.md +374 -0
  111. data/docs/guides/tool_middlewares.md +148 -0
  112. data/lib/solid_loop/configuration.rb +175 -0
  113. data/lib/solid_loop/engine.rb +14 -0
  114. data/lib/solid_loop/lease_heartbeat.rb +94 -0
  115. data/lib/solid_loop/lease_renewer.rb +347 -0
  116. data/lib/solid_loop/llm_metrics.rb +16 -0
  117. data/lib/solid_loop/mcp/call_context.rb +19 -0
  118. data/lib/solid_loop/mcp/client_factory.rb +61 -0
  119. data/lib/solid_loop/mcp/http_transport.rb +52 -0
  120. data/lib/solid_loop/mcp/principal.rb +82 -0
  121. data/lib/solid_loop/mcp/result.rb +13 -0
  122. data/lib/solid_loop/mcp/server.rb +246 -0
  123. data/lib/solid_loop/mcp/stdio_transport.rb +224 -0
  124. data/lib/solid_loop/mcp/toolset.rb +347 -0
  125. data/lib/solid_loop/mcp/transport.rb +20 -0
  126. data/lib/solid_loop/mcp.rb +25 -0
  127. data/lib/solid_loop/mcp_client.rb +176 -0
  128. data/lib/solid_loop/pipeline/builder.rb +38 -0
  129. data/lib/solid_loop/pipeline/context.rb +61 -0
  130. data/lib/solid_loop/pipeline/tool_context.rb +53 -0
  131. data/lib/solid_loop/pipeline.rb +40 -0
  132. data/lib/solid_loop/reaper.rb +313 -0
  133. data/lib/solid_loop/version.rb +3 -0
  134. data/lib/solid_loop.rb +94 -0
  135. data/lib/tasks/coverage.rake +206 -0
  136. data/lib/tasks/solid_loop_tasks.rake +4 -0
  137. metadata +228 -0
@@ -0,0 +1,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