griptape-nodes 0.42.0__py3-none-any.whl → 0.43.0__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 (132) hide show
  1. griptape_nodes/__init__.py +0 -0
  2. griptape_nodes/app/.python-version +0 -0
  3. griptape_nodes/app/__init__.py +1 -6
  4. griptape_nodes/app/api.py +199 -0
  5. griptape_nodes/app/app.py +140 -225
  6. griptape_nodes/app/watch.py +1 -1
  7. griptape_nodes/bootstrap/__init__.py +0 -0
  8. griptape_nodes/bootstrap/bootstrap_script.py +0 -0
  9. griptape_nodes/bootstrap/register_libraries_script.py +0 -0
  10. griptape_nodes/bootstrap/structure_config.yaml +0 -0
  11. griptape_nodes/bootstrap/workflow_executors/__init__.py +0 -0
  12. griptape_nodes/bootstrap/workflow_executors/local_workflow_executor.py +0 -0
  13. griptape_nodes/bootstrap/workflow_executors/workflow_executor.py +0 -0
  14. griptape_nodes/bootstrap/workflow_runners/__init__.py +0 -0
  15. griptape_nodes/bootstrap/workflow_runners/bootstrap_workflow_runner.py +0 -0
  16. griptape_nodes/bootstrap/workflow_runners/local_workflow_runner.py +0 -0
  17. griptape_nodes/bootstrap/workflow_runners/subprocess_workflow_runner.py +6 -2
  18. griptape_nodes/bootstrap/workflow_runners/workflow_runner.py +0 -0
  19. griptape_nodes/drivers/__init__.py +0 -0
  20. griptape_nodes/drivers/storage/__init__.py +0 -0
  21. griptape_nodes/drivers/storage/base_storage_driver.py +0 -0
  22. griptape_nodes/drivers/storage/griptape_cloud_storage_driver.py +0 -0
  23. griptape_nodes/drivers/storage/local_storage_driver.py +2 -1
  24. griptape_nodes/drivers/storage/storage_backend.py +0 -0
  25. griptape_nodes/exe_types/__init__.py +0 -0
  26. griptape_nodes/exe_types/connections.py +0 -0
  27. griptape_nodes/exe_types/core_types.py +0 -0
  28. griptape_nodes/exe_types/flow.py +0 -0
  29. griptape_nodes/exe_types/node_types.py +17 -1
  30. griptape_nodes/exe_types/type_validator.py +0 -0
  31. griptape_nodes/machines/__init__.py +0 -0
  32. griptape_nodes/machines/control_flow.py +41 -12
  33. griptape_nodes/machines/fsm.py +16 -2
  34. griptape_nodes/machines/node_resolution.py +0 -0
  35. griptape_nodes/mcp_server/__init__.py +1 -0
  36. griptape_nodes/mcp_server/server.py +126 -0
  37. griptape_nodes/mcp_server/ws_request_manager.py +268 -0
  38. griptape_nodes/node_library/__init__.py +0 -0
  39. griptape_nodes/node_library/advanced_node_library.py +0 -0
  40. griptape_nodes/node_library/library_registry.py +0 -0
  41. griptape_nodes/node_library/workflow_registry.py +1 -1
  42. griptape_nodes/py.typed +0 -0
  43. griptape_nodes/retained_mode/__init__.py +0 -0
  44. griptape_nodes/retained_mode/events/__init__.py +0 -0
  45. griptape_nodes/retained_mode/events/agent_events.py +0 -0
  46. griptape_nodes/retained_mode/events/app_events.py +6 -2
  47. griptape_nodes/retained_mode/events/arbitrary_python_events.py +0 -0
  48. griptape_nodes/retained_mode/events/base_events.py +6 -6
  49. griptape_nodes/retained_mode/events/config_events.py +0 -0
  50. griptape_nodes/retained_mode/events/connection_events.py +0 -0
  51. griptape_nodes/retained_mode/events/context_events.py +0 -0
  52. griptape_nodes/retained_mode/events/execution_events.py +0 -0
  53. griptape_nodes/retained_mode/events/flow_events.py +0 -0
  54. griptape_nodes/retained_mode/events/generate_request_payload_schemas.py +0 -0
  55. griptape_nodes/retained_mode/events/library_events.py +2 -2
  56. griptape_nodes/retained_mode/events/logger_events.py +0 -0
  57. griptape_nodes/retained_mode/events/node_events.py +0 -0
  58. griptape_nodes/retained_mode/events/object_events.py +0 -0
  59. griptape_nodes/retained_mode/events/os_events.py +104 -2
  60. griptape_nodes/retained_mode/events/parameter_events.py +0 -0
  61. griptape_nodes/retained_mode/events/payload_registry.py +0 -0
  62. griptape_nodes/retained_mode/events/secrets_events.py +0 -0
  63. griptape_nodes/retained_mode/events/static_file_events.py +0 -0
  64. griptape_nodes/retained_mode/events/validation_events.py +0 -0
  65. griptape_nodes/retained_mode/events/workflow_events.py +0 -0
  66. griptape_nodes/retained_mode/griptape_nodes.py +43 -40
  67. griptape_nodes/retained_mode/managers/__init__.py +0 -0
  68. griptape_nodes/retained_mode/managers/agent_manager.py +48 -22
  69. griptape_nodes/retained_mode/managers/arbitrary_code_exec_manager.py +0 -0
  70. griptape_nodes/retained_mode/managers/config_manager.py +0 -0
  71. griptape_nodes/retained_mode/managers/context_manager.py +0 -0
  72. griptape_nodes/retained_mode/managers/engine_identity_manager.py +0 -0
  73. griptape_nodes/retained_mode/managers/event_manager.py +0 -0
  74. griptape_nodes/retained_mode/managers/flow_manager.py +2 -0
  75. griptape_nodes/retained_mode/managers/library_lifecycle/__init__.py +45 -0
  76. griptape_nodes/retained_mode/managers/library_lifecycle/data_models.py +191 -0
  77. griptape_nodes/retained_mode/managers/library_lifecycle/library_directory.py +346 -0
  78. griptape_nodes/retained_mode/managers/library_lifecycle/library_fsm.py +439 -0
  79. griptape_nodes/retained_mode/managers/library_lifecycle/library_provenance/__init__.py +17 -0
  80. griptape_nodes/retained_mode/managers/library_lifecycle/library_provenance/base.py +82 -0
  81. griptape_nodes/retained_mode/managers/library_lifecycle/library_provenance/github.py +116 -0
  82. griptape_nodes/retained_mode/managers/library_lifecycle/library_provenance/local_file.py +352 -0
  83. griptape_nodes/retained_mode/managers/library_lifecycle/library_provenance/package.py +104 -0
  84. griptape_nodes/retained_mode/managers/library_lifecycle/library_provenance/sandbox.py +155 -0
  85. griptape_nodes/retained_mode/managers/library_lifecycle/library_provenance.py +18 -0
  86. griptape_nodes/retained_mode/managers/library_lifecycle/library_status.py +12 -0
  87. griptape_nodes/retained_mode/managers/library_manager.py +144 -39
  88. griptape_nodes/retained_mode/managers/node_manager.py +86 -72
  89. griptape_nodes/retained_mode/managers/object_manager.py +0 -0
  90. griptape_nodes/retained_mode/managers/operation_manager.py +0 -0
  91. griptape_nodes/retained_mode/managers/os_manager.py +517 -12
  92. griptape_nodes/retained_mode/managers/secrets_manager.py +0 -0
  93. griptape_nodes/retained_mode/managers/session_manager.py +0 -0
  94. griptape_nodes/retained_mode/managers/settings.py +0 -0
  95. griptape_nodes/retained_mode/managers/static_files_manager.py +0 -0
  96. griptape_nodes/retained_mode/managers/version_compatibility_manager.py +2 -2
  97. griptape_nodes/retained_mode/managers/workflow_manager.py +199 -2
  98. griptape_nodes/retained_mode/retained_mode.py +0 -0
  99. griptape_nodes/retained_mode/utils/__init__.py +0 -0
  100. griptape_nodes/retained_mode/utils/engine_identity.py +0 -0
  101. griptape_nodes/retained_mode/utils/name_generator.py +0 -0
  102. griptape_nodes/traits/__init__.py +0 -0
  103. griptape_nodes/traits/add_param_button.py +0 -0
  104. griptape_nodes/traits/button.py +0 -0
  105. griptape_nodes/traits/clamp.py +0 -0
  106. griptape_nodes/traits/compare.py +0 -0
  107. griptape_nodes/traits/compare_images.py +0 -0
  108. griptape_nodes/traits/file_system_picker.py +127 -0
  109. griptape_nodes/traits/minmax.py +0 -0
  110. griptape_nodes/traits/options.py +0 -0
  111. griptape_nodes/traits/slider.py +0 -0
  112. griptape_nodes/traits/trait_registry.py +0 -0
  113. griptape_nodes/traits/traits.json +0 -0
  114. griptape_nodes/updater/__init__.py +2 -2
  115. griptape_nodes/updater/__main__.py +0 -0
  116. griptape_nodes/utils/__init__.py +0 -0
  117. griptape_nodes/utils/dict_utils.py +0 -0
  118. griptape_nodes/utils/image_preview.py +128 -0
  119. griptape_nodes/utils/metaclasses.py +0 -0
  120. griptape_nodes/version_compatibility/__init__.py +0 -0
  121. griptape_nodes/version_compatibility/versions/__init__.py +0 -0
  122. griptape_nodes/version_compatibility/versions/v0_39_0/__init__.py +0 -0
  123. griptape_nodes/version_compatibility/versions/v0_39_0/modified_parameters_set_removal.py +5 -5
  124. griptape_nodes-0.43.0.dist-info/METADATA +90 -0
  125. griptape_nodes-0.43.0.dist-info/RECORD +129 -0
  126. griptape_nodes-0.43.0.dist-info/WHEEL +4 -0
  127. {griptape_nodes-0.42.0.dist-info → griptape_nodes-0.43.0.dist-info}/entry_points.txt +1 -0
  128. griptape_nodes/app/app_sessions.py +0 -554
  129. griptape_nodes-0.42.0.dist-info/METADATA +0 -78
  130. griptape_nodes-0.42.0.dist-info/RECORD +0 -113
  131. griptape_nodes-0.42.0.dist-info/WHEEL +0 -4
  132. griptape_nodes-0.42.0.dist-info/licenses/LICENSE +0 -201
