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,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DurableWorkflow
4
+ module Extensions
5
+ module AI
6
+ module Executors
7
+ class FileSearch < Core::Executors::Base
8
+ def call(state)
9
+ query = resolve(state, config.query)
10
+ files = config.files
11
+ max_results = config.max_results
12
+
13
+ results = search_files(query, files, max_results)
14
+
15
+ state = store(state, config.output, results) if config.output
16
+ continue(state, output: results)
17
+ end
18
+
19
+ private
20
+
21
+ def search_files(query, files, max_results)
22
+ # Placeholder - returns dummy results
23
+ # In production, integrate with vector stores (OpenAI, Pinecone, etc.)
24
+ dummy_results = generate_dummy_results(query, files, max_results)
25
+
26
+ {
27
+ query:,
28
+ results: dummy_results,
29
+ total: dummy_results.size,
30
+ searched_files: files.size,
31
+ max_results:
32
+ }
33
+ end
34
+
35
+ def generate_dummy_results(query, files, max_results)
36
+ return [] if files.empty?
37
+
38
+ # Generate plausible dummy results based on query
39
+ files.take(max_results).map.with_index do |file, idx|
40
+ {
41
+ file: file,
42
+ score: (0.95 - (idx * 0.1)).round(2),
43
+ snippet: "...relevant content matching '#{query}' found in #{file}...",
44
+ line_number: rand(1..100)
45
+ }
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DurableWorkflow
4
+ module Extensions
5
+ module AI
6
+ module Executors
7
+ class Guardrail < Core::Executors::Base
8
+ INJECTION_PATTERNS = [
9
+ /ignore\s+(all\s+)?previous\s+instructions/i,
10
+ /disregard\s+(all\s+)?previous/i,
11
+ /forget\s+(everything|all)/i,
12
+ /you\s+are\s+now\s+/i,
13
+ /new\s+instructions?:/i,
14
+ /system\s*:\s*/i,
15
+ /\[system\]/i,
16
+ /pretend\s+you\s+are/i,
17
+ /act\s+as\s+if/i,
18
+ /roleplay\s+as/i
19
+ ].freeze
20
+
21
+ PII_PATTERNS = [
22
+ /\b\d{3}-\d{2}-\d{4}\b/, # SSN
23
+ /\b\d{16}\b/, # Credit card (no spaces)
24
+ /\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b/, # Credit card (with spaces/dashes)
25
+ /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/, # Email
26
+ /\b\d{3}[-.]?\d{3}[-.]?\d{4}\b/ # Phone
27
+ ].freeze
28
+
29
+ def call(state)
30
+ content = resolve(state, config.content || config.input)
31
+ checks = parse_checks(config.checks)
32
+ on_fail = config.on_fail
33
+
34
+ results = checks.map { |check| run_check(check, content) }
35
+ failed = results.find { |r| !r.passed }
36
+
37
+ if failed
38
+ state = state.with_ctx(_guardrail_failure: {
39
+ check_type: failed.check_type,
40
+ reason: failed.reason
41
+ })
42
+ return on_fail ? continue(state, next_step: on_fail) : raise(ExecutionError, "Guardrail failed: #{failed.reason}")
43
+ end
44
+
45
+ continue(state)
46
+ end
47
+
48
+ private
49
+
50
+ def parse_checks(checks)
51
+ checks.map do |check|
52
+ case check
53
+ when Hash
54
+ GuardrailCheck.new(
55
+ type: check[:type],
56
+ pattern: check[:pattern],
57
+ block_on_match: check.fetch(:block_on_match, true),
58
+ max: check[:max],
59
+ min: check[:min]
60
+ )
61
+ when GuardrailCheck
62
+ check
63
+ else
64
+ GuardrailCheck.new(type: check.to_s)
65
+ end
66
+ end
67
+ end
68
+
69
+ def run_check(check, content)
70
+ case check.type
71
+ when 'prompt_injection'
72
+ check_prompt_injection(content)
73
+ when 'pii'
74
+ check_pii(content)
75
+ when 'moderation'
76
+ check_moderation(content)
77
+ when 'regex'
78
+ check_regex(content, check.pattern, check.block_on_match)
79
+ when 'length'
80
+ check_length(content, check.max, check.min)
81
+ else
82
+ GuardrailResult.new(passed: true, check_type: check.type)
83
+ end
84
+ end
85
+
86
+ def check_prompt_injection(content)
87
+ detected = INJECTION_PATTERNS.any? { |pattern| content.to_s.match?(pattern) }
88
+ GuardrailResult.new(
89
+ passed: !detected,
90
+ check_type: 'prompt_injection',
91
+ reason: detected ? 'Potential prompt injection detected' : nil
92
+ )
93
+ end
94
+
95
+ def check_pii(content)
96
+ detected = PII_PATTERNS.any? { |pattern| content.to_s.match?(pattern) }
97
+ GuardrailResult.new(
98
+ passed: !detected,
99
+ check_type: 'pii',
100
+ reason: detected ? 'PII detected in content' : nil
101
+ )
102
+ end
103
+
104
+ def check_moderation(content)
105
+ result = RubyLLM.moderate(content)
106
+ GuardrailResult.new(
107
+ passed: !result.flagged,
108
+ check_type: 'moderation',
109
+ reason: result.flagged ? 'Content flagged by moderation' : nil
110
+ )
111
+ rescue StandardError
112
+ # If moderation fails (e.g., no API key), pass by default
113
+ GuardrailResult.new(passed: true, check_type: 'moderation')
114
+ end
115
+
116
+ def check_regex(content, pattern, block_on_match = true)
117
+ return GuardrailResult.new(passed: true, check_type: 'regex') unless pattern
118
+
119
+ matches = content.to_s.match?(Regexp.new(pattern))
120
+ passed = block_on_match ? !matches : matches
121
+
122
+ GuardrailResult.new(
123
+ passed:,
124
+ check_type: 'regex',
125
+ reason: if passed
126
+ nil
127
+ else
128
+ "Content #{block_on_match ? 'matched' : 'did not match'} pattern"
129
+ end
130
+ )
131
+ end
132
+
133
+ def check_length(content, max, min)
134
+ len = content.to_s.length
135
+ passed = true
136
+ reason = nil
137
+
138
+ if max && len > max
139
+ passed = false
140
+ reason = "Content exceeds max length (#{len} > #{max})"
141
+ elsif min && len < min
142
+ passed = false
143
+ reason = "Content below min length (#{len} < #{min})"
144
+ end
145
+
146
+ GuardrailResult.new(passed:, check_type: 'length', reason:)
147
+ end
148
+ end
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DurableWorkflow
4
+ module Extensions
5
+ module AI
6
+ module Executors
7
+ class Handoff < Core::Executors::Base
8
+ def call(state)
9
+ target_agent = config.to || state.ctx[:_handoff_to]
10
+ raise ExecutionError, 'No handoff target specified' unless target_agent
11
+
12
+ workflow = DurableWorkflow.registry[state.workflow_id]
13
+ agents = Extension.agents(workflow)
14
+ raise ExecutionError, "Agent not found: #{target_agent}" unless agents.key?(target_agent)
15
+
16
+ new_ctx = state.ctx.except(:_handoff_to).merge(
17
+ _current_agent: target_agent,
18
+ _handoff_context: {
19
+ from: config.from,
20
+ to: target_agent,
21
+ reason: config.reason,
22
+ timestamp: Time.now.iso8601
23
+ }
24
+ )
25
+ state = state.with(ctx: new_ctx)
26
+
27
+ continue(state)
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DurableWorkflow
4
+ module Extensions
5
+ module AI
6
+ module Executors
7
+ class MCP < Core::Executors::Base
8
+ def call(state)
9
+ server_config = resolve_server(state, config.server)
10
+ tool_name = config.tool
11
+ arguments = resolve(state, config.arguments)
12
+
13
+ result = AI::MCP::Client.call_tool(server_config, tool_name, arguments)
14
+
15
+ # Extract text content from MCP response
16
+ output = extract_output(result)
17
+
18
+ state = store(state, config.output, output) if config.output
19
+ continue(state, output: output)
20
+ end
21
+
22
+ private
23
+
24
+ def resolve_server(state, server_id)
25
+ servers = Extension.mcp_servers(workflow(state))
26
+ server_config = Utils.fetch(servers, server_id)
27
+ raise ExecutionError, "MCP server not found: #{server_id}" unless server_config
28
+
29
+ server_config
30
+ end
31
+
32
+ def workflow(state)
33
+ DurableWorkflow.registry[state.workflow_id]
34
+ end
35
+
36
+ def extract_output(result)
37
+ if result.respond_to?(:content)
38
+ result.content.map { |c| Utils.fetch(c, :text) }.compact.join("\n")
39
+ else
40
+ result.to_s
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DurableWorkflow
4
+ module Extensions
5
+ module AI
6
+ module MCP
7
+ class Adapter
8
+ class << self
9
+ # Convert RubyLLM::Tool instance to MCP::Tool
10
+ def to_mcp_tool(ruby_llm_tool)
11
+ tool_name = extract_name(ruby_llm_tool)
12
+ tool_description = ruby_llm_tool.description
13
+ tool_schema = ruby_llm_tool.class.respond_to?(:params_schema) ? ruby_llm_tool.class.params_schema : {}
14
+
15
+ captured_tool = ruby_llm_tool
16
+ adapter = self # Capture Adapter class for use in block
17
+
18
+ ::MCP::Tool.define(
19
+ name: tool_name,
20
+ description: tool_description,
21
+ input_schema: normalize_schema(tool_schema)
22
+ ) do |server_context:, **params|
23
+ adapter.execute_tool(captured_tool, params, server_context)
24
+ end
25
+ end
26
+
27
+ def execute_tool(tool, params, _server_context)
28
+ result = tool.call(**params.transform_keys(&:to_sym))
29
+ formatted = format_result(result)
30
+
31
+ ::MCP::Tool::Response.new([
32
+ { type: 'text', text: formatted }
33
+ ])
34
+ rescue StandardError => e
35
+ ::MCP::Tool::Response.new([
36
+ { type: 'text', text: "Error: #{e.message}" }
37
+ ], is_error: true)
38
+ end
39
+
40
+ private
41
+
42
+ def extract_name(tool)
43
+ if tool.class.respond_to?(:tool_def) && tool.class.tool_def
44
+ tool.class.tool_def.id
45
+ elsif tool.respond_to?(:name)
46
+ tool.name
47
+ else
48
+ tool.class.name&.split('::')&.last&.gsub(/([A-Z])/, '_\1')&.downcase&.sub(/^_/, '') || 'unknown'
49
+ end
50
+ end
51
+
52
+ def normalize_schema(schema)
53
+ return { properties: {}, required: [] } if schema.nil? || schema.empty?
54
+
55
+ {
56
+ properties: Utils.fetch(schema, :properties, {}),
57
+ required: Utils.fetch(schema, :required, [])
58
+ }
59
+ end
60
+
61
+ def format_result(result)
62
+ case result
63
+ when String then result
64
+ when Hash, Array then JSON.pretty_generate(result)
65
+ else result.to_s
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DurableWorkflow
4
+ module Extensions
5
+ module AI
6
+ module MCP
7
+ class Client
8
+ @connections = {}
9
+
10
+ class << self
11
+ # Get or create client for server config
12
+ def for(server_config)
13
+ cache_key = server_config.url || server_config.command.to_s
14
+ @connections[cache_key] ||= build_client(server_config)
15
+ end
16
+
17
+ # List tools from server
18
+ def tools(server_config)
19
+ client = self.for(server_config)
20
+ client.tools
21
+ end
22
+
23
+ # Call tool on server
24
+ def call_tool(server_config, tool_name, arguments)
25
+ client = self.for(server_config)
26
+ tool = client.tools.find { |t| t.name == tool_name }
27
+ raise DurableWorkflow::ExecutionError, "MCP tool not found: #{tool_name}" unless tool
28
+
29
+ client.call_tool(tool: tool, arguments: arguments)
30
+ end
31
+
32
+ # Clear connection cache
33
+ def reset!
34
+ @connections = {}
35
+ end
36
+
37
+ private
38
+
39
+ def build_client(server_config)
40
+ transport = build_transport(server_config)
41
+ ::MCP::Client.new(transport: transport)
42
+ end
43
+
44
+ def build_transport(server_config)
45
+ case server_config.transport&.to_sym
46
+ when :stdio
47
+ build_stdio_transport(server_config)
48
+ else
49
+ build_http_transport(server_config)
50
+ end
51
+ end
52
+
53
+ def build_http_transport(config)
54
+ ::MCP::Client::HTTP.new(
55
+ url: config.url,
56
+ headers: interpolate_env(config.headers || {})
57
+ )
58
+ end
59
+
60
+ def build_stdio_transport(config)
61
+ # Stdio transport for command-line MCP servers
62
+ # This would require implementing or using a stdio transport
63
+ raise NotImplementedError, 'Stdio transport not yet implemented'
64
+ end
65
+
66
+ # Replace ${ENV_VAR} with actual values
67
+ def interpolate_env(headers)
68
+ headers.transform_values do |v|
69
+ v.to_s.gsub(/\$\{(\w+)\}/) { ENV.fetch(::Regexp.last_match(1), '') }
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'rack'
5
+
6
+ module DurableWorkflow
7
+ module Extensions
8
+ module AI
9
+ module MCP
10
+ class RackApp
11
+ def initialize(server)
12
+ @server = server
13
+ end
14
+
15
+ def call(env)
16
+ request = Rack::Request.new(env)
17
+
18
+ case request.request_method
19
+ when 'POST'
20
+ handle_post(request)
21
+ when 'GET'
22
+ handle_sse(request)
23
+ when 'DELETE'
24
+ handle_delete(request)
25
+ else
26
+ [405, { 'Content-Type' => 'text/plain' }, ['Method not allowed']]
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def handle_post(request)
33
+ body = request.body.read
34
+ result = @server.handle_json(body)
35
+
36
+ [200, { 'Content-Type' => 'application/json' }, [result]]
37
+ rescue JSON::ParserError => e
38
+ error_response(-32_700, "Parse error: #{e.message}")
39
+ rescue StandardError
40
+ error_response(-32_603, 'Internal error')
41
+ end
42
+
43
+ def handle_sse(_request)
44
+ # SSE for notifications (optional)
45
+ [501, { 'Content-Type' => 'text/plain' }, ['SSE not implemented']]
46
+ end
47
+
48
+ def handle_delete(_request)
49
+ # Session cleanup
50
+ [200, {}, ['']]
51
+ end
52
+
53
+ def error_response(code, message)
54
+ [400, { 'Content-Type' => 'application/json' }, [
55
+ JSON.generate({
56
+ jsonrpc: '2.0',
57
+ error: { code: code, message: message },
58
+ id: nil
59
+ })
60
+ ]]
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DurableWorkflow
4
+ module Extensions
5
+ module AI
6
+ module MCP
7
+ class Server
8
+ attr_reader :workflow, :options
9
+
10
+ def initialize(workflow, **options)
11
+ @workflow = workflow
12
+ @options = options
13
+ end
14
+
15
+ # Build MCP::Server with workflow tools
16
+ def build(server_context: {})
17
+ ::MCP::Server.new(
18
+ name: server_name,
19
+ version: server_version,
20
+ tools: build_tools,
21
+ server_context: server_context
22
+ )
23
+ end
24
+
25
+ # Run as stdio transport (for Claude Desktop)
26
+ def stdio(server_context: {})
27
+ require 'mcp/server/transports/stdio_transport'
28
+ server = build(server_context: server_context)
29
+ transport = ::MCP::Server::Transports::StdioTransport.new(server)
30
+ transport.open
31
+ end
32
+
33
+ # Build Rack app for HTTP transport
34
+ def rack_app(server_context: {})
35
+ server = build(server_context: server_context)
36
+ RackApp.new(server)
37
+ end
38
+
39
+ class << self
40
+ def build(workflow, **options)
41
+ new(workflow, **options).build
42
+ end
43
+
44
+ def stdio(workflow, **options)
45
+ new(workflow, **options).stdio
46
+ end
47
+
48
+ def rack_app(workflow, **options)
49
+ new(workflow, **options).rack_app
50
+ end
51
+ end
52
+
53
+ private
54
+
55
+ def server_name
56
+ options[:name] || "durable_workflow_#{workflow.id}"
57
+ end
58
+
59
+ def server_version
60
+ options[:version] || DurableWorkflow::VERSION
61
+ end
62
+
63
+ def build_tools
64
+ # Convert workflow tools to MCP tools
65
+ # for_workflow returns instances, not classes
66
+ mcp_tools = ToolRegistry.for_workflow(workflow).map do |tool_instance|
67
+ Adapter.to_mcp_tool(tool_instance)
68
+ end
69
+
70
+ # Optionally expose workflow itself as a tool
71
+ mcp_tools << build_workflow_tool if options[:expose_workflow]
72
+
73
+ mcp_tools
74
+ end
75
+
76
+ def build_workflow_tool
77
+ wf = workflow
78
+ store = DurableWorkflow.config&.store
79
+
80
+ ::MCP::Tool.define(
81
+ name: "run_#{workflow.id}",
82
+ description: workflow.description || "Run #{workflow.name} workflow",
83
+ input_schema: workflow_input_schema
84
+ ) do |server_context:, **params|
85
+ runner = DurableWorkflow::Runners::Sync.new(wf, store: store)
86
+ result = runner.run(params)
87
+
88
+ ::MCP::Tool::Response.new([{
89
+ type: 'text',
90
+ text: JSON.pretty_generate({
91
+ status: result.status,
92
+ output: result.output
93
+ })
94
+ }])
95
+ rescue StandardError => e
96
+ warn "Workflow error: #{e.message}\n#{e.backtrace.first(5).join("\n")}"
97
+ ::MCP::Tool::Response.new([{
98
+ type: 'text',
99
+ text: "Error: #{e.message}"
100
+ }], is_error: true)
101
+ end
102
+ end
103
+
104
+ def workflow_input_schema
105
+ props = {}
106
+ required = []
107
+
108
+ (workflow.inputs || []).each do |input_def|
109
+ props[input_def.name] = {
110
+ type: input_def.type,
111
+ description: input_def.description
112
+ }.compact
113
+ required << input_def.name if input_def.required
114
+ end
115
+
116
+ { properties: props, required: required }
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DurableWorkflow
4
+ module Extensions
5
+ module AI
6
+ class ToolRegistry
7
+ class << self
8
+ def registry
9
+ @registry ||= {}
10
+ end
11
+
12
+ # Register a RubyLLM::Tool class directly
13
+ def register(tool_class)
14
+ name = tool_name(tool_class)
15
+ registry[name] = tool_class
16
+ end
17
+
18
+ # Register from ToolDef (YAML-defined)
19
+ def register_from_def(tool_def)
20
+ tool_class = tool_def.to_ruby_llm_tool
21
+ registry[tool_def.id] = tool_class
22
+ end
23
+
24
+ # Get tool class by name
25
+ def [](name)
26
+ registry[name.to_s]
27
+ end
28
+
29
+ # Get all tool classes
30
+ def all
31
+ registry.values
32
+ end
33
+
34
+ # Get tools for a workflow
35
+ def for_workflow(workflow)
36
+ tool_ids = Extension.data_from(workflow)[:tools]&.keys || []
37
+ for_tool_ids(tool_ids)
38
+ end
39
+
40
+ # Get tool instances by IDs
41
+ def for_tool_ids(tool_ids)
42
+ tool_ids.map { |id| registry[id.to_s]&.new }.compact
43
+ end
44
+
45
+ # Clear registry (for testing)
46
+ def reset!
47
+ @registry = {}
48
+ end
49
+
50
+ private
51
+
52
+ def tool_name(tool_class)
53
+ if tool_class.respond_to?(:tool_def) && tool_class.tool_def
54
+ tool_class.tool_def.id
55
+ else
56
+ tool_class.name&.split('::')&.last&.gsub(/([A-Z])/, '_\1')&.downcase&.sub(/^_/, '') || 'unknown'
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end