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,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
|