@@ -65,6 +65,9 @@ class BaseNode(ABC):
65
65
  stop_flow: bool = False
66
66
  root_ui_element: BaseNodeElement
67
67
  _tracked_parameters: list[BaseNodeElement]
68
+ _entry_control_parameter: Parameter | None = (
69
+ None # The control input parameter used to enter this node during execution
70
+ )
68
71
 
69
72
  @property
70
73
  def parameters(self) -> list[Parameter]:
@@ -92,6 +95,7 @@ class BaseNode(ABC):
92
95
  self.root_ui_element._node_context = self
93
96
  self.process_generator = None
94
97
  self._tracked_parameters = []
98
+ self.set_entry_control_parameter(None)
95
99
 
96
100
  # This is gross and we need to have a universal pass on resolution state changes and emission of events. That's what this ticket does!
97
101
  # https://github.com/griptape-ai/griptape-nodes/issues/994
@@ -106,6 +110,18 @@ class BaseNode(ABC):
106
110
  )
107
111
  )
108
112
  self.state = NodeResolutionState.UNRESOLVED
113
+ # NOTE: _entry_control_parameter is NOT cleared here as it represents execution context
114
+ # that should persist through the resolve/unresolve cycle during a single execution
115
+
116
+ def set_entry_control_parameter(self, parameter: Parameter | None) -> None:
117
+ """Set the control parameter that was used to enter this node.
118
+
119
+ This should only be called by the ControlFlowContext during execution.
120
+
121
+ Args:
122
+ parameter: The control input parameter that triggered this node's execution, or None to clear
123
+ """
124
+ self._entry_control_parameter = parameter
109
125
 
