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,333 @@
1
+ # 05-MCP-CLIENT: Consume External MCP Servers
2
+
3
+ ## Goal
4
+
5
+ Workflow steps can call tools on external MCP servers. The `mcp` step executor uses MCP::Client.
6
+
7
+ ## Architecture
8
+
9
+ ```
10
+ Workflow step: MCP Client: External Server:
11
+ type: mcp → Client.for(config) → tools/list
12
+ server: github Client.call_tool() → tools/call
13
+ tool: list_issues
14
+ ```
15
+
16
+ ## Workflow YAML
17
+
18
+ ```yaml
19
+ mcp_servers:
20
+ github:
21
+ url: "https://mcp.github.com/v1"
22
+ headers:
23
+ Authorization: "Bearer ${GITHUB_TOKEN}"
24
+ local_db:
25
+ transport: stdio
26
+ command: ["python", "db_server.py"]
27
+
28
+ steps:
29
+ - id: get_issues
30
+ type: mcp
31
+ server: github
32
+ tool: list_issues
33
+ arguments:
34
+ repo: "$input.repo"
35
+ output: issues
36
+ ```
37
+
38
+ ## Files to Create
39
+
40
+ ### `lib/durable_workflow/extensions/ai/mcp/client.rb`
41
+
42
+ ```ruby
43
+ # frozen_string_literal: true
44
+
45
+ module DurableWorkflow
46
+ module Extensions
47
+ module AI
48
+ module MCP
49
+ class Client
50
+ @connections = {}
51
+
52
+ class << self
53
+ # Get or create client for server config
54
+ def for(server_config)
55
+ cache_key = server_config[:url] || server_config[:command].to_s
56
+ @connections[cache_key] ||= build_client(server_config)
57
+ end
58
+
59
+ # List tools from server
60
+ def tools(server_config)
61
+ client = self.for(server_config)
62
+ client.tools
63
+ end
64
+
65
+ # Call tool on server
66
+ def call_tool(server_config, tool_name, arguments)
67
+ client = self.for(server_config)
68
+ tool = client.tools.find { |t| t.name == tool_name }
69
+ raise DurableWorkflow::ExecutionError, "MCP tool not found: #{tool_name}" unless tool
70
+
71
+ client.call_tool(tool: tool, arguments: arguments)
72
+ end
73
+
74
+ # Clear connection cache
75
+ def reset!
76
+ @connections.clear
77
+ end
78
+
79
+ private
80
+
81
+ def build_client(server_config)
82
+ transport = build_transport(server_config)
83
+ ::MCP::Client.new(transport: transport)
84
+ end
85
+
86
+ def build_transport(server_config)
87
+ case server_config[:transport]&.to_sym
88
+ when :stdio
89
+ build_stdio_transport(server_config)
90
+ else
91
+ build_http_transport(server_config)
92
+ end
93
+ end
94
+
95
+ def build_http_transport(config)
96
+ ::MCP::Client::HTTP.new(
97
+ url: config[:url],
98
+ headers: interpolate_env(config[:headers] || {})
99
+ )
100
+ end
101
+
102
+ def build_stdio_transport(config)
103
+ ::MCP::Client::Stdio.new(
104
+ command: config[:command]
105
+ )
106
+ end
107
+
108
+ # Replace ${ENV_VAR} with actual values
109
+ def interpolate_env(headers)
110
+ headers.transform_values do |v|
111
+ v.gsub(/\$\{(\w+)\}/) { ENV.fetch(::Regexp.last_match(1), "") }
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
120
+ ```
121
+
122
+ ## Files to Update
123
+
124
+ ### `lib/durable_workflow/extensions/ai/types.rb`
125
+
126
+ Add MCPServerConfig:
127
+
128
+ ```ruby
129
+ class MCPServerConfig < BaseStruct
130
+ attribute? :url, Types::Strict::String.optional
131
+ attribute? :headers, Types::Hash.default({}.freeze)
132
+ attribute? :transport, Types::Coercible::Symbol.default(:http)
133
+ attribute? :command, Types::Strict::Array.of(Types::Strict::String).optional
134
+ end
135
+ ```
136
+
137
+ ### `lib/durable_workflow/extensions/ai/executors/mcp.rb`
138
+
139
+ Rewrite with real MCP::Client:
140
+
141
+ ```ruby
142
+ # frozen_string_literal: true
143
+
144
+ module DurableWorkflow
145
+ module Extensions
146
+ module AI
147
+ module Executors
148
+ class MCP < Core::Executors::Base
149
+ Core::Executors::Registry.register("mcp", self)
150
+
151
+ def call(state)
152
+ server_config = resolve_server(config.server)
153
+ tool_name = config.tool
154
+ arguments = resolve(state, config.arguments || {})
155
+
156
+ result = AI::MCP::Client.call_tool(server_config, tool_name, arguments)
157
+
158
+ # Extract text content from MCP response
159
+ output = extract_output(result)
160
+
161
+ state = store(state, config.output, output) if config.output
162
+ continue(state, output: output)
163
+ end
164
+
165
+ private
166
+
167
+ def resolve_server(server_id)
168
+ servers = AI.data_from(workflow)[:mcp_servers] || {}
169
+ server_config = servers[server_id.to_sym]
170
+ raise ExecutionError, "MCP server not found: #{server_id}" unless server_config
171
+ server_config
172
+ end
173
+
174
+ def extract_output(result)
175
+ if result.respond_to?(:content)
176
+ result.content.map { |c| c[:text] || c["text"] }.compact.join("\n")
177
+ else
178
+ result.to_s
179
+ end
180
+ end
181
+
182
+ def workflow
183
+ @workflow ||= DurableWorkflow.registry[state.workflow_id]
184
+ end
185
+ end
186
+ end
187
+ end
188
+ end
189
+ end
190
+ ```
191
+
192
+ ### Parser Updates
193
+
194
+ In `lib/durable_workflow/extensions/ai/ai.rb`, add mcp_servers parsing:
195
+
196
+ ```ruby
197
+ # In after_parse hook
198
+ def self.parse_mcp_servers(raw)
199
+ return {} unless raw[:mcp_servers]
200
+
201
+ raw[:mcp_servers].transform_values do |config|
202
+ MCPServerConfig.new(
203
+ url: config[:url],
204
+ headers: config[:headers] || {},
205
+ transport: config[:transport]&.to_sym || :http,
206
+ command: config[:command]
207
+ )
208
+ end
209
+ end
210
+ ```
211
+
212
+ ## Tests
213
+
214
+ ### `test/unit/extensions/ai/mcp/client_test.rb`
215
+
216
+ ```ruby
217
+ class ClientTest < Minitest::Test
218
+ def setup
219
+ AI::MCP::Client.reset!
220
+ end
221
+
222
+ def test_for_creates_client_with_http_transport
223
+ config = { url: "https://example.com/mcp" }
224
+
225
+ mock_transport = Minitest::Mock.new
226
+ mock_client = Minitest::Mock.new
227
+
228
+ ::MCP::Client::HTTP.stub :new, mock_transport do
229
+ ::MCP::Client.stub :new, mock_client do
230
+ result = AI::MCP::Client.for(config)
231
+ assert_equal mock_client, result
232
+ end
233
+ end
234
+ end
235
+
236
+ def test_for_caches_connections
237
+ config = { url: "https://example.com/mcp" }
238
+
239
+ client1 = AI::MCP::Client.for(config)
240
+ client2 = AI::MCP::Client.for(config)
241
+
242
+ assert_same client1, client2
243
+ end
244
+
245
+ def test_call_tool_invokes_tool
246
+ config = { url: "https://example.com/mcp" }
247
+
248
+ mock_tool = OpenStruct.new(name: "my_tool")
249
+ mock_response = OpenStruct.new(content: [{ text: "result" }])
250
+
251
+ mock_client = Minitest::Mock.new
252
+ mock_client.expect :tools, [mock_tool]
253
+ mock_client.expect :call_tool, mock_response, [{ tool: mock_tool, arguments: { a: 1 } }]
254
+
255
+ AI::MCP::Client.stub :for, mock_client do
256
+ result = AI::MCP::Client.call_tool(config, "my_tool", { a: 1 })
257
+ assert_equal mock_response, result
258
+ end
259
+ end
260
+
261
+ def test_call_tool_raises_for_unknown_tool
262
+ config = { url: "https://example.com/mcp" }
263
+
264
+ mock_client = Minitest::Mock.new
265
+ mock_client.expect :tools, []
266
+
267
+ AI::MCP::Client.stub :for, mock_client do
268
+ assert_raises(DurableWorkflow::ExecutionError) do
269
+ AI::MCP::Client.call_tool(config, "unknown", {})
270
+ end
271
+ end
272
+ end
273
+
274
+ def test_interpolate_env_replaces_variables
275
+ ENV["TEST_TOKEN"] = "secret123"
276
+ headers = { "Authorization" => "Bearer ${TEST_TOKEN}" }
277
+
278
+ result = AI::MCP::Client.send(:interpolate_env, headers)
279
+ assert_equal "Bearer secret123", result["Authorization"]
280
+ ensure
281
+ ENV.delete("TEST_TOKEN")
282
+ end
283
+ end
284
+ ```
285
+
286
+ ### `test/unit/extensions/ai/executors/mcp_test.rb`
287
+
288
+ ```ruby
289
+ class MCPExecutorTest < Minitest::Test
290
+ def test_mcp_executor_resolves_server_config
291
+ workflow = create_workflow_with_mcp_servers({
292
+ github: { url: "https://mcp.github.com" }
293
+ })
294
+
295
+ executor = create_mcp_executor(server: "github", tool: "list_issues")
296
+
297
+ AI::MCP::Client.stub :call_tool, mock_response do
298
+ outcome = executor.call(state)
299
+ # Verify server config was resolved
300
+ end
301
+ end
302
+
303
+ def test_mcp_executor_calls_tool
304
+ # ...
305
+ end
306
+
307
+ def test_mcp_executor_stores_result
308
+ # ...
309
+ end
310
+
311
+ def test_mcp_executor_raises_for_unknown_server
312
+ workflow = create_workflow_with_mcp_servers({})
313
+ executor = create_mcp_executor(server: "unknown", tool: "test")
314
+
315
+ assert_raises(ExecutionError) do
316
+ executor.call(state)
317
+ end
318
+ end
319
+ end
320
+ ```
321
+
322
+ ## Acceptance Criteria
323
+
324
+ 1. `Client.for` creates client with HTTP transport
325
+ 2. `Client.for` creates client with stdio transport
326
+ 3. `Client.for` caches connections
327
+ 4. `Client.call_tool` invokes tool and returns result
328
+ 5. `Client.call_tool` raises for unknown tool
329
+ 6. Environment variables interpolated in headers
330
+ 7. MCP executor resolves server from workflow config
331
+ 8. MCP executor calls tool via Client
332
+ 9. MCP executor stores result in output
333
+ 10. MCP executor raises for unknown server