vellum-ai 1.2.2__py3-none-any.whl → 1.2.4__py3-none-any.whl

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 (106) hide show
  1. vellum/__init__.py +40 -0
  2. vellum/client/core/client_wrapper.py +2 -2
  3. vellum/client/core/pydantic_utilities.py +3 -2
  4. vellum/client/reference.md +16 -0
  5. vellum/client/resources/workflow_executions/client.py +28 -4
  6. vellum/client/resources/workflow_executions/raw_client.py +32 -2
  7. vellum/client/types/__init__.py +40 -0
  8. vellum/client/types/audio_input_request.py +30 -0
  9. vellum/client/types/delimiter_chunker_config.py +20 -0
  10. vellum/client/types/delimiter_chunker_config_request.py +20 -0
  11. vellum/client/types/delimiter_chunking.py +21 -0
  12. vellum/client/types/delimiter_chunking_request.py +21 -0
  13. vellum/client/types/document_index_chunking.py +4 -1
  14. vellum/client/types/document_index_chunking_request.py +2 -1
  15. vellum/client/types/document_input_request.py +30 -0
  16. vellum/client/types/execution_audio_vellum_value.py +31 -0
  17. vellum/client/types/execution_document_vellum_value.py +31 -0
  18. vellum/client/types/execution_image_vellum_value.py +31 -0
  19. vellum/client/types/execution_vellum_value.py +8 -0
  20. vellum/client/types/execution_video_vellum_value.py +31 -0
  21. vellum/client/types/image_input_request.py +30 -0
  22. vellum/client/types/logical_operator.py +1 -0
  23. vellum/client/types/node_input_compiled_audio_value.py +23 -0
  24. vellum/client/types/node_input_compiled_document_value.py +23 -0
  25. vellum/client/types/node_input_compiled_image_value.py +23 -0
  26. vellum/client/types/node_input_compiled_video_value.py +23 -0
  27. vellum/client/types/node_input_variable_compiled_value.py +8 -0
  28. vellum/client/types/prompt_deployment_input_request.py +13 -1
  29. vellum/client/types/prompt_request_audio_input.py +26 -0
  30. vellum/client/types/prompt_request_document_input.py +26 -0
  31. vellum/client/types/prompt_request_image_input.py +26 -0
  32. vellum/client/types/prompt_request_input.py +13 -1
  33. vellum/client/types/prompt_request_video_input.py +26 -0
  34. vellum/client/types/video_input_request.py +30 -0
  35. vellum/types/audio_input_request.py +3 -0
  36. vellum/types/delimiter_chunker_config.py +3 -0
  37. vellum/types/delimiter_chunker_config_request.py +3 -0
  38. vellum/types/delimiter_chunking.py +3 -0
  39. vellum/types/delimiter_chunking_request.py +3 -0
  40. vellum/types/document_input_request.py +3 -0
  41. vellum/types/execution_audio_vellum_value.py +3 -0
  42. vellum/types/execution_document_vellum_value.py +3 -0
  43. vellum/types/execution_image_vellum_value.py +3 -0
  44. vellum/types/execution_video_vellum_value.py +3 -0
  45. vellum/types/image_input_request.py +3 -0
  46. vellum/types/node_input_compiled_audio_value.py +3 -0
  47. vellum/types/node_input_compiled_document_value.py +3 -0
  48. vellum/types/node_input_compiled_image_value.py +3 -0
  49. vellum/types/node_input_compiled_video_value.py +3 -0
  50. vellum/types/prompt_request_audio_input.py +3 -0
  51. vellum/types/prompt_request_document_input.py +3 -0
  52. vellum/types/prompt_request_image_input.py +3 -0
  53. vellum/types/prompt_request_video_input.py +3 -0
  54. vellum/types/video_input_request.py +3 -0
  55. vellum/workflows/context.py +27 -9
  56. vellum/workflows/events/context.py +53 -78
  57. vellum/workflows/events/node.py +5 -5
  58. vellum/workflows/events/relational_threads.py +41 -0
  59. vellum/workflows/events/tests/test_basic_workflow.py +50 -0
  60. vellum/workflows/events/tests/test_event.py +9 -0
  61. vellum/workflows/events/types.py +3 -1
  62. vellum/workflows/events/workflow.py +12 -1
  63. vellum/workflows/expressions/contains.py +7 -0
  64. vellum/workflows/expressions/tests/test_contains.py +175 -0
  65. vellum/workflows/graph/graph.py +52 -8
  66. vellum/workflows/graph/tests/test_graph.py +17 -0
  67. vellum/workflows/integrations/mcp_service.py +35 -5
  68. vellum/workflows/integrations/tests/test_mcp_service.py +120 -0
  69. vellum/workflows/nodes/core/error_node/node.py +4 -0
  70. vellum/workflows/nodes/core/map_node/node.py +7 -0
  71. vellum/workflows/nodes/core/map_node/tests/test_node.py +19 -0
  72. vellum/workflows/nodes/core/templating_node/node.py +3 -2
  73. vellum/workflows/nodes/core/templating_node/tests/test_templating_node.py +129 -0
  74. vellum/workflows/nodes/displayable/bases/inline_prompt_node/node.py +12 -0
  75. vellum/workflows/nodes/displayable/bases/inline_prompt_node/tests/test_inline_prompt_node.py +41 -0
  76. vellum/workflows/nodes/displayable/bases/utils.py +38 -1
  77. vellum/workflows/nodes/displayable/code_execution_node/utils.py +3 -20
  78. vellum/workflows/nodes/displayable/final_output_node/node.py +4 -0
  79. vellum/workflows/nodes/displayable/inline_prompt_node/node.py +3 -26
  80. vellum/workflows/nodes/displayable/prompt_deployment_node/node.py +3 -25
  81. vellum/workflows/nodes/displayable/subworkflow_deployment_node/node.py +1 -1
  82. vellum/workflows/nodes/utils.py +26 -1
  83. vellum/workflows/ports/node_ports.py +3 -0
  84. vellum/workflows/ports/port.py +7 -0
  85. vellum/workflows/state/context.py +35 -4
  86. vellum/workflows/types/definition.py +1 -0
  87. vellum/workflows/utils/functions.py +4 -0
  88. vellum/workflows/utils/tests/test_functions.py +6 -3
  89. vellum/workflows/utils/uuids.py +15 -0
  90. {vellum_ai-1.2.2.dist-info → vellum_ai-1.2.4.dist-info}/METADATA +1 -1
  91. {vellum_ai-1.2.2.dist-info → vellum_ai-1.2.4.dist-info}/RECORD +106 -60
  92. vellum_cli/__init__.py +6 -0
  93. vellum_cli/config.py +2 -0
  94. vellum_cli/push.py +3 -0
  95. vellum_cli/tests/test_pull.py +2 -0
  96. vellum_cli/tests/test_push.py +39 -0
  97. vellum_ee/workflows/display/nodes/vellum/error_node.py +1 -5
  98. vellum_ee/workflows/display/nodes/vellum/final_output_node.py +1 -5
  99. vellum_ee/workflows/display/nodes/vellum/tests/test_tool_calling_node.py +2 -0
  100. vellum_ee/workflows/display/tests/workflow_serialization/test_basic_tool_calling_node_mcp_serialization.py +1 -0
  101. vellum_ee/workflows/display/utils/events.py +24 -0
  102. vellum_ee/workflows/display/utils/tests/test_events.py +69 -0
  103. vellum_ee/workflows/tests/test_server.py +95 -0
  104. {vellum_ai-1.2.2.dist-info → vellum_ai-1.2.4.dist-info}/LICENSE +0 -0
  105. {vellum_ai-1.2.2.dist-info → vellum_ai-1.2.4.dist-info}/WHEEL +0 -0
  106. {vellum_ai-1.2.2.dist-info → vellum_ai-1.2.4.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,175 @@
1
+ import pytest
2
+
3
+ from vellum.workflows.constants import undefined
4
+ from vellum.workflows.descriptors.exceptions import InvalidExpressionException
5
+ from vellum.workflows.expressions.contains import ContainsExpression
6
+ from vellum.workflows.references.constant import ConstantValueReference
7
+ from vellum.workflows.state.base import BaseState
8
+
9
+
10
+ class TestState(BaseState):
11
+ dict_value: dict = {"key": "value"}
12
+ list_value: list = [1, 2, 3]
13
+ string_value: str = "hello world"
14
+
15
+
16
+ def test_dict_contains_dict_raises_error():
17
+ """
18
+ Tests that ContainsExpression raises clear error for dict-contains-dict scenarios.
19
+ """
20
+ state = TestState()
21
+ lhs_dict = {"foo": "bar"}
22
+ rhs_dict = {"foo": "bar"}
23
+
24
+ expression = ContainsExpression(lhs=lhs_dict, rhs=rhs_dict)
25
+
26
+ with pytest.raises(InvalidExpressionException, match="Cannot use dict as right-hand side"):
27
+ expression.resolve(state)
28
+
29
+
30
+ def test_dict_contains_different_dict_raises_error():
31
+ """
32
+ Tests that ContainsExpression raises clear error for different dict-contains-dict scenarios.
33
+ """
34
+ state = TestState()
35
+ lhs_dict = {"foo": "bar"}
36
+ rhs_dict = {"hello": "world"}
37
+
38
+ expression = ContainsExpression(lhs=lhs_dict, rhs=rhs_dict)
39
+
40
+ with pytest.raises(InvalidExpressionException, match="Cannot use dict as right-hand side"):
41
+ expression.resolve(state)
42
+
43
+
44
+ def test_string_contains_dict_raises_error():
45
+ """
46
+ Tests that ContainsExpression raises clear error for string-contains-dict scenarios.
47
+ """
48
+ state = TestState()
49
+ lhs_string = 'Response: {"status": "success"} was returned'
50
+ rhs_dict = {"status": "success"}
51
+
52
+ expression = ContainsExpression(lhs=lhs_string, rhs=rhs_dict)
53
+
54
+ with pytest.raises(InvalidExpressionException, match="Cannot use dict as right-hand side"):
55
+ expression.resolve(state)
56
+
57
+
58
+ def test_nested_dict_contains_dict_raises_error():
59
+ """
60
+ Tests that ContainsExpression raises clear error for nested dict scenarios.
61
+ """
62
+ state = TestState()
63
+ lhs_dict = {"user": {"name": "john", "age": 30}}
64
+ rhs_dict = {"age": 30, "name": "john"}
65
+
66
+ expression = ContainsExpression(lhs=lhs_dict, rhs=rhs_dict)
67
+
68
+ with pytest.raises(InvalidExpressionException, match="Cannot use dict as right-hand side"):
69
+ expression.resolve(state)
70
+
71
+
72
+ def test_list_contains_string():
73
+ """
74
+ Tests that ContainsExpression preserves original list functionality.
75
+ """
76
+ state = TestState()
77
+
78
+ expression = TestState.list_value.contains(2)
79
+ result = expression.resolve(state)
80
+
81
+ assert result is True
82
+
83
+
84
+ def test_string_contains_substring():
85
+ """
86
+ Tests that ContainsExpression preserves original string functionality.
87
+ """
88
+ state = TestState()
89
+
90
+ expression = TestState.string_value.contains("world")
91
+ result = expression.resolve(state)
92
+
93
+ assert result is True
94
+
95
+
96
+ def test_set_contains_item():
97
+ """
98
+ Tests that ContainsExpression works with sets.
99
+ """
100
+ state = TestState()
101
+ lhs_set = {1, 2, 3}
102
+ rhs_item = 2
103
+
104
+ expression = ContainsExpression(lhs=lhs_set, rhs=rhs_item)
105
+ result = expression.resolve(state)
106
+
107
+ assert result is True
108
+
109
+
110
+ def test_tuple_contains_item():
111
+ """
112
+ Tests that ContainsExpression works with tuples.
113
+ """
114
+ state = TestState()
115
+ lhs_tuple = (1, 2, 3)
116
+ rhs_item = 2
117
+
118
+ expression = ContainsExpression(lhs=lhs_tuple, rhs=rhs_item)
119
+ result = expression.resolve(state)
120
+
121
+ assert result is True
122
+
123
+
124
+ def test_invalid_lhs_type():
125
+ """
126
+ Tests that ContainsExpression raises exception for invalid LHS types.
127
+ """
128
+
129
+ class NoContainsSupport:
130
+ pass
131
+
132
+ state = TestState()
133
+ no_contains_obj = NoContainsSupport()
134
+ expression = ContainsExpression(lhs=no_contains_obj, rhs="test")
135
+
136
+ with pytest.raises(
137
+ InvalidExpressionException, match="Expected a LHS that supported `contains`, got `NoContainsSupport`"
138
+ ):
139
+ expression.resolve(state)
140
+
141
+
142
+ def test_undefined_lhs_returns_false():
143
+ """
144
+ Tests that ContainsExpression returns False for undefined LHS.
145
+ """
146
+ state = TestState()
147
+ expression = ContainsExpression(lhs=undefined, rhs="test")
148
+
149
+ result = expression.resolve(state)
150
+
151
+ assert result is False
152
+
153
+
154
+ def test_contains_with_constant_value_reference():
155
+ """
156
+ Tests ContainsExpression with ConstantValueReference for valid operations.
157
+ """
158
+ state = TestState()
159
+ lhs_ref = ConstantValueReference([1, 2, 3])
160
+ rhs_ref = ConstantValueReference(2)
161
+
162
+ expression: ContainsExpression = ContainsExpression(lhs=lhs_ref, rhs=rhs_ref)
163
+ result = expression.resolve(state)
164
+
165
+ assert result is True
166
+
167
+
168
+ def test_expression_metadata():
169
+ """
170
+ Tests that ContainsExpression has correct name and types properties.
171
+ """
172
+ expression = ContainsExpression(lhs=[1, 2, 3], rhs=2)
173
+
174
+ assert expression.name == "[1, 2, 3] contains 2"
175
+ assert expression.types == (bool,)
@@ -9,40 +9,67 @@ if TYPE_CHECKING:
9
9
  from vellum.workflows.nodes.bases.base import BaseNode
10
10
  from vellum.workflows.ports.port import Port
11
11
 
12
+
13
+ class NoPortsNode:
14
+ """Wrapper for nodes that have no ports defined."""
15
+
16
+ def __init__(self, node_class: Type["BaseNode"]):
17
+ self.node_class = node_class
18
+
19
+ def __repr__(self) -> str:
20
+ return self.node_class.__name__
21
+
22
+ def __rshift__(self, other: "GraphTarget") -> "Graph":
23
+ raise ValueError(
24
+ f"Cannot create edges from {self.node_class.__name__} because it has no ports defined. "
25
+ f"Nodes with empty Ports classes cannot be connected to other nodes."
26
+ )
27
+
28
+
12
29
  GraphTargetOfSets = Union[
13
30
  Set[NodeType],
14
31
  Set["Graph"],
15
32
  Set["Port"],
16
- Set[Union[Type["BaseNode"], "Graph", "Port"]],
33
+ Set[Union[Type["BaseNode"], "Graph", "Port", "NoPortsNode"]],
17
34
  ]
18
35
 
19
36
  GraphTarget = Union[
20
37
  Type["BaseNode"],
21
38
  "Port",
22
39
  "Graph",
40
+ "NoPortsNode",
23
41
  GraphTargetOfSets,
24
42
  ]
25
43
 
26
44
 
27
45
  class Graph:
28
- _entrypoints: Set["Port"]
46
+ _entrypoints: Set[Union["Port", "NoPortsNode"]]
29
47
  _edges: List[Edge]
30
- _terminals: Set["Port"]
31
-
32
- def __init__(self, entrypoints: Set["Port"], edges: List[Edge], terminals: Set["Port"]):
48
+ _terminals: Set[Union["Port", "NoPortsNode"]]
49
+
50
+ def __init__(
51
+ self,
52
+ entrypoints: Set[Union["Port", "NoPortsNode"]],
53
+ edges: List[Edge],
54
+ terminals: Set[Union["Port", "NoPortsNode"]],
55
+ ):
33
56
  self._edges = edges
34
57
  self._entrypoints = entrypoints
35
58
  self._terminals = terminals
36
59
 
37
60
  @staticmethod
38
61
  def from_port(port: "Port") -> "Graph":
39
- ports = {port}
62
+ ports: Set[Union["Port", "NoPortsNode"]] = {port}
40
63
  return Graph(entrypoints=ports, edges=[], terminals=ports)
41
64
 
42
65
  @staticmethod
43
66
  def from_node(node: Type["BaseNode"]) -> "Graph":
44
67
  ports = {port for port in node.Ports}
45
- return Graph(entrypoints=ports, edges=[], terminals=ports)
68
+ if not ports:
69
+ no_ports_node = NoPortsNode(node)
70
+ return Graph(entrypoints={no_ports_node}, edges=[], terminals={no_ports_node})
71
+ ports_set: Set[Union["Port", "NoPortsNode"]] = set(ports)
72
+ return Graph(entrypoints=ports_set, edges=[], terminals=ports_set)
46
73
 
47
74
  @staticmethod
48
75
  def from_set(targets: GraphTargetOfSets) -> "Graph":
@@ -73,10 +100,19 @@ class Graph:
73
100
  if not self._edges and not self._entrypoints:
74
101
  raise ValueError("Graph instance can only create new edges from nodes within existing edges")
75
102
 
103
+ if self._terminals and all(isinstance(terminal, NoPortsNode) for terminal in self._terminals):
104
+ terminal_names = [terminal.node_class.__name__ for terminal in self._terminals]
105
+ raise ValueError(
106
+ f"Cannot create edges from graph because all terminal nodes have no ports defined: "
107
+ f"{', '.join(terminal_names)}. Nodes with empty Ports classes cannot be connected to other nodes."
108
+ )
109
+
76
110
  if isinstance(other, set):
77
111
  new_terminals = set()
78
112
  for elem in other:
79
113
  for final_output_node in self._terminals:
114
+ if isinstance(final_output_node, NoPortsNode):
115
+ continue
80
116
  if isinstance(elem, Graph):
81
117
  midgraph = final_output_node >> set(elem.entrypoints)
82
118
  self._extend_edges(midgraph.edges)
@@ -98,6 +134,8 @@ class Graph:
98
134
 
99
135
  if isinstance(other, Graph):
100
136
  for final_output_node in self._terminals:
137
+ if isinstance(final_output_node, NoPortsNode):
138
+ continue
101
139
  midgraph = final_output_node >> set(other.entrypoints)
102
140
  self._extend_edges(midgraph.edges)
103
141
  self._extend_edges(other.edges)
@@ -106,6 +144,8 @@ class Graph:
106
144
 
107
145
  if hasattr(other, "Ports"):
108
146
  for final_output_node in self._terminals:
147
+ if isinstance(final_output_node, NoPortsNode):
148
+ continue
109
149
  subgraph = final_output_node >> other
110
150
  self._extend_edges(subgraph.edges)
111
151
  self._terminals = {port for port in other.Ports}
@@ -113,6 +153,8 @@ class Graph:
113
153
 
114
154
  # other is a Port
115
155
  for final_output_node in self._terminals:
156
+ if isinstance(final_output_node, NoPortsNode):
157
+ continue
116
158
  subgraph = final_output_node >> other
117
159
  self._extend_edges(subgraph.edges)
118
160
  self._terminals = {other}
@@ -238,8 +280,10 @@ class Graph:
238
280
 
239
281
  return "\n".join(lines)
240
282
 
241
- def _get_port_name(self, port: "Port") -> str:
283
+ def _get_port_name(self, port: Union["Port", "NoPortsNode"]) -> str:
242
284
  """Get a readable name for a port."""
285
+ if isinstance(port, NoPortsNode):
286
+ return f"{port.node_class.__name__} (no ports)"
243
287
  try:
244
288
  if hasattr(port, "node_class") and hasattr(port.node_class, "__name__"):
245
289
  node_name = port.node_class.__name__
@@ -583,3 +583,20 @@ def test_graph__str_single_node():
583
583
  # THEN it shows the single node
584
584
  assert "SingleNode.default" in result
585
585
  assert "Graph:" in result
586
+
587
+
588
+ def test_graph__from_node_with_empty_ports():
589
+ """
590
+ Tests that building a graph from a single node with empty Ports class generates 1 node.
591
+ """
592
+
593
+ # GIVEN a node with an empty Ports class
594
+ class NodeWithEmptyPorts(BaseNode):
595
+ class Ports(BaseNode.Ports):
596
+ pass
597
+
598
+ # WHEN we create a graph from the node
599
+ graph = Graph.from_node(NodeWithEmptyPorts)
600
+
601
+ # THEN the graph should have exactly 1 node
602
+ assert len(list(graph.nodes)) == 1
@@ -73,7 +73,7 @@ class MCPHttpClient:
73
73
  # Prepare headers
74
74
  headers = {
75
75
  "Content-Type": "application/json",
76
- "Accept": "application/json",
76
+ "Accept": "application/json, text/event-stream",
77
77
  }
78
78
 
79
79
  # Include session ID if we have one
@@ -88,11 +88,41 @@ class MCPHttpClient:
88
88
  # Check for session ID in response headers
89
89
  if "Mcp-Session-Id" in response.headers:
90
90
  self.session_id = response.headers["Mcp-Session-Id"]
91
- logger.debug(f"Received session ID: {self.session_id}")
92
91
 
93
- # Handle JSON response
94
- response_data = response.json()
95
- logger.debug(f"Received response: {json.dumps(response_data, indent=2)}")
92
+ # Handle response based on content type
93
+ content_type = response.headers.get("content-type", "").lower()
94
+
95
+ if "text/event-stream" in content_type:
96
+ # Handle SSE response
97
+ response_text = response.text
98
+
99
+ # Parse SSE format to extract JSON data
100
+ lines = response_text.strip().split("\n")
101
+ json_data = None
102
+
103
+ for line in lines:
104
+ if line.startswith("data: "):
105
+ data_content = line[6:] # Remove 'data: ' prefix
106
+ if data_content.strip() and data_content != "[DONE]":
107
+ try:
108
+ json_data = json.loads(data_content)
109
+ break
110
+ except json.JSONDecodeError:
111
+ continue
112
+
113
+ if json_data is None:
114
+ raise Exception("No valid JSON data found in SSE response")
115
+
116
+ response_data = json_data
117
+ else:
118
+ # Handle regular JSON response
119
+ if not response.text.strip():
120
+ raise Exception("Empty response received from server")
121
+
122
+ try:
123
+ response_data = response.json()
124
+ except json.JSONDecodeError as e:
125
+ raise Exception(f"Invalid JSON response: {str(e)}")
96
126
 
97
127
  if "error" in response_data:
98
128
  raise Exception(f"MCP Error: {response_data['error']}")
@@ -0,0 +1,120 @@
1
+ import asyncio
2
+ import json
3
+ from unittest import mock
4
+
5
+ from vellum.workflows.constants import AuthorizationType
6
+ from vellum.workflows.integrations.mcp_service import MCPHttpClient, MCPService
7
+ from vellum.workflows.types.definition import MCPServer
8
+
9
+
10
+ def test_mcp_http_client_sse_response():
11
+ """Test that SSE responses are correctly parsed to JSON"""
12
+ # GIVEN an SSE response from the server
13
+ sample_sse_response = (
14
+ "event: message\n"
15
+ 'data: {"result":{"protocolVersion":"2025-06-18",'
16
+ '"capabilities":{"tools":{"listChanged":true}},'
17
+ '"serverInfo":{"name":"TestServer","version":"1.0.0"},'
18
+ '"instructions":"Test server for unit tests."},'
19
+ '"jsonrpc":"2.0","id":1}\n\n'
20
+ )
21
+ expected_json = {
22
+ "result": {
23
+ "protocolVersion": "2025-06-18",
24
+ "capabilities": {"tools": {"listChanged": True}},
25
+ "serverInfo": {"name": "TestServer", "version": "1.0.0"},
26
+ "instructions": "Test server for unit tests.",
27
+ },
28
+ "jsonrpc": "2.0",
29
+ "id": 1,
30
+ }
31
+
32
+ with mock.patch("vellum.workflows.integrations.mcp_service.httpx.AsyncClient") as mock_client_class:
33
+ mock_client = mock.AsyncMock()
34
+ mock_client_class.return_value = mock_client
35
+
36
+ mock_response = mock.Mock()
37
+ mock_response.headers = {"content-type": "text/event-stream"}
38
+ mock_response.text = sample_sse_response
39
+ mock_client.post.return_value = mock_response
40
+
41
+ # WHEN we send a request through the MCP client
42
+ async def test_request():
43
+ async with MCPHttpClient("https://test.server.com", {}) as client:
44
+ result = await client._send_request("initialize", {"test": "params"})
45
+ return result
46
+
47
+ result = asyncio.run(test_request())
48
+
49
+ # THEN the SSE response should be parsed correctly to JSON
50
+ assert result == expected_json
51
+
52
+ # AND the request should have been made with correct headers
53
+ mock_client.post.assert_called_once()
54
+ call_args = mock_client.post.call_args
55
+ assert call_args[1]["headers"]["Accept"] == "application/json, text/event-stream"
56
+ assert call_args[1]["headers"]["Content-Type"] == "application/json"
57
+
58
+
59
+ def test_mcp_http_client_json_response():
60
+ """Test that regular JSON responses still work"""
61
+ # GIVEN a regular JSON response from the server
62
+ sample_json_response = {"result": {"test": "data"}, "jsonrpc": "2.0", "id": 1}
63
+
64
+ with mock.patch("vellum.workflows.integrations.mcp_service.httpx.AsyncClient") as mock_client_class:
65
+ mock_client = mock.AsyncMock()
66
+ mock_client_class.return_value = mock_client
67
+
68
+ mock_response = mock.Mock()
69
+ mock_response.headers = {"content-type": "application/json"}
70
+ mock_response.text = json.dumps(sample_json_response)
71
+ mock_response.json.return_value = sample_json_response
72
+ mock_client.post.return_value = mock_response
73
+
74
+ # WHEN we send a request through the MCP client
75
+ async def test_request():
76
+ async with MCPHttpClient("https://test.server.com", {}) as client:
77
+ result = await client._send_request("initialize", {"test": "params"})
78
+ return result
79
+
80
+ result = asyncio.run(test_request())
81
+
82
+ # THEN the JSON response should be returned as expected
83
+ assert result == sample_json_response
84
+
85
+
86
+ def test_mcp_service_bearer_token_auth():
87
+ """Test that bearer token auth headers are set correctly"""
88
+ # GIVEN an MCP server with bearer token auth
89
+ server = MCPServer(
90
+ name="test-server",
91
+ url="https://test.server.com",
92
+ authorization_type=AuthorizationType.BEARER_TOKEN,
93
+ bearer_token_value="test-token-123",
94
+ )
95
+
96
+ # WHEN we get auth headers
97
+ service = MCPService()
98
+ headers = service._get_auth_headers(server)
99
+
100
+ # THEN the Authorization header should be set correctly
101
+ assert headers == {"Authorization": "Bearer test-token-123"}
102
+
103
+
104
+ def test_mcp_service_api_key_auth():
105
+ """Test that API key auth headers are set correctly"""
106
+ # GIVEN an MCP server with API key auth
107
+ server = MCPServer(
108
+ name="test-server",
109
+ url="https://test.server.com",
110
+ authorization_type=AuthorizationType.API_KEY,
111
+ api_key_header_key="X-API-Key",
112
+ api_key_header_value="api-key-123",
113
+ )
114
+
115
+ # WHEN we get auth headers
116
+ service = MCPService()
117
+ headers = service._get_auth_headers(server)
118
+
119
+ # THEN the custom API key header should be set correctly
120
+ assert headers == {"X-API-Key": "api-key-123"}
@@ -4,6 +4,7 @@ from vellum.client.types.vellum_error import VellumError
4
4
  from vellum.workflows.errors.types import WorkflowError, WorkflowErrorCode, vellum_error_to_workflow_error
5
5
  from vellum.workflows.exceptions import NodeException
6
6
  from vellum.workflows.nodes.bases.base import BaseNode
7
+ from vellum.workflows.ports import NodePorts
7
8
 
8
9
 
9
10
  class ErrorNode(BaseNode):
@@ -15,6 +16,9 @@ class ErrorNode(BaseNode):
15
16
 
16
17
  error: ClassVar[Union[str, WorkflowError, VellumError]]
17
18
 
19
+ class Ports(NodePorts):
20
+ pass
21
+
18
22
  def run(self) -> BaseNode.Outputs:
19
23
  if isinstance(self.error, str):
20
24
  raise NodeException(message=self.error, code=WorkflowErrorCode.USER_DEFINED_ERROR)
@@ -30,6 +30,7 @@ from vellum.workflows.outputs.base import BaseOutput
30
30
  from vellum.workflows.references.output import OutputReference
31
31
  from vellum.workflows.state.context import WorkflowContext
32
32
  from vellum.workflows.types.generics import StateType
33
+ from vellum.workflows.utils.uuids import uuid4_from_hash
33
34
  from vellum.workflows.workflows.event_filters import all_workflow_event_filter
34
35
 
35
36
  if TYPE_CHECKING:
@@ -211,4 +212,10 @@ class MapNode(BaseAdornmentNode[StateType], Generic[StateType, MapNodeItemType])
211
212
  annotation = List[parameter_type] # type: ignore[valid-type]
212
213
 
213
214
  previous_annotations = {prev: annotation for prev in outputs_class.__annotations__ if not prev.startswith("_")}
215
+ # Map node output is a list of the same type so we use annotation=List[parameter_type] and not reference
216
+ # class Outputs(BaseOutputs):
217
+ # value: List[str]
214
218
  outputs_class.__annotations__ = {**previous_annotations, reference.name: annotation}
219
+
220
+ output_id = uuid4_from_hash(f"{cls.__id__}|{reference.name}")
221
+ cls.__output_ids__[reference.name] = output_id
@@ -119,6 +119,7 @@ def test_map_node__inner_try():
119
119
  # THEN the workflow should succeed
120
120
  assert outputs[-1].name == "final_output"
121
121
  assert len(outputs[-1].value) == 2
122
+ assert len(SimpleMapNode.__output_ids__) == 1
122
123
 
123
124
 
124
125
  def test_map_node__nested_map_node():
@@ -275,3 +276,21 @@ def test_map_node__shared_state_race_condition():
275
276
  # AND all results should be in correct order
276
277
  expected_result = ["a!", "b!", "c!", "d!", "e!", "f!"]
277
278
  assert final_result == expected_result, f"Failed on run {index}"
279
+
280
+
281
+ def test_map_node__output_ids():
282
+ class TestNode(BaseNode):
283
+ class Outputs(BaseOutputs):
284
+ value: str
285
+
286
+ class SimpleMapNodeWorkflow(BaseWorkflow[MapNode.SubworkflowInputs, BaseState]):
287
+ graph = TestNode
288
+
289
+ class Outputs(BaseWorkflow.Outputs):
290
+ final_output = TestNode.Outputs.value
291
+
292
+ class TestMapNode(MapNode):
293
+ items = [1, 2, 3]
294
+ subworkflow = SimpleMapNodeWorkflow
295
+
296
+ assert len(TestMapNode.__output_ids__) == 1
@@ -7,7 +7,7 @@ from vellum.workflows.errors import WorkflowErrorCode
7
7
  from vellum.workflows.exceptions import NodeException
8
8
  from vellum.workflows.nodes.bases import BaseNode
9
9
  from vellum.workflows.nodes.bases.base import BaseNodeMeta
10
- from vellum.workflows.nodes.utils import parse_type_from_str
10
+ from vellum.workflows.nodes.utils import parse_type_from_str, wrap_inputs_for_backward_compatibility
11
11
  from vellum.workflows.types.core import EntityInputsInterface
12
12
  from vellum.workflows.types.generics import StateType
13
13
  from vellum.workflows.types.utils import get_original_base
@@ -86,9 +86,10 @@ class TemplatingNode(BaseNode[StateType], Generic[StateType, _OutputType], metac
86
86
 
87
87
  def _render_template(self) -> str:
88
88
  try:
89
+ wrapped_inputs = wrap_inputs_for_backward_compatibility(self.inputs)
89
90
  return render_sandboxed_jinja_template(
90
91
  template=self.template,
91
- input_values=self.inputs,
92
+ input_values=wrapped_inputs,
92
93
  jinja_custom_filters={**self.jinja_custom_filters},
93
94
  jinja_globals=self.jinja_globals,
94
95
  )