mcp-vector-search 0.15.7__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.

Potentially problematic release.


This version of mcp-vector-search might be problematic. Click here for more details.

Files changed (86) hide show
  1. mcp_vector_search/__init__.py +10 -0
  2. mcp_vector_search/cli/__init__.py +1 -0
  3. mcp_vector_search/cli/commands/__init__.py +1 -0
  4. mcp_vector_search/cli/commands/auto_index.py +397 -0
  5. mcp_vector_search/cli/commands/chat.py +534 -0
  6. mcp_vector_search/cli/commands/config.py +393 -0
  7. mcp_vector_search/cli/commands/demo.py +358 -0
  8. mcp_vector_search/cli/commands/index.py +762 -0
  9. mcp_vector_search/cli/commands/init.py +658 -0
  10. mcp_vector_search/cli/commands/install.py +869 -0
  11. mcp_vector_search/cli/commands/install_old.py +700 -0
  12. mcp_vector_search/cli/commands/mcp.py +1254 -0
  13. mcp_vector_search/cli/commands/reset.py +393 -0
  14. mcp_vector_search/cli/commands/search.py +796 -0
  15. mcp_vector_search/cli/commands/setup.py +1133 -0
  16. mcp_vector_search/cli/commands/status.py +584 -0
  17. mcp_vector_search/cli/commands/uninstall.py +404 -0
  18. mcp_vector_search/cli/commands/visualize/__init__.py +39 -0
  19. mcp_vector_search/cli/commands/visualize/cli.py +265 -0
  20. mcp_vector_search/cli/commands/visualize/exporters/__init__.py +12 -0
  21. mcp_vector_search/cli/commands/visualize/exporters/html_exporter.py +33 -0
  22. mcp_vector_search/cli/commands/visualize/exporters/json_exporter.py +29 -0
  23. mcp_vector_search/cli/commands/visualize/graph_builder.py +709 -0
  24. mcp_vector_search/cli/commands/visualize/layout_engine.py +469 -0
  25. mcp_vector_search/cli/commands/visualize/server.py +201 -0
  26. mcp_vector_search/cli/commands/visualize/state_manager.py +428 -0
  27. mcp_vector_search/cli/commands/visualize/templates/__init__.py +16 -0
  28. mcp_vector_search/cli/commands/visualize/templates/base.py +218 -0
  29. mcp_vector_search/cli/commands/visualize/templates/scripts.py +3670 -0
  30. mcp_vector_search/cli/commands/visualize/templates/styles.py +779 -0
  31. mcp_vector_search/cli/commands/visualize.py.original +2536 -0
  32. mcp_vector_search/cli/commands/watch.py +287 -0
  33. mcp_vector_search/cli/didyoumean.py +520 -0
  34. mcp_vector_search/cli/export.py +320 -0
  35. mcp_vector_search/cli/history.py +295 -0
  36. mcp_vector_search/cli/interactive.py +342 -0
  37. mcp_vector_search/cli/main.py +484 -0
  38. mcp_vector_search/cli/output.py +414 -0
  39. mcp_vector_search/cli/suggestions.py +375 -0
  40. mcp_vector_search/config/__init__.py +1 -0
  41. mcp_vector_search/config/constants.py +24 -0
  42. mcp_vector_search/config/defaults.py +200 -0
  43. mcp_vector_search/config/settings.py +146 -0
  44. mcp_vector_search/core/__init__.py +1 -0
  45. mcp_vector_search/core/auto_indexer.py +298 -0
  46. mcp_vector_search/core/config_utils.py +394 -0
  47. mcp_vector_search/core/connection_pool.py +360 -0
  48. mcp_vector_search/core/database.py +1237 -0
  49. mcp_vector_search/core/directory_index.py +318 -0
  50. mcp_vector_search/core/embeddings.py +294 -0
  51. mcp_vector_search/core/exceptions.py +89 -0
  52. mcp_vector_search/core/factory.py +318 -0
  53. mcp_vector_search/core/git_hooks.py +345 -0
  54. mcp_vector_search/core/indexer.py +1002 -0
  55. mcp_vector_search/core/llm_client.py +453 -0
  56. mcp_vector_search/core/models.py +294 -0
  57. mcp_vector_search/core/project.py +350 -0
  58. mcp_vector_search/core/scheduler.py +330 -0
  59. mcp_vector_search/core/search.py +952 -0
  60. mcp_vector_search/core/watcher.py +322 -0
  61. mcp_vector_search/mcp/__init__.py +5 -0
  62. mcp_vector_search/mcp/__main__.py +25 -0
  63. mcp_vector_search/mcp/server.py +752 -0
  64. mcp_vector_search/parsers/__init__.py +8 -0
  65. mcp_vector_search/parsers/base.py +296 -0
  66. mcp_vector_search/parsers/dart.py +605 -0
  67. mcp_vector_search/parsers/html.py +413 -0
  68. mcp_vector_search/parsers/javascript.py +643 -0
  69. mcp_vector_search/parsers/php.py +694 -0
  70. mcp_vector_search/parsers/python.py +502 -0
  71. mcp_vector_search/parsers/registry.py +223 -0
  72. mcp_vector_search/parsers/ruby.py +678 -0
  73. mcp_vector_search/parsers/text.py +186 -0
  74. mcp_vector_search/parsers/utils.py +265 -0
  75. mcp_vector_search/py.typed +1 -0
  76. mcp_vector_search/utils/__init__.py +42 -0
  77. mcp_vector_search/utils/gitignore.py +250 -0
  78. mcp_vector_search/utils/gitignore_updater.py +212 -0
  79. mcp_vector_search/utils/monorepo.py +339 -0
  80. mcp_vector_search/utils/timing.py +338 -0
  81. mcp_vector_search/utils/version.py +47 -0
  82. mcp_vector_search-0.15.7.dist-info/METADATA +884 -0
  83. mcp_vector_search-0.15.7.dist-info/RECORD +86 -0
  84. mcp_vector_search-0.15.7.dist-info/WHEEL +4 -0
  85. mcp_vector_search-0.15.7.dist-info/entry_points.txt +3 -0
  86. mcp_vector_search-0.15.7.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,469 @@