110
126
  def emit_parameter_changes(self) -> None:
111
127
  if self._tracked_parameters:
@@ -577,7 +593,7 @@ class BaseNode(ABC):
577
593
 
578
594
  def _flatten(items: Iterable[Any]) -> Generator[Any, None, None]:
579
595
  for item in items:
580
- if isinstance(item, Iterable) and not isinstance(item, (str, bytes)):
596
+ if isinstance(item, Iterable) and not isinstance(item, (str, bytes, dict)):
581
597
  yield from _flatten(item)
582
598
  elif item:
583
599
  yield item
File without changes
File without changes
@@ -2,10 +2,12 @@
2
2
  from __future__ import annotations
3
3
 
4
4
  import logging
5
+ from dataclasses import dataclass
5
6
  from typing import TYPE_CHECKING
6
7
 
7
8
  from griptape.events import EventBus
8
9
 
10
+ from griptape_nodes.exe_types.core_types import Parameter
9
11
  from griptape_nodes.exe_types.node_types import BaseNode, NodeResolutionState
10
12
  from griptape_nodes.exe_types.type_validator import TypeValidator
11
13
  from griptape_nodes.machines.fsm import FSM, State
@@ -17,6 +19,15 @@ from griptape_nodes.retained_mode.events.execution_events import (
17
19
  SelectedControlOutputEvent,
18
20
  )
19
21
 
22
+
23
+ @dataclass
24
+ class NextNodeInfo:
25
+ """Information about the next node to execute and how to reach it."""
26
+
27
+ node: BaseNode
28
+ entry_parameter: Parameter | None
29
+
30
+
20
31
  if TYPE_CHECKING:
21
32
  from griptape_nodes.exe_types.core_types import Parameter
22
33
  from griptape_nodes.exe_types.flow import ControlFlow
@@ -36,18 +47,26 @@ class ControlFlowContext:
36
47
  self.resolution_machine = NodeResolutionMachine()
37
48
  self.current_node = None
38
49
 
39
- def get_next_node(self, output_parameter: Parameter) -> BaseNode | None:
50
+ def get_next_node(self, output_parameter: Parameter) -> NextNodeInfo | None:
51
+ """Get the next node and the target parameter that will receive the control flow.
52
+
53
+ Returns:
54
+ NextNodeInfo | None: Information about the next node or None if no connection
55
+ """
40
56
  if self.current_node is not None:
41
57
  from griptape_nodes.retained_mode.griptape_nodes import GriptapeNodes
42
58
 
43
- node = GriptapeNodes.FlowManager().get_connections().get_connected_node(self.current_node, output_parameter)
44
- if node is not None:
45
- node, _ = node
59
+ node_connection = (
60
+ GriptapeNodes.FlowManager().get_connections().get_connected_node(self.current_node, output_parameter)
61
+ )
62
+ if node_connection is not None:
63
+ node, entry_parameter = node_connection
64
+ return NextNodeInfo(node=node, entry_parameter=entry_parameter)
46
65
  # Continue Execution to the next node that needs to be executed using global execution queue
47
- else:
48
- # Get the next node in the execution queue, or None if queue is empty
49
- node = GriptapeNodes.FlowManager().get_next_node_from_execution_queue()
50
- return node
66
+ # Get the next node in the execution queue, or None if queue is empty
67
+ node = GriptapeNodes.FlowManager().get_next_node_from_execution_queue()
68
+ if node is not None:
69
+ return NextNodeInfo(node=node, entry_parameter=None)
51
70
  return None
52
71
 
53
72
  def reset(self) -> None:
@@ -111,10 +130,11 @@ class NextNodeState(State):
111
130
  context.current_node.stop_flow = False
112
131
  return CompleteState
113
132
  next_output = context.current_node.get_next_control_output()
114
- next_node = None
133
+ next_node_info = None
134
+
115
135
  if next_output is not None:
116
136
  context.selected_output = next_output
