hexdag 0.5.0.dev1__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 (261) hide show
  1. hexdag/__init__.py +116 -0
  2. hexdag/__main__.py +30 -0
  3. hexdag/adapters/executors/__init__.py +5 -0
  4. hexdag/adapters/executors/local_executor.py +316 -0
  5. hexdag/builtin/__init__.py +6 -0
  6. hexdag/builtin/adapters/__init__.py +51 -0
  7. hexdag/builtin/adapters/anthropic/__init__.py +5 -0
  8. hexdag/builtin/adapters/anthropic/anthropic_adapter.py +151 -0
  9. hexdag/builtin/adapters/database/__init__.py +6 -0
  10. hexdag/builtin/adapters/database/csv/csv_adapter.py +249 -0
  11. hexdag/builtin/adapters/database/pgvector/__init__.py +5 -0
  12. hexdag/builtin/adapters/database/pgvector/pgvector_adapter.py +478 -0
  13. hexdag/builtin/adapters/database/sqlalchemy/sqlalchemy_adapter.py +252 -0
  14. hexdag/builtin/adapters/database/sqlite/__init__.py +5 -0
  15. hexdag/builtin/adapters/database/sqlite/sqlite_adapter.py +410 -0
  16. hexdag/builtin/adapters/local/README.md +59 -0
  17. hexdag/builtin/adapters/local/__init__.py +7 -0
  18. hexdag/builtin/adapters/local/local_observer_manager.py +696 -0
  19. hexdag/builtin/adapters/memory/__init__.py +47 -0
  20. hexdag/builtin/adapters/memory/file_memory_adapter.py +297 -0
  21. hexdag/builtin/adapters/memory/in_memory_memory.py +216 -0
  22. hexdag/builtin/adapters/memory/schemas.py +57 -0
  23. hexdag/builtin/adapters/memory/session_memory.py +178 -0
  24. hexdag/builtin/adapters/memory/sqlite_memory_adapter.py +215 -0
  25. hexdag/builtin/adapters/memory/state_memory.py +280 -0
  26. hexdag/builtin/adapters/mock/README.md +89 -0
  27. hexdag/builtin/adapters/mock/__init__.py +15 -0
  28. hexdag/builtin/adapters/mock/hexdag.toml +50 -0
  29. hexdag/builtin/adapters/mock/mock_database.py +225 -0
  30. hexdag/builtin/adapters/mock/mock_embedding.py +223 -0
  31. hexdag/builtin/adapters/mock/mock_llm.py +177 -0
  32. hexdag/builtin/adapters/mock/mock_tool_adapter.py +192 -0
  33. hexdag/builtin/adapters/mock/mock_tool_router.py +232 -0
  34. hexdag/builtin/adapters/openai/__init__.py +5 -0
  35. hexdag/builtin/adapters/openai/openai_adapter.py +634 -0
  36. hexdag/builtin/adapters/secret/__init__.py +7 -0
  37. hexdag/builtin/adapters/secret/local_secret_adapter.py +248 -0
  38. hexdag/builtin/adapters/unified_tool_router.py +280 -0
  39. hexdag/builtin/macros/__init__.py +17 -0
  40. hexdag/builtin/macros/conversation_agent.py +390 -0
  41. hexdag/builtin/macros/llm_macro.py +151 -0
  42. hexdag/builtin/macros/reasoning_agent.py +423 -0
  43. hexdag/builtin/macros/tool_macro.py +380 -0
  44. hexdag/builtin/nodes/__init__.py +38 -0
  45. hexdag/builtin/nodes/_discovery.py +123 -0
  46. hexdag/builtin/nodes/agent_node.py +696 -0
  47. hexdag/builtin/nodes/base_node_factory.py +242 -0
  48. hexdag/builtin/nodes/composite_node.py +926 -0
  49. hexdag/builtin/nodes/data_node.py +201 -0
  50. hexdag/builtin/nodes/expression_node.py +487 -0
  51. hexdag/builtin/nodes/function_node.py +454 -0
  52. hexdag/builtin/nodes/llm_node.py +491 -0
  53. hexdag/builtin/nodes/loop_node.py +920 -0
  54. hexdag/builtin/nodes/mapped_input.py +518 -0
  55. hexdag/builtin/nodes/port_call_node.py +269 -0
  56. hexdag/builtin/nodes/tool_call_node.py +195 -0
  57. hexdag/builtin/nodes/tool_utils.py +390 -0
  58. hexdag/builtin/prompts/__init__.py +68 -0
  59. hexdag/builtin/prompts/base.py +422 -0
  60. hexdag/builtin/prompts/chat_prompts.py +303 -0
  61. hexdag/builtin/prompts/error_correction_prompts.py +320 -0
  62. hexdag/builtin/prompts/tool_prompts.py +160 -0
  63. hexdag/builtin/tools/builtin_tools.py +84 -0
  64. hexdag/builtin/tools/database_tools.py +164 -0
  65. hexdag/cli/__init__.py +17 -0
  66. hexdag/cli/__main__.py +7 -0
  67. hexdag/cli/commands/__init__.py +27 -0
  68. hexdag/cli/commands/build_cmd.py +812 -0
  69. hexdag/cli/commands/create_cmd.py +208 -0
  70. hexdag/cli/commands/docs_cmd.py +293 -0
  71. hexdag/cli/commands/generate_types_cmd.py +252 -0
  72. hexdag/cli/commands/init_cmd.py +188 -0
  73. hexdag/cli/commands/pipeline_cmd.py +494 -0
  74. hexdag/cli/commands/plugin_dev_cmd.py +529 -0
  75. hexdag/cli/commands/plugins_cmd.py +441 -0
  76. hexdag/cli/commands/studio_cmd.py +101 -0
  77. hexdag/cli/commands/validate_cmd.py +221 -0
  78. hexdag/cli/main.py +84 -0
  79. hexdag/core/__init__.py +83 -0
  80. hexdag/core/config/__init__.py +20 -0
  81. hexdag/core/config/loader.py +479 -0
  82. hexdag/core/config/models.py +150 -0
  83. hexdag/core/configurable.py +294 -0
  84. hexdag/core/context/__init__.py +37 -0
  85. hexdag/core/context/execution_context.py +378 -0
  86. hexdag/core/docs/__init__.py +26 -0
  87. hexdag/core/docs/extractors.py +678 -0
  88. hexdag/core/docs/generators.py +890 -0
  89. hexdag/core/docs/models.py +120 -0
  90. hexdag/core/domain/__init__.py +10 -0
  91. hexdag/core/domain/dag.py +1225 -0
  92. hexdag/core/exceptions.py +234 -0
  93. hexdag/core/expression_parser.py +569 -0
  94. hexdag/core/logging.py +449 -0
  95. hexdag/core/models/__init__.py +17 -0
  96. hexdag/core/models/base.py +138 -0
  97. hexdag/core/orchestration/__init__.py +46 -0
  98. hexdag/core/orchestration/body_executor.py +481 -0
  99. hexdag/core/orchestration/components/__init__.py +97 -0
  100. hexdag/core/orchestration/components/adapter_lifecycle_manager.py +113 -0
  101. hexdag/core/orchestration/components/checkpoint_manager.py +134 -0
  102. hexdag/core/orchestration/components/execution_coordinator.py +360 -0
  103. hexdag/core/orchestration/components/health_check_manager.py +176 -0
  104. hexdag/core/orchestration/components/input_mapper.py +143 -0
  105. hexdag/core/orchestration/components/lifecycle_manager.py +583 -0
  106. hexdag/core/orchestration/components/node_executor.py +377 -0
  107. hexdag/core/orchestration/components/secret_manager.py +202 -0
  108. hexdag/core/orchestration/components/wave_executor.py +158 -0
  109. hexdag/core/orchestration/constants.py +17 -0
  110. hexdag/core/orchestration/events/README.md +312 -0
  111. hexdag/core/orchestration/events/__init__.py +104 -0
  112. hexdag/core/orchestration/events/batching.py +330 -0
  113. hexdag/core/orchestration/events/decorators.py +139 -0
  114. hexdag/core/orchestration/events/events.py +573 -0
  115. hexdag/core/orchestration/events/observers/__init__.py +30 -0
  116. hexdag/core/orchestration/events/observers/core_observers.py +690 -0
  117. hexdag/core/orchestration/events/observers/models.py +111 -0
  118. hexdag/core/orchestration/events/taxonomy.py +269 -0
  119. hexdag/core/orchestration/hook_context.py +237 -0
  120. hexdag/core/orchestration/hooks.py +437 -0
  121. hexdag/core/orchestration/models.py +418 -0
  122. hexdag/core/orchestration/orchestrator.py +910 -0
  123. hexdag/core/orchestration/orchestrator_factory.py +275 -0
  124. hexdag/core/orchestration/port_wrappers.py +327 -0
  125. hexdag/core/orchestration/prompt/__init__.py +32 -0
  126. hexdag/core/orchestration/prompt/template.py +332 -0
  127. hexdag/core/pipeline_builder/__init__.py +21 -0
  128. hexdag/core/pipeline_builder/component_instantiator.py +386 -0
  129. hexdag/core/pipeline_builder/include_tag.py +265 -0
  130. hexdag/core/pipeline_builder/pipeline_config.py +133 -0
  131. hexdag/core/pipeline_builder/py_tag.py +223 -0
  132. hexdag/core/pipeline_builder/tag_discovery.py +268 -0
  133. hexdag/core/pipeline_builder/yaml_builder.py +1196 -0
  134. hexdag/core/pipeline_builder/yaml_validator.py +569 -0
  135. hexdag/core/ports/__init__.py +65 -0
  136. hexdag/core/ports/api_call.py +133 -0
  137. hexdag/core/ports/database.py +489 -0
  138. hexdag/core/ports/embedding.py +215 -0
  139. hexdag/core/ports/executor.py +237 -0
  140. hexdag/core/ports/file_storage.py +117 -0
  141. hexdag/core/ports/healthcheck.py +87 -0
  142. hexdag/core/ports/llm.py +551 -0
  143. hexdag/core/ports/memory.py +70 -0
  144. hexdag/core/ports/observer_manager.py +130 -0
  145. hexdag/core/ports/secret.py +145 -0
  146. hexdag/core/ports/tool_router.py +94 -0
  147. hexdag/core/ports_builder.py +623 -0
  148. hexdag/core/protocols.py +273 -0
  149. hexdag/core/resolver.py +304 -0
  150. hexdag/core/schema/__init__.py +9 -0
  151. hexdag/core/schema/generator.py +742 -0
  152. hexdag/core/secrets.py +242 -0
  153. hexdag/core/types.py +413 -0
  154. hexdag/core/utils/async_warnings.py +206 -0
  155. hexdag/core/utils/schema_conversion.py +78 -0
  156. hexdag/core/utils/sql_validation.py +86 -0
  157. hexdag/core/validation/secure_json.py +148 -0
  158. hexdag/core/yaml_macro.py +517 -0
  159. hexdag/mcp_server.py +3120 -0
  160. hexdag/studio/__init__.py +10 -0
  161. hexdag/studio/build_ui.py +92 -0
  162. hexdag/studio/server/__init__.py +1 -0
  163. hexdag/studio/server/main.py +100 -0
  164. hexdag/studio/server/routes/__init__.py +9 -0
  165. hexdag/studio/server/routes/execute.py +208 -0
  166. hexdag/studio/server/routes/export.py +558 -0
  167. hexdag/studio/server/routes/files.py +207 -0
  168. hexdag/studio/server/routes/plugins.py +419 -0
  169. hexdag/studio/server/routes/validate.py +220 -0
  170. hexdag/studio/ui/index.html +13 -0
  171. hexdag/studio/ui/package-lock.json +2992 -0
  172. hexdag/studio/ui/package.json +31 -0
  173. hexdag/studio/ui/postcss.config.js +6 -0
  174. hexdag/studio/ui/public/hexdag.svg +5 -0
  175. hexdag/studio/ui/src/App.tsx +251 -0
  176. hexdag/studio/ui/src/components/Canvas.tsx +408 -0
  177. hexdag/studio/ui/src/components/ContextMenu.tsx +187 -0
  178. hexdag/studio/ui/src/components/FileBrowser.tsx +123 -0
  179. hexdag/studio/ui/src/components/Header.tsx +181 -0
  180. hexdag/studio/ui/src/components/HexdagNode.tsx +193 -0
  181. hexdag/studio/ui/src/components/NodeInspector.tsx +512 -0
  182. hexdag/studio/ui/src/components/NodePalette.tsx +262 -0
  183. hexdag/studio/ui/src/components/NodePortsSection.tsx +403 -0
  184. hexdag/studio/ui/src/components/PluginManager.tsx +347 -0
  185. hexdag/studio/ui/src/components/PortsEditor.tsx +481 -0
  186. hexdag/studio/ui/src/components/PythonEditor.tsx +195 -0
  187. hexdag/studio/ui/src/components/ValidationPanel.tsx +105 -0
  188. hexdag/studio/ui/src/components/YamlEditor.tsx +196 -0
  189. hexdag/studio/ui/src/components/index.ts +8 -0
  190. hexdag/studio/ui/src/index.css +92 -0
  191. hexdag/studio/ui/src/main.tsx +10 -0
  192. hexdag/studio/ui/src/types/index.ts +123 -0
  193. hexdag/studio/ui/src/vite-env.d.ts +1 -0
  194. hexdag/studio/ui/tailwind.config.js +29 -0
  195. hexdag/studio/ui/tsconfig.json +37 -0
  196. hexdag/studio/ui/tsconfig.node.json +13 -0
  197. hexdag/studio/ui/vite.config.ts +35 -0
  198. hexdag/visualization/__init__.py +69 -0
  199. hexdag/visualization/dag_visualizer.py +1020 -0
  200. hexdag-0.5.0.dev1.dist-info/METADATA +369 -0
  201. hexdag-0.5.0.dev1.dist-info/RECORD +261 -0
  202. hexdag-0.5.0.dev1.dist-info/WHEEL +4 -0
  203. hexdag-0.5.0.dev1.dist-info/entry_points.txt +4 -0
  204. hexdag-0.5.0.dev1.dist-info/licenses/LICENSE +190 -0
  205. hexdag_plugins/.gitignore +43 -0
  206. hexdag_plugins/README.md +73 -0
  207. hexdag_plugins/__init__.py +1 -0
  208. hexdag_plugins/azure/LICENSE +21 -0
  209. hexdag_plugins/azure/README.md +414 -0
  210. hexdag_plugins/azure/__init__.py +21 -0
  211. hexdag_plugins/azure/azure_blob_adapter.py +450 -0
  212. hexdag_plugins/azure/azure_cosmos_adapter.py +383 -0
  213. hexdag_plugins/azure/azure_keyvault_adapter.py +314 -0
  214. hexdag_plugins/azure/azure_openai_adapter.py +415 -0
  215. hexdag_plugins/azure/pyproject.toml +107 -0
  216. hexdag_plugins/azure/tests/__init__.py +1 -0
  217. hexdag_plugins/azure/tests/test_azure_blob_adapter.py +350 -0
  218. hexdag_plugins/azure/tests/test_azure_cosmos_adapter.py +323 -0
  219. hexdag_plugins/azure/tests/test_azure_keyvault_adapter.py +330 -0
  220. hexdag_plugins/azure/tests/test_azure_openai_adapter.py +329 -0
  221. hexdag_plugins/hexdag_etl/README.md +168 -0
  222. hexdag_plugins/hexdag_etl/__init__.py +53 -0
  223. hexdag_plugins/hexdag_etl/examples/01_simple_pandas_transform.py +270 -0
  224. hexdag_plugins/hexdag_etl/examples/02_simple_pandas_only.py +149 -0
  225. hexdag_plugins/hexdag_etl/examples/03_file_io_pipeline.py +109 -0
  226. hexdag_plugins/hexdag_etl/examples/test_pandas_transform.py +84 -0
  227. hexdag_plugins/hexdag_etl/hexdag.toml +25 -0
  228. hexdag_plugins/hexdag_etl/hexdag_etl/__init__.py +48 -0
  229. hexdag_plugins/hexdag_etl/hexdag_etl/nodes/__init__.py +13 -0
  230. hexdag_plugins/hexdag_etl/hexdag_etl/nodes/api_extract.py +230 -0
  231. hexdag_plugins/hexdag_etl/hexdag_etl/nodes/base_node_factory.py +181 -0
  232. hexdag_plugins/hexdag_etl/hexdag_etl/nodes/file_io.py +415 -0
  233. hexdag_plugins/hexdag_etl/hexdag_etl/nodes/outlook.py +492 -0
  234. hexdag_plugins/hexdag_etl/hexdag_etl/nodes/pandas_transform.py +563 -0
  235. hexdag_plugins/hexdag_etl/hexdag_etl/nodes/sql_extract_load.py +112 -0
  236. hexdag_plugins/hexdag_etl/pyproject.toml +82 -0
  237. hexdag_plugins/hexdag_etl/test_transform.py +54 -0
  238. hexdag_plugins/hexdag_etl/tests/test_plugin_integration.py +62 -0
  239. hexdag_plugins/mysql_adapter/LICENSE +21 -0
  240. hexdag_plugins/mysql_adapter/README.md +224 -0
  241. hexdag_plugins/mysql_adapter/__init__.py +6 -0
  242. hexdag_plugins/mysql_adapter/mysql_adapter.py +408 -0
  243. hexdag_plugins/mysql_adapter/pyproject.toml +93 -0
  244. hexdag_plugins/mysql_adapter/tests/test_mysql_adapter.py +259 -0
  245. hexdag_plugins/storage/README.md +184 -0
  246. hexdag_plugins/storage/__init__.py +19 -0
  247. hexdag_plugins/storage/file/__init__.py +5 -0
  248. hexdag_plugins/storage/file/local.py +325 -0
  249. hexdag_plugins/storage/ports/__init__.py +5 -0
  250. hexdag_plugins/storage/ports/vector_store.py +236 -0
  251. hexdag_plugins/storage/sql/__init__.py +7 -0
  252. hexdag_plugins/storage/sql/base.py +187 -0
  253. hexdag_plugins/storage/sql/mysql.py +27 -0
  254. hexdag_plugins/storage/sql/postgresql.py +27 -0
  255. hexdag_plugins/storage/tests/__init__.py +1 -0
  256. hexdag_plugins/storage/tests/test_local_file_storage.py +161 -0
  257. hexdag_plugins/storage/tests/test_sql_adapters.py +212 -0
  258. hexdag_plugins/storage/vector/__init__.py +7 -0
  259. hexdag_plugins/storage/vector/chromadb.py +223 -0
  260. hexdag_plugins/storage/vector/in_memory.py +285 -0
  261. hexdag_plugins/storage/vector/pgvector.py +502 -0