1
+ """Layout calculation algorithms for visualization V2.0.
2
+
3
+ This module implements layout algorithms for hierarchical list-based navigation:
4
+ - List Layout: Vertical alphabetical positioning of root nodes
5
+ - Fan Layout: Horizontal arc (180°) for expanded directory/file children
6
+
7
+ Design Principles:
8
+ - Deterministic: Same input → same output (no randomness)
9
+ - Adaptive: Radius and spacing adjust to child count
10
+ - Sorted: Alphabetical order (directories first) for predictability
11
+ - Performance: O(n) time complexity, <50ms for 100 nodes
12
+
13
+ Reference: docs/development/VISUALIZATION_ARCHITECTURE_V2.md
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import math
19
+ from typing import Any
20
+
21
+ from loguru import logger
22
+
23
+
24
+ def calculate_list_layout(
25
+ nodes: list[dict[str, Any]], canvas_width: int, canvas_height: int
26
+ ) -> dict[str, tuple[float, float]]:
27
+ """Calculate vertical list positions for nodes.
28
+
29
+ Positions nodes in a vertical list with fixed spacing, sorted
30
+ alphabetically with directories appearing before files.
31
+
32
+ Design Decision: Vertical list for alphabetical browsing
33
+
34
+ Rationale: Users expect alphabetical file/folder listings (like Finder, Explorer).
35
+ Vertical layout mirrors familiar file manager interfaces.
36
+
37
+ Trade-offs:
38
+ - Familiarity: Matches OS file managers vs. novel layouts
39
+ - Vertical space: Requires scrolling for many files vs. compact layouts
40
+ - Readability: One item per line is clearest vs. dense grids
41
+
42
+ Args:
43
+ nodes: List of node dictionaries with 'id', 'name', and 'type' keys
44
+ canvas_width: SVG viewport width in pixels
45
+ canvas_height: SVG viewport height in pixels
46
+
47
+ Returns:
48
+ Dictionary mapping node_id -> (x, y) position
49
+
50
+ Time Complexity: O(n log n) where n = number of nodes (due to sorting)
51
+ Space Complexity: O(n) for position dictionary
52
+
53
+ Example:
54
+ >>> nodes = [
55
+ ... {'id': 'dir1', 'name': 'src', 'type': 'directory'},
56
+ ... {'id': 'file1', 'name': 'main.py', 'type': 'file'}
57
+ ... ]
58
+ >>> positions = calculate_list_layout(nodes, 1920, 1080)
59
+ >>> positions['dir1'] # (x, y) coordinates
60
+ (100, 490.0)
61
+ >>> positions['file1']
62
+ (100, 540.0)
63
+ """
64
+ if not nodes:
65
+ logger.debug("No nodes to layout")
66
+ return {}
67
+
68
+ # Sort alphabetically (directories first, then files)
69
+ sorted_nodes = sorted(
70
+ nodes,
71
+ key=lambda n: (
72
+ 0 if n.get("type") == "directory" else 1, # Directories first
73
+ (n.get("name") or "").lower(), # Then alphabetical
74
+ ),
75
+ )
76
+
77
+ # Layout parameters
78
+ node_height = 50 # Vertical space per node (icon + label)
79
+ x_position = 100 # Left margin
80
+ total_height = len(sorted_nodes) * node_height
81
+
82
+ # Center vertically in viewport
83
+ start_y = (canvas_height - total_height) / 2
84
+
85
+ # Calculate positions
86
+ positions = {}
87
+ for i, node in enumerate(sorted_nodes):
88
+ node_id = node.get("id")
89
+ if not node_id:
90
+ logger.warning(f"Node missing 'id': {node}")
91
+ continue
92
+
93
+ y_position = start_y + (i * node_height)
94
+ positions[node_id] = (x_position, y_position)
95
+
96
+ logger.debug(
97
+ f"List layout: {len(positions)} nodes, "
98
+ f"height={total_height}px, "
99
+ f"start_y={start_y:.1f}"
100
+ )
101
+
102
+ return positions
103
+
104
+
105
+ def calculate_fan_layout(
106
+ parent_pos: tuple[float, float],
107
+ children: list[dict[str, Any]],
108
+ canvas_width: int,
109
+ canvas_height: int,
110
+ fan_type: str = "horizontal",
111
+ ) -> dict[str, tuple[float, float]]:
112
+ """Calculate horizontal fan positions for child nodes.
113
+
114
+ Arranges children in a 180° arc (horizontal fan) emanating from parent node.
115
+ Radius adapts to child count (200-400px range). Children sorted alphabetically.
116
+
117
+ Design Decision: Horizontal fan for directory expansion
118
+
119
+ Rationale: Horizontal layout provides intuitive left-to-right navigation,
120
+ matching Western reading patterns. 180° arc provides clear parent-child
121
+ visual connection while maintaining adequate spacing between nodes.
122
+
123
+ Trade-offs:
124
+ - Horizontal space: Limited by viewport width vs. vertical fans
125
+ - Readability: Left-to-right natural vs. radial (360°) all directions
126
+ - Density: 180° arc provides more space per node vs. full circle
127
+ - Visual connection: Clear parent→child line vs. grid layout
128
+
129
+ Alternatives Considered:
130
+ 1. Full circle (360°): Rejected - confusing orientation, no clear "top"
131
+ 2. Vertical fan: Rejected - conflicts with list view scrolling
132
+ 3. Grid layout: Rejected - loses parent-child visual connection
133
+ 4. Tree layout: Rejected - doesn't support sibling switching gracefully
134
+
135
+ Extension Points: Arc angle parameterizable (currently 180°) for different
136
+ density preferences. Radius calculation can use different formulas.
137
+
138
+ Args:
139
+ parent_pos: (x, y) coordinates of parent node
140
+ children: List of child node dictionaries with 'id', 'name', 'type'
141
+ canvas_width: SVG viewport width in pixels
142
+ canvas_height: SVG viewport height in pixels
143
+ fan_type: Type of fan ("horizontal" for 180° arc)
144
+
145
+ Returns:
146
+ Dictionary mapping child_id -> (x, y) position
147
+
148
+ Time Complexity: O(n log n) where n = number of children (sorting)
149
+ Space Complexity: O(n) for position dictionary
150
+
151
+ Performance:
152
+ - Expected: <10ms for 50 children
153
+ - Tested: <50ms for 500 children
154
+
155
+ Example:
156
+ >>> parent = (500, 400)
157
+ >>> children = [
158
+ ... {'id': 'c1', 'name': 'utils', 'type': 'directory'},
159
+ ... {'id': 'c2', 'name': 'tests', 'type': 'directory'},
160
+ ... {'id': 'c3', 'name': 'main.py', 'type': 'file'}
161
+ ... ]
162
+ >>> positions = calculate_fan_layout(parent, children, 1920, 1080)
163
+ >>> # Children positioned in 180° arc from left to right
164
+ >>> len(positions)
165
+ 3
166
+ """
167
+ if not children:
168
+ logger.debug("No children to layout in fan")
169
+ return {}
170
+
171
+ parent_x, parent_y = parent_pos
172
+
173
+ # Calculate adaptive radius based on child count
174
+ # More children → larger radius to prevent overlap
175
+ base_radius = 200 # Minimum radius
176
+ max_radius = 400 # Maximum radius
177
+ spacing_per_child = 60 # Horizontal space needed per child
178
+
179
+ # Arc length = radius * angle (in radians)
180
+ # For 180° arc: arc_length = radius * π
181
+ # We want: arc_length >= num_children * spacing_per_child
182
+ # Therefore: radius >= (num_children * spacing_per_child) / π
183
+ calculated_radius = (len(children) * spacing_per_child) / math.pi
184
+ radius = max(base_radius, min(calculated_radius, max_radius))
185
+
186
+ # Horizontal fan: 180° arc from left to right
187
+ # Start at π radians (180°, left side)
188
+ # End at 0 radians (0°, right side)
189
+ start_angle = math.pi # Left (180°)
190
+ end_angle = 0 # Right (0°)
191
+ angle_range = start_angle - end_angle # π radians
192
+
193
+ # Sort children (directories first, then alphabetical)
194
+ sorted_children = sorted(
195
+ children,
196
+ key=lambda n: (
197
+ 0 if n.get("type") == "directory" else 1, # Directories first
198
+ (n.get("name") or "").lower(), # Then alphabetical
199
+ ),
200
+ )
201
+
202
+ # Calculate positions
203
+ positions = {}
204
+ num_children = len(sorted_children)
205
+
206
+ for i, child in enumerate(sorted_children):
207
+ child_id = child.get("id")
208
+ if not child_id:
209
+ logger.warning(f"Child node missing 'id': {child}")
210
+ continue
211
+
212
+ # Calculate angle for this child
213
+ if num_children == 1:
214
+ # Single child: center of arc (90°)
215
+ angle = math.pi / 2
216
+ else:
217
+ # Distribute evenly across arc
218
+ # angle = start_angle - (progress * angle_range)
219
+ # progress = i / (num_children - 1) goes from 0 to 1
220
+ progress = i / (num_children - 1)
221
+ angle = start_angle - (progress * angle_range)
222
+
223
+ # Convert polar coordinates (radius, angle) to cartesian (x, y)
224
+ x = parent_x + radius * math.cos(angle)
225
+ y = parent_y + radius * math.sin(angle)
226
+
227
+ positions[child_id] = (x, y)
228
+
229
+ logger.debug(
230
+ f"Fan layout: {len(positions)} children, "
231
+ f"radius={radius:.1f}px, "
232
+ f"arc={math.degrees(angle_range):.0f}°"
233
+ )
234
+
235
+ return positions
236
+
237
+
238
+ def calculate_compact_folder_layout(
239
+ parent_pos: tuple[float, float],
240
+ children: list[dict[str, Any]],
241
+ canvas_width: int,
242
+ canvas_height: int,
243
+ ) -> dict[str, tuple[float, float]]:
244
+ """Calculate compact horizontal folder layout for directory children.
245
+
246
+ Arranges children horizontally in a straight line to the right of parent,
247
+ with fixed 800px spacing. Designed for directory-only views where vertical
248
+ space is limited.
249
+
250
+ This layout is optimized for horizontal directory navigation without
251
+ the arc geometry of fan layout.
252
+
253
+ Args:
254
+ parent_pos: (x, y) coordinates of parent node
255
+ children: List of child node dictionaries with 'id', 'name', 'type'
256
+ canvas_width: SVG viewport width in pixels
257
+ canvas_height: SVG viewport height in pixels
258
+
259
+ Returns:
260
+ Dictionary mapping child_id -> (x, y) position
261
+
262
+ Time Complexity: O(n log n) where n = number of children (sorting)
263
+ Space Complexity: O(n) for position dictionary
264
+ """
265
+ if not children:
266
+ logger.debug("No children to layout in compact folder mode")
267
+ return {}
268
+
269
+ parent_x, parent_y = parent_pos
270
+
271
+ # Compact folder layout parameters
272
+ horizontal_offset = 800 # Fixed horizontal spacing from parent
273
+ vertical_spacing = 50 # Vertical spacing between children
274
+
275
+ # Sort children (directories first, then alphabetical)
276
+ sorted_children = sorted(
277
+ children,
278
+ key=lambda n: (
279
+ 0 if n.get("type") == "directory" else 1,
280
+ (n.get("name") or "").lower(),
281
+ ),
282
+ )
283
+
284
+ # Calculate vertical centering
285
+ total_height = len(sorted_children) * vertical_spacing
286
+ start_y = parent_y - (total_height / 2)
287
+
288
+ # Calculate positions
289
+ positions = {}
290
+ for i, child in enumerate(sorted_children):
291
+ child_id = child.get("id")
292
+ if not child_id:
293
+ logger.warning(f"Child node missing 'id': {child}")
294
+ continue
295
+
296
+ x = parent_x + horizontal_offset
297
+ y = start_y + (i * vertical_spacing)
298
+
299
+ positions[child_id] = (x, y)
300
+
301
+ logger.debug(
302
+ f"Compact folder layout: {len(positions)} children, "
303
+ f"offset={horizontal_offset}px, "
304
+ f"spacing={vertical_spacing}px"
305
+ )
306
+
307
+ return positions
308
+
309
+
310
+ def calculate_tree_layout(
311
+ nodes: list[dict[str, Any]],
312
+ expansion_path: list[str],
313
+ canvas_width: int,
314
+ canvas_height: int,
315
+ level_spacing: int = 300,
316
+ node_spacing: int = 50,
317
+ ) -> dict[str, tuple[float, float]]:
318
+ """Calculate positions for rightward tree layout.
319
+
320
+ Design Decision: Rightward Tree Layout
321
+
322
+ Rationale: Traditional file explorer tree layout matches user expectations
323
+ for hierarchical file system navigation. Nodes expand horizontally to the
324
+ right (like Finder/Explorer) rather than radially, providing clear depth
325
+ visualization and natural left-to-right reading flow.
326
+
327
+ Trade-offs:
328
+ - Familiarity: Matches OS file explorers vs. novel graph layouts
329
+ - Depth visibility: Clear level separation vs. radial compactness
330
+ - Horizontal space: Requires wider viewport vs. vertical layouts
331
+ - Simplicity: No angle calculations vs. fan layouts
332
+
333
+ Args:
334
+ nodes: All nodes in the graph
335
+ expansion_path: List of expanded node IDs (root → current)
336
+ canvas_width: Canvas width
337
+ canvas_height: Canvas height
338
+ level_spacing: Horizontal distance between tree levels (default 300px)
339
+ node_spacing: Vertical distance between sibling nodes (default 50px)
340
+
341
+ Returns:
342
+ Dict mapping node_id → (x, y) position
343
+
344
+ Layout Logic:
345
+ - Root nodes: x=100, y=vertical list (50px spacing)
346
+ - Level 1 (children of expanded root): x=400, y=vertical under parent
347
+ - Level 2: x=700, etc.
348
+ - Center view by shifting all x positions left as tree grows
349
+
350
+ Time Complexity: O(n log n) where n = number of nodes (sorting)
351
+ Space Complexity: O(n) for position dictionary and level grouping
352
+
353
+ Example:
354
+ >>> nodes = [
355
+ ... {'id': 'root1', 'name': 'src', 'type': 'directory', 'parent': None},
356
+ ... {'id': 'child1', 'name': 'main.py', 'type': 'file', 'parent': 'root1'}
357
+ ... ]
358
+ >>> expansion_path = ['root1']
359
+ >>> positions = calculate_tree_layout(nodes, expansion_path, 1920, 1080)
360
+ >>> # root1 at x=100, child1 at x=400
361
+ """
362
+ if not nodes:
363
+ logger.debug("No nodes to layout in tree")
364
+ return {}
365
+
366
+ positions = {}
367
+
368
+ # Group nodes by depth level based on expansion path
369
+ levels = _build_tree_levels(nodes, expansion_path)
370
+
371
+ if not levels:
372
+ logger.warning("No levels built from expansion path")
373
+ return {}
374
+
375
+ # Calculate positions level by level
376
+ for level_index, level_nodes in enumerate(levels):
377
+ x = 100 + (level_index * level_spacing)
378
+
379
+ # Sort alphabetically (directories first)
380
+ sorted_nodes = sorted(
381
+ level_nodes,
382
+ key=lambda n: (
383
+ 0 if n.get("type") == "directory" else 1,
384
+ (n.get("name") or "").lower(),
385
+ ),
386
+ )
387
+
388
+ # Position vertically
389
+ total_height = len(sorted_nodes) * node_spacing
390
+ start_y = (canvas_height - total_height) / 2
391
+
392
+ for i, node in enumerate(sorted_nodes):
393
+ node_id = node.get("id")
394
+ if not node_id:
395
+ logger.warning(f"Node missing 'id': {node}")
396
+ continue
397
+
398
+ y = start_y + (i * node_spacing)
399
+ positions[node_id] = (x, y)
400
+
401
+ # Center the view (shift left based on deepest level)
402
+ max_level = len(levels) - 1
403
+ shift_x = (max_level * level_spacing) // 2
404
+
405
+ for node_id in positions:
406
+ x, y = positions[node_id]
407
+ positions[node_id] = (x - shift_x, y)
408
+
409
+ logger.debug(
410
+ f"Tree layout: {len(positions)} nodes across {len(levels)} levels, "
411
+ f"shift_x={shift_x}px"
412
+ )
413
+
414
+ return positions
415
+
416
+
417
+ def _build_tree_levels(
418
+ nodes: list[dict[str, Any]], expansion_path: list[str]
419
+ ) -> list[list[dict[str, Any]]]:
420
+ """Build tree levels based on expansion path.
421
+
422
+ Returns:
423
+ List of levels, where each level is a list of nodes
424
+ Level 0: Root nodes (no parent)
425
+ Level 1: Children of expanded root node
426
+ Level 2: Children of expanded level-1 node
427
+ etc.
428
+
429
+ Example:
430
+ >>> nodes = [
431
+ ... {'id': 'a', 'parent': None},
432
+ ... {'id': 'b', 'parent': 'a'},
433
+ ... {'id': 'c', 'parent': 'a'}
434
+ ... ]
435
+ >>> expansion_path = ['a']
436
+ >>> levels = _build_tree_levels(nodes, expansion_path)
437
+ >>> len(levels)
438
+ 2
439
+ >>> len(levels[0]) # Root level
440
+ 1
441
+ >>> len(levels[1]) # Children of 'a'
442
+ 2
443
+ """
444
+ levels: list[list[dict[str, Any]]] = []
445
+
446
+ # Build node ID to node map for quick lookup
447
+ node_map = {node.get("id"): node for node in nodes if node.get("id")}
448
+
449
+ # Level 0: Root nodes (no parent or parent not in graph)
450
+ root_nodes = [
451
+ node
452
+ for node in nodes
453
+ if not node.get("parent") or node.get("parent") not in node_map
454
+ ]
455
+ levels.append(root_nodes)
456
+
457
+ # Subsequent levels: Children of expanded nodes in expansion path
458
+ for expanded_node_id in expansion_path:
459
+ # Find children of this expanded node
460
+ children = [node for node in nodes if node.get("parent") == expanded_node_id]
461
+
462
+ if children:
463
+ levels.append(children)
464
+
465
+ logger.debug(
466
+ f"Built {len(levels)} tree levels from expansion path: {expansion_path}"
467
+ )
468
+
469
+ return levels
@@ -0,0 +1,201 @@
1
+ """HTTP server for visualization with streaming JSON support.
2
+
3
+ This module handles running the local HTTP server to serve the
4
+ D3.js visualization interface with chunked transfer for large JSON files.
5
+ """
6
+
7
+ import asyncio
8
+ import socket
9
+ import webbrowser
10
+ from collections.abc import AsyncGenerator
11
+ from pathlib import Path
12
+
13
+ import uvicorn
14
+ from fastapi import FastAPI, Response
15
+ from fastapi.responses import FileResponse, StreamingResponse
16
+ from fastapi.staticfiles import StaticFiles
17
+ from rich.console import Console
18
+ from rich.panel import Panel
19
+
20
+ console = Console()
21
+
22
+
23
+ def find_free_port(start_port: int = 8080, end_port: int = 8099) -> int:
24
+ """Find a free port in the given range.
25
+
26
+ Args:
27
+ start_port: Starting port number to check
28
+ end_port: Ending port number to check
29
+
30
+ Returns:
31
+ First available port in the range
32
+
33
+ Raises:
34
+ OSError: If no free ports available in range
35
+ """
36
+ for test_port in range(start_port, end_port + 1):
37
+ try:
38
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
39
+ s.bind(("", test_port))
40
+ return test_port
41
+ except OSError:
42
+ continue
43
+ raise OSError(f"No free ports available in range {start_port}-{end_port}")
44
+
45
+
46
+ def create_app(viz_dir: Path) -> FastAPI:
47
+ """Create FastAPI application for visualization server.
48
+
49
+ Args:
50
+ viz_dir: Directory containing visualization files
51
+
52
+ Returns:
53
+ Configured FastAPI application
54
+
55
+ Design Decision: Streaming JSON with chunked transfer
56
+
57
+ Rationale: Safari's JSON.parse() cannot handle 6.3MB files in memory.
58
+ Selected streaming approach to send JSON in 100KB chunks, avoiding
59
+ browser memory limits and parser crashes.
60
+
61
+ Trade-offs:
62
+ - Memory: Constant memory usage vs. 6.3MB loaded at once
63
+ - Complexity: Requires streaming parser vs. simple JSON.parse()
64
+ - Performance: Slightly slower parsing but prevents crashes
65
+
66
+ Alternatives Considered:
67
+ 1. Compress JSON (gzip): Rejected - still requires full parse after decompression
68
+ 2. Split into multiple files: Rejected - requires graph structure changes
69
+ 3. Binary format (protobuf): Rejected - requires major refactoring
70
+
71
+ Error Handling:
72
+ - File not found: Returns 404 with clear error message
73
+ - Read errors: Logs exception and returns 500
74
+ - Connection interruption: Stream closes gracefully
75
+
76
+ Performance:
77
+ - Time: O(n) single file read pass
78
+ - Space: O(1) constant memory (100KB buffer)
79
+ - Expected: <10s for 6.3MB file on localhost
80
+ """
81
+ app = FastAPI(title="MCP Vector Search Visualization")
82
+
83
+ @app.get("/api/graph-data")
84
+ async def stream_graph_data() -> StreamingResponse:
85
+ """Stream chunk-graph.json in 100KB chunks.
86
+
87
+ Returns:
88
+ StreamingResponse with chunked transfer encoding
89
+
90
+ Performance:
91
+ - Chunk Size: 100KB (optimal for localhost transfer)
92
+ - Memory: O(1) constant buffer, not O(n) file size
93
+ - Transfer: Progressive, allows incremental parsing
94
+ """
95
+ graph_file = viz_dir / "chunk-graph.json"
96
+
97
+ if not graph_file.exists():
98
+ return Response(
99
+ content='{"error": "Graph data not found"}',
100
+ status_code=404,
101
+ media_type="application/json",
102
+ )
103
+
104
+ async def generate_chunks() -> AsyncGenerator[bytes, None]:
105
+ """Generate 100KB chunks from graph file.
106
+
107
+ Yields:
108
+ Byte chunks of JSON data
109
+ """
110
+ try:
111
+ # Read file in chunks to avoid loading entire file in memory
112
+ chunk_size = 100 * 1024 # 100KB chunks
113
+ with open(graph_file, "rb") as f:
114
+ while chunk := f.read(chunk_size):
115
+ yield chunk
116
+ # Small delay to prevent overwhelming the browser
117
+ await asyncio.sleep(0.01)
118
+ except Exception as e:
119
+ console.print(f"[red]Error streaming graph data: {e}[/red]")
120
+ raise
121
+
122
+ return StreamingResponse(
123
+ generate_chunks(),
124
+ media_type="application/json",
125
+ headers={"Cache-Control": "no-cache", "X-Content-Type-Options": "nosniff"},
126
+ )
127
+
128
+ @app.get("/")
129
+ async def serve_index() -> FileResponse:
130
+ """Serve index.html with no-cache headers to prevent stale content."""
131
+ return FileResponse(
132
+ viz_dir / "index.html",
133
+ headers={
134
+ "Cache-Control": "no-cache, no-store, must-revalidate",
135
+ "Pragma": "no-cache",
136
+ "Expires": "0",
137
+ },
138
+ )
139
+
140
+ # Mount static files (favicon, etc.)
141
+ app.mount("/", StaticFiles(directory=str(viz_dir), html=True), name="static")
142
+
143
+ return app
144
+
145
+
146
+ def start_visualization_server(
147
+ port: int, viz_dir: Path, auto_open: bool = True
148
+ ) -> None:
149
+ """Start HTTP server for visualization with streaming support.
150
+
151
+ Args:
152
+ port: Port number to use
153
+ viz_dir: Directory containing visualization files
154
+ auto_open: Whether to automatically open browser
155
+
156
+ Raises:
157
+ typer.Exit: If server fails to start
158
+ """
159
+ try:
160
+ app = create_app(viz_dir)
161
+ url = f"http://localhost:{port}"
162
+
163
+ console.print()
164
+ console.print(
165
+ Panel.fit(
166
+ f"[green]✓[/green] Visualization server running\n\n"
167
+ f"URL: [cyan]{url}[/cyan]\n"
168
+ f"Directory: [dim]{viz_dir}[/dim]\n\n"
169
+ f"[dim]Press Ctrl+C to stop[/dim]",
170
+ title="Server Started",
171
+ border_style="green",
172
+ )
173
+ )
174
+
175
+ # Open browser
176
+ if auto_open:
177
+ webbrowser.open(url)
178
+
179
+ # Run server
180
+ config = uvicorn.Config(
181
+ app,
182
+ host="127.0.0.1",
183
+ port=port,
184
+ log_level="warning", # Reduce noise
185
+ access_log=False,
186
+ )
187
+ server = uvicorn.Server(config)
188
+ server.run()
189
+
190
+ except KeyboardInterrupt:
191
+ console.print("\n[yellow]Stopping server...[/yellow]")
192
+ except OSError as e:
193
+ if "Address already in use" in str(e):
194
+ console.print(
195
+ f"[red]✗ Port {port} is already in use. Try a different port with --port[/red]"
196
+ )
197
+ else:
198
+ console.print(f"[red]✗ Server error: {e}[/red]")
199
+ import typer
200
+
201
+ raise typer.Exit(1)