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.
- checksums.yaml +7 -0
- data/.claude/todo/01.amend.md +133 -0
- data/.claude/todo/02.amend.md +444 -0
- data/.claude/todo/phase-1-core/01-GEMSPEC.md +193 -0
- data/.claude/todo/phase-1-core/02-TYPES.md +462 -0
- data/.claude/todo/phase-1-core/03-EXECUTION.md +551 -0
- data/.claude/todo/phase-1-core/04-STEPS.md +603 -0
- data/.claude/todo/phase-1-core/05-PARSER.md +719 -0
- data/.claude/todo/phase-1-core/todo.md +574 -0
- data/.claude/todo/phase-2-runtime/01-STORAGE.md +641 -0
- data/.claude/todo/phase-2-runtime/02-RUNNERS.md +511 -0
- data/.claude/todo/phase-3-extensions/01-EXTENSION-SYSTEM.md +298 -0
- data/.claude/todo/phase-3-extensions/02-AI-PLUGIN.md +936 -0
- data/.claude/todo/phase-3-extensions/todo.md +262 -0
- data/.claude/todo/phase-4-ai-rework/01-DEPENDENCIES.md +107 -0
- data/.claude/todo/phase-4-ai-rework/02-CONFIGURATION.md +123 -0
- data/.claude/todo/phase-4-ai-rework/03-TOOL-REGISTRY.md +237 -0
- data/.claude/todo/phase-4-ai-rework/04-MCP-SERVER.md +432 -0
- data/.claude/todo/phase-4-ai-rework/05-MCP-CLIENT.md +333 -0
- data/.claude/todo/phase-4-ai-rework/06-EXECUTORS.md +397 -0
- data/.claude/todo/phase-4-ai-rework/todo.md +265 -0
- data/.claude/todo/phase-5-validation/.DS_Store +0 -0
- data/.claude/todo/phase-5-validation/01-TEST-GAPS.md +615 -0
- data/.claude/todo/phase-5-validation/01-TESTS.md +2378 -0
- data/.claude/todo/phase-5-validation/02-EXAMPLES-SIMPLE.md +744 -0
- data/.claude/todo/phase-5-validation/02-EXAMPLES.md +1857 -0
- data/.claude/todo/phase-5-validation/03-EXAMPLE-SUPPORT-AGENT.md +95 -0
- data/.claude/todo/phase-5-validation/04-EXAMPLE-ORDER-FULFILLMENT.md +94 -0
- data/.claude/todo/phase-5-validation/05-EXAMPLE-DATA-PIPELINE.md +145 -0
- data/.env.example +3 -0
- data/.rubocop.yml +64 -0
- data/0.3.amend.md +89 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/Gemfile +22 -0
- data/Gemfile.lock +192 -0
- data/LICENSE.txt +21 -0
- data/README.md +39 -0
- data/Rakefile +16 -0
- data/durable_workflow.gemspec +43 -0
- data/examples/approval_request.rb +106 -0
- data/examples/calculator.rb +154 -0
- data/examples/file_search_demo.rb +77 -0
- data/examples/hello_workflow.rb +57 -0
- data/examples/item_processor.rb +96 -0
- data/examples/order_fulfillment/Gemfile +6 -0
- data/examples/order_fulfillment/README.md +84 -0
- data/examples/order_fulfillment/run.rb +85 -0
- data/examples/order_fulfillment/services.rb +146 -0
- data/examples/order_fulfillment/workflow.yml +188 -0
- data/examples/parallel_fetch.rb +102 -0
- data/examples/service_integration.rb +137 -0
- data/examples/support_agent/Gemfile +6 -0
- data/examples/support_agent/README.md +91 -0
- data/examples/support_agent/config/claude_desktop.json +12 -0
- data/examples/support_agent/mcp_server.rb +49 -0
- data/examples/support_agent/run.rb +67 -0
- data/examples/support_agent/services.rb +113 -0
- data/examples/support_agent/workflow.yml +286 -0
- data/lib/durable_workflow/core/condition.rb +45 -0
- data/lib/durable_workflow/core/engine.rb +145 -0
- data/lib/durable_workflow/core/executors/approval.rb +51 -0
- data/lib/durable_workflow/core/executors/assign.rb +18 -0
- data/lib/durable_workflow/core/executors/base.rb +90 -0
- data/lib/durable_workflow/core/executors/call.rb +76 -0
- data/lib/durable_workflow/core/executors/end.rb +19 -0
- data/lib/durable_workflow/core/executors/halt.rb +24 -0
- data/lib/durable_workflow/core/executors/loop.rb +118 -0
- data/lib/durable_workflow/core/executors/parallel.rb +77 -0
- data/lib/durable_workflow/core/executors/registry.rb +34 -0
- data/lib/durable_workflow/core/executors/router.rb +26 -0
- data/lib/durable_workflow/core/executors/start.rb +61 -0
- data/lib/durable_workflow/core/executors/transform.rb +71 -0
- data/lib/durable_workflow/core/executors/workflow.rb +32 -0
- data/lib/durable_workflow/core/parser.rb +189 -0
- data/lib/durable_workflow/core/resolver.rb +61 -0
- data/lib/durable_workflow/core/schema_validator.rb +47 -0
- data/lib/durable_workflow/core/types/base.rb +41 -0
- data/lib/durable_workflow/core/types/condition.rb +25 -0
- data/lib/durable_workflow/core/types/configs.rb +103 -0
- data/lib/durable_workflow/core/types/entry.rb +26 -0
- data/lib/durable_workflow/core/types/results.rb +41 -0
- data/lib/durable_workflow/core/types/state.rb +95 -0
- data/lib/durable_workflow/core/types/step_def.rb +15 -0
- data/lib/durable_workflow/core/types/workflow_def.rb +43 -0
- data/lib/durable_workflow/core/types.rb +29 -0
- data/lib/durable_workflow/core/validator.rb +318 -0
- data/lib/durable_workflow/extensions/ai/ai.rb +149 -0
- data/lib/durable_workflow/extensions/ai/configuration.rb +41 -0
- data/lib/durable_workflow/extensions/ai/executors/agent.rb +150 -0
- data/lib/durable_workflow/extensions/ai/executors/file_search.rb +52 -0
- data/lib/durable_workflow/extensions/ai/executors/guardrail.rb +152 -0
- data/lib/durable_workflow/extensions/ai/executors/handoff.rb +33 -0
- data/lib/durable_workflow/extensions/ai/executors/mcp.rb +47 -0
- data/lib/durable_workflow/extensions/ai/mcp/adapter.rb +73 -0
- data/lib/durable_workflow/extensions/ai/mcp/client.rb +77 -0
- data/lib/durable_workflow/extensions/ai/mcp/rack_app.rb +66 -0
- data/lib/durable_workflow/extensions/ai/mcp/server.rb +122 -0
- data/lib/durable_workflow/extensions/ai/tool_registry.rb +63 -0
- data/lib/durable_workflow/extensions/ai/types.rb +213 -0
- data/lib/durable_workflow/extensions/ai.rb +6 -0
- data/lib/durable_workflow/extensions/base.rb +77 -0
- data/lib/durable_workflow/runners/adapters/inline.rb +42 -0
- data/lib/durable_workflow/runners/adapters/sidekiq.rb +69 -0
- data/lib/durable_workflow/runners/async.rb +100 -0
- data/lib/durable_workflow/runners/stream.rb +126 -0
- data/lib/durable_workflow/runners/sync.rb +40 -0
- data/lib/durable_workflow/storage/active_record.rb +148 -0
- data/lib/durable_workflow/storage/redis.rb +133 -0
- data/lib/durable_workflow/storage/sequel.rb +144 -0
- data/lib/durable_workflow/storage/store.rb +43 -0
- data/lib/durable_workflow/utils.rb +25 -0
- data/lib/durable_workflow/version.rb +5 -0
- data/lib/durable_workflow.rb +70 -0
- data/sig/durable_workflow.rbs +4 -0
- 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
|