atlas-chat 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (250) hide show
  1. atlas/__init__.py +40 -0
  2. atlas/application/__init__.py +7 -0
  3. atlas/application/chat/__init__.py +7 -0
  4. atlas/application/chat/agent/__init__.py +10 -0
  5. atlas/application/chat/agent/act_loop.py +179 -0
  6. atlas/application/chat/agent/factory.py +142 -0
  7. atlas/application/chat/agent/protocols.py +46 -0
  8. atlas/application/chat/agent/react_loop.py +338 -0
  9. atlas/application/chat/agent/think_act_loop.py +171 -0
  10. atlas/application/chat/approval_manager.py +151 -0
  11. atlas/application/chat/elicitation_manager.py +191 -0
  12. atlas/application/chat/events/__init__.py +1 -0
  13. atlas/application/chat/events/agent_event_relay.py +112 -0
  14. atlas/application/chat/modes/__init__.py +1 -0
  15. atlas/application/chat/modes/agent.py +125 -0
  16. atlas/application/chat/modes/plain.py +74 -0
  17. atlas/application/chat/modes/rag.py +81 -0
  18. atlas/application/chat/modes/tools.py +179 -0
  19. atlas/application/chat/orchestrator.py +213 -0
  20. atlas/application/chat/policies/__init__.py +1 -0
  21. atlas/application/chat/policies/tool_authorization.py +99 -0
  22. atlas/application/chat/preprocessors/__init__.py +1 -0
  23. atlas/application/chat/preprocessors/message_builder.py +92 -0
  24. atlas/application/chat/preprocessors/prompt_override_service.py +104 -0
  25. atlas/application/chat/service.py +454 -0
  26. atlas/application/chat/utilities/__init__.py +6 -0
  27. atlas/application/chat/utilities/error_handler.py +367 -0
  28. atlas/application/chat/utilities/event_notifier.py +546 -0
  29. atlas/application/chat/utilities/file_processor.py +613 -0
  30. atlas/application/chat/utilities/tool_executor.py +789 -0
  31. atlas/atlas_chat_cli.py +347 -0
  32. atlas/atlas_client.py +238 -0
  33. atlas/core/__init__.py +0 -0
  34. atlas/core/auth.py +205 -0
  35. atlas/core/authorization_manager.py +27 -0
  36. atlas/core/capabilities.py +123 -0
  37. atlas/core/compliance.py +215 -0
  38. atlas/core/domain_whitelist.py +147 -0
  39. atlas/core/domain_whitelist_middleware.py +82 -0
  40. atlas/core/http_client.py +28 -0
  41. atlas/core/log_sanitizer.py +102 -0
  42. atlas/core/metrics_logger.py +59 -0
  43. atlas/core/middleware.py +131 -0
  44. atlas/core/otel_config.py +242 -0
  45. atlas/core/prompt_risk.py +200 -0
  46. atlas/core/rate_limit.py +0 -0
  47. atlas/core/rate_limit_middleware.py +64 -0
  48. atlas/core/security_headers_middleware.py +51 -0
  49. atlas/domain/__init__.py +37 -0
  50. atlas/domain/chat/__init__.py +1 -0
  51. atlas/domain/chat/dtos.py +85 -0
  52. atlas/domain/errors.py +96 -0
  53. atlas/domain/messages/__init__.py +12 -0
  54. atlas/domain/messages/models.py +160 -0
  55. atlas/domain/rag_mcp_service.py +664 -0
  56. atlas/domain/sessions/__init__.py +7 -0
  57. atlas/domain/sessions/models.py +36 -0
  58. atlas/domain/unified_rag_service.py +371 -0
  59. atlas/infrastructure/__init__.py +10 -0
  60. atlas/infrastructure/app_factory.py +135 -0
  61. atlas/infrastructure/events/__init__.py +1 -0
  62. atlas/infrastructure/events/cli_event_publisher.py +140 -0
  63. atlas/infrastructure/events/websocket_publisher.py +140 -0
  64. atlas/infrastructure/sessions/in_memory_repository.py +56 -0
  65. atlas/infrastructure/transport/__init__.py +7 -0
  66. atlas/infrastructure/transport/websocket_connection_adapter.py +33 -0
  67. atlas/init_cli.py +226 -0
  68. atlas/interfaces/__init__.py +15 -0
  69. atlas/interfaces/events.py +134 -0
  70. atlas/interfaces/llm.py +54 -0
  71. atlas/interfaces/rag.py +40 -0
  72. atlas/interfaces/sessions.py +75 -0
  73. atlas/interfaces/tools.py +57 -0
  74. atlas/interfaces/transport.py +24 -0
  75. atlas/main.py +564 -0
  76. atlas/mcp/api_key_demo/README.md +76 -0
  77. atlas/mcp/api_key_demo/main.py +172 -0
  78. atlas/mcp/api_key_demo/run.sh +56 -0
  79. atlas/mcp/basictable/main.py +147 -0
  80. atlas/mcp/calculator/main.py +149 -0
  81. atlas/mcp/code-executor/execution_engine.py +98 -0
  82. atlas/mcp/code-executor/execution_environment.py +95 -0
  83. atlas/mcp/code-executor/main.py +528 -0
  84. atlas/mcp/code-executor/result_processing.py +276 -0
  85. atlas/mcp/code-executor/script_generation.py +195 -0
  86. atlas/mcp/code-executor/security_checker.py +140 -0
  87. atlas/mcp/corporate_cars/main.py +437 -0
  88. atlas/mcp/csv_reporter/main.py +545 -0
  89. atlas/mcp/duckduckgo/main.py +182 -0
  90. atlas/mcp/elicitation_demo/README.md +171 -0
  91. atlas/mcp/elicitation_demo/main.py +262 -0
  92. atlas/mcp/env-demo/README.md +158 -0
  93. atlas/mcp/env-demo/main.py +199 -0
  94. atlas/mcp/file_size_test/main.py +284 -0
  95. atlas/mcp/filesystem/main.py +348 -0
  96. atlas/mcp/image_demo/main.py +113 -0
  97. atlas/mcp/image_demo/requirements.txt +4 -0
  98. atlas/mcp/logging_demo/README.md +72 -0
  99. atlas/mcp/logging_demo/main.py +103 -0
  100. atlas/mcp/many_tools_demo/main.py +50 -0
  101. atlas/mcp/order_database/__init__.py +0 -0
  102. atlas/mcp/order_database/main.py +369 -0
  103. atlas/mcp/order_database/signal_data.csv +1001 -0
  104. atlas/mcp/pdfbasic/main.py +394 -0
  105. atlas/mcp/pptx_generator/main.py +760 -0
  106. atlas/mcp/pptx_generator/requirements.txt +13 -0
  107. atlas/mcp/pptx_generator/run_test.sh +1 -0
  108. atlas/mcp/pptx_generator/test_pptx_generator_security.py +169 -0
  109. atlas/mcp/progress_demo/main.py +167 -0
  110. atlas/mcp/progress_updates_demo/QUICKSTART.md +273 -0
  111. atlas/mcp/progress_updates_demo/README.md +120 -0
  112. atlas/mcp/progress_updates_demo/main.py +497 -0
  113. atlas/mcp/prompts/main.py +222 -0
  114. atlas/mcp/public_demo/main.py +189 -0
  115. atlas/mcp/sampling_demo/README.md +169 -0
  116. atlas/mcp/sampling_demo/main.py +234 -0
  117. atlas/mcp/thinking/main.py +77 -0
  118. atlas/mcp/tool_planner/main.py +240 -0
  119. atlas/mcp/ui-demo/badmesh.png +0 -0
  120. atlas/mcp/ui-demo/main.py +383 -0
  121. atlas/mcp/ui-demo/templates/button_demo.html +32 -0
  122. atlas/mcp/ui-demo/templates/data_visualization.html +32 -0
  123. atlas/mcp/ui-demo/templates/form_demo.html +28 -0
  124. atlas/mcp/username-override-demo/README.md +320 -0
  125. atlas/mcp/username-override-demo/main.py +308 -0
  126. atlas/modules/__init__.py +0 -0
  127. atlas/modules/config/__init__.py +34 -0
  128. atlas/modules/config/cli.py +231 -0
  129. atlas/modules/config/config_manager.py +1096 -0
  130. atlas/modules/file_storage/__init__.py +22 -0
  131. atlas/modules/file_storage/cli.py +330 -0
  132. atlas/modules/file_storage/content_extractor.py +290 -0
  133. atlas/modules/file_storage/manager.py +295 -0
  134. atlas/modules/file_storage/mock_s3_client.py +402 -0
  135. atlas/modules/file_storage/s3_client.py +417 -0
  136. atlas/modules/llm/__init__.py +19 -0
  137. atlas/modules/llm/caller.py +287 -0
  138. atlas/modules/llm/litellm_caller.py +675 -0
  139. atlas/modules/llm/models.py +19 -0
  140. atlas/modules/mcp_tools/__init__.py +17 -0
  141. atlas/modules/mcp_tools/client.py +2123 -0
  142. atlas/modules/mcp_tools/token_storage.py +556 -0
  143. atlas/modules/prompts/prompt_provider.py +130 -0
  144. atlas/modules/rag/__init__.py +24 -0
  145. atlas/modules/rag/atlas_rag_client.py +336 -0
  146. atlas/modules/rag/client.py +129 -0
  147. atlas/routes/admin_routes.py +865 -0
  148. atlas/routes/config_routes.py +484 -0
  149. atlas/routes/feedback_routes.py +361 -0
  150. atlas/routes/files_routes.py +274 -0
  151. atlas/routes/health_routes.py +40 -0
  152. atlas/routes/mcp_auth_routes.py +223 -0
  153. atlas/server_cli.py +164 -0
  154. atlas/tests/conftest.py +20 -0
  155. atlas/tests/integration/test_mcp_auth_integration.py +152 -0
  156. atlas/tests/manual_test_sampling.py +87 -0
  157. atlas/tests/modules/mcp_tools/test_client_auth.py +226 -0
  158. atlas/tests/modules/mcp_tools/test_client_env.py +191 -0
  159. atlas/tests/test_admin_mcp_server_management_routes.py +141 -0
  160. atlas/tests/test_agent_roa.py +135 -0
  161. atlas/tests/test_app_factory_smoke.py +47 -0
  162. atlas/tests/test_approval_manager.py +439 -0
  163. atlas/tests/test_atlas_client.py +188 -0
  164. atlas/tests/test_atlas_rag_client.py +447 -0
  165. atlas/tests/test_atlas_rag_integration.py +224 -0
  166. atlas/tests/test_attach_file_flow.py +287 -0
  167. atlas/tests/test_auth_utils.py +165 -0
  168. atlas/tests/test_backend_public_url.py +185 -0
  169. atlas/tests/test_banner_logging.py +287 -0
  170. atlas/tests/test_capability_tokens_and_injection.py +203 -0
  171. atlas/tests/test_compliance_level.py +54 -0
  172. atlas/tests/test_compliance_manager.py +253 -0
  173. atlas/tests/test_config_manager.py +617 -0
  174. atlas/tests/test_config_manager_paths.py +12 -0
  175. atlas/tests/test_core_auth.py +18 -0
  176. atlas/tests/test_core_utils.py +190 -0
  177. atlas/tests/test_docker_env_sync.py +202 -0
  178. atlas/tests/test_domain_errors.py +329 -0
  179. atlas/tests/test_domain_whitelist.py +359 -0
  180. atlas/tests/test_elicitation_manager.py +408 -0
  181. atlas/tests/test_elicitation_routing.py +296 -0
  182. atlas/tests/test_env_demo_server.py +88 -0
  183. atlas/tests/test_error_classification.py +113 -0
  184. atlas/tests/test_error_flow_integration.py +116 -0
  185. atlas/tests/test_feedback_routes.py +333 -0
  186. atlas/tests/test_file_content_extraction.py +1134 -0
  187. atlas/tests/test_file_extraction_routes.py +158 -0
  188. atlas/tests/test_file_library.py +107 -0
  189. atlas/tests/test_file_manager_unit.py +18 -0
  190. atlas/tests/test_health_route.py +49 -0
  191. atlas/tests/test_http_client_stub.py +8 -0
  192. atlas/tests/test_imports_smoke.py +30 -0
  193. atlas/tests/test_interfaces_llm_response.py +9 -0
  194. atlas/tests/test_issue_access_denied_fix.py +136 -0
  195. atlas/tests/test_llm_env_expansion.py +836 -0
  196. atlas/tests/test_log_level_sensitive_data.py +285 -0
  197. atlas/tests/test_mcp_auth_routes.py +341 -0
  198. atlas/tests/test_mcp_client_auth.py +331 -0
  199. atlas/tests/test_mcp_data_injection.py +270 -0
  200. atlas/tests/test_mcp_get_authorized_servers.py +95 -0
  201. atlas/tests/test_mcp_hot_reload.py +512 -0
  202. atlas/tests/test_mcp_image_content.py +424 -0
  203. atlas/tests/test_mcp_logging.py +172 -0
  204. atlas/tests/test_mcp_progress_updates.py +313 -0
  205. atlas/tests/test_mcp_prompt_override_system_prompt.py +102 -0
  206. atlas/tests/test_mcp_prompts_server.py +39 -0
  207. atlas/tests/test_mcp_tool_result_parsing.py +296 -0
  208. atlas/tests/test_metrics_logger.py +56 -0
  209. atlas/tests/test_middleware_auth.py +379 -0
  210. atlas/tests/test_prompt_risk_and_acl.py +141 -0
  211. atlas/tests/test_rag_mcp_aggregator.py +204 -0
  212. atlas/tests/test_rag_mcp_service.py +224 -0
  213. atlas/tests/test_rate_limit_middleware.py +45 -0
  214. atlas/tests/test_routes_config_smoke.py +60 -0
  215. atlas/tests/test_routes_files_download_token.py +41 -0
  216. atlas/tests/test_routes_files_health.py +18 -0
  217. atlas/tests/test_runtime_imports.py +53 -0
  218. atlas/tests/test_sampling_integration.py +482 -0
  219. atlas/tests/test_security_admin_routes.py +61 -0
  220. atlas/tests/test_security_capability_tokens.py +65 -0
  221. atlas/tests/test_security_file_stats_scope.py +21 -0
  222. atlas/tests/test_security_header_injection.py +191 -0
  223. atlas/tests/test_security_headers_and_filename.py +63 -0
  224. atlas/tests/test_shared_session_repository.py +101 -0
  225. atlas/tests/test_system_prompt_loading.py +181 -0
  226. atlas/tests/test_token_storage.py +505 -0
  227. atlas/tests/test_tool_approval_config.py +93 -0
  228. atlas/tests/test_tool_approval_utils.py +356 -0
  229. atlas/tests/test_tool_authorization_group_filtering.py +223 -0
  230. atlas/tests/test_tool_details_in_config.py +108 -0
  231. atlas/tests/test_tool_planner.py +300 -0
  232. atlas/tests/test_unified_rag_service.py +398 -0
  233. atlas/tests/test_username_override_in_approval.py +258 -0
  234. atlas/tests/test_websocket_auth_header.py +168 -0
  235. atlas/version.py +6 -0
  236. atlas_chat-0.1.0.data/data/.env.example +253 -0
  237. atlas_chat-0.1.0.data/data/config/defaults/compliance-levels.json +44 -0
  238. atlas_chat-0.1.0.data/data/config/defaults/domain-whitelist.json +123 -0
  239. atlas_chat-0.1.0.data/data/config/defaults/file-extractors.json +74 -0
  240. atlas_chat-0.1.0.data/data/config/defaults/help-config.json +198 -0
  241. atlas_chat-0.1.0.data/data/config/defaults/llmconfig-buggy.yml +11 -0
  242. atlas_chat-0.1.0.data/data/config/defaults/llmconfig.yml +19 -0
  243. atlas_chat-0.1.0.data/data/config/defaults/mcp.json +138 -0
  244. atlas_chat-0.1.0.data/data/config/defaults/rag-sources.json +17 -0
  245. atlas_chat-0.1.0.data/data/config/defaults/splash-config.json +16 -0
  246. atlas_chat-0.1.0.dist-info/METADATA +236 -0
  247. atlas_chat-0.1.0.dist-info/RECORD +250 -0
  248. atlas_chat-0.1.0.dist-info/WHEEL +5 -0
  249. atlas_chat-0.1.0.dist-info/entry_points.txt +4 -0
  250. atlas_chat-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,760 @@