hexdag/mcp_server.py ADDED
@@ -0,0 +1,3120 @@
1
+ """MCP (Model Context Protocol) server for hexDAG.
2
+
3
+ Exposes hexDAG functionality as MCP tools for LLM-powered editors like Claude Code and Cursor.
4
+ This server enables LLMs to:
5
+ - Discover available components by scanning builtin modules
6
+ - Build YAML pipelines with guided, structured approaches
7
+ - Validate pipeline configurations
8
+ - Generate pipeline templates
9
+
10
+ Components are discovered by scanning module contents (no registry needed).
11
+
12
+ Installation
13
+ ------------
14
+ uv add "hexdag[mcp]"
15
+
16
+ Usage
17
+ -----
18
+ Development mode::
19
+
20
+ uv run mcp dev hexdag/mcp_server.py
21
+
22
+ Install for Claude Desktop/Cursor::
23
+
24
+ uv run mcp install hexdag/mcp_server.py --name hexdag
25
+
26
+ Example Claude Desktop config::
27
+
28
+ {
29
+ "mcpServers": {
30
+ "hexdag": {
31
+ "command": "uv",
32
+ "args": ["run", "python", "-m", "hexdag.mcp_server"]
33
+ }
34
+ }
35
+ }
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import json
41
+ from enum import Enum
42
+ from pathlib import Path
43
+ from typing import Any
44
+
45
+ import yaml
46
+ from mcp.server.fastmcp import FastMCP
47
+
48
+ from hexdag.core.pipeline_builder import YamlPipelineBuilder
49
+ from hexdag.core.pipeline_builder.tag_discovery import discover_tags, get_tag_schema
50
+ from hexdag.core.resolver import ResolveError, resolve
51
+ from hexdag.core.schema import SchemaGenerator
52
+
53
+ # Generated documentation directory
54
+ _GENERATED_DOCS_DIR = Path(__file__).parent.parent / "docs" / "generated" / "mcp"
55
+
56
+
57
+ def _load_generated_doc(filename: str) -> str | None:
58
+ """Load generated documentation from file.
59
+
60
+ Parameters
61
+ ----------
62
+ filename : str
63
+ Name of the documentation file (e.g., "adapter_guide.md")
64
+
65
+ Returns
66
+ -------
67
+ str | None
68
+ File contents if exists, None otherwise
69
+ """
70
+ doc_path = _GENERATED_DOCS_DIR / filename
71
+ if doc_path.exists():
72
+ return doc_path.read_text()
73
+ return None
74
+
75
+
76
+ def _create_pipeline_base(
77
+ name: str,
78
+ description: str = "",
79
+ ports: dict[str, Any] | None = None,
80
+ ) -> dict[str, Any]:
81
+ """Create base pipeline configuration structure.
82
+
83
+ Centralizes pipeline structure creation to avoid duplication.
84
+
85
+ Parameters
86
+ ----------
87
+ name : str
88
+ Pipeline name (metadata.name)
89
+ description : str, optional
90
+ Pipeline description
91
+ ports : dict[str, Any] | None, optional
92
+ Port configurations (llm, memory, etc.)
93
+
94
+ Returns
95
+ -------
96
+ dict[str, Any]
97
+ Pipeline configuration dict ready for adding nodes
98
+ """
99
+ config: dict[str, Any] = {
100
+ "apiVersion": "hexdag/v1",
101
+ "kind": "Pipeline",
102
+ "metadata": {
103
+ "name": name,
104
+ },
105
+ "spec": {
106
+ "nodes": [],
107
+ },
108
+ }
109
+
110
+ if description:
111
+ config["metadata"]["description"] = description
112
+
113
+ if ports:
114
+ config["spec"]["ports"] = ports
115
+
116
+ return config
117
+
118
+
119
+ # Create MCP server
120
+ mcp = FastMCP(
121
+ "hexDAG",
122
+ dependencies=["pydantic>=2.0", "pyyaml>=6.0", "jinja2>=3.1.0"],
123
+ )
124
+
125
+
126
+ # ============================================================================
127
+ # Component Discovery (Module-based instead of registry)
128
+ # ============================================================================
129
+
130
+
131
+ def _discover_components_in_module(
132
+ module: Any, suffix: str | None = None, base_class: type | None = None
133
+ ) -> list[dict[str, Any]]:
134
+ """Discover components in a module by class name convention or base class.
135
+
136
+ Parameters
137
+ ----------
138
+ module : Any
139
+ Module to scan
140
+ suffix : str | None
141
+ Class name suffix to filter by (e.g., "Node", "Adapter")
142
+ base_class : type | None
143
+ Base class to filter by (alternative to suffix)
144
+
145
+ Returns
146
+ -------
147
+ list[dict[str, Any]]
148
+ List of component info dicts
149
+ """
150
+ result = []
151
+
152
+ for name in dir(module):
153
+ if name.startswith("_"):
154
+ continue
155
+
156
+ obj = getattr(module, name, None)
157
+ if obj is None or not isinstance(obj, type):
158
+ continue
159
+
160
+ # Filter by suffix or base class
161
+ matches = False
162
+ if suffix and name.endswith(suffix):
163
+ matches = True
164
+ if base_class and issubclass(obj, base_class) and obj is not base_class:
165
+ matches = True
166
+
167
+ if matches:
168
+ result.append({
169
+ "name": name,
170
+ "module": f"{module.__name__}.{name}",
171
+ "description": (obj.__doc__ or "").split("\n")[0].strip(),
172
+ })
173
+
174
+ return result
175
+
176
+
177
+ # ============================================================================
178
+ # Component Discovery Tools (Dynamic from Registry)
179
+ # ============================================================================
180
+
181
+
182
+ @mcp.tool() # type: ignore[misc]
183
+ def list_nodes() -> str:
184
+ """List all available node types with auto-generated documentation.
185
+
186
+ Returns detailed information about each node type including:
187
+ - Node name, namespace, and module path
188
+ - Description (from docstring)
189
+ - Parameters summary (from _yaml_schema if available)
190
+ - Required vs optional parameters
191
+
192
+ Returns
193
+ -------
194
+ JSON string with available nodes grouped by namespace
195
+
196
+ Examples
197
+ --------
198
+ >>> list_nodes() # doctest: +SKIP
199
+ {
200
+ "core": [
201
+ {
202
+ "name": "ConditionalNode",
203
+ "namespace": "core",
204
+ "module_path": "hexdag.builtin.nodes.ConditionalNode",
205
+ "description": "Multi-branch conditional router...",
206
+ "parameters": {
207
+ "required": ["branches"],
208
+ "optional": ["else_action", "tie_break"]
209
+ }
210
+ }
211
+ ]
212
+ }
213
+ """
214
+ from hexdag.builtin import nodes as builtin_nodes
215
+
216
+ nodes_by_namespace: dict[str, list[dict[str, Any]]] = {"core": []}
217
+
218
+ # Scan builtin nodes module
219
+ for name in dir(builtin_nodes):
220
+ if name.startswith("_"):
221
+ continue
222
+
223
+ obj = getattr(builtin_nodes, name, None)
224
+ if obj is None or not isinstance(obj, type):
225
+ continue
226
+
227
+ # Check if it's a node class (ends with Node or has BaseNodeFactory parent)
228
+ if not name.endswith("Node"):
229
+ continue
230
+
231
+ # Extract description from _yaml_schema or docstring
232
+ yaml_schema = getattr(obj, "_yaml_schema", None)
233
+ if yaml_schema and isinstance(yaml_schema, dict):
234
+ description = yaml_schema.get(
235
+ "description", (obj.__doc__ or "No description").split("\n")[0].strip()
236
+ )
237
+ # Extract parameter info from schema
238
+ properties = yaml_schema.get("properties", {})
239
+ required = yaml_schema.get("required", [])
240
+ optional = [k for k in properties if k not in required]
241
+ params_info = {"required": required, "optional": optional}
242
+ else:
243
+ description = (obj.__doc__ or "No description available").split("\n")[0].strip()
244
+ params_info = None
245
+
246
+ node_info: dict[str, Any] = {
247
+ "name": name,
248
+ "namespace": "core",
249
+ "module_path": f"hexdag.builtin.nodes.{name}",
250
+ "description": description,
251
+ }
252
+
253
+ if params_info:
254
+ node_info["parameters"] = params_info
255
+
256
+ nodes_by_namespace["core"].append(node_info)
257
+
258
+ return json.dumps(nodes_by_namespace, indent=2)
259
+
260
+
261
+ @mcp.tool() # type: ignore[misc]
262
+ def list_adapters(port_type: str | None = None) -> str:
263
+ """List all available adapters in the hexDAG registry.
264
+
265
+ Args
266
+ ----
267
+ port_type: Optional filter by port type (e.g., "llm", "memory", "database", "secret")
268
+
269
+ Returns
270
+ -------
271
+ JSON string with available adapters grouped by port type
272
+
273
+ Examples
274
+ --------
275
+ >>> list_adapters(port_type="llm") # doctest: +SKIP
276
+ {
277
+ "llm": [
278
+ {
279
+ "name": "openai",
280
+ "namespace": "core",
281
+ "port_type": "llm",
282
+ "description": "OpenAI LLM adapter"
283
+ }
284
+ ]
285
+ }
286
+ """
287
+ from hexdag.builtin import adapters as builtin_adapters
288
+
289
+ adapters_by_port: dict[str, list[dict[str, Any]]] = {}
290
+
291
+ # Scan builtin adapters module
292
+ for name in dir(builtin_adapters):
293
+ if name.startswith("_"):
294
+ continue
295
+
296
+ obj = getattr(builtin_adapters, name, None)
297
+ if obj is None or not isinstance(obj, type):
298
+ continue
299
+
300
+ # Check if it's an adapter class
301
+ if not name.endswith("Adapter"):
302
+ continue
303
+
304
+ # Guess port type from name
305
+ guessed_port = _guess_port_type_from_name(name)
306
+
307
+ # Filter by port type if specified
308
+ if port_type and guessed_port != port_type:
309
+ continue
310
+
311
+ if guessed_port not in adapters_by_port:
312
+ adapters_by_port[guessed_port] = []
313
+
314
+ adapter_info = {
315
+ "name": name,
316
+ "namespace": "core",
317
+ "module_path": f"hexdag.builtin.adapters.{name}",
318
+ "port_type": guessed_port,
319
+ "description": (obj.__doc__ or "No description available").split("\n")[0].strip(),
320
+ }
321
+
322
+ adapters_by_port[guessed_port].append(adapter_info)
323
+
324
+ return json.dumps(adapters_by_port, indent=2)
325
+
326
+
327
+ def _guess_port_type_from_name(adapter_name: str) -> str:
328
+ """Guess port type from adapter class name."""
329
+ name_lower = adapter_name.lower()
330
+ if "llm" in name_lower or "openai" in name_lower or "anthropic" in name_lower:
331
+ return "llm"
332
+ if "memory" in name_lower:
333
+ return "memory"
334
+ if "database" in name_lower or "sql" in name_lower:
335
+ return "database"
336
+ if "secret" in name_lower or "keyvault" in name_lower:
337
+ return "secret"
338
+ if "storage" in name_lower or "blob" in name_lower:
339
+ return "storage"
340
+ if "tool" in name_lower:
341
+ return "tool_router"
342
+ return "unknown"
343
+
344
+
345
+ @mcp.tool() # type: ignore[misc]
346
+ def list_tools(namespace: str | None = None) -> str:
347
+ """List all available tools in the hexDAG registry.
348
+
349
+ Args
350
+ ----
351
+ namespace: Optional filter by namespace (e.g., "core", "user", "plugin")
352
+
353
+ Returns
354
+ -------
355
+ JSON string with available tools and their schemas
356
+
357
+ Examples
358
+ --------
359
+ >>> list_tools(namespace="core") # doctest: +SKIP
360
+ {
361
+ "core": [
362
+ {
363
+ "name": "tool_end",
364
+ "namespace": "core",
365
+ "description": "End agent execution"
366
+ }
367
+ ]
368
+ }
369
+ """
370
+ from hexdag.builtin.tools import builtin_tools
371
+
372
+ tools_by_namespace: dict[str, list[dict[str, Any]]] = {"core": []}
373
+
374
+ # Filter by namespace if specified (only core namespace for builtin)
375
+ if namespace and namespace != "core":
376
+ return json.dumps(tools_by_namespace, indent=2)
377
+
378
+ # Scan builtin tools module
379
+ for name in dir(builtin_tools):
380
+ if name.startswith("_"):
381
+ continue
382
+
383
+ obj = getattr(builtin_tools, name, None)
384
+ if obj is None:
385
+ continue
386
+
387
+ # Check if it's a callable (function or class)
388
+ if not callable(obj):
389
+ continue
390
+
391
+ # Skip non-tool items
392
+ if name in ("Any", "TypeVar"):
393
+ continue
394
+
395
+ tool_info = {
396
+ "name": name,
397
+ "namespace": "core",
398
+ "module_path": f"hexdag.builtin.tools.{name}",
399
+ "description": (obj.__doc__ or "No description available").split("\n")[0].strip(),
400
+ }
401
+
402
+ tools_by_namespace["core"].append(tool_info)
403
+
404
+ return json.dumps(tools_by_namespace, indent=2)
405
+
406
+
407
+ @mcp.tool() # type: ignore[misc]
408
+ def list_macros() -> str:
409
+ """List all available macros in the hexDAG registry.
410
+
411
+ Macros are reusable pipeline templates that expand into subgraphs.
412
+
413
+ Returns
414
+ -------
415
+ JSON string with available macros and their descriptions
416
+
417
+ Examples
418
+ --------
419
+ >>> list_macros() # doctest: +SKIP
420
+ [
421
+ {
422
+ "name": "reasoning_agent",
423
+ "namespace": "core",
424
+ "description": "ReAct reasoning agent pattern"
425
+ }
426
+ ]
427
+ """
428
+ from hexdag.builtin import macros as builtin_macros
429
+
430
+ macros_list: list[dict[str, Any]] = []
431
+
432
+ # Scan builtin macros module
433
+ for name in dir(builtin_macros):
434
+ if name.startswith("_"):
435
+ continue
436
+
437
+ obj = getattr(builtin_macros, name, None)
438
+ if obj is None or not isinstance(obj, type):
439
+ continue
440
+
441
+ # Check if it's a macro class (ends with Macro or has ConfigurableMacro parent)
442
+ if not name.endswith("Macro"):
443
+ continue
444
+
445
+ macro_info = {
446
+ "name": name,
447
+ "namespace": "core",
448
+ "module_path": f"hexdag.builtin.macros.{name}",
449
+ "description": (obj.__doc__ or "No description available").split("\n")[0].strip(),
450
+ }
451
+ macros_list.append(macro_info)
452
+
453
+ return json.dumps(macros_list, indent=2)
454
+
455
+
456
+ @mcp.tool() # type: ignore[misc]
457
+ def list_tags() -> str:
458
+ """List all available YAML custom tags.
459
+
460
+ Returns detailed information about each tag including:
461
+ - Tag name (e.g., "!py", "!include")
462
+ - Description
463
+ - Module path
464
+ - Syntax examples
465
+ - Security warnings (if applicable)
466
+
467
+ Returns
468
+ -------
469
+ JSON string with available tags and their documentation
470
+
471
+ Examples
472
+ --------
473
+ >>> list_tags() # doctest: +SKIP
474
+ {
475
+ "!py": {
476
+ "name": "!py",
477
+ "description": "Compile inline Python code into callable functions",
478
+ "module": "hexdag.core.pipeline_builder.py_tag",
479
+ "syntax": ["!py | <python_code> # Inline Python code block"],
480
+ "is_registered": true,
481
+ "security_warning": "Executes arbitrary Python code..."
482
+ },
483
+ "!include": {
484
+ "name": "!include",
485
+ "description": "Include content from external YAML files",
486
+ "module": "hexdag.core.pipeline_builder.include_tag",
487
+ "syntax": ["!include ./path/to/file.yaml"],
488
+ "is_registered": true
489
+ }
490
+ }
491
+ """
492
+ tags = discover_tags()
493
+
494
+ # Format for MCP response - include essential info
495
+ result: dict[str, dict[str, Any]] = {}
496
+ for tag_name, tag_info in tags.items():
497
+ result[tag_name] = {
498
+ "name": tag_info["name"],
499
+ "description": tag_info["description"],
500
+ "module": tag_info["module"],
501
+ "syntax": tag_info["syntax"],
502
+ "is_registered": tag_info["is_registered"],
503
+ }
504
+
505
+ # Add security warning for !py
506
+ if tag_name == "!py":
507
+ result[tag_name]["security_warning"] = (
508
+ "Executes arbitrary Python code. Only use with trusted YAML files."
509
+ )
510
+
511
+ return json.dumps(result, indent=2)
512
+
513
+
514
+ @mcp.tool() # type: ignore[misc]
515
+ def get_component_schema(
516
+ component_type: str,
517
+ name: str,
518
+ namespace: str = "core",
519
+ ) -> str:
520
+ """Get detailed auto-generated schema for a specific component.
521
+
522
+ Auto-extracts documentation from:
523
+ - _yaml_schema class attribute (preferred, with full descriptions)
524
+ - __call__ method signature (fallback)
525
+ - Class/function docstrings
526
+
527
+ Args
528
+ ----
529
+ component_type: Type of component (node, adapter, tool, macro, policy, tag)
530
+ name: Component name (class name, full module path, or tag name like "!py")
531
+ namespace: Component namespace (default: "core") - ignored for tags
532
+
533
+ Returns
534
+ -------
535
+ JSON string with:
536
+ - schema: Full JSON schema with property descriptions
537
+ - parameters: Detailed parameter documentation
538
+ - yaml_example: Ready-to-use YAML example
539
+ - documentation: Full docstring
540
+
541
+ Examples
542
+ --------
543
+ >>> get_component_schema("node", "ConditionalNode", "core") # doctest: +SKIP
544
+ {
545
+ "name": "ConditionalNode",
546
+ "type": "node",
547
+ "schema": {...},
548
+ "parameters": [
549
+ {"name": "branches", "type": "array", "required": true, "description": "..."}
550
+ ],
551
+ "yaml_example": "..."
552
+ }
553
+
554
+ >>> get_component_schema("tag", "!py") # doctest: +SKIP
555
+ {
556
+ "name": "!py",
557
+ "type": "tag",
558
+ "description": "Compile inline Python code into callable functions",
559
+ "schema": {...},
560
+ "yaml_example": "body: !py |\\n async def process(...):\\n return item * 2"
561
+ }
562
+ """
563
+ # Handle tag component type
564
+ if component_type == "tag":
565
+ return _get_tag_schema_response(name)
566
+
567
+ try:
568
+ # If name is a full module path, resolve directly
569
+ if "." in name:
570
+ component_obj = resolve(name)
571
+ else:
572
+ # Try to resolve from builtin modules based on component type
573
+ module_map = {
574
+ "node": "hexdag.builtin.nodes",
575
+ "adapter": "hexdag.builtin.adapters",
576
+ "tool": "hexdag.builtin.tools",
577
+ "macro": "hexdag.builtin.macros",
578
+ "policy": "hexdag.builtin.policies",
579
+ }
580
+
581
+ base_module = module_map.get(component_type)
582
+ if not base_module:
583
+ raise ResolveError(name, f"Unknown component type: {component_type}")
584
+
585
+ # Try to resolve with full path
586
+ full_path = f"{base_module}.{name}"
587
+ component_obj = resolve(full_path)
588
+
589
+ # Check for explicit _yaml_schema (preferred - has full descriptions)
590
+ yaml_schema = getattr(component_obj, "_yaml_schema", None)
591
+
592
+ if yaml_schema and isinstance(yaml_schema, dict):
593
+ # Use explicit schema with full documentation
594
+ schema = yaml_schema
595
+
596
+ # Extract detailed parameter documentation
597
+ parameters = _extract_parameters_from_schema(yaml_schema)
598
+
599
+ # Generate rich YAML example from schema
600
+ yaml_example = _generate_yaml_example_from_schema(name, yaml_schema)
601
+ else:
602
+ # Fall back to signature introspection
603
+ if isinstance(component_obj, type):
604
+ try:
605
+ component_instance = component_obj()
606
+ schema = SchemaGenerator.from_callable(component_instance) # type: ignore[arg-type]
607
+ except TypeError:
608
+ schema = SchemaGenerator.from_callable(component_obj) # type: ignore[arg-type]
609
+ else:
610
+ schema = SchemaGenerator.from_callable(component_obj) # type: ignore[arg-type]
611
+
612
+ parameters = _extract_parameters_from_schema(schema) if isinstance(schema, dict) else []
613
+ yaml_example = (
614
+ SchemaGenerator.generate_example_yaml(name, schema)
615
+ if isinstance(schema, dict) and schema
616
+ else ""
617
+ )
618
+
619
+ # Extract documentation from docstring
620
+ doc = ""
621
+ if hasattr(component_obj, "__doc__") and component_obj.__doc__:
622
+ doc = component_obj.__doc__.strip()
623
+
624
+ result = {
625
+ "name": name,
626
+ "namespace": namespace,
627
+ "type": component_type,
628
+ "schema": schema,
629
+ "parameters": parameters,
630
+ "yaml_example": yaml_example,
631
+ "documentation": doc,
632
+ }
633
+
634
+ return json.dumps(result, indent=2)
635
+
636
+ except Exception as e:
637
+ return json.dumps(
638
+ {
639
+ "error": str(e),
640
+ "component_type": component_type,
641
+ "name": name,
642
+ "namespace": namespace,
643
+ },
644
+ indent=2,
645
+ )
646
+
647
+
648
+ def _get_tag_schema_response(name: str) -> str:
649
+ """Get schema information for a YAML custom tag.
650
+
651
+ Parameters
652
+ ----------
653
+ name : str
654
+ Tag name (e.g., "!py" or "!include")
655
+
656
+ Returns
657
+ -------
658
+ str
659
+ JSON string with tag schema information
660
+ """
661
+ try:
662
+ # Normalize tag name (add ! prefix if missing)
663
+ tag_name = name if name.startswith("!") else f"!{name}"
664
+
665
+ schema = get_tag_schema(tag_name)
666
+
667
+ # Generate YAML examples based on tag type
668
+ yaml_examples: dict[str, str] = {
669
+ "!py": """body: !py |
670
+ async def process(item, index, state, **ports):
671
+ '''Process an item.'''
672
+ return item * 2""",
673
+ "!include": """# Simple include:
674
+ nodes:
675
+ - !include ./shared/validation_nodes.yaml
676
+
677
+ # Include with variables:
678
+ nodes:
679
+ - !include
680
+ path: ./templates/processor.yaml
681
+ vars:
682
+ node_name: custom_processor
683
+ timeout: 30""",
684
+ }
685
+
686
+ result: dict[str, Any] = {
687
+ "name": schema["name"],
688
+ "type": "tag",
689
+ "namespace": "core",
690
+ "description": schema["description"],
691
+ "schema": schema.get("input_schema", {}),
692
+ "output": schema.get("output", {}),
693
+ "documentation": schema.get("documentation", ""),
694
+ "syntax": schema.get("syntax", []),
695
+ "yaml_example": yaml_examples.get(tag_name, ""),
696
+ }
697
+
698
+ # Add security warning if present
699
+ if "security_warning" in schema:
700
+ result["security_warning"] = schema["security_warning"]
701
+
702
+ return json.dumps(result, indent=2)
703
+
704
+ except ValueError as e:
705
+ return json.dumps(
706
+ {
707
+ "error": str(e),
708
+ "component_type": "tag",
709
+ "name": name,
710
+ },
711
+ indent=2,
712
+ )
713
+ except Exception as e:
714
+ return json.dumps(
715
+ {
716
+ "error": str(e),
717
+ "component_type": "tag",
718
+ "name": name,
719
+ },
720
+ indent=2,
721
+ )
722
+
723
+
724
+ def _extract_parameters_from_schema(schema: dict[str, Any]) -> list[dict[str, Any]]:
725
+ """Extract detailed parameter documentation from JSON schema.
726
+
727
+ Args
728
+ ----
729
+ schema: JSON schema dict with properties
730
+
731
+ Returns
732
+ -------
733
+ List of parameter dicts with name, type, required, default, description
734
+ """
735
+ parameters = []
736
+ properties = schema.get("properties", {})
737
+ required = set(schema.get("required", []))
738
+
739
+ for prop_name, prop_schema in properties.items():
740
+ param: dict[str, Any] = {
741
+ "name": prop_name,
742
+ "type": prop_schema.get("type", "any"),
743
+ "required": prop_name in required,
744
+ }
745
+
746
+ if "description" in prop_schema:
747
+ param["description"] = prop_schema["description"]
748
+
749
+ if "default" in prop_schema:
750
+ param["default"] = prop_schema["default"]
751
+
752
+ if "enum" in prop_schema:
753
+ param["allowed_values"] = prop_schema["enum"]
754
+
755
+ # Handle nested objects (like branch items)
756
+ if prop_schema.get("type") == "array" and "items" in prop_schema:
757
+ items_schema = prop_schema["items"]
758
+ if items_schema.get("type") == "object" and "properties" in items_schema:
759
+ param["item_properties"] = list(items_schema["properties"].keys())
760
+
761
+ parameters.append(param)
762
+
763
+ return parameters
764
+
765
+
766
+ def _generate_yaml_example_from_schema(node_name: str, schema: dict[str, Any]) -> str:
767
+ """Generate a rich YAML example from schema with comments.
768
+
769
+ Args
770
+ ----
771
+ node_name: Name of the node type
772
+ schema: JSON schema dict
773
+
774
+ Returns
775
+ -------
776
+ YAML string with example values and comments
777
+ """
778
+ properties = schema.get("properties", {})
779
+ required = set(schema.get("required", []))
780
+
781
+ # Build example spec
782
+ spec: dict[str, Any] = {}
783
+
784
+ for prop_name, prop_schema in properties.items():
785
+ prop_type = prop_schema.get("type", "string")
786
+
787
+ # Use default if available
788
+ if "default" in prop_schema:
789
+ if prop_name in required:
790
+ spec[prop_name] = prop_schema["default"]
791
+ continue # Skip optional with defaults
792
+
793
+ # Generate example value based on type
794
+ if prop_type == "string":
795
+ if "enum" in prop_schema:
796
+ spec[prop_name] = prop_schema["enum"][0]
797
+ else:
798
+ spec[prop_name] = f"<{prop_name}>"
799
+ elif prop_type == "integer":
800
+ spec[prop_name] = 0
801
+ elif prop_type == "number":
802
+ spec[prop_name] = 0.0
803
+ elif prop_type == "boolean":
804
+ spec[prop_name] = False
805
+ elif prop_type == "array":
806
+ items = prop_schema.get("items", {})
807
+ if items.get("type") == "object":
808
+ # Generate example array item
809
+ item_props = items.get("properties", {})
810
+ example_item = {}
811
+ for item_key, item_schema in item_props.items():
812
+ if item_schema.get("type") == "string":
813
+ example_item[item_key] = f"<{item_key}>"
814
+ else:
815
+ example_item[item_key] = f"<{item_key}>"
816
+ spec[prop_name] = [example_item]
817
+ else:
818
+ spec[prop_name] = []
819
+ elif prop_type == "object":
820
+ spec[prop_name] = {}
821
+ else:
822
+ spec[prop_name] = f"<{prop_name}>"
823
+
824
+ # Build full YAML structure
825
+ example = {
826
+ "kind": node_name.lower().replace("node", "_node") if "Node" in node_name else node_name,
827
+ "metadata": {"name": f"my_{node_name.lower().replace('node', '')}"},
828
+ "spec": spec,
829
+ "dependencies": [],
830
+ }
831
+
832
+ return yaml.dump(example, sort_keys=False, default_flow_style=False)
833
+
834
+
835
+ @mcp.tool() # type: ignore[misc]
836
+ def get_syntax_reference() -> str:
837
+ """Get reference for hexDAG YAML syntax including variable references.
838
+
839
+ Returns comprehensive documentation on:
840
+ - $input.field - Reference initial pipeline input
841
+ - {{node.output}} - Jinja2 template for node outputs
842
+ - ${ENV_VAR} - Environment variables
843
+ - input_mapping syntax and usage
844
+
845
+ Returns
846
+ -------
847
+ Detailed syntax reference documentation
848
+
849
+ Examples
850
+ --------
851
+ >>> get_syntax_reference() # doctest: +SKIP
852
+ # hexDAG Variable Reference Syntax
853
+ ...
854
+ """
855
+ # Try to load auto-generated documentation first
856
+ generated = _load_generated_doc("syntax_reference.md")
857
+ if generated:
858
+ return generated
859
+
860
+ # Fallback to static documentation
861
+ return """# hexDAG Variable Reference Syntax
862
+
863
+ ## 1. Initial Input Reference: $input
864
+
865
+ Use `$input.field` in `input_mapping` to access the original pipeline input.
866
+ This allows passing data from the initial request through multiple pipeline stages.
867
+
868
+ ```yaml
869
+ nodes:
870
+ - kind: function_node
871
+ metadata:
872
+ name: processor
873
+ spec:
874
+ fn: myapp.process
875
+ input_mapping:
876
+ load_id: $input.load_id # Gets initial input's load_id
877
+ carrier: $input.carrier_mc # Gets initial input's carrier_mc
878
+ dependencies: [extractor]
879
+ ```
880
+
881
+ **Key Points:**
882
+ - `$input` refers to the ENTIRE initial pipeline input
883
+ - `$input.field` extracts a specific field from initial input
884
+ - Works regardless of node dependencies
885
+ - Useful for passing request context through the pipeline
886
+
887
+ ## 2. Node Output Reference in Prompt Templates: {{node.field}}
888
+
889
+ Use Jinja2 syntax in prompt templates to reference previous node outputs.
890
+
891
+ ```yaml
892
+ - kind: llm_node
893
+ metadata:
894
+ name: analyzer
895
+ spec:
896
+ prompt_template: |
897
+ Analyze this data:
898
+ {{extractor.result}}
899
+
900
+ Previous analysis:
901
+ {{validator.summary}}
902
+ ```
903
+
904
+ **Key Points:**
905
+ - Double curly braces `{{}}` for Jinja2 templates
906
+ - `{{node_name.field}}` extracts field from named node's output
907
+ - Only available in `prompt_template` fields
908
+ - Resolved at runtime during LLM call
909
+
910
+ ## 3. Environment Variables: ${VAR}
911
+
912
+ Environment variables are resolved in two phases:
913
+
914
+ ### Non-Secrets (Build-time resolution)
915
+ ```yaml
916
+ spec:
917
+ ports:
918
+ llm:
919
+ config:
920
+ model: ${MODEL} # Resolved when YAML is parsed
921
+ timeout: ${TIMEOUT:30} # Default value if not set
922
+ ```
923
+
924
+ ### Secrets (Runtime resolution)
925
+ Secret-like variables are deferred to runtime for security:
926
+ ```yaml
927
+ spec:
928
+ ports:
929
+ llm:
930
+ config:
931
+ api_key: ${OPENAI_API_KEY} # Resolved when adapter is created
932
+ ```
933
+
934
+ **Secret Patterns (deferred to runtime):**
935
+ - `*_API_KEY` (e.g., OPENAI_API_KEY)
936
+ - `*_SECRET` (e.g., DB_SECRET)
937
+ - `*_TOKEN` (e.g., AUTH_TOKEN)
938
+ - `*_PASSWORD` (e.g., DB_PASSWORD)
939
+ - `*_CREDENTIAL` (e.g., SERVICE_CREDENTIAL)
940
+ - `SECRET_*` (e.g., SECRET_KEY)
941
+
942
+ **Default Values:**
943
+ - `${VAR:default}` - Use "default" if VAR is not set
944
+ - `${VAR:}` - Use empty string if VAR is not set
945
+
946
+ ## 4. Input Mapping
947
+
948
+ The `input_mapping` field transforms input data for a node:
949
+
950
+ ```yaml
951
+ - kind: function_node
952
+ metadata:
953
+ name: merger
954
+ spec:
955
+ fn: myapp.merge_results
956
+ input_mapping:
957
+ # From initial pipeline input
958
+ request_id: $input.id
959
+
960
+ # From specific dependency outputs
961
+ analysis: analyzer.result
962
+ validation_status: validator.is_valid
963
+
964
+ # Nested path extraction
965
+ score: analyzer.metadata.confidence_score
966
+ dependencies: [analyzer, validator]
967
+ ```
968
+
969
+ **Mapping Sources:**
970
+ - `$input.path` - Extract from initial pipeline input
971
+ - `$input` - Entire initial input
972
+ - `node_name.path` - Extract from specific node's output
973
+ - `field_name` - Extract from base input (single dependency case)
974
+
975
+ ## 5. Node Aliases
976
+
977
+ Define short aliases for node module paths:
978
+
979
+ ```yaml
980
+ spec:
981
+ aliases:
982
+ fn: hexdag.builtin.nodes.FunctionNode
983
+ my_processor: myapp.nodes.ProcessorNode
984
+ nodes:
985
+ - kind: fn # Uses alias!
986
+ metadata:
987
+ name: parser
988
+ spec:
989
+ fn: json.loads
990
+ ```
991
+
992
+ ## Quick Reference Table
993
+
994
+ | Syntax | Location | Purpose |
995
+ |--------|----------|---------|
996
+ | `$input.field` | input_mapping | Access initial pipeline input |
997
+ | `$input` | input_mapping | Entire initial input |
998
+ | `{{node.field}}` | prompt_template | Jinja2 template reference |
999
+ | `${VAR}` | Any string value | Environment variable |
1000
+ | `${VAR:default}` | Any string value | Env var with default |
1001
+ | `node.path` | input_mapping | Dependency output extraction |
1002
+ """
1003
+
1004
+
1005
+ @mcp.tool() # type: ignore[misc]
1006
+ def validate_yaml_pipeline_lenient(yaml_content: str) -> str:
1007
+ """Validate YAML pipeline structure without requiring environment variables.
1008
+
1009
+ Use this for CI/CD validation where secrets aren't available.
1010
+ This validates structure only, without instantiating adapters.
1011
+
1012
+ Validates:
1013
+ - YAML syntax
1014
+ - Node structure and dependencies
1015
+ - Port configuration format
1016
+ - Manifest format (apiVersion, kind, metadata, spec)
1017
+
1018
+ Does NOT validate:
1019
+ - Environment variable values
1020
+ - Adapter instantiation
1021
+ - Module path resolution
1022
+
1023
+ Args
1024
+ ----
1025
+ yaml_content: YAML pipeline configuration as a string
1026
+
1027
+ Returns
1028
+ -------
1029
+ JSON string with validation results (success/errors/warnings)
1030
+
1031
+ Examples
1032
+ --------
1033
+ >>> validate_yaml_pipeline_lenient(pipeline_yaml) # doctest: +SKIP
1034
+ {
1035
+ "valid": true,
1036
+ "message": "Pipeline structure is valid",
1037
+ "node_count": 3,
1038
+ "nodes": ["step1", "step2", "step3"],
1039
+ "warnings": []
1040
+ }
1041
+ """
1042
+ try:
1043
+ # Parse YAML
1044
+ parsed = yaml.safe_load(yaml_content)
1045
+
1046
+ if not isinstance(parsed, dict):
1047
+ return json.dumps(
1048
+ {
1049
+ "valid": False,
1050
+ "error": "YAML must be a dictionary",
1051
+ "error_type": "ParseError",
1052
+ },
1053
+ indent=2,
1054
+ )
1055
+
1056
+ warnings: list[str] = []
1057
+ nodes: list[str] = []
1058
+
1059
+ # Check manifest format
1060
+ if "kind" not in parsed:
1061
+ return json.dumps(
1062
+ {
1063
+ "valid": False,
1064
+ "error": "Missing 'kind' field. Use declarative manifest format.",
1065
+ "error_type": "ManifestError",
1066
+ },
1067
+ indent=2,
1068
+ )
1069
+
1070
+ if "metadata" not in parsed:
1071
+ warnings.append("Missing 'metadata' field")
1072
+
1073
+ if "spec" not in parsed:
1074
+ return json.dumps(
1075
+ {
1076
+ "valid": False,
1077
+ "error": "Missing 'spec' field",
1078
+ "error_type": "ManifestError",
1079
+ },
1080
+ indent=2,
1081
+ )
1082
+
1083
+ spec = parsed.get("spec", {})
1084
+
1085
+ # Check nodes
1086
+ nodes_list = spec.get("nodes", [])
1087
+ if not nodes_list:
1088
+ warnings.append("No nodes defined in pipeline")
1089
+
1090
+ node_ids = set()
1091
+ for i, node in enumerate(nodes_list):
1092
+ if not isinstance(node, dict):
1093
+ return json.dumps(
1094
+ {
1095
+ "valid": False,
1096
+ "error": f"Node {i} is not a dictionary",
1097
+ "error_type": "NodeError",
1098
+ },
1099
+ indent=2,
1100
+ )
1101
+
1102
+ metadata = node.get("metadata", {})
1103
+ node_id = metadata.get("name")
1104
+ if not node_id:
1105
+ warnings.append(f"Node {i} missing 'metadata.name'")
1106
+ node_id = f"unnamed_{i}"
1107
+
1108
+ if node_id in node_ids:
1109
+ return json.dumps(
1110
+ {
1111
+ "valid": False,
1112
+ "error": f"Duplicate node name: {node_id}",
1113
+ "error_type": "NodeError",
1114
+ },
1115
+ indent=2,
1116
+ )
1117
+
1118
+ node_ids.add(node_id)
1119
+ nodes.append(node_id)
1120
+
1121
+ # Check dependencies reference valid nodes
1122
+ deps = node.get("dependencies", [])
1123
+ all_node_names = node_ids | {n.get("metadata", {}).get("name") for n in nodes_list}
1124
+ warnings.extend(
1125
+ f"Node '{node_id}' depends on '{dep}' which may not exist"
1126
+ for dep in deps
1127
+ if dep not in all_node_names
1128
+ )
1129
+
1130
+ # Check ports structure
1131
+ ports = spec.get("ports", {})
1132
+ for port_name, port_config in ports.items():
1133
+ if not isinstance(port_config, dict):
1134
+ warnings.append(f"Port '{port_name}' config is not a dictionary")
1135
+ elif "adapter" not in port_config and "name" not in port_config:
1136
+ warnings.append(f"Port '{port_name}' missing 'adapter' field")
1137
+
1138
+ return json.dumps(
1139
+ {
1140
+ "valid": True,
1141
+ "message": "Pipeline structure is valid",
1142
+ "node_count": len(nodes),
1143
+ "nodes": nodes,
1144
+ "ports": list(ports.keys()),
1145
+ "warnings": warnings,
1146
+ },
1147
+ indent=2,
1148
+ )
1149
+
1150
+ except yaml.YAMLError as e:
1151
+ return json.dumps(
1152
+ {
1153
+ "valid": False,
1154
+ "error": f"YAML parse error: {e}",
1155
+ "error_type": "ParseError",
1156
+ },
1157
+ indent=2,
1158
+ )
1159
+ except Exception as e:
1160
+ return json.dumps(
1161
+ {
1162
+ "valid": False,
1163
+ "error": str(e),
1164
+ "error_type": type(e).__name__,
1165
+ },
1166
+ indent=2,
1167
+ )
1168
+
1169
+
1170
+ # ============================================================================
1171
+ # Helper Functions
1172
+ # ============================================================================
1173
+
1174
+
1175
+ def _normalize_for_yaml(obj: Any) -> Any:
1176
+ """Recursively convert enum values to strings for YAML serialization.
1177
+
1178
+ This ensures that enums are serialized using their .value attribute
1179
+ instead of their name, preventing validation errors when the YAML
1180
+ is loaded back.
1181
+
1182
+ Args
1183
+ ----
1184
+ obj: Object to normalize (can be dict, list, enum, or primitive)
1185
+
1186
+ Returns
1187
+ -------
1188
+ Normalized object with enums converted to their string values
1189
+
1190
+ Examples
1191
+ --------
1192
+ >>> from enum import Enum # doctest: +SKIP
1193
+ >>> class Format(str, Enum): # doctest: +SKIP
1194
+ ... MIXED = "mixed"
1195
+ >>> _normalize_for_yaml({"format": Format.MIXED}) # doctest: +SKIP
1196
+ {'format': 'mixed'}
1197
+ """
1198
+ if isinstance(obj, Enum):
1199
+ return obj.value
1200
+ if isinstance(obj, dict):
1201
+ return {k: _normalize_for_yaml(v) for k, v in obj.items()}
1202
+ if isinstance(obj, list):
1203
+ return [_normalize_for_yaml(item) for item in obj]
1204
+ return obj
1205
+
1206
+
1207
+ # ============================================================================
1208
+ # YAML Pipeline Building Tools
1209
+ # ============================================================================
1210
+
1211
+
1212
+ @mcp.tool() # type: ignore[misc]
1213
+ def validate_yaml_pipeline(yaml_content: str) -> str:
1214
+ """Validate a YAML pipeline configuration.
1215
+
1216
+ Args
1217
+ ----
1218
+ yaml_content: YAML pipeline configuration as a string
1219
+
1220
+ Returns
1221
+ -------
1222
+ JSON string with validation results (success/errors)
1223
+
1224
+ Examples
1225
+ --------
1226
+ >>> validate_yaml_pipeline(pipeline_yaml) # doctest: +SKIP
1227
+ {
1228
+ "valid": true,
1229
+ "message": "Pipeline is valid",
1230
+ "node_count": 3,
1231
+ "nodes": ["step1", "step2", "step3"]
1232
+ }
1233
+ """
1234
+ try:
1235
+ # Attempt to build the pipeline
1236
+ builder = YamlPipelineBuilder()
1237
+ graph, config = builder.build_from_yaml_string(yaml_content)
1238
+
1239
+ return json.dumps(
1240
+ {
1241
+ "valid": True,
1242
+ "message": "Pipeline is valid",
1243
+ "node_count": len(graph.nodes),
1244
+ "nodes": [node.name for node in graph.nodes.values()],
1245
+ "ports": list(config.ports.keys()) if config.ports else [],
1246
+ },
1247
+ indent=2,
1248
+ )
1249
+ except Exception as e:
1250
+ return json.dumps(
1251
+ {
1252
+ "valid": False,
1253
+ "error": str(e),
1254
+ "error_type": type(e).__name__,
1255
+ },
1256
+ indent=2,
1257
+ )
1258
+
1259
+
1260
+ @mcp.tool() # type: ignore[misc]
1261
+ def generate_pipeline_template(
1262
+ pipeline_name: str,
1263
+ description: str,
1264
+ node_types: list[str],
1265
+ ) -> str:
1266
+ """Generate a YAML pipeline template with specified node types.
1267
+
1268
+ Args
1269
+ ----
1270
+ pipeline_name: Name for the pipeline
1271
+ description: Pipeline description
1272
+ node_types: List of node types to include (e.g., ["llm_node", "agent_node"])
1273
+
1274
+ Returns
1275
+ -------
1276
+ YAML pipeline template as a string
1277
+
1278
+ Examples
1279
+ --------
1280
+ >>> generate_pipeline_template( # doctest: +SKIP
1281
+ ... "my-workflow",
1282
+ ... "Example workflow",
1283
+ ... ["llm_node", "function_node"]
1284
+ ... )
1285
+ apiVersion: hexdag/v1
1286
+ kind: Pipeline
1287
+ metadata:
1288
+ name: my-workflow
1289
+ description: Example workflow
1290
+ spec:
1291
+ nodes:
1292
+ - kind: llm_node
1293
+ metadata:
1294
+ name: llm_1
1295
+ spec:
1296
+ prompt_template: "Your prompt here: {{input}}"
1297
+ output_key: result
1298
+ dependencies: []
1299
+ """
1300
+ # Create basic pipeline structure using helper
1301
+ pipeline = _create_pipeline_base(pipeline_name, description)
1302
+
1303
+ # Add node templates
1304
+ for i, node_type in enumerate(node_types, 1):
1305
+ # Remove '_node' suffix if present for cleaner names
1306
+ node_name_base = node_type.replace("_node", "")
1307
+
1308
+ node_template = {
1309
+ "kind": node_type,
1310
+ "metadata": {
1311
+ "name": f"{node_name_base}_{i}",
1312
+ },
1313
+ "spec": _get_node_spec_template(node_type),
1314
+ "dependencies": [] if i == 1 else [f"{node_types[i - 2].replace('_node', '')}_{i - 1}"],
1315
+ }
1316
+ pipeline["spec"]["nodes"].append(node_template) # type: ignore[index]
1317
+
1318
+ # Normalize enums before serialization
1319
+ pipeline = _normalize_for_yaml(pipeline)
1320
+ return yaml.dump(pipeline, sort_keys=False, default_flow_style=False)
1321
+
1322
+
1323
+ def _get_node_spec_template(node_type: str) -> dict[str, Any]:
1324
+ """Get a spec template for a given node type.
1325
+
1326
+ Args
1327
+ ----
1328
+ node_type: Type of node (e.g., "llm_node", "agent_node")
1329
+
1330
+ Returns
1331
+ -------
1332
+ Dict with common spec fields for the node type
1333
+ """
1334
+ templates = {
1335
+ "llm_node": {
1336
+ "prompt_template": "Your prompt here: {{input}}",
1337
+ "output_key": "result",
1338
+ },
1339
+ "agent_node": {
1340
+ "initial_prompt_template": "Task: {{task}}",
1341
+ "max_steps": 5,
1342
+ "output_key": "agent_result",
1343
+ "tools": [],
1344
+ },
1345
+ "function_node": {
1346
+ "fn": "your_module.your_function",
1347
+ "input_schema": {"param": "str"},
1348
+ "output_schema": {"result": "str"},
1349
+ },
1350
+ "conditional_node": {
1351
+ "condition": "{{input}} > 0",
1352
+ "true_path": [],
1353
+ "false_path": [],
1354
+ },
1355
+ "loop_node": {
1356
+ "loop_variable": "item",
1357
+ "items": "{{input_list}}",
1358
+ "body": [],
1359
+ },
1360
+ }
1361
+
1362
+ return templates.get(node_type, {}) # type: ignore[return-value]
1363
+
1364
+
1365
+ @mcp.tool() # type: ignore[misc]
1366
+ def build_yaml_pipeline_interactive(
1367
+ pipeline_name: str,
1368
+ description: str,
1369
+ nodes: list[dict[str, Any]],
1370
+ ports: dict[str, dict[str, Any]] | None = None,
1371
+ ) -> str:
1372
+ """Build a complete YAML pipeline with full specifications.
1373
+
1374
+ This is the recommended tool for building complete pipelines with LLM assistance.
1375
+
1376
+ Args
1377
+ ----
1378
+ pipeline_name: Name for the pipeline
1379
+ description: Pipeline description
1380
+ nodes: List of node specifications with full config
1381
+ ports: Optional port configurations (llm, memory, database, etc.)
1382
+
1383
+ Returns
1384
+ -------
1385
+ Complete YAML pipeline configuration
1386
+
1387
+ Examples
1388
+ --------
1389
+ >>> build_yaml_pipeline_interactive( # doctest: +SKIP
1390
+ ... "analysis-pipeline",
1391
+ ... "Analyze documents",
1392
+ ... nodes=[
1393
+ ... {
1394
+ ... "kind": "llm_node",
1395
+ ... "name": "analyzer",
1396
+ ... "spec": {"prompt_template": "Analyze: {{input}}"},
1397
+ ... "dependencies": []
1398
+ ... }
1399
+ ... ],
1400
+ ... ports={
1401
+ ... "llm": {
1402
+ ... "adapter": "openai",
1403
+ ... "config": {"api_key": "${OPENAI_API_KEY}", "model": "gpt-4"}
1404
+ ... }
1405
+ ... }
1406
+ ... )
1407
+ apiVersion: hexdag/v1
1408
+ kind: Pipeline
1409
+ metadata:
1410
+ name: analysis-pipeline
1411
+ description: Analyze documents
1412
+ spec:
1413
+ ports:
1414
+ llm:
1415
+ adapter: openai
1416
+ config:
1417
+ api_key: ${OPENAI_API_KEY}
1418
+ model: gpt-4
1419
+ nodes:
1420
+ - kind: llm_node
1421
+ metadata:
1422
+ name: analyzer
1423
+ spec:
1424
+ prompt_template: "Analyze: {{input}}"
1425
+ dependencies: []
1426
+ """
1427
+ # Create pipeline using helper
1428
+ pipeline = _create_pipeline_base(pipeline_name, description, ports)
1429
+
1430
+ # Add nodes
1431
+ for node_def in nodes:
1432
+ node = {
1433
+ "kind": node_def["kind"],
1434
+ "metadata": {
1435
+ "name": node_def["name"],
1436
+ },
1437
+ "spec": node_def["spec"],
1438
+ "dependencies": node_def.get("dependencies", []),
1439
+ }
1440
+ pipeline["spec"]["nodes"].append(node) # type: ignore[index]
1441
+
1442
+ # Normalize enums before serialization
1443
+ pipeline = _normalize_for_yaml(pipeline)
1444
+ return yaml.dump(pipeline, sort_keys=False, default_flow_style=False)
1445
+
1446
+
1447
+ @mcp.tool() # type: ignore[misc]
1448
+ def create_environment_pipelines(
1449
+ pipeline_name: str,
1450
+ description: str,
1451
+ nodes: list[dict[str, Any]],
1452
+ dev_ports: dict[str, dict[str, Any]] | None = None,
1453
+ staging_ports: dict[str, dict[str, Any]] | None = None,
1454
+ prod_ports: dict[str, dict[str, Any]] | None = None,
1455
+ ) -> str:
1456
+ """Create dev, staging, and production YAML pipelines in one call.
1457
+
1458
+ This generates up to 3 YAML files with environment-specific port configurations:
1459
+ - dev: Uses mock adapters (no API keys)
1460
+ - staging: Uses real APIs with staging credentials
1461
+ - prod: Uses real APIs with production credentials
1462
+
1463
+ Args
1464
+ ----
1465
+ pipeline_name: Base name for pipelines (will be suffixed with -dev, -staging, -prod)
1466
+ description: Pipeline description
1467
+ nodes: Node specifications (shared across all environments)
1468
+ dev_ports: Port config for dev (defaults to mock adapters if not provided)
1469
+ staging_ports: Port config for staging (optional)
1470
+ prod_ports: Port config for production (optional)
1471
+
1472
+ Returns
1473
+ -------
1474
+ JSON string with separate YAML content for each environment
1475
+
1476
+ Examples
1477
+ --------
1478
+ >>> create_environment_pipelines( # doctest: +SKIP
1479
+ ... "research-agent",
1480
+ ... "Research agent",
1481
+ ... nodes=[{"kind": "macro_invocation", ...}],
1482
+ ... prod_ports={"llm": {"adapter": "core:openai", ...}}
1483
+ ... )
1484
+ {
1485
+ "dev": "apiVersion: hexdag/v1\\nkind: Pipeline...",
1486
+ "staging": "apiVersion: hexdag/v1\\nkind: Pipeline...",
1487
+ "prod": "apiVersion: hexdag/v1\\nkind: Pipeline..."
1488
+ }
1489
+ """
1490
+ result = {}
1491
+
1492
+ # Default dev ports to mock adapters
1493
+ if dev_ports is None:
1494
+ dev_ports = {
1495
+ "llm": {
1496
+ "adapter": "hexdag.builtin.adapters.mock.MockLLMAdapter",
1497
+ "config": {
1498
+ "responses": [
1499
+ "I'll search for information. INVOKE_TOOL: search(query='...')",
1500
+ "Let me gather more details. INVOKE_TOOL: search(query='...')",
1501
+ "Based on research, here are my findings: [Mock comprehensive answer]",
1502
+ ],
1503
+ "delay_seconds": 0.1,
1504
+ },
1505
+ },
1506
+ "tool_router": {
1507
+ "adapter": "hexdag.builtin.adapters.mock.MockToolRouterAdapter",
1508
+ "config": {"available_tools": ["search", "calculate"]},
1509
+ },
1510
+ }
1511
+
1512
+ # Helper to build pipeline for an environment
1513
+ def build_env_pipeline(env_name: str, env_suffix: str, env_ports: dict[str, Any]) -> str:
1514
+ pipeline = _create_pipeline_base(
1515
+ f"{pipeline_name}-{env_name}",
1516
+ f"{description} ({env_suffix})",
1517
+ env_ports,
1518
+ )
1519
+ for node_def in nodes:
1520
+ node = {
1521
+ "kind": node_def["kind"],
1522
+ "metadata": {"name": node_def["name"]},
1523
+ "spec": node_def["spec"],
1524
+ "dependencies": node_def.get("dependencies", []),
1525
+ }
1526
+ pipeline["spec"]["nodes"].append(node)
1527
+ pipeline = _normalize_for_yaml(pipeline)
1528
+ return yaml.dump(pipeline, sort_keys=False, default_flow_style=False)
1529
+
1530
+ # Build dev environment
1531
+ result["dev"] = build_env_pipeline("dev", "DEV - Mock Adapters", dev_ports)
1532
+
1533
+ # Build staging environment (if provided)
1534
+ if staging_ports:
1535
+ result["staging"] = build_env_pipeline("staging", "STAGING", staging_ports)
1536
+
1537
+ # Build production environment (if provided)
1538
+ if prod_ports:
1539
+ result["prod"] = build_env_pipeline("prod", "PRODUCTION", prod_ports)
1540
+
1541
+ return json.dumps(result, indent=2)
1542
+
1543
+
1544
+ @mcp.tool() # type: ignore[misc]
1545
+ def create_environment_pipelines_with_includes(
1546
+ pipeline_name: str,
1547
+ description: str,
1548
+ nodes: list[dict[str, Any]],
1549
+ dev_ports: dict[str, dict[str, Any]] | None = None,
1550
+ staging_ports: dict[str, dict[str, Any]] | None = None,
1551
+ prod_ports: dict[str, dict[str, Any]] | None = None,
1552
+ ) -> str:
1553
+ """Create base + environment-specific YAML files using the include pattern.
1554
+
1555
+ This generates a base YAML with shared nodes and separate environment configs
1556
+ that include the base file. This approach reduces duplication and makes it
1557
+ easier to maintain consistent logic across environments.
1558
+
1559
+ Generated files:
1560
+ - base.yaml: Shared node definitions
1561
+ - dev.yaml: Includes base + dev ports (mock adapters)
1562
+ - staging.yaml: Includes base + staging ports (optional)
1563
+ - prod.yaml: Includes base + prod ports (optional)
1564
+
1565
+ Args
1566
+ ----
1567
+ pipeline_name: Base name for pipelines
1568
+ description: Pipeline description
1569
+ nodes: Node specifications (shared across all environments)
1570
+ dev_ports: Port config for dev (defaults to mock adapters if not provided)
1571
+ staging_ports: Port config for staging (optional)
1572
+ prod_ports: Port config for production (optional)
1573
+
1574
+ Returns
1575
+ -------
1576
+ JSON string with base YAML and environment-specific includes
1577
+
1578
+ Examples
1579
+ --------
1580
+ >>> create_environment_pipelines_with_includes( # doctest: +SKIP
1581
+ ... "research-agent",
1582
+ ... "Research agent",
1583
+ ... nodes=[{"kind": "macro_invocation", ...}],
1584
+ ... prod_ports={"llm": {"adapter": "core:openai", ...}}
1585
+ ... )
1586
+ {
1587
+ "base": "apiVersion: hexdag/v1\\n...",
1588
+ "dev": "include: ./research_agent_base.yaml\\nports:\\n llm:\\n adapter: "
1589
+ "hexdag.builtin.adapters.mock.MockLLMAdapter",
1590
+ "prod": "include: ./research_agent_base.yaml\\nports:\\n llm:\\n adapter: ..."
1591
+ }
1592
+ """
1593
+ result = {}
1594
+
1595
+ # Default dev ports to mock adapters
1596
+ if dev_ports is None:
1597
+ dev_ports = {
1598
+ "llm": {
1599
+ "adapter": "hexdag.builtin.adapters.mock.MockLLMAdapter",
1600
+ "config": {
1601
+ "responses": [
1602
+ "I'll search for information. INVOKE_TOOL: search(query='...')",
1603
+ "Let me gather more details. INVOKE_TOOL: search(query='...')",
1604
+ "Based on research, here are my findings: [Mock comprehensive answer]",
1605
+ ],
1606
+ "delay_seconds": 0.1,
1607
+ },
1608
+ },
1609
+ "tool_router": {
1610
+ "adapter": "hexdag.builtin.adapters.mock.MockToolRouterAdapter",
1611
+ "config": {"available_tools": ["search", "calculate"]},
1612
+ },
1613
+ }
1614
+
1615
+ # Build base YAML (nodes only, no ports)
1616
+ base_pipeline = _create_pipeline_base(
1617
+ f"{pipeline_name}-base",
1618
+ f"{description} (Base Configuration)",
1619
+ )
1620
+ for node_def in nodes:
1621
+ node = {
1622
+ "kind": node_def["kind"],
1623
+ "metadata": {"name": node_def["name"]},
1624
+ "spec": node_def["spec"],
1625
+ "dependencies": node_def.get("dependencies", []),
1626
+ }
1627
+ base_pipeline["spec"]["nodes"].append(node)
1628
+ base_pipeline = _normalize_for_yaml(base_pipeline)
1629
+ result["base"] = yaml.dump(base_pipeline, sort_keys=False, default_flow_style=False)
1630
+
1631
+ # Helper to build environment include file
1632
+ def build_env_include(env_name: str, env_suffix: str, env_ports: dict[str, Any]) -> str:
1633
+ env_config = {
1634
+ "include": f"./{pipeline_name}_base.yaml",
1635
+ "metadata": {
1636
+ "name": f"{pipeline_name}-{env_name}",
1637
+ "description": f"{description} ({env_suffix})",
1638
+ },
1639
+ "ports": env_ports,
1640
+ }
1641
+ env_config = _normalize_for_yaml(env_config)
1642
+ return yaml.dump(env_config, sort_keys=False, default_flow_style=False)
1643
+
1644
+ # Build environment include files
1645
+ result["dev"] = build_env_include("dev", "DEV - Mock Adapters", dev_ports)
1646
+
1647
+ if staging_ports:
1648
+ result["staging"] = build_env_include("staging", "STAGING", staging_ports)
1649
+
1650
+ if prod_ports:
1651
+ result["prod"] = build_env_include("prod", "PRODUCTION", prod_ports)
1652
+
1653
+ return json.dumps(result, indent=2)
1654
+
1655
+
1656
+ @mcp.tool() # type: ignore[misc]
1657
+ def explain_yaml_structure() -> str:
1658
+ """Explain the structure of hexDAG YAML pipelines.
1659
+
1660
+ Returns
1661
+ -------
1662
+ Detailed explanation of YAML pipeline structure with examples
1663
+ """
1664
+ return """# hexDAG YAML Pipeline Structure
1665
+
1666
+ ## Basic Structure
1667
+
1668
+ ```yaml
1669
+ apiVersion: hexdag/v1
1670
+ kind: Pipeline
1671
+ metadata:
1672
+ name: pipeline-name
1673
+ description: What this pipeline does
1674
+ spec:
1675
+ ports: # Optional: Configure adapters
1676
+ llm:
1677
+ adapter: openai
1678
+ config:
1679
+ api_key: ${OPENAI_API_KEY}
1680
+ model: gpt-4
1681
+ nodes: # Required: Pipeline nodes
1682
+ - kind: llm_node
1683
+ metadata:
1684
+ name: node_name
1685
+ spec:
1686
+ prompt_template: "Your prompt: {{input}}"
1687
+ dependencies: []
1688
+ ```
1689
+
1690
+ ## Key Concepts
1691
+
1692
+ 1. **apiVersion**: Always "hexdag/v1"
1693
+ 2. **kind**: Always "Pipeline" (or "Macro" for macro definitions)
1694
+ 3. **metadata**: Pipeline name and description
1695
+ 4. **spec**: Pipeline specification
1696
+ - **ports**: Adapter configurations (llm, memory, database, secret)
1697
+ - **nodes**: List of processing nodes
1698
+
1699
+ ## Node Structure
1700
+
1701
+ Each node has:
1702
+ - **kind**: Node type (llm_node, agent_node, function_node, etc.)
1703
+ - **metadata.name**: Unique identifier for the node
1704
+ - **spec**: Node-specific configuration
1705
+ - **dependencies**: List of node names this node depends on
1706
+
1707
+ ## Available Node Types
1708
+
1709
+ - **llm_node**: LLM interactions with prompt templates
1710
+ - **agent_node**: ReAct agents with tool access
1711
+ - **function_node**: Execute Python functions
1712
+ - **conditional_node**: Conditional execution paths
1713
+ - **loop_node**: Iterative processing
1714
+
1715
+ ## Templating
1716
+
1717
+ Use Jinja2 syntax for dynamic values:
1718
+ - `{{variable}}` - Reference node outputs or inputs
1719
+ - `{{node_name.output_key}}` - Reference specific node outputs
1720
+ - `${ENV_VAR}` - Environment variables (resolved at build or runtime)
1721
+
1722
+ ## Port Configuration
1723
+
1724
+ ```yaml
1725
+ ports:
1726
+ llm:
1727
+ adapter: openai
1728
+ config:
1729
+ api_key: ${OPENAI_API_KEY}
1730
+ model: gpt-4
1731
+ memory:
1732
+ adapter: in_memory
1733
+ database:
1734
+ adapter: sqlite
1735
+ config:
1736
+ database_path: ./data.db
1737
+ ```
1738
+
1739
+ ## Dependencies
1740
+
1741
+ Dependencies define execution order:
1742
+ ```yaml
1743
+ nodes:
1744
+ - metadata:
1745
+ name: step1
1746
+ dependencies: [] # Runs first
1747
+
1748
+ - metadata:
1749
+ name: step2
1750
+ dependencies: [step1] # Runs after step1
1751
+
1752
+ - metadata:
1753
+ name: step3
1754
+ dependencies: [step1, step2] # Runs after both
1755
+ ```
1756
+
1757
+ ## Secret Handling
1758
+
1759
+ Secret-like environment variables are deferred to runtime:
1760
+ - `*_API_KEY`, `*_SECRET`, `*_TOKEN`, `*_PASSWORD`
1761
+ - Allows building pipelines without secrets present
1762
+ - Runtime injection via SecretPort → Memory
1763
+
1764
+ ## Best Practices
1765
+
1766
+ 1. Use descriptive node names
1767
+ 2. Add comprehensive descriptions
1768
+ 3. Leverage environment variables for secrets
1769
+ 4. Keep pipelines modular and reusable
1770
+ 5. Validate before execution using validate_yaml_pipeline()
1771
+ 6. Use macro_invocation for reusable patterns
1772
+ """
1773
+
1774
+
1775
+ @mcp.tool() # type: ignore[misc]
1776
+ def get_custom_adapter_guide() -> str:
1777
+ """Get a comprehensive guide for creating custom adapters.
1778
+
1779
+ Returns documentation on:
1780
+ - Creating adapters with the @adapter decorator
1781
+ - Secret handling with the secrets parameter
1782
+ - Using custom adapters in YAML pipelines
1783
+ - Testing patterns for adapters
1784
+
1785
+ Returns
1786
+ -------
1787
+ Detailed guide for creating custom adapters
1788
+
1789
+ Examples
1790
+ --------
1791
+ >>> get_custom_adapter_guide() # doctest: +SKIP
1792
+ # Creating Custom Adapters in hexDAG
1793
+ ...
1794
+ """
1795
+ # Try to load auto-generated documentation first
1796
+ generated = _load_generated_doc("adapter_guide.md")
1797
+ if generated:
1798
+ return generated
1799
+
1800
+ # Fallback to static documentation
1801
+ return '''# Creating Custom Adapters in hexDAG
1802
+
1803
+ ## Overview
1804
+
1805
+ hexDAG uses a decorator-based pattern for creating adapters. Adapters implement
1806
+ "ports" (interfaces) that connect your pipelines to external services like LLMs,
1807
+ databases, and APIs.
1808
+
1809
+ ## Quick Start
1810
+
1811
+ ### Simple Adapter (No Secrets)
1812
+
1813
+ ```python
1814
+ from hexdag.core.registry import adapter
1815
+
1816
+ @adapter("cache", name="memory_cache")
1817
+ class MemoryCacheAdapter:
1818
+ """Simple in-memory cache adapter."""
1819
+
1820
+ def __init__(self, max_size: int = 100, ttl: int = 3600):
1821
+ self.cache = {}
1822
+ self.max_size = max_size
1823
+ self.ttl = ttl
1824
+
1825
+ async def aget(self, key: str):
1826
+ return self.cache.get(key)
1827
+
1828
+ async def aset(self, key: str, value: any):
1829
+ self.cache[key] = value
1830
+ ```
1831
+
1832
+ ### Adapter with Secrets
1833
+
1834
+ ```python
1835
+ from hexdag.core.registry import adapter
1836
+
1837
+ @adapter("llm", name="openai", secrets={"api_key": "OPENAI_API_KEY"})
1838
+ class OpenAIAdapter:
1839
+ """OpenAI LLM adapter with automatic secret resolution."""
1840
+
1841
+ def __init__(
1842
+ self,
1843
+ api_key: str, # Auto-resolved from OPENAI_API_KEY env var
1844
+ model: str = "gpt-4",
1845
+ temperature: float = 0.7
1846
+ ):
1847
+ self.api_key = api_key
1848
+ self.model = model
1849
+ self.temperature = temperature
1850
+
1851
+ async def aresponse(self, messages: list) -> str:
1852
+ # Your OpenAI API implementation
1853
+ ...
1854
+ ```
1855
+
1856
+ ### Adapter with Multiple Secrets
1857
+
1858
+ ```python
1859
+ @adapter(
1860
+ "database",
1861
+ name="postgres",
1862
+ secrets={
1863
+ "username": "DB_USERNAME",
1864
+ "password": "DB_PASSWORD"
1865
+ }
1866
+ )
1867
+ class PostgresAdapter:
1868
+ def __init__(
1869
+ self,
1870
+ username: str, # From DB_USERNAME
1871
+ password: str, # From DB_PASSWORD
1872
+ host: str = "localhost",
1873
+ port: int = 5432,
1874
+ database: str = "mydb"
1875
+ ):
1876
+ self.connection_string = (
1877
+ f"postgresql://{username}:{password}@{host}:{port}/{database}"
1878
+ )
1879
+ ```
1880
+
1881
+ ## The @adapter Decorator
1882
+
1883
+ ### Parameters
1884
+
1885
+ | Parameter | Type | Description |
1886
+ |-----------|------|-------------|
1887
+ | `port_type` | str | Port this adapter implements ("llm", "memory", "database", etc.) |
1888
+ | `name` | str | Unique adapter name for registration |
1889
+ | `namespace` | str | Namespace (default: "plugin") |
1890
+ | `secrets` | dict | Map of param names to env var names |
1891
+
1892
+ ### Secret Resolution Order
1893
+
1894
+ Secrets are resolved in this order:
1895
+ 1. **Explicit kwargs** - Values passed directly to `__init__`
1896
+ 2. **Environment variables** - From the `secrets` mapping
1897
+ 3. **Memory port** - From orchestrator memory (with `secret:` prefix)
1898
+ 4. **Error** - If required and no default
1899
+
1900
+ ## Using Custom Adapters in YAML
1901
+
1902
+ ### Register Your Adapter
1903
+
1904
+ Your adapter module must be importable. Either:
1905
+ - Install as a package
1906
+ - Add to `PYTHONPATH`
1907
+ - Place in `hexdag_plugins/` directory
1908
+
1909
+ ### Reference in YAML Pipeline
1910
+
1911
+ ```yaml
1912
+ apiVersion: hexdag/v1
1913
+ kind: Pipeline
1914
+ metadata:
1915
+ name: my-pipeline
1916
+ spec:
1917
+ ports:
1918
+ llm:
1919
+ adapter: mycompany.adapters.CustomLLMAdapter
1920
+ config:
1921
+ api_key: ${MY_API_KEY}
1922
+ model: gpt-4-turbo
1923
+ temperature: 0.5
1924
+
1925
+ database:
1926
+ adapter: mycompany.adapters.PostgresAdapter
1927
+ config:
1928
+ host: ${DB_HOST}
1929
+ port: 5432
1930
+ database: production
1931
+
1932
+ nodes:
1933
+ - kind: llm_node
1934
+ metadata:
1935
+ name: analyzer
1936
+ spec:
1937
+ prompt_template: "Analyze: {{input}}"
1938
+ dependencies: []
1939
+ ```
1940
+
1941
+ ### Using Aliases for Cleaner YAML
1942
+
1943
+ ```yaml
1944
+ spec:
1945
+ aliases:
1946
+ my_llm: mycompany.adapters.CustomLLMAdapter
1947
+ my_db: mycompany.adapters.PostgresAdapter
1948
+
1949
+ ports:
1950
+ llm:
1951
+ adapter: my_llm # Uses alias!
1952
+ config:
1953
+ model: gpt-4
1954
+ ```
1955
+
1956
+ ## Plugin Directory Structure
1957
+
1958
+ For organized plugin development:
1959
+
1960
+ ```
1961
+ hexdag_plugins/
1962
+ └── my_adapter/
1963
+ ├── __init__.py
1964
+ ├── my_adapter.py # Adapter implementation
1965
+ ├── pyproject.toml # Dependencies
1966
+ └── tests/
1967
+ └── test_my_adapter.py
1968
+ ```
1969
+
1970
+ ### pyproject.toml for Plugin
1971
+
1972
+ ```toml
1973
+ [project]
1974
+ name = "hexdag-my-adapter"
1975
+ version = "0.1.0"
1976
+ dependencies = [
1977
+ "hexdag>=0.2.0",
1978
+ "httpx>=0.25.0", # Your adapter dependencies
1979
+ ]
1980
+
1981
+ [tool.hexdag]
1982
+ plugins = ["hexdag_plugins.my_adapter"]
1983
+ ```
1984
+
1985
+ ## Testing Your Adapter
1986
+
1987
+ ### Unit Test Pattern
1988
+
1989
+ ```python
1990
+ import pytest
1991
+ from mycompany.adapters import CustomLLMAdapter
1992
+
1993
+ @pytest.fixture
1994
+ def adapter():
1995
+ return CustomLLMAdapter(
1996
+ api_key="test-key",
1997
+ model="gpt-4",
1998
+ temperature=0.5
1999
+ )
2000
+
2001
+ @pytest.mark.asyncio
2002
+ async def test_adapter_response(adapter, mocker):
2003
+ # Mock external API call
2004
+ mock_response = mocker.patch.object(
2005
+ adapter, "_call_api",
2006
+ return_value="Test response"
2007
+ )
2008
+
2009
+ result = await adapter.aresponse([{"role": "user", "content": "Hello"}])
2010
+
2011
+ assert result == "Test response"
2012
+ mock_response.assert_called_once()
2013
+ ```
2014
+
2015
+ ### Integration Test with Mock
2016
+
2017
+ ```python
2018
+ from hexdag.builtin.adapters.mock import MockLLM
2019
+
2020
+ def test_pipeline_with_mock():
2021
+ """Test pipeline logic without real API calls."""
2022
+ mock_llm = MockLLM(responses=["Analysis complete", "Summary done"])
2023
+
2024
+ # Use mock_llm in your pipeline tests
2025
+ ```
2026
+
2027
+ ## Common Port Types
2028
+
2029
+ | Port | Purpose | Key Methods |
2030
+ |------|---------|-------------|
2031
+ | `llm` | Language models | `aresponse(messages) -> str` |
2032
+ | `memory` | Key-value storage | `aget(key)`, `aset(key, value)` |
2033
+ | `database` | SQL/NoSQL databases | `aexecute_query(sql, params)` |
2034
+ | `secret` | Secret management | `aget_secret(name)` |
2035
+ | `tool_router` | Tool execution | `acall_tool(name, args)` |
2036
+ | `observer_manager` | Event observation | `notify(event)` |
2037
+
2038
+ ## Best Practices
2039
+
2040
+ 1. **Async First**: Use `async def` for I/O operations
2041
+ 2. **Type Hints**: Add type annotations for better tooling
2042
+ 3. **Docstrings**: Document your adapter's purpose and config
2043
+ 4. **Error Handling**: Wrap external calls in try/except
2044
+ 5. **Logging**: Use `hexdag.core.logging.get_logger(__name__)`
2045
+ 6. **Secrets**: Never hardcode secrets; use the `secrets` parameter
2046
+
2047
+ ## CLI Commands for Plugin Development
2048
+
2049
+ ```bash
2050
+ # Create a new plugin
2051
+ hexdag plugin new my_adapter --port llm
2052
+
2053
+ # Lint and test
2054
+ hexdag plugin lint my_adapter
2055
+ hexdag plugin test my_adapter
2056
+
2057
+ # Install dependencies
2058
+ hexdag plugin install my_adapter
2059
+ ```
2060
+ '''
2061
+
2062
+
2063
+ @mcp.tool() # type: ignore[misc]
2064
+ def get_custom_node_guide() -> str:
2065
+ """Get a comprehensive guide for creating custom nodes.
2066
+
2067
+ Returns documentation on:
2068
+ - Creating nodes with the @node decorator
2069
+ - Node factory pattern
2070
+ - Input/output schemas
2071
+ - Using custom nodes in YAML pipelines
2072
+
2073
+ Returns
2074
+ -------
2075
+ Detailed guide for creating custom nodes
2076
+
2077
+ Examples
2078
+ --------
2079
+ >>> get_custom_node_guide() # doctest: +SKIP
2080
+ # Creating Custom Nodes in hexDAG
2081
+ ...
2082
+ """
2083
+ # Try to load auto-generated documentation first
2084
+ generated = _load_generated_doc("node_guide.md")
2085
+ if generated:
2086
+ return generated
2087
+
2088
+ # Fallback to static documentation
2089
+ return '''# Creating Custom Nodes in hexDAG
2090
+
2091
+ ## Overview
2092
+
2093
+ Nodes are the building blocks of hexDAG pipelines. Each node performs a specific
2094
+ task and can be connected to other nodes via dependencies.
2095
+
2096
+ ## Quick Start
2097
+
2098
+ ### Simple Function Node
2099
+
2100
+ The easiest way to create a custom node is using FunctionNode with your own function:
2101
+
2102
+ ```yaml
2103
+ # In YAML - reference any Python function by module path
2104
+ - kind: function_node
2105
+ metadata:
2106
+ name: my_processor
2107
+ spec:
2108
+ fn: mycompany.processors.process_data
2109
+ dependencies: []
2110
+ ```
2111
+
2112
+ ```python
2113
+ # mycompany/processors.py
2114
+ def process_data(input_data: dict) -> dict:
2115
+ """Your processing logic."""
2116
+ return {"result": input_data["value"] * 2}
2117
+ ```
2118
+
2119
+ ### Custom Node Class
2120
+
2121
+ For more complex logic, create a node class:
2122
+
2123
+ ```python
2124
+ from hexdag.core.registry import node
2125
+ from hexdag.builtin.nodes import BaseNodeFactory
2126
+ from hexdag.core.domain.dag import NodeSpec
2127
+
2128
+ @node(name="custom_processor", namespace="plugin")
2129
+ class CustomProcessorNode(BaseNodeFactory):
2130
+ """Custom node for specialized processing."""
2131
+
2132
+ def __init__(self):
2133
+ super().__init__()
2134
+
2135
+ def __call__(
2136
+ self,
2137
+ name: str,
2138
+ threshold: float = 0.5,
2139
+ mode: str = "standard",
2140
+ **kwargs
2141
+ ) -> NodeSpec:
2142
+ async def process_fn(input_data: dict) -> dict:
2143
+ # Your async processing logic
2144
+ if input_data.get("score", 0) > threshold:
2145
+ return {"status": "pass", "mode": mode}
2146
+ return {"status": "fail", "mode": mode}
2147
+
2148
+ return NodeSpec(
2149
+ name=name,
2150
+ fn=process_fn,
2151
+ deps=frozenset(kwargs.get("deps", [])),
2152
+ params={"threshold": threshold, "mode": mode},
2153
+ )
2154
+ ```
2155
+
2156
+ ## Using Custom Nodes in YAML
2157
+
2158
+ ### With Full Module Path
2159
+
2160
+ ```yaml
2161
+ apiVersion: hexdag/v1
2162
+ kind: Pipeline
2163
+ metadata:
2164
+ name: custom-pipeline
2165
+ spec:
2166
+ nodes:
2167
+ - kind: mycompany.nodes.CustomProcessorNode
2168
+ metadata:
2169
+ name: processor
2170
+ spec:
2171
+ threshold: 0.7
2172
+ mode: strict
2173
+ dependencies: []
2174
+ ```
2175
+
2176
+ ### With Aliases
2177
+
2178
+ ```yaml
2179
+ spec:
2180
+ aliases:
2181
+ processor: mycompany.nodes.CustomProcessorNode
2182
+
2183
+ nodes:
2184
+ - kind: processor # Uses alias!
2185
+ metadata:
2186
+ name: my_processor
2187
+ spec:
2188
+ threshold: 0.7
2189
+ dependencies: []
2190
+ ```
2191
+
2192
+ ## Node Factory Pattern
2193
+
2194
+ hexDAG nodes use the factory pattern:
2195
+
2196
+ ```python
2197
+ class MyNode(BaseNodeFactory):
2198
+ def __call__(self, name: str, **params) -> NodeSpec:
2199
+ # Factory method creates NodeSpec when called
2200
+ return NodeSpec(
2201
+ name=name,
2202
+ fn=self._create_function(**params),
2203
+ deps=frozenset(params.get("deps", [])),
2204
+ params=params,
2205
+ )
2206
+
2207
+ def _create_function(self, **params):
2208
+ async def node_function(input_data):
2209
+ # Actual processing logic
2210
+ return {"result": "processed"}
2211
+ return node_function
2212
+ ```
2213
+
2214
+ ## Input/Output Schemas
2215
+
2216
+ Define schemas for type validation:
2217
+
2218
+ ```python
2219
+ from pydantic import BaseModel
2220
+
2221
+ class ProcessorInput(BaseModel):
2222
+ text: str
2223
+ options: dict = {}
2224
+
2225
+ class ProcessorOutput(BaseModel):
2226
+ result: str
2227
+ confidence: float
2228
+
2229
+ @node(name="typed_processor", namespace="plugin")
2230
+ class TypedProcessorNode(BaseNodeFactory):
2231
+ def __call__(
2232
+ self,
2233
+ name: str,
2234
+ **kwargs
2235
+ ) -> NodeSpec:
2236
+ async def process_fn(input_data: ProcessorInput) -> ProcessorOutput:
2237
+ return ProcessorOutput(
2238
+ result=input_data.text.upper(),
2239
+ confidence=0.95
2240
+ )
2241
+
2242
+ return NodeSpec(
2243
+ name=name,
2244
+ fn=process_fn,
2245
+ in_model=ProcessorInput,
2246
+ out_model=ProcessorOutput,
2247
+ deps=frozenset(kwargs.get("deps", [])),
2248
+ )
2249
+ ```
2250
+
2251
+ ### In YAML
2252
+
2253
+ ```yaml
2254
+ - kind: mycompany.nodes.TypedProcessorNode
2255
+ metadata:
2256
+ name: processor
2257
+ spec:
2258
+ input_schema:
2259
+ text: str
2260
+ options: dict
2261
+ output_schema:
2262
+ result: str
2263
+ confidence: float
2264
+ dependencies: []
2265
+ ```
2266
+
2267
+ ## Builder Pattern Nodes
2268
+
2269
+ For complex configuration, use the builder pattern:
2270
+
2271
+ ```python
2272
+ @node(name="configurable_node", namespace="plugin")
2273
+ class ConfigurableNode(BaseNodeFactory):
2274
+ def __init__(self):
2275
+ super().__init__()
2276
+ self._name = None
2277
+ self._config = {}
2278
+ self._validators = []
2279
+
2280
+ def name(self, n: str) -> "ConfigurableNode":
2281
+ self._name = n
2282
+ return self
2283
+
2284
+ def config(self, **kwargs) -> "ConfigurableNode":
2285
+ self._config.update(kwargs)
2286
+ return self
2287
+
2288
+ def validate_with(self, validator) -> "ConfigurableNode":
2289
+ self._validators.append(validator)
2290
+ return self
2291
+
2292
+ def build(self) -> NodeSpec:
2293
+ async def process_fn(input_data):
2294
+ for validator in self._validators:
2295
+ input_data = validator(input_data)
2296
+ return {"processed": True, **self._config}
2297
+
2298
+ return NodeSpec(
2299
+ name=self._name,
2300
+ fn=process_fn,
2301
+ params=self._config,
2302
+ )
2303
+ ```
2304
+
2305
+ Usage:
2306
+ ```python
2307
+ node = (ConfigurableNode()
2308
+ .name("my_node")
2309
+ .config(threshold=0.5, mode="strict")
2310
+ .validate_with(my_validator)
2311
+ .build())
2312
+ ```
2313
+
2314
+ ## Providing YAML Schema
2315
+
2316
+ For MCP tools to show proper schemas, add `_yaml_schema`:
2317
+
2318
+ ```python
2319
+ @node(name="documented_node", namespace="plugin")
2320
+ class DocumentedNode(BaseNodeFactory):
2321
+ # Schema for MCP tools and documentation
2322
+ _yaml_schema = {
2323
+ "type": "object",
2324
+ "properties": {
2325
+ "threshold": {
2326
+ "type": "number",
2327
+ "description": "Processing threshold (0-1)",
2328
+ "default": 0.5
2329
+ },
2330
+ "mode": {
2331
+ "type": "string",
2332
+ "enum": ["standard", "strict", "lenient"],
2333
+ "description": "Processing mode"
2334
+ }
2335
+ },
2336
+ "required": ["mode"]
2337
+ }
2338
+
2339
+ def __call__(self, name: str, threshold: float = 0.5, mode: str = "standard"):
2340
+ ...
2341
+ ```
2342
+
2343
+ ## Best Practices
2344
+
2345
+ 1. **Async Functions**: Use `async def` for the node function
2346
+ 2. **Immutable**: Don't modify input_data; return new dict
2347
+ 3. **Type Hints**: Add types for better IDE support
2348
+ 4. **Docstrings**: Document purpose and parameters
2349
+ 5. **Small & Focused**: Each node should do one thing well
2350
+ 6. **Testable**: Design for easy unit testing
2351
+
2352
+ ## Testing Custom Nodes
2353
+
2354
+ ```python
2355
+ import pytest
2356
+ from mycompany.nodes import CustomProcessorNode
2357
+
2358
+ @pytest.mark.asyncio
2359
+ async def test_custom_processor():
2360
+ # Create node spec
2361
+ node_factory = CustomProcessorNode()
2362
+ node_spec = node_factory(name="test", threshold=0.5)
2363
+
2364
+ # Test the function
2365
+ result = await node_spec.fn({"score": 0.8})
2366
+
2367
+ assert result["status"] == "pass"
2368
+ ```
2369
+ '''
2370
+
2371
+
2372
+ @mcp.tool() # type: ignore[misc]
2373
+ def get_custom_tool_guide() -> str:
2374
+ """Get a guide for creating custom tools for agents.
2375
+
2376
+ Returns documentation on creating tools that agents can use
2377
+ during execution.
2378
+
2379
+ Returns
2380
+ -------
2381
+ Guide for creating custom tools
2382
+ """
2383
+ # Try to load auto-generated documentation first
2384
+ generated = _load_generated_doc("tool_guide.md")
2385
+ if generated:
2386
+ return generated
2387
+
2388
+ # Fallback to static documentation
2389
+ return '''# Creating Custom Tools for hexDAG Agents
2390
+
2391
+ ## Overview
2392
+
2393
+ Tools are functions that agents can invoke during execution. They enable
2394
+ agents to interact with external systems, perform calculations, or access data.
2395
+
2396
+ ## Quick Start
2397
+
2398
+ ### Simple Tool Function
2399
+
2400
+ ```python
2401
+ from hexdag.core.registry import tool
2402
+
2403
+ @tool(name="calculate", namespace="custom", description="Perform calculations")
2404
+ def calculate(expression: str) -> str:
2405
+ """Safely evaluate a mathematical expression.
2406
+
2407
+ Args:
2408
+ expression: Math expression like "2 + 2" or "sqrt(16)"
2409
+
2410
+ Returns:
2411
+ Result as a string
2412
+ """
2413
+ import ast
2414
+ import operator
2415
+
2416
+ # Safe evaluation (simplified example)
2417
+ result = eval(expression, {"__builtins__": {}}, {"sqrt": math.sqrt})
2418
+ return str(result)
2419
+ ```
2420
+
2421
+ ### Async Tool
2422
+
2423
+ ```python
2424
+ @tool(name="fetch_data", namespace="custom", description="Fetch data from API")
2425
+ async def fetch_data(url: str, timeout: int = 30) -> dict:
2426
+ """Fetch JSON data from a URL.
2427
+
2428
+ Args:
2429
+ url: API endpoint URL
2430
+ timeout: Request timeout in seconds
2431
+
2432
+ Returns:
2433
+ JSON response as dict
2434
+ """
2435
+ import httpx
2436
+
2437
+ async with httpx.AsyncClient() as client:
2438
+ response = await client.get(url, timeout=timeout)
2439
+ return response.json()
2440
+ ```
2441
+
2442
+ ## Tool Schema Generation
2443
+
2444
+ Tool schemas are auto-generated from:
2445
+ - Function signature (parameter types)
2446
+ - Docstring (descriptions)
2447
+ - Type hints (for validation)
2448
+
2449
+ ```python
2450
+ @tool(name="search", namespace="custom", description="Search documents")
2451
+ def search(
2452
+ query: str,
2453
+ limit: int = 10,
2454
+ filters: dict | None = None
2455
+ ) -> list[dict]:
2456
+ """Search for documents matching query.
2457
+
2458
+ Args:
2459
+ query: Search query string
2460
+ limit: Maximum results to return (default: 10)
2461
+ filters: Optional filters like {"category": "tech"}
2462
+
2463
+ Returns:
2464
+ List of matching documents
2465
+ """
2466
+ # Implementation
2467
+ ...
2468
+ ```
2469
+
2470
+ This generates schema:
2471
+ ```json
2472
+ {
2473
+ "name": "search",
2474
+ "description": "Search for documents matching query.",
2475
+ "parameters": {
2476
+ "type": "object",
2477
+ "properties": {
2478
+ "query": {"type": "string", "description": "Search query string"},
2479
+ "limit": {"type": "integer", "default": 10},
2480
+ "filters": {"type": "object", "nullable": true}
2481
+ },
2482
+ "required": ["query"]
2483
+ }
2484
+ }
2485
+ ```
2486
+
2487
+ ## Using Tools with Agents
2488
+
2489
+ ### In YAML Pipeline
2490
+
2491
+ ```yaml
2492
+ - kind: agent_node
2493
+ metadata:
2494
+ name: research_agent
2495
+ spec:
2496
+ initial_prompt_template: "Research: {{topic}}"
2497
+ max_steps: 5
2498
+ tools:
2499
+ - mycompany.tools.search
2500
+ - mycompany.tools.fetch_data
2501
+ - mycompany.tools.calculate
2502
+ dependencies: []
2503
+ ```
2504
+
2505
+ ### Tool Invocation Format
2506
+
2507
+ Agents invoke tools using this format in their output:
2508
+ ```
2509
+ INVOKE_TOOL: tool_name(param1="value", param2=123)
2510
+ ```
2511
+
2512
+ ## Built-in Tools
2513
+
2514
+ hexDAG provides these built-in tools:
2515
+
2516
+ | Tool | Description |
2517
+ |------|-------------|
2518
+ | `tool_end` | Signal agent completion |
2519
+ | `tool_noop` | No operation (thinking step) |
2520
+
2521
+ ## Best Practices
2522
+
2523
+ 1. **Type Hints**: Always add parameter and return types
2524
+ 2. **Docstrings**: Write clear descriptions for LLM understanding
2525
+ 3. **Error Handling**: Return error messages, don't raise exceptions
2526
+ 4. **Idempotent**: Tools should be safe to retry
2527
+ 5. **Async**: Use async for I/O operations
2528
+ 6. **Validation**: Validate inputs before processing
2529
+ '''
2530
+
2531
+
2532
+ @mcp.tool() # type: ignore[misc]
2533
+ def get_extension_guide(component_type: str | None = None) -> str:
2534
+ """Get a guide for extending hexDAG with custom components.
2535
+
2536
+ Args
2537
+ ----
2538
+ component_type: Optional specific type (adapter, node, tool, macro, policy)
2539
+ If not specified, returns overview of all extension points.
2540
+
2541
+ Returns
2542
+ -------
2543
+ Guide for the requested extension type or overview
2544
+
2545
+ Examples
2546
+ --------
2547
+ >>> get_extension_guide() # doctest: +SKIP
2548
+ # Extending hexDAG - Overview
2549
+ ...
2550
+ >>> get_extension_guide("adapter") # doctest: +SKIP
2551
+ # See get_custom_adapter_guide() for details
2552
+ """
2553
+ if component_type == "adapter":
2554
+ return "Use get_custom_adapter_guide() for detailed adapter documentation."
2555
+ if component_type == "node":
2556
+ return "Use get_custom_node_guide() for detailed node documentation."
2557
+ if component_type == "tool":
2558
+ return "Use get_custom_tool_guide() for detailed tool documentation."
2559
+
2560
+ # Try to load auto-generated documentation first
2561
+ generated = _load_generated_doc("extension_guide.md")
2562
+ if generated:
2563
+ return generated
2564
+
2565
+ # Fallback to static documentation
2566
+ return """# Extending hexDAG - Overview
2567
+
2568
+ ## Extension Points
2569
+
2570
+ hexDAG can be extended at multiple levels:
2571
+
2572
+ | Component | Purpose | Decorator |
2573
+ |-----------|---------|-----------|
2574
+ | **Adapter** | Connect to external services | `@adapter()` |
2575
+ | **Node** | Custom processing logic | `@node()` |
2576
+ | **Tool** | Agent-callable functions | `@tool()` |
2577
+ | **Macro** | Reusable pipeline patterns | `@macro()` |
2578
+ | **Policy** | Execution control rules | `@policy()` |
2579
+
2580
+ ## Quick Reference
2581
+
2582
+ ### Adapters
2583
+ ```python
2584
+ @adapter("llm", name="my_llm", secrets={"api_key": "MY_API_KEY"})
2585
+ class MyLLMAdapter:
2586
+ def __init__(self, api_key: str, model: str = "default"):
2587
+ ...
2588
+ ```
2589
+ → Use `get_custom_adapter_guide()` for full documentation
2590
+
2591
+ ### Nodes
2592
+ ```python
2593
+ @node(name="my_node", namespace="plugin")
2594
+ class MyNode(BaseNodeFactory):
2595
+ def __call__(self, name: str, **params) -> NodeSpec:
2596
+ ...
2597
+ ```
2598
+ → Use `get_custom_node_guide()` for full documentation
2599
+
2600
+ ### Tools
2601
+ ```python
2602
+ @tool(name="my_tool", namespace="plugin", description="Does something")
2603
+ def my_tool(param: str) -> str:
2604
+ ...
2605
+ ```
2606
+ → Use `get_custom_tool_guide()` for full documentation
2607
+
2608
+ ### Macros
2609
+ ```python
2610
+ @macro(name="my_pattern", namespace="plugin")
2611
+ class MyMacro(ConfigurableMacro):
2612
+ def expand(self, **params) -> list[NodeSpec]:
2613
+ # Return list of nodes that implement the pattern
2614
+ ...
2615
+ ```
2616
+
2617
+ ### Policies
2618
+ ```python
2619
+ @policy(name="my_policy", description="Custom retry logic")
2620
+ class MyPolicy:
2621
+ def __init__(self, max_retries: int = 3):
2622
+ ...
2623
+
2624
+ async def evaluate(self, context: PolicyContext) -> PolicyResponse:
2625
+ ...
2626
+ ```
2627
+
2628
+ ## Plugin Structure
2629
+
2630
+ Organize extensions in `hexdag_plugins/`:
2631
+
2632
+ ```
2633
+ hexdag_plugins/
2634
+ ├── my_adapter/
2635
+ │ ├── __init__.py
2636
+ │ ├── adapter.py
2637
+ │ ├── pyproject.toml
2638
+ │ └── tests/
2639
+ ├── my_nodes/
2640
+ │ ├── __init__.py
2641
+ │ ├── processor.py
2642
+ │ └── analyzer.py
2643
+ └── my_tools/
2644
+ ├── __init__.py
2645
+ └── search.py
2646
+ ```
2647
+
2648
+ ## Using Extensions in YAML
2649
+
2650
+ ```yaml
2651
+ apiVersion: hexdag/v1
2652
+ kind: Pipeline
2653
+ metadata:
2654
+ name: extended-pipeline
2655
+ spec:
2656
+ # Aliases for cleaner references
2657
+ aliases:
2658
+ my_processor: mycompany.nodes.ProcessorNode
2659
+ my_analyzer: mycompany.nodes.AnalyzerNode
2660
+
2661
+ # Custom adapters
2662
+ ports:
2663
+ llm:
2664
+ adapter: mycompany.adapters.CustomLLMAdapter
2665
+ config:
2666
+ api_key: ${MY_API_KEY}
2667
+
2668
+ # Custom nodes
2669
+ nodes:
2670
+ - kind: my_processor
2671
+ metadata:
2672
+ name: step1
2673
+ spec:
2674
+ mode: fast
2675
+ dependencies: []
2676
+
2677
+ - kind: agent_node
2678
+ metadata:
2679
+ name: agent
2680
+ spec:
2681
+ tools:
2682
+ - mycompany.tools.search # Custom tool
2683
+ - mycompany.tools.analyze
2684
+ dependencies: [step1]
2685
+ ```
2686
+
2687
+ ## MCP Tools for Development
2688
+
2689
+ Use these MCP tools when building pipelines:
2690
+
2691
+ | Tool | Purpose |
2692
+ |------|---------|
2693
+ | `list_nodes()` | See available nodes |
2694
+ | `list_adapters()` | See available adapters |
2695
+ | `get_component_schema()` | Get config schema |
2696
+ | `get_syntax_reference()` | Variable syntax help |
2697
+ | `validate_yaml_pipeline()` | Validate your YAML |
2698
+ | `get_custom_adapter_guide()` | Adapter creation guide |
2699
+ | `get_custom_node_guide()` | Node creation guide |
2700
+ | `get_custom_tool_guide()` | Tool creation guide |
2701
+ | `init_pipeline()` | Create new empty pipeline |
2702
+ | `add_node_to_pipeline()` | Add node to pipeline |
2703
+ | `remove_node_from_pipeline()` | Remove node from pipeline |
2704
+ | `update_node_config()` | Update node configuration |
2705
+ | `list_pipeline_nodes()` | List nodes with dependencies |
2706
+ """
2707
+
2708
+
2709
+ # ============================================================================
2710
+ # Pipeline Manipulation Tools
2711
+ # ============================================================================
2712
+
2713
+
2714
+ def _parse_pipeline_yaml(yaml_content: str) -> tuple[dict[str, Any], str | None]:
2715
+ """Parse pipeline YAML with error handling.
2716
+
2717
+ Parameters
2718
+ ----------
2719
+ yaml_content : str
2720
+ YAML content to parse
2721
+
2722
+ Returns
2723
+ -------
2724
+ tuple[dict[str, Any], str | None]
2725
+ Tuple of (parsed_config, error_message)
2726
+ If error_message is not None, parsed_config will be empty dict
2727
+ """
2728
+ try:
2729
+ config = yaml.safe_load(yaml_content)
2730
+ if not isinstance(config, dict):
2731
+ return {}, "YAML must be a dictionary/object"
2732
+ return config, None
2733
+ except yaml.YAMLError as e:
2734
+ return {}, f"YAML parse error: {e}"
2735
+
2736
+
2737
+ def _find_node_by_name(nodes: list[dict[str, Any]], name: str) -> int | None:
2738
+ """Find node index by metadata.name.
2739
+
2740
+ Parameters
2741
+ ----------
2742
+ nodes : list[dict[str, Any]]
2743
+ List of node configurations
2744
+ name : str
2745
+ Node name to find
2746
+
2747
+ Returns
2748
+ -------
2749
+ int | None
2750
+ Index of node if found, None otherwise
2751
+ """
2752
+ for i, node in enumerate(nodes):
2753
+ node_name = node.get("metadata", {}).get("name")
2754
+ if node_name == name:
2755
+ return i
2756
+ return None
2757
+
2758
+
2759
+ def _get_node_names(nodes: list[dict[str, Any]]) -> set[str]:
2760
+ """Get set of all node names.
2761
+
2762
+ Parameters
2763
+ ----------
2764
+ nodes : list[dict[str, Any]]
2765
+ List of node configurations
2766
+
2767
+ Returns
2768
+ -------
2769
+ set[str]
2770
+ Set of node names
2771
+ """
2772
+ names = set()
2773
+ for node in nodes:
2774
+ name = node.get("metadata", {}).get("name")
2775
+ if name:
2776
+ names.add(name)
2777
+ return names
2778
+
2779
+
2780
+ def _compute_execution_order(nodes: list[dict[str, Any]]) -> list[str]:
2781
+ """Compute topological execution order using Kahn's algorithm.
2782
+
2783
+ Parameters
2784
+ ----------
2785
+ nodes : list[dict[str, Any]]
2786
+ List of node configurations
2787
+
2788
+ Returns
2789
+ -------
2790
+ list[str]
2791
+ Node names in topological order
2792
+ """
2793
+ # Build adjacency list and in-degree count
2794
+ in_degree: dict[str, int] = {}
2795
+ graph: dict[str, list[str]] = {}
2796
+ all_nodes: set[str] = set()
2797
+
2798
+ for node in nodes:
2799
+ name = node.get("metadata", {}).get("name")
2800
+ if not name:
2801
+ continue
2802
+ all_nodes.add(name)
2803
+ in_degree.setdefault(name, 0)
2804
+ graph.setdefault(name, [])
2805
+
2806
+ deps = node.get("dependencies", [])
2807
+ for dep in deps:
2808
+ if dep in all_nodes or dep in in_degree:
2809
+ graph.setdefault(dep, []).append(name)
2810
+ in_degree[name] = in_degree.get(name, 0) + 1
2811
+
2812
+ # Kahn's algorithm
2813
+ queue = [n for n in all_nodes if in_degree.get(n, 0) == 0]
2814
+ result: list[str] = []
2815
+
2816
+ while queue:
2817
+ node = queue.pop(0)
2818
+ result.append(node)
2819
+ for neighbor in graph.get(node, []):
2820
+ in_degree[neighbor] -= 1
2821
+ if in_degree[neighbor] == 0:
2822
+ queue.append(neighbor)
2823
+
2824
+ return result
2825
+
2826
+
2827
+ @mcp.tool() # type: ignore[misc]
2828
+ def init_pipeline(name: str, description: str = "") -> str:
2829
+ """Create a new minimal pipeline YAML configuration.
2830
+
2831
+ Creates an empty pipeline structure ready for adding nodes.
2832
+
2833
+ Parameters
2834
+ ----------
2835
+ name : str
2836
+ Pipeline name (used in metadata.name)
2837
+ description : str, optional
2838
+ Pipeline description
2839
+
2840
+ Returns
2841
+ -------
2842
+ str
2843
+ JSON with {success: bool, yaml_content: str}
2844
+ """
2845
+ # Use centralized helper for pipeline creation
2846
+ config = _create_pipeline_base(name, description)
2847
+ yaml_content = yaml.dump(config, sort_keys=False, default_flow_style=False)
2848
+
2849
+ return json.dumps(
2850
+ {
2851
+ "success": True,
2852
+ "yaml_content": yaml_content,
2853
+ "message": f"Created empty pipeline '{name}'",
2854
+ },
2855
+ indent=2,
2856
+ )
2857
+
2858
+
2859
+ @mcp.tool() # type: ignore[misc]
2860
+ def add_node_to_pipeline(yaml_content: str, node_config: dict[str, Any]) -> str:
2861
+ """Add a node to an existing pipeline YAML.
2862
+
2863
+ Parameters
2864
+ ----------
2865
+ yaml_content : str
2866
+ Existing pipeline YAML as string
2867
+ node_config : dict[str, Any]
2868
+ Node configuration with keys:
2869
+ - kind: Node type (e.g., "llm_node", "function_node")
2870
+ - name: Unique node identifier
2871
+ - spec: Node-specific configuration dict
2872
+ - dependencies: Optional list of dependency node names
2873
+
2874
+ Returns
2875
+ -------
2876
+ str
2877
+ JSON with {success: bool, yaml_content: str, warnings: list, node_count: int}
2878
+ """
2879
+ config, error = _parse_pipeline_yaml(yaml_content)
2880
+ if error:
2881
+ return json.dumps({"success": False, "error": error}, indent=2)
2882
+
2883
+ # Validate required fields
2884
+ if "kind" not in node_config:
2885
+ return json.dumps(
2886
+ {"success": False, "error": "node_config must have 'kind' field"}, indent=2
2887
+ )
2888
+ if "name" not in node_config:
2889
+ return json.dumps(
2890
+ {"success": False, "error": "node_config must have 'name' field"}, indent=2
2891
+ )
2892
+
2893
+ # Get or create nodes list
2894
+ spec = config.setdefault("spec", {})
2895
+ nodes = spec.setdefault("nodes", [])
2896
+
2897
+ # Check for duplicate name
2898
+ existing_names = _get_node_names(nodes)
2899
+ node_name = node_config["name"]
2900
+ if node_name in existing_names:
2901
+ return json.dumps(
2902
+ {"success": False, "error": f"Node '{node_name}' already exists"}, indent=2
2903
+ )
2904
+
2905
+ # Build node structure
2906
+ new_node: dict[str, Any] = {
2907
+ "kind": node_config["kind"],
2908
+ "metadata": {"name": node_name},
2909
+ "spec": node_config.get("spec", {}),
2910
+ "dependencies": node_config.get("dependencies", []),
2911
+ }
2912
+
2913
+ # Check for missing dependencies (warn, don't fail)
2914
+ deps = node_config.get("dependencies", [])
2915
+ warnings: list[str] = [
2916
+ f"Dependency '{dep}' not found in pipeline" for dep in deps if dep not in existing_names
2917
+ ]
2918
+
2919
+ nodes.append(new_node)
2920
+
2921
+ yaml_output = yaml.dump(config, sort_keys=False, default_flow_style=False)
2922
+
2923
+ return json.dumps(
2924
+ {
2925
+ "success": True,
2926
+ "yaml_content": yaml_output,
2927
+ "node_count": len(nodes),
2928
+ "warnings": warnings if warnings else None,
2929
+ },
2930
+ indent=2,
2931
+ )
2932
+
2933
+
2934
+ @mcp.tool() # type: ignore[misc]
2935
+ def remove_node_from_pipeline(yaml_content: str, node_name: str) -> str:
2936
+ """Remove a node from a pipeline YAML.
2937
+
2938
+ Parameters
2939
+ ----------
2940
+ yaml_content : str
2941
+ Existing pipeline YAML as string
2942
+ node_name : str
2943
+ Name of the node to remove
2944
+
2945
+ Returns
2946
+ -------
2947
+ str
2948
+ JSON with {success: bool, yaml_content: str, warnings: list}
2949
+ Warns if other nodes depend on the removed node.
2950
+ """
2951
+ config, error = _parse_pipeline_yaml(yaml_content)
2952
+ if error:
2953
+ return json.dumps({"success": False, "error": error}, indent=2)
2954
+
2955
+ nodes = config.get("spec", {}).get("nodes", [])
2956
+
2957
+ # Find node index
2958
+ node_idx = _find_node_by_name(nodes, node_name)
2959
+ if node_idx is None:
2960
+ return json.dumps({"success": False, "error": f"Node '{node_name}' not found"}, indent=2)
2961
+
2962
+ # Check for dependents
2963
+ warnings: list[str] = []
2964
+ for node in nodes:
2965
+ deps = node.get("dependencies", [])
2966
+ if node_name in deps:
2967
+ dependent_name = node.get("metadata", {}).get("name", "unknown")
2968
+ warnings.append(f"Node '{dependent_name}' depends on '{node_name}'")
2969
+
2970
+ # Remove the node
2971
+ nodes.pop(node_idx)
2972
+
2973
+ yaml_output = yaml.dump(config, sort_keys=False, default_flow_style=False)
2974
+
2975
+ return json.dumps(
2976
+ {
2977
+ "success": True,
2978
+ "yaml_content": yaml_output,
2979
+ "node_count": len(nodes),
2980
+ "removed": True,
2981
+ "warnings": warnings if warnings else None,
2982
+ },
2983
+ indent=2,
2984
+ )
2985
+
2986
+
2987
+ @mcp.tool() # type: ignore[misc]
2988
+ def update_node_config(yaml_content: str, node_name: str, config_updates: dict[str, Any]) -> str:
2989
+ """Update a node's configuration in pipeline YAML.
2990
+
2991
+ Parameters
2992
+ ----------
2993
+ yaml_content : str
2994
+ Existing pipeline YAML as string
2995
+ node_name : str
2996
+ Name of the node to update
2997
+ config_updates : dict[str, Any]
2998
+ Updates to apply:
2999
+ - spec: Dict of spec fields to merge/update
3000
+ - dependencies: New dependencies list (replaces existing)
3001
+ - kind: New node type (use with caution)
3002
+
3003
+ Returns
3004
+ -------
3005
+ str
3006
+ JSON with {success: bool, yaml_content: str}
3007
+ """
3008
+ config, error = _parse_pipeline_yaml(yaml_content)
3009
+ if error:
3010
+ return json.dumps({"success": False, "error": error}, indent=2)
3011
+
3012
+ nodes = config.get("spec", {}).get("nodes", [])
3013
+
3014
+ # Find node index
3015
+ node_idx = _find_node_by_name(nodes, node_name)
3016
+ if node_idx is None:
3017
+ return json.dumps({"success": False, "error": f"Node '{node_name}' not found"}, indent=2)
3018
+
3019
+ node = nodes[node_idx]
3020
+ warnings: list[str] = []
3021
+
3022
+ # Apply updates
3023
+ if "spec" in config_updates:
3024
+ # Deep merge spec updates
3025
+ node_spec = node.setdefault("spec", {})
3026
+ for key, value in config_updates["spec"].items():
3027
+ node_spec[key] = value
3028
+
3029
+ if "dependencies" in config_updates:
3030
+ # Replace dependencies
3031
+ node["dependencies"] = config_updates["dependencies"]
3032
+
3033
+ if "kind" in config_updates:
3034
+ # Change node type (warn user)
3035
+ old_kind = node.get("kind")
3036
+ new_kind = config_updates["kind"]
3037
+ if old_kind != new_kind:
3038
+ warnings.append(f"Changed node type from '{old_kind}' to '{new_kind}'")
3039
+ node["kind"] = new_kind
3040
+
3041
+ yaml_output = yaml.dump(config, sort_keys=False, default_flow_style=False)
3042
+
3043
+ return json.dumps(
3044
+ {
3045
+ "success": True,
3046
+ "yaml_content": yaml_output,
3047
+ "warnings": warnings if warnings else None,
3048
+ },
3049
+ indent=2,
3050
+ )
3051
+
3052
+
3053
+ @mcp.tool() # type: ignore[misc]
3054
+ def list_pipeline_nodes(yaml_content: str) -> str:
3055
+ """List all nodes in a pipeline with their dependencies.
3056
+
3057
+ Parameters
3058
+ ----------
3059
+ yaml_content : str
3060
+ Pipeline YAML as string
3061
+
3062
+ Returns
3063
+ -------
3064
+ str
3065
+ JSON with:
3066
+ {
3067
+ success: bool,
3068
+ pipeline_name: str,
3069
+ node_count: int,
3070
+ nodes: [{name, kind, dependencies, dependents}],
3071
+ execution_order: [str]
3072
+ }
3073
+ """
3074
+ config, error = _parse_pipeline_yaml(yaml_content)
3075
+ if error:
3076
+ return json.dumps({"success": False, "error": error}, indent=2)
3077
+
3078
+ pipeline_name = config.get("metadata", {}).get("name", "unknown")
3079
+ nodes = config.get("spec", {}).get("nodes", [])
3080
+
3081
+ # Build node info with reverse dependencies
3082
+ node_infos: list[dict[str, Any]] = []
3083
+ all_names = _get_node_names(nodes)
3084
+
3085
+ # Build reverse dependency map
3086
+ dependents_map: dict[str, list[str]] = {name: [] for name in all_names}
3087
+ for node in nodes:
3088
+ node_name = node.get("metadata", {}).get("name")
3089
+ deps = node.get("dependencies", [])
3090
+ for dep in deps:
3091
+ if dep in dependents_map:
3092
+ dependents_map[dep].append(node_name)
3093
+
3094
+ for node in nodes:
3095
+ node_name = node.get("metadata", {}).get("name", "unknown")
3096
+ node_infos.append({
3097
+ "name": node_name,
3098
+ "kind": node.get("kind", "unknown"),
3099
+ "dependencies": node.get("dependencies", []),
3100
+ "dependents": dependents_map.get(node_name, []),
3101
+ })
3102
+
3103
+ # Compute execution order
3104
+ execution_order = _compute_execution_order(nodes)
3105
+
3106
+ return json.dumps(
3107
+ {
3108
+ "success": True,
3109
+ "pipeline_name": pipeline_name,
3110
+ "node_count": len(nodes),
3111
+ "nodes": node_infos,
3112
+ "execution_order": execution_order,
3113
+ },
3114
+ indent=2,
3115
+ )
3116
+
3117
+
3118
+ # Run the server
3119
+ if __name__ == "__main__":
3120
+ mcp.run()