117
- next_node = context.get_next_node(context.selected_output)
137
+ next_node_info = context.get_next_node(context.selected_output)
118
138
  EventBus.publish_event(
119
139
  ExecutionGriptapeNodeEvent(
120
140
  wrapped_event=ExecutionEvent(
@@ -130,11 +150,18 @@ class NextNodeState(State):
130
150
 
131
151
  # Get the next node in the execution queue, or None if queue is empty
132
152
  next_node = GriptapeNodes.FlowManager().get_next_node_from_execution_queue()
153
+ if next_node is not None:
154
+ next_node_info = NextNodeInfo(node=next_node, entry_parameter=None)
155
+
133
156
  # The parameter that will be evaluated next
134
- if next_node is None:
157
+ if next_node_info is None:
135
158
  # If no node attached
136
159
  return CompleteState
137
- context.current_node = next_node
160
+
161
+ # Always set the entry control parameter (None for execution queue nodes)
162
+ next_node_info.node.set_entry_control_parameter(next_node_info.entry_parameter)
163
+
164
+ context.current_node = next_node_info.node
138
165
  context.selected_output = None
139
166
  if not context.paused:
140
167
  return ResolveNodeState
@@ -177,6 +204,8 @@ class ControlFlowMachine(FSM[ControlFlowContext]):
177
204
 
178
205
  def start_flow(self, start_node: BaseNode, debug_mode: bool = False) -> None: # noqa: FBT001, FBT002
179
206
  self._context.current_node = start_node
207
+ # Set entry control parameter for initial node (None for workflow start)
208
+ start_node.set_entry_control_parameter(None)
180
209
  # Set up to debug
181
210
  self._context.paused = debug_mode
182
211
  self.start(ResolveNodeState) # Begins the flow
@@ -1,4 +1,4 @@
1
- from typing import Any, Generic, TypeVar
1
+ from typing import Any, TypeVar
2
2
 
3
3
  T = TypeVar("T")
4
4
 
@@ -19,8 +19,13 @@ class State:
19
19
  """Called when exiting the state."""
20
20
  return
21
21
 
22
+ @staticmethod
23
+ def on_event(context: Any, event: Any) -> type["State"] | None: # noqa: ARG004
24
+ """Called on an event, which may trigger a State transition."""
25
+ return None
26
+
22
27
 
23
- class FSM(Generic[T]):
28
+ class FSM[T]:
24
29
  def __init__(self, context: T) -> None:
25
30
  self._context = context
26
31
  self._current_state = None
@@ -53,3 +58,12 @@ class FSM(Generic[T]):
53
58
 
54
59
  if new_state is not None:
55
60
  self.transition_state(new_state)
61
+
62
+ def handle_event(self, event: Any) -> None:
63
+ if self._current_state is None:
64
+ new_state = None
65
+ else:
66
+ new_state = self._current_state.on_event(self._context, event)
67
+
68
+ if new_state is not None:
69
+ self.transition_state(new_state)
File without changes
@@ -0,0 +1 @@
1
+ """Runs the Griptape Nodes MCP server."""
@@ -0,0 +1,126 @@
1
+ import contextlib
2
+ import json
3
+ import logging
4
+ import os
5
+ from collections.abc import AsyncIterator
6
+
7
+ import uvicorn
8
+ from fastapi import FastAPI
9
+ from mcp.server.lowlevel import Server
10
+ from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
11
+ from mcp.types import (
12
+ TextContent,
13
+ Tool,
14
+ )
15
+ from pydantic import TypeAdapter
16
+ from rich.logging import RichHandler
17
+ from starlette.types import Receive, Scope, Send
18
+
19
+ from griptape_nodes.mcp_server.ws_request_manager import AsyncRequestManager, WebSocketConnectionManager
20
+ from griptape_nodes.retained_mode.events.base_events import RequestPayload
21
+ from griptape_nodes.retained_mode.events.connection_events import (
22
+ CreateConnectionRequest,
23
+ DeleteConnectionRequest,
24
+ ListConnectionsForNodeRequest,
25
+ )
26
+ from griptape_nodes.retained_mode.events.flow_events import ListNodesInFlowRequest
27
+ from griptape_nodes.retained_mode.events.node_events import (
28
+ CreateNodeRequest,
29
+ DeleteNodeRequest,
30
+ GetNodeMetadataRequest,
31
+ GetNodeResolutionStateRequest,
32
+ ListParametersOnNodeRequest,
33
+ SetNodeMetadataRequest,
34
+ )
35
+ from griptape_nodes.retained_mode.events.parameter_events import (
36
+ GetParameterValueRequest,
37
+ SetParameterValueRequest,
38
+ )
39
+ from griptape_nodes.retained_mode.managers.config_manager import ConfigManager
40
+ from griptape_nodes.retained_mode.managers.secrets_manager import SecretsManager
41
+
42
+ SUPPORTED_REQUEST_EVENTS: dict[str, type[RequestPayload]] = {
43
+ # Nodes
44
+ "CreateNodeRequest": CreateNodeRequest,
45
+ "DeleteNodeRequest": DeleteNodeRequest,
46
+ "ListNodesInFlowRequest": ListNodesInFlowRequest,
47
+ "GetNodeResolutionStateRequest": GetNodeResolutionStateRequest,
48
+ "GetNodeMetadataRequest": GetNodeMetadataRequest,
49
+ "SetNodeMetadataRequest": SetNodeMetadataRequest,
50
+ # Connections
51
+ "CreateConnectionRequest": CreateConnectionRequest,
52
+ "DeleteConnectionRequest": DeleteConnectionRequest,
53
+ "ListConnectionsForNodeRequest": ListConnectionsForNodeRequest,
54
+ # Parameters
55
+ "ListParametersOnNodeRequest": ListParametersOnNodeRequest,
56
+ "GetParameterValueRequest": GetParameterValueRequest,
57
+ "SetParameterValueRequest": SetParameterValueRequest,
58
+ }
59
+
60
+ GTN_MCP_SERVER_PORT = int(os.getenv("GTN_MCP_SERVER_PORT", "9927"))
61
+
62
+ config_manager = ConfigManager()
63
+ secrets_manager = SecretsManager(config_manager)
64
+
65
+
66
+ def main(api_key: str) -> None:
67
+ """Main entry point for the Griptape Nodes MCP server."""
68
+ mcp_server_logger = logging.getLogger("griptape_nodes_mcp_server")
69
+ mcp_server_logger.addHandler(RichHandler(show_time=True, show_path=False, markup=True, rich_tracebacks=True))
70
+ mcp_server_logger.setLevel(logging.INFO)
71
+ mcp_server_logger.info("Starting MCP GTN server...")
72
+
73
+ # Give these a session ID
74
+ connection_manager = WebSocketConnectionManager()
75
+ request_manager = AsyncRequestManager(connection_manager, api_key)
76
+
77
+ app = Server("mcp-gtn")
78
+
79
+ @app.list_tools()
80
+ async def list_tools() -> list[Tool]:
81
+ return [
82
+ Tool(name=event.__name__, description=event.__doc__, inputSchema=TypeAdapter(event).json_schema())
83
+ for (name, event) in SUPPORTED_REQUEST_EVENTS.items()
84
+ ]
85
+
86
+ @app.call_tool()
87
+ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
88
+ if name not in SUPPORTED_REQUEST_EVENTS:
89
+ msg = f"Unsupported tool: {name}"
90
+ raise ValueError(msg)
91
+
92
+ request_payload = SUPPORTED_REQUEST_EVENTS[name](**arguments)
93
+
94
+ await request_manager.connect()
95
+ result = await request_manager.create_request_event(
96
+ request_payload.__class__.__name__, request_payload.__dict__, timeout_ms=5000
97
+ )
98
+ mcp_server_logger.debug("Got result: %s", result)
99
+
100
+ return [TextContent(type="text", text=json.dumps(result))]
101
+
102
+ # Create the session manager with our app and event store
103
+ session_manager = StreamableHTTPSessionManager(
104
+ app=app,
105
+ )
106
+
107
+ @contextlib.asynccontextmanager
108
+ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
109
+ """Context manager for managing session manager lifecycle."""
110
+ async with session_manager.run():
111
+ mcp_server_logger.info("GTN MCP server started with StreamableHTTP session manager!")
112
+ try:
113
+ yield
114
+ finally:
115
+ mcp_server_logger.info("GTN MCP server shutting down...")
116
+
117
+ # Create an ASGI application using the transport
118
+ mcp_server_app = FastAPI(lifespan=lifespan)
119
+
120
+ # ASGI handler for streamable HTTP connections
121
+ async def handle_streamable_http(scope: Scope, receive: Receive, send: Send) -> None:
122
+ await session_manager.handle_request(scope, receive, send)
123
+
124
+ mcp_server_app.mount("/mcp", app=handle_streamable_http)
125
+
126
+ uvicorn.run(mcp_server_app, host="127.0.0.1", port=GTN_MCP_SERVER_PORT)
@@ -0,0 +1,268 @@
1
+ import asyncio
2
+ import contextlib
3
+ import json
4
+ import logging
5
+ import os
6
+ import uuid
7
+ from collections.abc import Callable
8
+ from typing import Any, Generic, TypeVar
9
+ from urllib.parse import urljoin
10
+
11
+ import websockets
12
+
13
+ logger = logging.getLogger("griptape_nodes_mcp_server")
14
+
15
+ T = TypeVar("T")
16
+
17
+
18
+ class WebSocketConnectionManager:
19
+ """Python equivalent of WebSocketConnectionManager in TypeScript."""
20
+
21
+ def __init__(
22
+ self,
23
+ websocket_url: str = urljoin(
24
+ os.getenv("GRIPTAPE_NODES_API_BASE_URL", "https://api.nodes.griptape.ai").replace("http", "ws"),
25
+ "/ws/engines/events?version=v2",
26
+ ),
27
+ ):
28
+ self.websocket_url = websocket_url
29
+ self.websocket: Any = None
30
+ self.connected = False
31
+ self.event_handlers: dict[str, list[Callable]] = {}
32
+ self.request_handlers: dict[str, tuple[Callable, Callable]] = {}
33
+ self._process_task: asyncio.Task | None = None
34
+
35
+ async def send(self, data: dict[str, Any]) -> None:
36
+ """Send a message to the WebSocket server."""
37
+ if not self.websocket:
38
+ msg = "Not connected to WebSocket server"
39
+ raise ConnectionError(msg)
40
+
41
+ try:
42
+ message = json.dumps(data)
43
+ await self.websocket.send(message)
44
+ logger.debug("📤 Sent message: %s", message)
45
+ except Exception as e:
46
+ logger.error("Failed to send message: %s", e)
47
+ raise
48
+
49
+ async def _process_messages(self) -> None:
50
+ """Process incoming WebSocket messages."""
51
+ if not self.websocket:
52
+ logger.warning("WebSocket is not connected, cannot process messages")
53
+ return
54
+
55
+ try:
56
+ async for message in self.websocket:
57
+ try:
58
+ data = json.loads(message)
59
+ logger.debug("📥 Received message: %s", message)
60
+ await self._handle_message(data)
61
+ except json.JSONDecodeError:
62
+ logger.error("Failed to parse message: %s", message)
63
+ except Exception as e:
64
+ logger.error("Error processing message: %s", e)
65
+ except websockets.exceptions.ConnectionClosed:
66
+ logger.warning("WebSocket connection closed")
67
+ self.connected = False
68
+ except asyncio.CancelledError:
69
+ # Task was cancelled, just exit
70
+ pass
71
+ except Exception as e:
72
+ logger.error("Error in message processing loop: %s", e)
73
+ self.connected = False
74
+
75
+ async def _handle_message(self, data: dict[str, Any]) -> None:
76
+ request = data.get("payload", {}).get("request", {})
77
+ request_id = request.get("request_id")
78
+
79
+ if request_id and request_id in self.request_handlers:
80
+ success_handler, failure_handler = self.request_handlers[request_id]
81
+ try:
82
+ if data.get("type") == "success_result":
83
+ success_handler(data, request)
84
+ else:
85
+ failure_handler(data, request)
86
+ except Exception as e:
87
+ logger.error("Error in request handler: %s", e)
88
+
89
+ def subscribe_to_request_event(
90
+ self, success_handler: Callable[[Any, Any], None], failure_handler: Callable[[Any, Any], None]
91
+ ) -> str:
92
+ """Subscribe to a request-response event."""
93
+ request_id = str(uuid.uuid4())
94
+ self.request_handlers[request_id] = (success_handler, failure_handler)
95
+ return request_id
96
+
97
+ def unsubscribe_from_request_event(self, request_id: str) -> None:
98
+ """Unsubscribe from a request-response event."""
99
+ if request_id in self.request_handlers:
100
+ del self.request_handlers[request_id]
101
+
102
+
103
+ class AsyncRequestManager(Generic[T]): # noqa: UP046
104
+ def __init__(self, connection_manager: WebSocketConnectionManager, api_key: str):
105
+ self.connection_manager = connection_manager
106
+ self.api_key = api_key
107
+
108
+ async def _subscribe_to_topic(self, topic: str) -> None:
109
+ """Subscribe to a specific topic in the message bus."""
110
+ if self.connection_manager.websocket is None:
111
+ logger.warning("WebSocket connection not available for subscribing to topic")
112
+ return
113
+
114
+ try:
115
+ body = {"type": "subscribe", "topic": topic, "payload": {}}
116
+ await self.connection_manager.websocket.send(json.dumps(body))
117
+ logger.debug("Subscribed to topic: %s", topic)
118
+ except websockets.exceptions.WebSocketException as e:
119
+ logger.error("Error subscribing to topic %s: %s", topic, e)
120
+ except Exception as e:
121
+ logger.error("Unexpected error while subscribing to topic %s: %s", topic, e)
122
+
123
+ async def _subscribe_to_topics(self) -> None:
124
+ from griptape_nodes.retained_mode.managers.session_manager import SessionManager
125
+ from griptape_nodes.retained_mode.utils.engine_identity import EngineIdentity
126
+
127
+ # Subscribe to response topic (engine discovery)
128
+ await self._subscribe_to_topic("response")
129
+
130
+ # Get engine ID and subscribe to engine_id/response
131
+ engine_id = EngineIdentity.get_engine_id()
132
+ if engine_id:
133
+ await self._subscribe_to_topic(f"engines/{engine_id}/response")
134
+ else:
135
+ logger.warning("Engine ID not available for subscription")
136
+
137
+ # Get session ID and subscribe to session_id/response if available
138
+ session_id = SessionManager.get_saved_session_id()
139
+ if session_id:
140
+ topic = f"sessions/{session_id}/response"
141
+ await self._subscribe_to_topic(topic)
142
+ else:
143
+ logger.info("No session ID available for subscription")
144
+
145
+ async def connect(self) -> None:
146
+ """Connect to the WebSocket server."""
147
+ from griptape_nodes.app.app import _create_websocket_connection
148
+
149
+ try:
150
+ self.connection_manager.websocket = await _create_websocket_connection(self.api_key)
151
+ logger.debug("🟢 WebSocket connection established: %s", self.connection_manager.websocket)
152
+
153
+ await self._subscribe_to_topics()
154
+
155
+ # Start processing messages
156
+ self.connection_manager._process_task = asyncio.create_task(self.connection_manager._process_messages())
157
+
158
+ except Exception as e:
159
+ self.connection_manager.connected = False
160
+ logger.error("🔴 WebSocket connection failed: %s", str(e))
161
+ msg = f"Failed to connect to WebSocket: {e!s}"
162
+ raise ConnectionError(msg) from e
163
+
164
+ async def disconnect(self) -> None:
165
+ """Disconnect from the WebSocket server."""
166
+ if self.connection_manager.websocket:
167
+ await self.connection_manager.websocket.close()
168
+ self.connection_manager.websocket = None
169
+ self.connection_manager.connected = False
170
+
171
+ # Cancel processing task if it's running
172
+ if self.connection_manager._process_task:
173
+ self.connection_manager._process_task.cancel()
174
+ with contextlib.suppress(asyncio.CancelledError):
175
+ await self.connection_manager._process_task
176
+ self.connection_manager._process_task = None
177
+
178
+ logger.debug("WebSocket disconnected")
179
+
180
+ async def send_api_message(self, data: dict[str, Any]) -> None:
181
+ """Send a message to the API via WebSocket."""
182
+ try:
183
+ await self.connection_manager.send(data)
184
+ except ConnectionError as e:
185
+ logger.error("Failed to send API message: %s", e)
186
+ raise
187
+ except Exception as e:
188
+ logger.error("Unexpected error sending API message: %s", e)
189
+ raise
190
+
191
+ async def create_event(self, request_type: str, payload: dict[str, Any]) -> None:
192
+ """Send an event to the API without waiting for a response."""
193
+ from griptape_nodes.app.app import _determine_request_topic
194
+
195
+ logger.debug("📝 Creating Event: %s - %s", request_type, json.dumps(payload))
196
+
197
+ data = {"event_type": "EventRequest", "request_type": request_type, "request": payload}
198
+ topic = _determine_request_topic()
199
+
200
+ request_data = {"payload": data, "type": data["event_type"], "topic": topic}
201
+
202
+ if not request_data["payload"]["request"].get("request_id"):
203
+ request_data["payload"]["request"]["request_id"] = ""
204
+
205
+ await self.send_api_message(request_data)
206
+
207
+ async def create_request_event(
208
+ self, request_type: str, payload: dict[str, Any], timeout_ms: int | None = None
209
+ ) -> T:
210
+ """Send a request and wait for its response.
211
+
212
+ Args:
213
+ request_type: Type of request to send
214
+ payload: Data to send with the request
215
+ timeout_ms: Optional timeout in milliseconds
216
+
217
+ Returns:
218
+ The response data
219
+
220
+ Raises:
221
+ asyncio.TimeoutError: If the request times out
222
+ Exception: If the request fails
223
+ """
224
+ # Create a future that will be resolved when the response arrives
225
+ response_future = asyncio.Future()
226
+
227
+ # Convert timeout from milliseconds to seconds for asyncio
228
+ timeout_sec = timeout_ms / 1000 if timeout_ms else None
229
+
230
+ # Define handlers that will resolve/reject the future
231
+ def success_handler(response: Any, _: Any) -> None:
232
+ if not response_future.done():
233
+ result = response.get("payload", {}).get("result", "Success")
234
+ logger.debug("✅ Request succeeded: %s", result)
235
+ response_future.set_result(result)
236
+
237
+ def failure_handler(response: Any, _: Any) -> None:
238
+ if not response_future.done():
239
+ error = (
240
+ response.get("payload", {}).get("result", {}).get("exception", "Unknown error") or "Unknown error"
241
+ )
242
+ logger.error("❌ Request failed: %s", error)
243
+ response_future.set_exception(Exception(error))
244
+
245
+ # Generate request ID and subscribe
246
+ request_id = self.connection_manager.subscribe_to_request_event(success_handler, failure_handler)
247
+ payload["request_id"] = request_id
248
+
249
+ logger.debug("🚀 Request (%s): %s %s", request_id, request_type, json.dumps(payload))
250
+
251
+ try:
252
+ # Send the event
253
+ await self.create_event(request_type, payload)
254
+
255
+ # Wait for the response with optional timeout
256
+ if timeout_sec:
257
+ return await asyncio.wait_for(response_future, timeout=timeout_sec)
258
+ return await response_future
259
+
260
+ except TimeoutError:
261
+ logger.error("Request timed out after %s ms: %s", timeout_ms, request_id)
262
+ self.connection_manager.unsubscribe_from_request_event(request_id)
263
+ raise
264
+
265
+ except Exception as e:
266
+ logger.error("Request failed: %s - %s", request_id, e)
267
+ self.connection_manager.unsubscribe_from_request_event(request_id)
268
+ raise
File without changes
File without changes
File without changes
@@ -13,7 +13,7 @@ from griptape_nodes.utils.metaclasses import SingletonMeta
13
13
 
14
14
 
15
15
  class WorkflowMetadata(BaseModel):
16
- LATEST_SCHEMA_VERSION: ClassVar[str] = "0.6.0"
16
+ LATEST_SCHEMA_VERSION: ClassVar[str] = "0.6.1"
17
17
 
18
18
  name: str
19
19
  schema_version: str
griptape_nodes/py.typed CHANGED
File without changes
File without changes
File without changes
File without changes
@@ -82,6 +82,12 @@ class AppInitializationComplete(AppPayload):
82
82
  """Application initialization completed successfully. All subsystems ready."""
83
83
 
84
84
 
85
+ @dataclass
86
+ @PayloadRegistry.register
87
+ class AppConnectionEstablished(AppPayload):
88
+ """Notification that a connection to the API has been established."""
89
+
90
+
85
91
  @dataclass
86
92
  @PayloadRegistry.register
87
93
  class GetEngineVersionRequest(RequestPayload):
@@ -192,7 +198,6 @@ class EngineHeartbeatResultSuccess(ResultPayloadSuccess):
192
198
  instance_region: Cloud instance region (None if not applicable)
193
199
  instance_provider: Cloud provider name (None if not applicable)
194
200
  deployment_type: Type of deployment (None if not applicable)
195
- public_ip: Public IP address (None if not available)
196
201
  current_workflow: Name of active workflow (None if none)
197
202
  workflow_file_path: Path to workflow file (None if none)
198
203
  has_active_flow: Whether there's an active flow running
@@ -208,7 +213,6 @@ class EngineHeartbeatResultSuccess(ResultPayloadSuccess):
208
213
  instance_region: str | None
209
214
  instance_provider: str | None
210
215
  deployment_type: str | None
211
- public_ip: str | None
212
216
  current_workflow: str | None
213
217
  workflow_file_path: str | None
214
218
  has_active_flow: bool