1
+ """
2
+ PowerPoint Generator MCP Server using FastMCP.
3
+
4
+ Converts markdown content into a professional PowerPoint presentation with 16:9 aspect ratio.
5
+ Markdown headers (# or ##) become slide titles and content below each header becomes slide content.
6
+ Supports bullet point lists and optional image integration.
7
+
8
+ Tools:
9
+ - markdown_to_pptx: Converts markdown content to PowerPoint presentation
10
+
11
+ Demonstrates: Markdown parsing, file output with base64 encoding, and professional templating.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import base64
17
+ import html
18
+ import io
19
+ import logging
20
+ import os
21
+ import re
22
+ import tempfile
23
+ from pathlib import Path
24
+ from typing import Annotated, Any, Dict, List, Optional
25
+
26
+ import requests
27
+ from fastmcp import FastMCP
28
+ from PIL import Image
29
+ from pptx import Presentation
30
+ from pptx.dml.color import RGBColor
31
+ from pptx.enum.shapes import MSO_SHAPE
32
+ from pptx.enum.text import PP_ALIGN
33
+ from pptx.util import Inches, Pt
34
+
35
+ # Configuration
36
+ VERBOSE = True
37
+
38
+ # Configure logging first
39
+ logging.basicConfig(
40
+ level=logging.INFO,
41
+ format='%(asctime)s - PPTX_GENERATOR - %(name)s - %(levelname)s - %(message)s',
42
+ handlers=[
43
+ logging.StreamHandler()
44
+ ]
45
+ )
46
+ logger = logging.getLogger(__name__)
47
+
48
+ # Configure paths and add file handler if available
49
+ current_dir = Path(__file__).parent
50
+ logger.info(f"Current dir: {current_dir.absolute()}")
51
+ backend_dir = current_dir.parent.parent
52
+ logger.info(f"Backend dir: {backend_dir.absolute()}")
53
+ project_root = backend_dir.parent
54
+ logger.info(f"Project root: {project_root.absolute()}")
55
+ logs_dir = project_root / 'logs'
56
+ logger.info(f"Logs dir: {logs_dir.absolute()}")
57
+ main_log_path = logs_dir / 'app.jsonl'
58
+ logger.info(f"Log path: {main_log_path.absolute()}")
59
+ logger.info(f"Log path exists: {main_log_path.exists()}")
60
+ logger.info(f"Logs dir exists: {logs_dir.exists()}")
61
+
62
+ # Add file handler if log path exists
63
+ if main_log_path.exists():
64
+ file_handler = logging.FileHandler(main_log_path)
65
+ file_handler.setFormatter(logging.Formatter('%(asctime)s - PPTX_GENERATOR - %(name)s - %(levelname)s - %(message)s'))
66
+ logger.addHandler(file_handler)
67
+
68
+ mcp = FastMCP("pptx_generator")
69
+
70
+ # Sandia National Laboratories color scheme
71
+ SANDIA_BLUE = RGBColor(0, 51, 102) # Dark blue - primary brand color
72
+ SANDIA_LIGHT_BLUE = RGBColor(0, 102, 153) # Lighter blue for accents
73
+ SANDIA_RED = RGBColor(153, 0, 0) # Red accent
74
+ SANDIA_GRAY = RGBColor(102, 102, 102) # Gray for secondary text
75
+ SANDIA_WHITE = RGBColor(255, 255, 255)
76
+
77
+ # 16:9 slide dimensions (standard widescreen)
78
+ # Height is 7.5 inches, width is calculated for exact 16:9 ratio
79
+ SLIDE_HEIGHT = Inches(7.5)
80
+ SLIDE_WIDTH = Inches(7.5 * 16 / 9) # 16:9 aspect ratio = 13.333... inches
81
+
82
+ # Allowed base paths for local file access (security constraint)
83
+ # Only allow files relative to the current working directory
84
+ ALLOWED_BASE_PATH = Path(".").resolve()
85
+
86
+
87
+ def _escape_html(text: str) -> str:
88
+ """Escape HTML special characters to prevent XSS attacks."""
89
+ return html.escape(text, quote=True)
90
+
91
+
92
+ def _is_safe_local_path(filepath: str) -> bool:
93
+ """Check if a local file path is safe (within allowed base directory).
94
+
95
+ Prevents path traversal attacks by ensuring the resolved path
96
+ is within the allowed base directory.
97
+
98
+ Args:
99
+ filepath: The file path to validate
100
+
101
+ Returns:
102
+ True if the path is safe to access, False otherwise
103
+ """
104
+ if not filepath:
105
+ return False
106
+
107
+ try:
108
+ requested_path = Path(filepath)
109
+ if requested_path.is_absolute():
110
+ resolved_path = requested_path.resolve()
111
+ else:
112
+ resolved_path = (ALLOWED_BASE_PATH / requested_path).resolve()
113
+
114
+ # Ensure the path is within the allowed base directory
115
+ resolved_path.relative_to(ALLOWED_BASE_PATH)
116
+ return True
117
+ except (ValueError, OSError):
118
+ return False
119
+
120
+
121
+ def _calculate_indent_level(leading_spaces: int) -> int:
122
+ """Calculate bullet indent level from leading whitespace."""
123
+ return leading_spaces // 2 if leading_spaces >= 2 else 0
124
+
125
+
126
+ def _clean_markdown_text(text: str) -> str:
127
+ """Clean markdown formatting from text while preserving content."""
128
+ if not text:
129
+ return ""
130
+
131
+ # Remove bold markers (**text** or __text__)
132
+ text = re.sub(r'\*\*(.+?)\*\*', r'\1', text)
133
+ text = re.sub(r'__(.+?)__', r'\1', text)
134
+
135
+ # Remove italic markers (*text* or _text_) - be careful not to match bullet points
136
+ text = re.sub(r'(?<!\*)\*([^*\n]+?)\*(?!\*)', r'\1', text)
137
+ text = re.sub(r'(?<!_)_([^_\n]+?)_(?!_)', r'\1', text)
138
+
139
+ # Remove inline code markers (`text`)
140
+ text = re.sub(r'`([^`]+?)`', r'\1', text)
141
+
142
+ # Remove image syntax ![alt](url) - must come before link syntax
143
+ text = re.sub(r'!\[([^\]]*?)\]\([^)]+?\)', r'\1', text)
144
+
145
+ # Remove link syntax [text](url) - keep the text
146
+ text = re.sub(r'\[([^\]]+?)\]\([^)]+?\)', r'\1', text)
147
+
148
+ # Clean up any remaining markdown artifacts
149
+ text = re.sub(r'^\s*#{1,6}\s*', '', text) # Remove header markers at start of line
150
+
151
+ return text.strip()
152
+
153
+
154
+ def _apply_sandia_template(prs: Presentation) -> None:
155
+ """Apply Sandia-style template settings to the presentation."""
156
+ # Set 16:9 aspect ratio
157
+ prs.slide_width = SLIDE_WIDTH
158
+ prs.slide_height = SLIDE_HEIGHT
159
+
160
+
161
+ def _add_footer_bar(slide_obj, slide_num: int, total_slides: int) -> None:
162
+ """Add a professional footer bar to the slide."""
163
+ # Add footer bar at bottom
164
+ footer_height = Inches(0.4)
165
+ footer_top = SLIDE_HEIGHT - footer_height
166
+
167
+ # Add footer rectangle
168
+ footer_shape = slide_obj.shapes.add_shape(
169
+ MSO_SHAPE.RECTANGLE,
170
+ Inches(0), footer_top,
171
+ SLIDE_WIDTH, footer_height
172
+ )
173
+ footer_shape.fill.solid()
174
+ footer_shape.fill.fore_color.rgb = SANDIA_BLUE
175
+ footer_shape.line.fill.background()
176
+
177
+ # Add slide number text
178
+ slide_num_box = slide_obj.shapes.add_textbox(
179
+ SLIDE_WIDTH - Inches(1), footer_top + Inches(0.05),
180
+ Inches(0.9), Inches(0.3)
181
+ )
182
+ tf = slide_num_box.text_frame
183
+ tf.paragraphs[0].text = f"{slide_num}/{total_slides}"
184
+ tf.paragraphs[0].font.size = Pt(10)
185
+ tf.paragraphs[0].font.color.rgb = SANDIA_WHITE
186
+ tf.paragraphs[0].alignment = PP_ALIGN.RIGHT
187
+
188
+
189
+ def _add_header_bar(slide_obj) -> None:
190
+ """Add a professional header accent bar to the slide."""
191
+ # Add thin accent bar at top
192
+ header_height = Inches(0.1)
193
+ header_shape = slide_obj.shapes.add_shape(
194
+ MSO_SHAPE.RECTANGLE,
195
+ Inches(0), Inches(0),
196
+ SLIDE_WIDTH, header_height
197
+ )
198
+ header_shape.fill.solid()
199
+ header_shape.fill.fore_color.rgb = SANDIA_LIGHT_BLUE
200
+ header_shape.line.fill.background()
201
+
202
+
203
+ def _style_title(title_shape) -> None:
204
+ """Apply Sandia styling to title text."""
205
+ if title_shape and title_shape.has_text_frame:
206
+ for paragraph in title_shape.text_frame.paragraphs:
207
+ paragraph.font.color.rgb = SANDIA_BLUE
208
+ paragraph.font.bold = True
209
+ paragraph.font.size = Pt(36)
210
+
211
+ def _sanitize_filename(filename: str, max_length: int = 50) -> str:
212
+ """Sanitize filename by removing bad characters and truncating."""
213
+ # Remove bad characters (anything not alphanumeric, underscore, or dash)
214
+ cleaned_filename = re.sub(r'[^\w\-]', '', filename)
215
+ # Remove newlines and extra spaces
216
+ cleaned_filename = re.sub(r'\s+', '', cleaned_filename)
217
+ # Truncate to max length
218
+ return cleaned_filename[:max_length] if cleaned_filename else "presentation"
219
+
220
+ def _is_backend_download_path(s: str) -> bool:
221
+ """Detect backend-relative download paths like /api/files/download/...."""
222
+ return isinstance(s, str) and s.startswith("/api/files/download/")
223
+
224
+
225
+ def _backend_base_url() -> str:
226
+ """Resolve backend base URL from environment variable."""
227
+ return os.environ.get("CHATUI_BACKEND_BASE_URL", "http://127.0.0.1:8000")
228
+
229
+
230
+ def _load_image_bytes(filename: str, file_data_base64: str = "") -> Optional[bytes]:
231
+ """Load image data from filename or base64 data."""
232
+ if file_data_base64:
233
+ try:
234
+ return base64.b64decode(file_data_base64)
235
+ except Exception as e:
236
+ if VERBOSE:
237
+ logger.info(f"Error decoding base64 image data: {e}")
238
+ return None
239
+
240
+ if _is_backend_download_path(filename):
241
+ # Backend provided a download path
242
+ full_url = _backend_base_url() + filename
243
+ try:
244
+ if VERBOSE:
245
+ logger.info(f"Fetching image from {full_url}")
246
+ response = requests.get(full_url, timeout=30)
247
+ response.raise_for_status()
248
+ return response.content
249
+ except Exception as e:
250
+ if VERBOSE:
251
+ logger.info(f"Error fetching image from {full_url}: {e}")
252
+ return None
253
+
254
+ # Try as local file path - with path traversal protection
255
+ if _is_safe_local_path(filename) and os.path.isfile(filename):
256
+ try:
257
+ with open(filename, "rb") as f:
258
+ return f.read()
259
+ except Exception as e:
260
+ if VERBOSE:
261
+ logger.info(f"Error reading local image file {filename}: {e}")
262
+ return None
263
+ elif not _is_safe_local_path(filename):
264
+ if VERBOSE:
265
+ logger.warning(f"Blocked access to unsafe path: {filename}")
266
+ return None
267
+
268
+ if VERBOSE:
269
+ logger.info(f"Image file not found: {filename}")
270
+ return None
271
+
272
+
273
+ def _parse_markdown_slides(markdown_content: str) -> List[Dict[str, str]]:
274
+ """Parse markdown content into slides with improved bullet point handling."""
275
+ slides = []
276
+
277
+ # Split by headers (# or ##)
278
+ sections = re.split(r'^#{1,2}\s+(.+)$', markdown_content, flags=re.MULTILINE)
279
+
280
+ # Remove empty first element if exists
281
+ if sections and not sections[0].strip():
282
+ sections = sections[1:]
283
+
284
+ # Group into title/content pairs
285
+ for i in range(0, len(sections), 2):
286
+ if i + 1 < len(sections):
287
+ title = _clean_markdown_text(sections[i].strip())
288
+ content = sections[i + 1].strip() if i + 1 < len(sections) else ""
289
+ slides.append({"title": title, "content": content})
290
+ elif sections[i].strip():
291
+ # Handle case where there's a title but no content
292
+ slides.append({"title": _clean_markdown_text(sections[i].strip()), "content": ""})
293
+
294
+ # If no headers found, treat entire content as one slide
295
+ if not slides and markdown_content.strip():
296
+ slides.append({"title": "Slide 1", "content": markdown_content.strip()})
297
+
298
+ return slides
299
+
300
+
301
+ def _add_image_to_slide(slide_obj, image_bytes: bytes,
302
+ left: Inches = Inches(1), top: Inches = Inches(2),
303
+ width: Inches = Inches(10), height: Inches = Inches(5)):
304
+ """Add image to a slide with 16:9 optimized positioning."""
305
+ try:
306
+ # Create a temporary file for the image
307
+ with tempfile.NamedTemporaryFile(delete=False, suffix='.png') as tmp_file:
308
+ tmp_file.write(image_bytes)
309
+ tmp_file.flush()
310
+
311
+ # Add image to slide
312
+ pic = slide_obj.shapes.add_picture(tmp_file.name, left, top, width, height)
313
+
314
+ # Clean up
315
+ os.unlink(tmp_file.name)
316
+
317
+ return pic
318
+ except Exception as e:
319
+ logger.error(f"Error adding image to slide: {e}")
320
+ return None
321
+
322
+
323
+
324
+ @mcp.tool
325
+ def markdown_to_pptx(
326
+ markdown_content: Annotated[str, "Markdown content with headers (# or ##) as slide titles and content below each header"],
327
+ file_name: Annotated[Optional[str], "Output file name (base name for generated files without extension)"] = None,
328
+ image_filename: Annotated[Optional[str], "Optional image filename to integrate into the presentation"] = "",
329
+ image_data_base64: Annotated[Optional[str], "Framework may supply Base64 image content as fallback"] = ""
330
+ ) -> Dict[str, Any]:
331
+ """
332
+ Converts markdown content to a professional PowerPoint presentation with 16:9 aspect ratio.
333
+
334
+ Creates polished presentations with Sandia-style professional templating, proper bullet
335
+ point formatting, and clean markdown text handling.
336
+
337
+ Args:
338
+ markdown_content: Markdown content where headers (# or ##) become slide titles and content below becomes slide content
339
+ file_name: Output file name (base name for generated files without extension)
340
+ image_filename: Optional image filename to integrate into the presentation
341
+ image_data_base64: Framework may supply Base64 image content as fallback
342
+
343
+ Returns:
344
+ Dictionary with 'results' and 'artifacts' keys:
345
+ - 'results': Success message or error message
346
+ - 'artifacts': List of artifact dictionaries with 'name', 'b64', and 'mime' keys
347
+ """
348
+ if VERBOSE:
349
+ logger.info("Starting markdown_to_pptx execution...")
350
+ try:
351
+ # Handle None values and sanitize the output filename
352
+ image_filename = image_filename or ""
353
+ image_data_base64 = image_data_base64 or ""
354
+ # Use file_name if provided, otherwise use default "presentation"
355
+ output_filename = _sanitize_filename(file_name or "presentation")
356
+
357
+ # Parse markdown into slides
358
+ slides = _parse_markdown_slides(markdown_content)
359
+ if VERBOSE:
360
+ logger.info(f"Parsed {len(slides)} slides from markdown")
361
+
362
+ if not slides:
363
+ return {"results": {"error": "No slides could be parsed from markdown content"}}
364
+
365
+ total_slides = len(slides)
366
+
367
+ # Load image if provided
368
+ image_bytes = None
369
+ if image_filename:
370
+ image_bytes = _load_image_bytes(image_filename, image_data_base64)
371
+ if image_bytes:
372
+ if VERBOSE:
373
+ logger.info(f"Loaded image: {image_filename}")
374
+ else:
375
+ if VERBOSE:
376
+ logger.info(f"Failed to load image: {image_filename}")
377
+
378
+ # Create presentation with 16:9 aspect ratio
379
+ prs = Presentation()
380
+ _apply_sandia_template(prs)
381
+ if VERBOSE:
382
+ logger.info("Created PowerPoint presentation with 16:9 aspect ratio")
383
+
384
+ for i, slide_data in enumerate(slides):
385
+ title = slide_data.get('title', 'Untitled Slide')
386
+ content = slide_data.get('content', '')
387
+
388
+ # Add slide using blank layout for full control
389
+ slide_layout = prs.slide_layouts[6] # Blank layout
390
+ slide_obj = prs.slides.add_slide(slide_layout)
391
+
392
+ # Add header accent bar
393
+ _add_header_bar(slide_obj)
394
+
395
+ # Add title text box
396
+ title_box = slide_obj.shapes.add_textbox(
397
+ Inches(0.5), Inches(0.3),
398
+ SLIDE_WIDTH - Inches(1), Inches(0.8)
399
+ )
400
+ title_tf = title_box.text_frame
401
+ title_tf.word_wrap = True
402
+ title_p = title_tf.paragraphs[0]
403
+ title_p.text = title
404
+ title_p.font.size = Pt(36)
405
+ title_p.font.bold = True
406
+ title_p.font.color.rgb = SANDIA_BLUE
407
+ title_p.alignment = PP_ALIGN.LEFT
408
+
409
+ if VERBOSE:
410
+ logger.info(f"Added slide {i+1}: {title}")
411
+
412
+ # Add content text box
413
+ content_box = slide_obj.shapes.add_textbox(
414
+ Inches(0.5), Inches(1.3),
415
+ SLIDE_WIDTH - Inches(1), SLIDE_HEIGHT - Inches(2.0)
416
+ )
417
+ tf = content_box.text_frame
418
+ tf.word_wrap = True
419
+
420
+ # Process content - handle bullet points and regular text with improved cleanup
421
+ if content.strip():
422
+ lines = content.split('\n')
423
+ first_paragraph = True
424
+
425
+ for line in lines:
426
+ line = line.rstrip()
427
+
428
+ if not line.strip():
429
+ continue
430
+
431
+ # Calculate indentation level
432
+ indent_level = 0
433
+ stripped_line = line.lstrip()
434
+ leading_spaces = len(line) - len(stripped_line)
435
+
436
+ # Check for various bullet point formats
437
+ is_bullet = False
438
+ bullet_text = stripped_line
439
+
440
+ # Handle numbered lists (1. 2. etc.)
441
+ numbered_match = re.match(r'^(\d+)\.\s+(.+)$', stripped_line)
442
+ if numbered_match:
443
+ is_bullet = True
444
+ bullet_text = numbered_match.group(2)
445
+ indent_level = _calculate_indent_level(leading_spaces)
446
+ # Handle bullet points (-, *, +) with regex for proper text extraction
447
+ else:
448
+ bullet_match = re.match(r'^[-*+]\s+(.+)$', stripped_line)
449
+ if bullet_match:
450
+ is_bullet = True
451
+ bullet_text = bullet_match.group(1)
452
+ indent_level = _calculate_indent_level(leading_spaces)
453
+
454
+ # Clean the bullet text from markdown formatting
455
+ bullet_text = _clean_markdown_text(bullet_text.strip())
456
+
457
+ if not bullet_text:
458
+ continue
459
+
460
+ if first_paragraph:
461
+ p = tf.paragraphs[0]
462
+ first_paragraph = False
463
+ else:
464
+ p = tf.add_paragraph()
465
+
466
+ p.text = bullet_text
467
+ p.level = min(indent_level, 4) # Cap at level 4
468
+ p.font.size = Pt(20)
469
+ p.font.color.rgb = SANDIA_GRAY if indent_level > 0 else RGBColor(51, 51, 51)
470
+ p.space_after = Pt(8)
471
+ p.alignment = PP_ALIGN.LEFT
472
+
473
+ # Add footer bar with slide number
474
+ _add_footer_bar(slide_obj, i + 1, total_slides)
475
+
476
+ # Add image to first slide if provided
477
+ if i == 0 and image_bytes:
478
+ _add_image_to_slide(slide_obj, image_bytes,
479
+ left=Inches(8), top=Inches(2),
480
+ width=Inches(4.5), height=Inches(4))
481
+
482
+ # Write outputs to a temporary directory and clean up after encoding
483
+ with tempfile.TemporaryDirectory() as tmpdir:
484
+ # Save presentation
485
+ pptx_output_path = os.path.join(tmpdir, f"output_{output_filename}.pptx")
486
+ prs.save(pptx_output_path)
487
+ if VERBOSE:
488
+ logger.info(f"Saved PowerPoint presentation to {pptx_output_path}")
489
+
490
+ # Create HTML file instead of PDF
491
+ html_output_path = os.path.join(tmpdir, f"output_{output_filename}.html")
492
+ if VERBOSE:
493
+ logger.info(f"Starting HTML creation to {html_output_path}")
494
+
495
+ # Create HTML representation of the presentation with Sandia styling
496
+ html_content = """<!DOCTYPE html>
497
+ <html lang="en">
498
+ <head>
499
+ <meta charset="UTF-8">
500
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
501
+ <title>PowerPoint Presentation</title>
502
+ <style>
503
+ body { font-family: Arial, sans-serif; margin: 0; padding: 20px; background-color: #f0f0f0; }
504
+ .slide {
505
+ background: white;
506
+ margin: 20px auto;
507
+ padding: 0;
508
+ max-width: 960px;
509
+ aspect-ratio: 16/9;
510
+ box-shadow: 0 4px 12px rgba(0,0,0,0.15);
511
+ border-radius: 4px;
512
+ page-break-after: always;
513
+ position: relative;
514
+ overflow: hidden;
515
+ }
516
+ .header-bar {
517
+ background: #006699;
518
+ height: 8px;
519
+ width: 100%;
520
+ }
521
+ .footer-bar {
522
+ background: #003366;
523
+ height: 32px;
524
+ width: 100%;
525
+ position: absolute;
526
+ bottom: 0;
527
+ display: flex;
528
+ align-items: center;
529
+ justify-content: flex-end;
530
+ padding-right: 20px;
531
+ box-sizing: border-box;
532
+ }
533
+ .slide-number {
534
+ color: white;
535
+ font-size: 12px;
536
+ }
537
+ .slide-content-wrapper {
538
+ padding: 20px 40px;
539
+ }
540
+ .slide-title {
541
+ color: #003366;
542
+ font-size: 28px;
543
+ font-weight: bold;
544
+ margin-bottom: 20px;
545
+ }
546
+ .slide-content {
547
+ font-size: 16px;
548
+ line-height: 1.6;
549
+ color: #333;
550
+ }
551
+ .slide-content ul {
552
+ margin: 0;
553
+ padding-left: 30px;
554
+ list-style-type: disc;
555
+ }
556
+ .slide-content ul ul {
557
+ list-style-type: circle;
558
+ color: #666;
559
+ }
560
+ .slide-content li {
561
+ margin-bottom: 8px;
562
+ }
563
+ .slide-image {
564
+ max-width: 350px;
565
+ max-height: 250px;
566
+ display: block;
567
+ margin: 20px auto;
568
+ border-radius: 4px;
569
+ }
570
+ </style>
571
+ </head>
572
+ <body>"""
573
+
574
+ for i, slide_data in enumerate(slides):
575
+ title = slide_data.get('title', 'Untitled Slide')
576
+ content = slide_data.get('content', '')
577
+
578
+ # Escape HTML to prevent XSS attacks
579
+ safe_title = _escape_html(_clean_markdown_text(title))
580
+
581
+ html_content += f"""
582
+ <div class="slide">
583
+ <div class="header-bar"></div>
584
+ <div class="slide-content-wrapper">
585
+ <div class="slide-title">{safe_title}</div>
586
+ <div class="slide-content">"""
587
+
588
+ # Add image to first slide if provided
589
+ if i == 0 and image_bytes:
590
+ try:
591
+ img = Image.open(io.BytesIO(image_bytes))
592
+ mime_type = Image.MIME.get(img.format)
593
+ if mime_type:
594
+ img_b64 = base64.b64encode(image_bytes).decode('utf-8')
595
+ html_content += f'<img src="data:{mime_type};base64,{img_b64}" class="slide-image" />'
596
+ except Exception as e:
597
+ if VERBOSE:
598
+ logger.warning(f"Could not process image for HTML conversion: {e}")
599
+
600
+ if content.strip():
601
+ lines = content.split('\n')
602
+ in_list = False
603
+ current_indent = 0
604
+
605
+ for line in lines:
606
+ stripped = line.strip()
607
+ if not stripped:
608
+ continue
609
+
610
+ # Detect bullet points with various formats
611
+ is_bullet = False
612
+ bullet_text = stripped
613
+ indent = _calculate_indent_level(len(line) - len(line.lstrip()))
614
+
615
+ # Numbered list
616
+ numbered_match = re.match(r'^(\d+)\.\s+(.+)$', stripped)
617
+ if numbered_match:
618
+ is_bullet = True
619
+ bullet_text = numbered_match.group(2)
620
+ else:
621
+ # Handle bullet points (-, *, +) with regex
622
+ bullet_match = re.match(r'^[-*+]\s+(.+)$', stripped)
623
+ if bullet_match:
624
+ is_bullet = True
625
+ bullet_text = bullet_match.group(1)
626
+
627
+ # Clean markdown from text and escape HTML
628
+ bullet_text = _escape_html(_clean_markdown_text(bullet_text))
629
+
630
+ if is_bullet:
631
+ if not in_list:
632
+ html_content += "<ul>"
633
+ in_list = True
634
+ current_indent = indent
635
+
636
+ # Handle indent changes
637
+ while current_indent < indent:
638
+ html_content += "<ul>"
639
+ current_indent += 1
640
+ while current_indent > indent:
641
+ html_content += "</ul>"
642
+ current_indent -= 1
643
+
644
+ html_content += f"<li>{bullet_text}</li>"
645
+ else:
646
+ # Close all open lists
647
+ while current_indent > 0:
648
+ html_content += "</ul>"
649
+ current_indent -= 1
650
+ if in_list:
651
+ html_content += "</ul>"
652
+ in_list = False
653
+ # Escape HTML in paragraph text to prevent XSS
654
+ safe_paragraph = _escape_html(_clean_markdown_text(stripped))
655
+ html_content += f"<p>{safe_paragraph}</p>"
656
+
657
+ # Close any remaining open lists
658
+ while current_indent > 0:
659
+ html_content += "</ul>"
660
+ current_indent -= 1
661
+ if in_list:
662
+ html_content += "</ul>"
663
+
664
+ html_content += f"""
665
+ </div>
666
+ </div>
667
+ <div class="footer-bar">
668
+ <span class="slide-number">{i+1}/{total_slides}</span>
669
+ </div>
670
+ </div>"""
671
+
672
+ html_content += """
673
+ </body>
674
+ </html>"""
675
+
676
+ # Save HTML file
677
+ try:
678
+ if VERBOSE:
679
+ logger.info("Saving HTML file...")
680
+ with open(html_output_path, "w", encoding="utf-8") as f:
681
+ f.write(html_content)
682
+ if VERBOSE:
683
+ logger.info(f"HTML successfully created at {html_output_path}")
684
+ except Exception as e:
685
+ # If HTML save fails, continue with just PPTX
686
+ if VERBOSE:
687
+ logger.warning(f"HTML creation failed: {str(e)}")
688
+ # Remove the HTML file if it exists
689
+ if os.path.exists(html_output_path):
690
+ os.remove(html_output_path)
691
+ if VERBOSE:
692
+ logger.info("HTML file removed due to creation error")
693
+
694
+ # Read PPTX file as bytes
695
+ with open(pptx_output_path, "rb") as f:
696
+ pptx_bytes = f.read()
697
+
698
+ # Encode PPTX as base64
699
+ pptx_b64 = base64.b64encode(pptx_bytes).decode('utf-8')
700
+ if VERBOSE:
701
+ logger.info("PPTX file successfully encoded to base64")
702
+
703
+ # Read HTML file as bytes if it exists
704
+ html_b64 = None
705
+ if os.path.exists(html_output_path):
706
+ with open(html_output_path, "r", encoding="utf-8") as f:
707
+ html_content_file = f.read()
708
+ html_b64 = base64.b64encode(html_content_file.encode('utf-8')).decode('utf-8')
709
+ if VERBOSE:
710
+ logger.info("HTML file successfully encoded to base64")
711
+
712
+ # Prepare artifacts
713
+ artifacts = [
714
+ {
715
+ "name": f"{output_filename}.pptx",
716
+ "b64": pptx_b64,
717
+ "mime": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
718
+ }
719
+ ]
720
+
721
+ # Add HTML if creation was successful
722
+ if html_b64:
723
+ artifacts.append({
724
+ "name": f"{output_filename}.html",
725
+ "b64": html_b64,
726
+ "mime": "text/html",
727
+ })
728
+ if VERBOSE:
729
+ logger.info(f"Added {len(artifacts)} artifacts to response")
730
+ else:
731
+ if VERBOSE:
732
+ logger.info("No HTML artifact added due to creation failure")
733
+
734
+ return {
735
+ "results": {
736
+ "operation": "markdown_to_pptx",
737
+ "message": "PowerPoint presentation and HTML file generated successfully from markdown.",
738
+ "html_generated": html_b64 is not None,
739
+ "image_included": image_bytes is not None,
740
+ },
741
+ "artifacts": artifacts,
742
+ "display": {
743
+ "open_canvas": True,
744
+ "primary_file": f"{output_filename}.pptx",
745
+ "mode": "replace",
746
+ "viewer_hint": "powerpoint",
747
+ },
748
+ "meta_data": {
749
+ "generated_slides": len(slides),
750
+ "output_files": [f"{output_filename}.pptx", f"{output_filename}.html"] if html_b64 else [f"{output_filename}.pptx"],
751
+ "output_file_paths": [f"temp:output_{output_filename}.pptx", f"temp:output_{output_filename}.html"] if html_b64 else [f"temp:output_{output_filename}.pptx"],
752
+ },
753
+ }
754
+ except Exception as e:
755
+ logger.error(f"Error in markdown_to_pptx: {str(e)}")
756
+ return {"results": {"error": f"Error creating PowerPoint from markdown: {str(e)}"}}
757
+
758
+
759
+ if __name__ == "__main__":
760
+ mcp.run()