durable_workflow 0.1.0

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 (116) hide show
  1. checksums.yaml +7 -0
  2. data/.claude/todo/01.amend.md +133 -0
  3. data/.claude/todo/02.amend.md +444 -0
  4. data/.claude/todo/phase-1-core/01-GEMSPEC.md +193 -0
  5. data/.claude/todo/phase-1-core/02-TYPES.md +462 -0
  6. data/.claude/todo/phase-1-core/03-EXECUTION.md +551 -0
  7. data/.claude/todo/phase-1-core/04-STEPS.md +603 -0
  8. data/.claude/todo/phase-1-core/05-PARSER.md +719 -0
  9. data/.claude/todo/phase-1-core/todo.md +574 -0
  10. data/.claude/todo/phase-2-runtime/01-STORAGE.md +641 -0
  11. data/.claude/todo/phase-2-runtime/02-RUNNERS.md +511 -0
  12. data/.claude/todo/phase-3-extensions/01-EXTENSION-SYSTEM.md +298 -0
  13. data/.claude/todo/phase-3-extensions/02-AI-PLUGIN.md +936 -0
  14. data/.claude/todo/phase-3-extensions/todo.md +262 -0
  15. data/.claude/todo/phase-4-ai-rework/01-DEPENDENCIES.md +107 -0
  16. data/.claude/todo/phase-4-ai-rework/02-CONFIGURATION.md +123 -0
  17. data/.claude/todo/phase-4-ai-rework/03-TOOL-REGISTRY.md +237 -0
  18. data/.claude/todo/phase-4-ai-rework/04-MCP-SERVER.md +432 -0
  19. data/.claude/todo/phase-4-ai-rework/05-MCP-CLIENT.md +333 -0
  20. data/.claude/todo/phase-4-ai-rework/06-EXECUTORS.md +397 -0
  21. data/.claude/todo/phase-4-ai-rework/todo.md +265 -0
  22. data/.claude/todo/phase-5-validation/.DS_Store +0 -0
  23. data/.claude/todo/phase-5-validation/01-TEST-GAPS.md +615 -0
  24. data/.claude/todo/phase-5-validation/01-TESTS.md +2378 -0
  25. data/.claude/todo/phase-5-validation/02-EXAMPLES-SIMPLE.md +744 -0
  26. data/.claude/todo/phase-5-validation/02-EXAMPLES.md +1857 -0
  27. data/.claude/todo/phase-5-validation/03-EXAMPLE-SUPPORT-AGENT.md +95 -0
  28. data/.claude/todo/phase-5-validation/04-EXAMPLE-ORDER-FULFILLMENT.md +94 -0
  29. data/.claude/todo/phase-5-validation/05-EXAMPLE-DATA-PIPELINE.md +145 -0
  30. data/.env.example +3 -0
  31. data/.rubocop.yml +64 -0
  32. data/0.3.amend.md +89 -0
  33. data/CHANGELOG.md +5 -0
  34. data/CODE_OF_CONDUCT.md +84 -0
  35. data/Gemfile +22 -0
  36. data/Gemfile.lock +192 -0
  37. data/LICENSE.txt +21 -0
  38. data/README.md +39 -0
  39. data/Rakefile +16 -0
  40. data/durable_workflow.gemspec +43 -0
  41. data/examples/approval_request.rb +106 -0
  42. data/examples/calculator.rb +154 -0
  43. data/examples/file_search_demo.rb +77 -0
  44. data/examples/hello_workflow.rb +57 -0
  45. data/examples/item_processor.rb +96 -0
  46. data/examples/order_fulfillment/Gemfile +6 -0
  47. data/examples/order_fulfillment/README.md +84 -0
  48. data/examples/order_fulfillment/run.rb +85 -0
  49. data/examples/order_fulfillment/services.rb +146 -0
  50. data/examples/order_fulfillment/workflow.yml +188 -0
  51. data/examples/parallel_fetch.rb +102 -0
  52. data/examples/service_integration.rb +137 -0
  53. data/examples/support_agent/Gemfile +6 -0
  54. data/examples/support_agent/README.md +91 -0
  55. data/examples/support_agent/config/claude_desktop.json +12 -0
  56. data/examples/support_agent/mcp_server.rb +49 -0
  57. data/examples/support_agent/run.rb +67 -0
  58. data/examples/support_agent/services.rb +113 -0
  59. data/examples/support_agent/workflow.yml +286 -0
  60. data/lib/durable_workflow/core/condition.rb +45 -0
  61. data/lib/durable_workflow/core/engine.rb +145 -0
  62. data/lib/durable_workflow/core/executors/approval.rb +51 -0
  63. data/lib/durable_workflow/core/executors/assign.rb +18 -0
  64. data/lib/durable_workflow/core/executors/base.rb +90 -0
  65. data/lib/durable_workflow/core/executors/call.rb +76 -0
  66. data/lib/durable_workflow/core/executors/end.rb +19 -0
  67. data/lib/durable_workflow/core/executors/halt.rb +24 -0
  68. data/lib/durable_workflow/core/executors/loop.rb +118 -0
  69. data/lib/durable_workflow/core/executors/parallel.rb +77 -0
  70. data/lib/durable_workflow/core/executors/registry.rb +34 -0
  71. data/lib/durable_workflow/core/executors/router.rb +26 -0
  72. data/lib/durable_workflow/core/executors/start.rb +61 -0
  73. data/lib/durable_workflow/core/executors/transform.rb +71 -0
  74. data/lib/durable_workflow/core/executors/workflow.rb +32 -0
  75. data/lib/durable_workflow/core/parser.rb +189 -0
  76. data/lib/durable_workflow/core/resolver.rb +61 -0
  77. data/lib/durable_workflow/core/schema_validator.rb +47 -0
  78. data/lib/durable_workflow/core/types/base.rb +41 -0
  79. data/lib/durable_workflow/core/types/condition.rb +25 -0
  80. data/lib/durable_workflow/core/types/configs.rb +103 -0
  81. data/lib/durable_workflow/core/types/entry.rb +26 -0
  82. data/lib/durable_workflow/core/types/results.rb +41 -0
  83. data/lib/durable_workflow/core/types/state.rb +95 -0
  84. data/lib/durable_workflow/core/types/step_def.rb +15 -0
  85. data/lib/durable_workflow/core/types/workflow_def.rb +43 -0
  86. data/lib/durable_workflow/core/types.rb +29 -0
  87. data/lib/durable_workflow/core/validator.rb +318 -0
  88. data/lib/durable_workflow/extensions/ai/ai.rb +149 -0
  89. data/lib/durable_workflow/extensions/ai/configuration.rb +41 -0
  90. data/lib/durable_workflow/extensions/ai/executors/agent.rb +150 -0
  91. data/lib/durable_workflow/extensions/ai/executors/file_search.rb +52 -0
  92. data/lib/durable_workflow/extensions/ai/executors/guardrail.rb +152 -0
  93. data/lib/durable_workflow/extensions/ai/executors/handoff.rb +33 -0
  94. data/lib/durable_workflow/extensions/ai/executors/mcp.rb +47 -0
  95. data/lib/durable_workflow/extensions/ai/mcp/adapter.rb +73 -0
  96. data/lib/durable_workflow/extensions/ai/mcp/client.rb +77 -0
  97. data/lib/durable_workflow/extensions/ai/mcp/rack_app.rb +66 -0
  98. data/lib/durable_workflow/extensions/ai/mcp/server.rb +122 -0
  99. data/lib/durable_workflow/extensions/ai/tool_registry.rb +63 -0
  100. data/lib/durable_workflow/extensions/ai/types.rb +213 -0
  101. data/lib/durable_workflow/extensions/ai.rb +6 -0
  102. data/lib/durable_workflow/extensions/base.rb +77 -0
  103. data/lib/durable_workflow/runners/adapters/inline.rb +42 -0
  104. data/lib/durable_workflow/runners/adapters/sidekiq.rb +69 -0
  105. data/lib/durable_workflow/runners/async.rb +100 -0
  106. data/lib/durable_workflow/runners/stream.rb +126 -0
  107. data/lib/durable_workflow/runners/sync.rb +40 -0
  108. data/lib/durable_workflow/storage/active_record.rb +148 -0
  109. data/lib/durable_workflow/storage/redis.rb +133 -0
  110. data/lib/durable_workflow/storage/sequel.rb +144 -0
  111. data/lib/durable_workflow/storage/store.rb +43 -0
  112. data/lib/durable_workflow/utils.rb +25 -0
  113. data/lib/durable_workflow/version.rb +5 -0
  114. data/lib/durable_workflow.rb +70 -0
  115. data/sig/durable_workflow.rbs +4 -0
  116. metadata +275 -0
@@ -0,0 +1,237 @@
1
+ # 03-TOOL-REGISTRY: RubyLLM::Tool as Source of Truth
2
+
3
+ ## Goal
4
+
5
+ Tools defined in workflow YAML convert to RubyLLM::Tool classes. Single registry for all tools.
6
+
7
+ ## Architecture
8
+
9
+ ```
10
+ YAML tools: Ruby classes: Registry:
11
+ lookup_order → RubyLLM::Tool subclass → ToolRegistry["lookup_order"]
12
+ create_ticket → RubyLLM::Tool subclass → ToolRegistry["create_ticket"]
13
+ ```
14
+
15
+ ## Files to Update
16
+
17
+ ### `lib/durable_workflow/extensions/ai/types.rb`
18
+
19
+ Add `to_ruby_llm_tool` method to ToolDef:
20
+
21
+ ```ruby
22
+ class ToolDef < BaseStruct
23
+ attribute :id, Types::Strict::String
24
+ attribute :description, Types::Strict::String
25
+ attribute :parameters, Types::Strict::Array.default([].freeze)
26
+ attribute :service, Types::Strict::String
27
+ attribute :method_name, Types::Strict::String
28
+
29
+ # Convert to RubyLLM::Tool class
30
+ def to_ruby_llm_tool
31
+ tool_def = self
32
+
33
+ Class.new(RubyLLM::Tool) do
34
+ # Store reference to original definition
35
+ @tool_def = tool_def
36
+
37
+ # Set description
38
+ description tool_def.description
39
+
40
+ # Define parameters
41
+ tool_def.parameters.each do |p|
42
+ param p.name.to_sym,
43
+ type: p.type.to_sym,
44
+ desc: p.description,
45
+ required: p.required
46
+ end
47
+
48
+ # Execute calls the service method
49
+ define_method(:execute) do |**args|
50
+ svc = Object.const_get(tool_def.service)
51
+ svc.public_send(tool_def.method_name, **args)
52
+ end
53
+
54
+ class << self
55
+ attr_reader :tool_def
56
+ end
57
+ end
58
+ end
59
+ end
60
+ ```
61
+
62
+ ## Files to Create
63
+
64
+ ### `lib/durable_workflow/extensions/ai/tool_registry.rb`
65
+
66
+ ```ruby
67
+ # frozen_string_literal: true
68
+
69
+ module DurableWorkflow
70
+ module Extensions
71
+ module AI
72
+ class ToolRegistry
73
+ class << self
74
+ def registry
75
+ @registry ||= {}
76
+ end
77
+
78
+ # Register a RubyLLM::Tool class directly
79
+ def register(tool_class)
80
+ name = tool_name(tool_class)
81
+ registry[name] = tool_class
82
+ end
83
+
84
+ # Register from ToolDef (YAML-defined)
85
+ def register_from_def(tool_def)
86
+ tool_class = tool_def.to_ruby_llm_tool
87
+ registry[tool_def.id] = tool_class
88
+ end
89
+
90
+ # Get tool class by name
91
+ def [](name)
92
+ registry[name.to_s]
93
+ end
94
+
95
+ # Get all tool classes
96
+ def all
97
+ registry.values
98
+ end
99
+
100
+ # Get tools for a workflow
101
+ def for_workflow(workflow)
102
+ tool_ids = AI.data_from(workflow)[:tools]&.keys || []
103
+ tool_ids.map { |id| registry[id.to_s] }.compact
104
+ end
105
+
106
+ # Clear registry (for testing)
107
+ def reset!
108
+ @registry = {}
109
+ end
110
+
111
+ private
112
+
113
+ def tool_name(tool_class)
114
+ if tool_class.respond_to?(:tool_def) && tool_class.tool_def
115
+ tool_class.tool_def.id
116
+ else
117
+ tool_class.name&.demodulize&.underscore || "unknown"
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
125
+ ```
126
+
127
+ ## Usage
128
+
129
+ ### From YAML
130
+
131
+ ```yaml
132
+ tools:
133
+ lookup_order:
134
+ description: "Look up order by ID"
135
+ parameters:
136
+ - name: order_id
137
+ type: string
138
+ required: true
139
+ service: OrderService
140
+ method: find
141
+ ```
142
+
143
+ Parser automatically registers:
144
+
145
+ ```ruby
146
+ # In AI extension after_parse hook
147
+ tool_defs.each { |td| ToolRegistry.register_from_def(td) }
148
+ ```
149
+
150
+ ### From Ruby
151
+
152
+ ```ruby
153
+ class LookupOrder < RubyLLM::Tool
154
+ description "Look up order by ID"
155
+ param :order_id, type: :string, required: true
156
+
157
+ def execute(order_id:)
158
+ OrderService.find(order_id)
159
+ end
160
+ end
161
+
162
+ DurableWorkflow::Extensions::AI::ToolRegistry.register(LookupOrder)
163
+ ```
164
+
165
+ ### Retrieve
166
+
167
+ ```ruby
168
+ tool_class = ToolRegistry["lookup_order"]
169
+ tool = tool_class.new
170
+ result = tool.call(order_id: "123")
171
+ ```
172
+
173
+ ## Tests
174
+
175
+ ### `test/unit/extensions/ai/tool_registry_test.rb`
176
+
177
+ ```ruby
178
+ class ToolRegistryTest < Minitest::Test
179
+ def setup
180
+ ToolRegistry.reset!
181
+ end
182
+
183
+ def test_to_ruby_llm_tool_creates_subclass
184
+ tool_def = ToolDef.new(
185
+ id: "test_tool",
186
+ description: "A test tool",
187
+ parameters: [],
188
+ service: "TestService",
189
+ method_name: "call"
190
+ )
191
+
192
+ tool_class = tool_def.to_ruby_llm_tool
193
+ assert tool_class < RubyLLM::Tool
194
+ end
195
+
196
+ def test_generated_tool_has_description
197
+ tool_def = ToolDef.new(
198
+ id: "test_tool",
199
+ description: "My description",
200
+ parameters: [],
201
+ service: "TestService",
202
+ method_name: "call"
203
+ )
204
+
205
+ tool_class = tool_def.to_ruby_llm_tool
206
+ assert_equal "My description", tool_class.new.description
207
+ end
208
+
209
+ def test_register_stores_tool
210
+ tool_def = ToolDef.new(...)
211
+ ToolRegistry.register_from_def(tool_def)
212
+
213
+ assert_equal tool_def.to_ruby_llm_tool, ToolRegistry["test_tool"]
214
+ end
215
+
216
+ def test_for_workflow_returns_workflow_tools
217
+ # Setup workflow with tools in extensions[:ai][:tools]
218
+ workflow = create_workflow_with_tools(["tool_a", "tool_b"])
219
+
220
+ ToolRegistry.register_from_def(tool_a_def)
221
+ ToolRegistry.register_from_def(tool_b_def)
222
+
223
+ tools = ToolRegistry.for_workflow(workflow)
224
+ assert_equal 2, tools.size
225
+ end
226
+ end
227
+ ```
228
+
229
+ ## Acceptance Criteria
230
+
231
+ 1. `ToolDef#to_ruby_llm_tool` creates valid RubyLLM::Tool subclass
232
+ 2. Generated tool has correct description and parameters
233
+ 3. Generated tool execute calls service method
234
+ 4. `ToolRegistry.register` stores Ruby tool classes
235
+ 5. `ToolRegistry.register_from_def` stores YAML-defined tools
236
+ 6. `ToolRegistry[]` retrieves tool by name
237
+ 7. `ToolRegistry.for_workflow` returns tools for specific workflow
@@ -0,0 +1,432 @@
1
+ # 04-MCP-SERVER: Expose Workflow Tools via MCP
2
+
3
+ ## Goal
4
+
5
+ Expose workflow tools as an MCP server. External AI agents (Claude Desktop, etc.) can discover and call them.
6
+
7
+ ## Architecture
8
+
9
+ ```
10
+ Workflow tools (RubyLLM::Tool)
11
+
12
+ MCP::Adapter
13
+
14
+ MCP::Server
15
+
16
+ ┌──────┴──────┐
17
+ │ │
18
+ Stdio HTTP
19
+ (Claude) (Remote)
20
+ ```
21
+
22
+ ## Files to Create
23
+
24
+ ### `lib/durable_workflow/extensions/ai/mcp/adapter.rb`
25
+
26
+ Converts RubyLLM::Tool → MCP::Tool:
27
+
28
+ ```ruby
29
+ # frozen_string_literal: true
30
+
31
+ module DurableWorkflow
32
+ module Extensions
33
+ module AI
34
+ module MCP
35
+ class Adapter
36
+ class << self
37
+ # Convert RubyLLM::Tool instance to MCP::Tool
38
+ def to_mcp_tool(ruby_llm_tool)
39
+ tool_name = extract_name(ruby_llm_tool)
40
+ tool_description = ruby_llm_tool.description
41
+ tool_schema = ruby_llm_tool.params_schema
42
+
43
+ captured_tool = ruby_llm_tool
44
+
45
+ ::MCP::Tool.define(
46
+ name: tool_name,
47
+ description: tool_description,
48
+ input_schema: normalize_schema(tool_schema)
49
+ ) do |server_context:, **params|
50
+ execute_tool(captured_tool, params, server_context)
51
+ end
52
+ end
53
+
54
+ def execute_tool(tool, params, server_context)
55
+ result = tool.call(params.transform_keys(&:to_sym))
56
+ formatted = format_result(result)
57
+
58
+ ::MCP::Tool::Response.new([
59
+ { type: "text", text: formatted }
60
+ ])
61
+ rescue StandardError => e
62
+ ::MCP::Tool::Response.new([
63
+ { type: "text", text: "Error: #{e.message}" }
64
+ ], error: true)
65
+ end
66
+
67
+ private
68
+
69
+ def extract_name(tool)
70
+ if tool.class.respond_to?(:tool_def) && tool.class.tool_def
71
+ tool.class.tool_def.id
72
+ else
73
+ tool.name || tool.class.name&.demodulize&.underscore || "unknown"
74
+ end
75
+ end
76
+
77
+ def normalize_schema(schema)
78
+ return { properties: {}, required: [] } if schema.nil?
79
+ {
80
+ properties: schema["properties"] || schema[:properties] || {},
81
+ required: schema["required"] || schema[:required] || []
82
+ }
83
+ end
84
+
85
+ def format_result(result)
86
+ case result
87
+ when String then result
88
+ when Hash, Array then JSON.pretty_generate(result)
89
+ else result.to_s
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
98
+ ```
99
+
100
+ ### `lib/durable_workflow/extensions/ai/mcp/server.rb`
101
+
102
+ Builds MCP::Server from workflow:
103
+
104
+ ```ruby
105
+ # frozen_string_literal: true
106
+
107
+ module DurableWorkflow
108
+ module Extensions
109
+ module AI
110
+ module MCP
111
+ class Server
112
+ attr_reader :workflow, :options
113
+
114
+ def initialize(workflow, **options)
115
+ @workflow = workflow
116
+ @options = options
117
+ end
118
+
119
+ # Build MCP::Server with workflow tools
120
+ def build(server_context: {})
121
+ ::MCP::Server.new(
122
+ name: server_name,
123
+ version: server_version,
124
+ tools: build_tools,
125
+ server_context: server_context
126
+ )
127
+ end
128
+
129
+ # Run as stdio transport (for Claude Desktop)
130
+ def stdio(server_context: {})
131
+ server = build(server_context: server_context)
132
+ transport = ::MCP::Server::Transports::StdioTransport.new(server)
133
+ transport.open
134
+ end
135
+
136
+ # Build Rack app for HTTP transport
137
+ def rack_app(server_context: {})
138
+ server = build(server_context: server_context)
139
+ RackApp.new(server)
140
+ end
141
+
142
+ class << self
143
+ def build(workflow, **options)
144
+ new(workflow, **options).build
145
+ end
146
+
147
+ def stdio(workflow, **options)
148
+ new(workflow, **options).stdio
149
+ end
150
+
151
+ def rack_app(workflow, **options)
152
+ new(workflow, **options).rack_app
153
+ end
154
+ end
155
+
156
+ private
157
+
158
+ def server_name
159
+ options[:name] || "durable_workflow_#{workflow.id}"
160
+ end
161
+
162
+ def server_version
163
+ options[:version] || DurableWorkflow::VERSION
164
+ end
165
+
166
+ def build_tools
167
+ mcp_tools = []
168
+
169
+ # Convert workflow tools to MCP tools
170
+ ToolRegistry.for_workflow(workflow).each do |tool_class|
171
+ tool_instance = tool_class.new
172
+ mcp_tools << Adapter.to_mcp_tool(tool_instance)
173
+ end
174
+
175
+ # Optionally expose workflow itself as a tool
176
+ if options[:expose_workflow]
177
+ mcp_tools << build_workflow_tool
178
+ end
179
+
180
+ mcp_tools
181
+ end
182
+
183
+ def build_workflow_tool
184
+ wf = workflow
185
+ store = DurableWorkflow.config&.store
186
+
187
+ ::MCP::Tool.define(
188
+ name: "run_#{workflow.id}",
189
+ description: workflow.description || "Run #{workflow.name} workflow",
190
+ input_schema: workflow_input_schema
191
+ ) do |server_context:, **params|
192
+ runner = DurableWorkflow::Runners::Sync.new(wf, store: store)
193
+ result = runner.run(params)
194
+
195
+ ::MCP::Tool::Response.new([{
196
+ type: "text",
197
+ text: JSON.pretty_generate({
198
+ status: result.status,
199
+ output: result.output
200
+ })
201
+ }])
202
+ end
203
+ end
204
+
205
+ def workflow_input_schema
206
+ props = {}
207
+ required = []
208
+
209
+ (workflow.inputs || []).each do |input_def|
210
+ props[input_def.name] = {
211
+ type: input_def.type,
212
+ description: input_def.description
213
+ }.compact
214
+ required << input_def.name if input_def.required
215
+ end
216
+
217
+ { properties: props, required: required }
218
+ end
219
+ end
220
+ end
221
+ end
222
+ end
223
+ end
224
+ ```
225
+
226
+ ### `lib/durable_workflow/extensions/ai/mcp/rack_app.rb`
227
+
228
+ HTTP transport wrapper:
229
+
230
+ ```ruby
231
+ # frozen_string_literal: true
232
+
233
+ require "json"
234
+
235
+ module DurableWorkflow
236
+ module Extensions
237
+ module AI
238
+ module MCP
239
+ class RackApp
240
+ def initialize(server)
241
+ @server = server
242
+ end
243
+
244
+ def call(env)
245
+ request = Rack::Request.new(env)
246
+
247
+ case request.request_method
248
+ when "POST"
249
+ handle_post(request)
250
+ when "GET"
251
+ handle_sse(request)
252
+ when "DELETE"
253
+ handle_delete(request)
254
+ else
255
+ [405, { "Content-Type" => "text/plain" }, ["Method not allowed"]]
256
+ end
257
+ end
258
+
259
+ private
260
+
261
+ def handle_post(request)
262
+ body = request.body.read
263
+ result = @server.handle_json(body)
264
+
265
+ [200, { "Content-Type" => "application/json" }, [result]]
266
+ rescue JSON::ParserError => e
267
+ error_response(-32700, "Parse error: #{e.message}")
268
+ rescue StandardError => e
269
+ error_response(-32603, "Internal error")
270
+ end
271
+
272
+ def handle_sse(request)
273
+ # SSE for notifications (optional)
274
+ [501, { "Content-Type" => "text/plain" }, ["SSE not implemented"]]
275
+ end
276
+
277
+ def handle_delete(request)
278
+ # Session cleanup
279
+ [200, {}, [""]]
280
+ end
281
+
282
+ def error_response(code, message)
283
+ [400, { "Content-Type" => "application/json" }, [
284
+ JSON.generate({
285
+ jsonrpc: "2.0",
286
+ error: { code: code, message: message },
287
+ id: nil
288
+ })
289
+ ]]
290
+ end
291
+ end
292
+ end
293
+ end
294
+ end
295
+ end
296
+ ```
297
+
298
+ ### `exe/durable_workflow_mcp`
299
+
300
+ CLI for Claude Desktop:
301
+
302
+ ```ruby
303
+ #!/usr/bin/env ruby
304
+ # frozen_string_literal: true
305
+
306
+ require "durable_workflow"
307
+ require "durable_workflow/extensions/ai"
308
+
309
+ workflow_path = ARGV[0]
310
+ unless workflow_path
311
+ $stderr.puts "Usage: durable_workflow_mcp <workflow.yml>"
312
+ exit 1
313
+ end
314
+
315
+ # Suppress stdout logging (corrupts MCP protocol)
316
+ DurableWorkflow.configure do |c|
317
+ c.logger = Logger.new("/dev/null")
318
+ end
319
+
320
+ workflow = DurableWorkflow.load(workflow_path)
321
+ DurableWorkflow::Extensions::AI::MCP::Server.stdio(workflow)
322
+ ```
323
+
324
+ Make executable: `chmod +x exe/durable_workflow_mcp`
325
+
326
+ ## Usage
327
+
328
+ ### Claude Desktop Configuration
329
+
330
+ `~/.config/claude/claude_desktop_config.json`:
331
+
332
+ ```json
333
+ {
334
+ "mcpServers": {
335
+ "my_workflow": {
336
+ "command": "bundle",
337
+ "args": ["exec", "durable_workflow_mcp", "path/to/workflow.yml"],
338
+ "cwd": "/path/to/project"
339
+ }
340
+ }
341
+ }
342
+ ```
343
+
344
+ ### Rails Integration
345
+
346
+ ```ruby
347
+ # config/routes.rb
348
+ workflow = DurableWorkflow.load("support.yml")
349
+ mount DurableWorkflow::Extensions::AI::MCP::Server.rack_app(workflow), at: "/mcp"
350
+ ```
351
+
352
+ ### Expose Workflow as Tool
353
+
354
+ ```ruby
355
+ # Workflow itself becomes a callable tool
356
+ server = MCP::Server.build(workflow, expose_workflow: true)
357
+
358
+ # Claude can now call: run_support_workflow(input)
359
+ ```
360
+
361
+ ## Tests
362
+
363
+ ### `test/unit/extensions/ai/mcp/adapter_test.rb`
364
+
365
+ ```ruby
366
+ class AdapterTest < Minitest::Test
367
+ def test_to_mcp_tool_converts_ruby_llm_tool
368
+ ruby_tool = create_ruby_llm_tool
369
+ mcp_tool = Adapter.to_mcp_tool(ruby_tool)
370
+
371
+ assert_respond_to mcp_tool, :name
372
+ assert_respond_to mcp_tool, :description
373
+ end
374
+
375
+ def test_converted_tool_executes
376
+ ruby_tool = create_ruby_llm_tool_that_returns("result")
377
+ mcp_tool = Adapter.to_mcp_tool(ruby_tool)
378
+
379
+ response = mcp_tool.call(server_context: {}, arg: "value")
380
+ assert_includes response.content.first[:text], "result"
381
+ end
382
+
383
+ def test_converted_tool_handles_errors
384
+ ruby_tool = create_ruby_llm_tool_that_raises
385
+ mcp_tool = Adapter.to_mcp_tool(ruby_tool)
386
+
387
+ response = mcp_tool.call(server_context: {})
388
+ assert response.error
389
+ end
390
+ end
391
+ ```
392
+
393
+ ### `test/unit/extensions/ai/mcp/server_test.rb`
394
+
395
+ ```ruby
396
+ class ServerTest < Minitest::Test
397
+ def test_build_creates_mcp_server
398
+ workflow = create_workflow_with_tools
399
+ server = MCP::Server.build(workflow)
400
+
401
+ assert_instance_of ::MCP::Server, server
402
+ end
403
+
404
+ def test_server_includes_workflow_tools
405
+ workflow = create_workflow_with_tools(["tool_a", "tool_b"])
406
+ server = MCP::Server.build(workflow)
407
+
408
+ tool_names = server.tools.map(&:name)
409
+ assert_includes tool_names, "tool_a"
410
+ assert_includes tool_names, "tool_b"
411
+ end
412
+
413
+ def test_expose_workflow_adds_workflow_tool
414
+ workflow = create_workflow(id: "my_flow")
415
+ server = MCP::Server.build(workflow, expose_workflow: true)
416
+
417
+ tool_names = server.tools.map(&:name)
418
+ assert_includes tool_names, "run_my_flow"
419
+ end
420
+ end
421
+ ```
422
+
423
+ ## Acceptance Criteria
424
+
425
+ 1. `Adapter.to_mcp_tool` converts RubyLLM::Tool to MCP::Tool
426
+ 2. Converted tools execute correctly
427
+ 3. Converted tools handle errors gracefully
428
+ 4. `Server.build` creates MCP::Server with workflow tools
429
+ 5. `Server.stdio` runs stdio transport
430
+ 6. `Server.rack_app` returns Rack-compatible app
431
+ 7. `expose_workflow: true` adds workflow as callable tool
432
+ 8. CLI works with Claude Desktop