spectro-kernel 0.1.3__tar.gz → 0.1.4__tar.gz

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 (242) hide show
  1. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/CHANGELOG.md +109 -0
  2. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/PKG-INFO +1 -1
  3. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/embedding/embed_lick_indices.py +42 -5
  4. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/version.py +1 -1
  5. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_mcp/__main__.py +63 -16
  6. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_mcp/auto_tools.py +49 -14
  7. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_mcp/observability.py +9 -2
  8. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_mcp/session.py +80 -5
  9. spectro_kernel-0.1.4/src/spectro_mcp/url_safety.py +155 -0
  10. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/unit/test_embedding_remote_lick.py +63 -0
  11. spectro_kernel-0.1.4/tests/unit/test_mcp_security.py +169 -0
  12. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/.gitignore +0 -0
  13. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/LICENSE +0 -0
  14. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/README.md +0 -0
  15. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/concepts/algorithms.md +0 -0
  16. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/concepts/architecture.md +0 -0
  17. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/concepts/data-types.md +0 -0
  18. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/concepts/pipelines.md +0 -0
  19. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/contributing.md +0 -0
  20. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/cookbook/bess-dashboard.md +0 -0
  21. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/cookbook/multi-star-viewer.md +0 -0
  22. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/cookbook/web-playground.md +0 -0
  23. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/gen_catalogue.py +0 -0
  24. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/getting-started.md +0 -0
  25. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/images/notebooks/alpha_cyg_ew_timeseries.png +0 -0
  26. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/images/notebooks/alpha_cyg_halpha_phase_stack.png +0 -0
  27. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/images/notebooks/alpha_cyg_halpha_timeseries.png +0 -0
  28. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/images/notebooks/alpha_cyg_overlay.png +0 -0
  29. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/images/notebooks/alpha_cyg_pc1_vs_phase.png +0 -0
  30. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/images/notebooks/alpha_cyg_pca_2d.png +0 -0
  31. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/images/notebooks/alpha_cyg_pca_3d.png +0 -0
  32. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/images/notebooks/alpha_cyg_periodogram.png +0 -0
  33. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/images/notebooks/alpha_cyg_phase_folded.png +0 -0
  34. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/images/notebooks/alpha_cyg_rv_timeseries.png +0 -0
  35. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/images/notebooks/alpha_cyg_similarity.png +0 -0
  36. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/images/notebooks/alpha_cyg_similarity_date.png +0 -0
  37. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/images/notebooks/alpha_dra_halpha_phase_stack.png +0 -0
  38. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/images/notebooks/alpha_dra_pc1_vs_phase.png +0 -0
  39. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/images/notebooks/alpha_dra_pca_2d.png +0 -0
  40. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/images/notebooks/alpha_dra_pca_3d.png +0 -0
  41. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/images/notebooks/alpha_dra_periodogram.png +0 -0
  42. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/images/notebooks/alpha_dra_phase_folded.png +0 -0
  43. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/images/notebooks/alpha_dra_rv_timeseries.png +0 -0
  44. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/images/notebooks/alpha_dra_similarity_date.png +0 -0
  45. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/images/notebooks/alpha_dra_similarity_phase.png +0 -0
  46. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/index.md +0 -0
  47. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/notebooks/alpha-cyg-time-series.md +0 -0
  48. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/notebooks/alpha-dra-binary-period.md +0 -0
  49. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/notebooks/aurora-line-monitor.md +0 -0
  50. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/notebooks/be-star-variability.md +0 -0
  51. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/notebooks/claude-mcp-end-to-end.md +0 -0
  52. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/notebooks/exoplanet-transit-rv.md +0 -0
  53. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/notebooks/full-reduction-walkthrough.md +0 -0
  54. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/notebooks/native-vs-easyspec-showdown.md +0 -0
  55. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/notebooks/your-first-sb2.md +0 -0
  56. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/reference.md +0 -0
  57. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/tutorials/add-an-algorithm.md +0 -0
  58. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/tutorials/analyse-a-spectrum.md +0 -0
  59. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/tutorials/discover-the-catalogue.md +0 -0
  60. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/tutorials/first-spectrum.md +0 -0
  61. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/usage/cli.md +0 -0
  62. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/usage/library.md +0 -0
  63. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/usage/mcp.md +0 -0
  64. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/why.md +0 -0
  65. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/playground/README.md +0 -0
  66. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/pyproject.toml +0 -0
  67. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/__init__.py +0 -0
  68. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/adapters/__init__.py +0 -0
  69. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/adapters/easyspec.py +0 -0
  70. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/__init__.py +0 -0
  71. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/_common.py +0 -0
  72. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/advanced/__init__.py +0 -0
  73. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/advanced/aperture_photometry.py +0 -0
  74. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/advanced/disentangle_sb2.py +0 -0
  75. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/catalogs/__init__.py +0 -0
  76. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/catalogs/gaia.py +0 -0
  77. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/catalogs/simbad.py +0 -0
  78. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/catalogs/vizier.py +0 -0
  79. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/continuum/__init__.py +0 -0
  80. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/continuum/compare_normalisations.py +0 -0
  81. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/continuum/normalize_edges.py +0 -0
  82. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/continuum/normalize_max.py +0 -0
  83. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/continuum/normalize_percentile.py +0 -0
  84. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/continuum/normalize_polynomial.py +0 -0
  85. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/continuum/subtract_continuum.py +0 -0
  86. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/corrections/__init__.py +0 -0
  87. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/corrections/air_vacuum.py +0 -0
  88. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/corrections/barycentric.py +0 -0
  89. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/corrections/doppler_shift.py +0 -0
  90. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/corrections/extinction_correct_easyspec.py +0 -0
  91. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/corrections/fit_telluric_scaling.py +0 -0
  92. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/corrections/flux_calibrate_easyspec.py +0 -0
  93. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/corrections/remove_telluric.py +0 -0
  94. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/corrections/synth_telluric.py +0 -0
  95. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/embedding/__init__.py +0 -0
  96. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/embedding/embed_band_power.py +0 -0
  97. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/embedding/embed_continuum_subtracted.py +0 -0
  98. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/embedding/embed_log_lambda.py +0 -0
  99. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/embedding/embed_pretrained.py +0 -0
  100. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/embedding/embed_remote.py +0 -0
  101. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/embedding/embed_spectrum.py +0 -0
  102. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/embedding/embed_wavelets.py +0 -0
  103. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/exports/__init__.py +0 -0
  104. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/exports/export_csv.py +0 -0
  105. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/exports/export_fits.py +0 -0
  106. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/exports/export_hdf5.py +0 -0
  107. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/exports/export_votable.py +0 -0
  108. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/extraction/__init__.py +0 -0
  109. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/extraction/easyspec_extract.py +0 -0
  110. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/io/__init__.py +0 -0
  111. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/io/read_ascii.py +0 -0
  112. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/io/read_echelle.py +0 -0
  113. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/io/read_fits.py +0 -0
  114. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/io/read_sdss.py +0 -0
  115. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/io/read_votable.py +0 -0
  116. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/lines/__init__.py +0 -0
  117. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/lines/_profiles.py +0 -0
  118. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/lines/catalogs.py +0 -0
  119. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/lines/compare_line_fits.py +0 -0
  120. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/lines/detect.py +0 -0
  121. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/lines/equivalent_width.py +0 -0
  122. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/lines/fit_gaussian.py +0 -0
  123. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/lines/fit_lorentzian.py +0 -0
  124. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/lines/fit_voigt.py +0 -0
  125. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/quality/__init__.py +0 -0
  126. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/quality/compare_snr_methods.py +0 -0
  127. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/quality/snr_der.py +0 -0
  128. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/quality/snr_edge.py +0 -0
  129. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/quality/snr_linear_fit.py +0 -0
  130. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/reduction/__init__.py +0 -0
  131. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/reduction/_easyspec_apply.py +0 -0
  132. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/reduction/_easyspec_helpers.py +0 -0
  133. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/reduction/bias_combine.py +0 -0
  134. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/reduction/clip_cosmic_rays.py +0 -0
  135. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/reduction/dark_subtract.py +0 -0
  136. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/reduction/easyspec_bias.py +0 -0
  137. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/reduction/easyspec_cosmic_ray.py +0 -0
  138. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/reduction/easyspec_dark.py +0 -0
  139. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/reduction/easyspec_flat.py +0 -0
  140. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/reduction/easyspec_flat_normalize.py +0 -0
  141. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/reduction/easyspec_subtract_bias.py +0 -0
  142. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/reduction/easyspec_subtract_dark.py +0 -0
  143. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/reduction/extract_spectrum_sum.py +0 -0
  144. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/reduction/flat_normalize.py +0 -0
  145. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/reduction/subtract_sky_2d.py +0 -0
  146. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/reduction/wavelength_calibrate.py +0 -0
  147. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/rv/__init__.py +0 -0
  148. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/rv/cross_correlate.py +0 -0
  149. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/rv/fit_keplerian_orbit.py +0 -0
  150. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/rv/measure.py +0 -0
  151. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/rv/precision_bouchy.py +0 -0
  152. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/smoothing/__init__.py +0 -0
  153. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/smoothing/compare_smoothings.py +0 -0
  154. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/smoothing/smooth_gaussian.py +0 -0
  155. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/smoothing/smooth_savgol.py +0 -0
  156. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/stacking/__init__.py +0 -0
  157. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/stacking/merge_echelle_orders.py +0 -0
  158. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/stacking/stack.py +0 -0
  159. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/timeseries/__init__.py +0 -0
  160. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/timeseries/lomb_scargle.py +0 -0
  161. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/timeseries/phase_fold.py +0 -0
  162. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/transforms/__init__.py +0 -0
  163. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/transforms/clip_sigma.py +0 -0
  164. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/transforms/extract_region.py +0 -0
  165. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/transforms/mask_range.py +0 -0
  166. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/transforms/resample.py +0 -0
  167. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/transforms/resample_flux_conserving.py +0 -0
  168. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/viz/__init__.py +0 -0
  169. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/viz/plot_3d_surface.py +0 -0
  170. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/viz/plot_animation.py +0 -0
  171. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/viz/plot_dynamic_spectrum.py +0 -0
  172. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/viz/plot_plotly.py +0 -0
  173. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/wavelength_calibration/__init__.py +0 -0
  174. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/wavelength_calibration/easyspec_wavelength.py +0 -0
  175. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/base.py +0 -0
  176. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/cli.py +0 -0
  177. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/embeddings.py +0 -0
  178. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/errors.py +0 -0
  179. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/io/__init__.py +0 -0
  180. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/io/ascii.py +0 -0
  181. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/io/fits.py +0 -0
  182. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/io/votable.py +0 -0
  183. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/pipeline.py +0 -0
  184. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/presets/__init__.py +0 -0
  185. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/presets/catalog/analysis/balmer_quick.yaml +0 -0
  186. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/presets/catalog/analysis/embed_quick.yaml +0 -0
  187. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/presets/catalog/analysis/quality_report.yaml +0 -0
  188. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/presets/catalog/analysis/rv_quick.yaml +0 -0
  189. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/presets/catalog/analysis/snr_check.yaml +0 -0
  190. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/presets/catalog/analysis/time_series_overview.yaml +0 -0
  191. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/presets/catalog/reduction/full_reduction_easyspec.yaml +0 -0
  192. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/presets/loader.py +0 -0
  193. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/py.typed +0 -0
  194. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/registry.py +0 -0
  195. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/types/__init__.py +0 -0
  196. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/types/catalog.py +0 -0
  197. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/types/context.py +0 -0
  198. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/types/enums.py +0 -0
  199. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/types/history.py +0 -0
  200. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/types/image.py +0 -0
  201. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/types/line.py +0 -0
  202. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/types/spectrum.py +0 -0
  203. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/types/timeseries.py +0 -0
  204. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_mcp/__init__.py +0 -0
  205. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_mcp/auth.py +0 -0
  206. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_mcp/py.typed +0 -0
  207. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_mcp/server.py +0 -0
  208. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/conftest.py +0 -0
  209. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/reference/conftest.py +0 -0
  210. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/reference/data/.gitkeep +0 -0
  211. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/reference/data/README.md +0 -0
  212. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/reference/data/sun/sun_reference_stis_002.fits +0 -0
  213. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/reference/data/vega/alpha_lyr_stis_011.fits +0 -0
  214. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/reference/test_known_answers.py +0 -0
  215. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/reference/test_sun.py +0 -0
  216. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/reference/test_vega.py +0 -0
  217. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/unit/test_base.py +0 -0
  218. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/unit/test_cli.py +0 -0
  219. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/unit/test_combine.py +0 -0
  220. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/unit/test_continuum.py +0 -0
  221. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/unit/test_corrections.py +0 -0
  222. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/unit/test_easyspec_wrappers.py +0 -0
  223. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/unit/test_embedding.py +0 -0
  224. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/unit/test_embedding_pretrained.py +0 -0
  225. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/unit/test_embedding_tier1.py +0 -0
  226. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/unit/test_io.py +0 -0
  227. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/unit/test_line_profiles.py +0 -0
  228. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/unit/test_lines.py +0 -0
  229. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/unit/test_mcp.py +0 -0
  230. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/unit/test_mcp_production.py +0 -0
  231. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/unit/test_misc_algorithms.py +0 -0
  232. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/unit/test_no_circular_imports.py +0 -0
  233. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/unit/test_pipeline.py +0 -0
  234. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/unit/test_quality.py +0 -0
  235. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/unit/test_read_sdss.py +0 -0
  236. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/unit/test_registry.py +0 -0
  237. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/unit/test_smoothing.py +0 -0
  238. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/unit/test_timeseries.py +0 -0
  239. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/unit/test_transforms.py +0 -0
  240. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/unit/test_types.py +0 -0
  241. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/unit/test_viz.py +0 -0
  242. {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/website/README.md +0 -0
@@ -6,6 +6,115 @@ Until `1.0.0` the public API may change between minor versions.
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.1.4] — 2026-06-10
10
+
11
+ ### Fixed — Lick atomic equivalent-width formula (CRITICAL)
12
+
13
+ `embed_lick_indices` was computing
14
+ ``EW = (λ_hi − λ_lo) × (1 − mean(F/Fc))`` for atomic indices instead of
15
+ the canonical Worthey-1994 form ``EW = ∫(1 − F/Fc) dλ``. The two only
16
+ agree when pixel edges fall exactly on the band limits — which never
17
+ happens on real data — so every atomic Lick value produced by ≤ 0.1.3
18
+ was systematically **inflated by ~1–2 Å**. The molecular-magnitude form
19
+ was correct.
20
+
21
+ Algorithm version bumped 1.0.0 → 2.0.0; any indexed Lick embedding from
22
+ ≤ 0.1.3 should be **re-computed**. Companion fix in ``_band_mean_flux``:
23
+ the band mean now uses the actual covered pixel span (not the nominal
24
+ ``hi − lo``) and refuses bands less than half-covered by the spectrum.
25
+
26
+ ### Fixed — SSRF on `load_spectrum` / `load_spectrum_from_url` (CRITICAL)
27
+
28
+ The MCP file-loading tools forwarded the user-supplied URL directly to
29
+ ``urllib.request.urlretrieve`` with no host validation, no redirect
30
+ control, no timeout and no size cap. An attacker could fetch instance
31
+ metadata (``http://169.254.169.254/latest/meta-data/iam/...``), scan the
32
+ VPC interior, or stream a 10 GB file to exhaust the worker.
33
+
34
+ New module ``spectro_mcp/url_safety.py`` gates every URL coming from
35
+ MCP through:
36
+ - scheme allowlist (``http``/``https`` only);
37
+ - ``localhost`` and DNS-resolved IP validation (refuses loopback,
38
+ RFC1918, link-local, multicast, reserved, unspecified);
39
+ - a no-redirect HTTP handler (a friendly upstream can't 302 to
40
+ ``169.254.169.254`` after the hostname guard passes);
41
+ - 60-second timeout plus 256 MiB byte cap on downloads.
42
+
43
+ ### Fixed — Path traversal on the HTTP MCP server (HIGH)
44
+
45
+ ``load_spectrum(session_id, path)`` accepted any local filesystem path,
46
+ including ``/etc/passwd`` and ``/proc/self/environ`` (which leaks
47
+ ``SPECTRO_MCP_API_KEY``, ``AWS_*``, ``SPECTRO_MCP_REDIS_URL``, …).
48
+
49
+ Local paths are now refused when the new env var
50
+ ``SPECTRO_MCP_DENY_LOCAL_PATHS=1`` is set, and the ``--http`` entry
51
+ point sets it automatically. ``--stdio`` mode (the local default) is
52
+ unaffected — the user's own machine is trusted by definition.
53
+
54
+ ### Fixed — Pickle deserialisation from Redis (HIGH)
55
+
56
+ ``RedisSessionManager.get()`` called ``pickle.loads`` on whatever
57
+ arbitrary bytes Redis returned. Any path that lets an attacker write to
58
+ the Redis backing store (compromised Redis, MITM with no TLS, leaked
59
+ ``SPECTRO_MCP_REDIS_URL``) → arbitrary code execution on the next
60
+ ``get``.
61
+
62
+ Payloads are now wrapped with an HMAC-SHA256 signature derived from
63
+ ``SPECTRO_MCP_SESSION_SECRET`` (env). Without that env var the server
64
+ generates a one-shot random secret at boot (sessions don't survive a
65
+ restart in that case — a clear stderr warning is emitted). Unsigned or
66
+ mis-signed blobs raise ``ValueError`` before ``pickle.loads`` runs.
67
+
68
+ ### Fixed — MCP stdio corruption from boot-time stdout prints (HIGH)
69
+
70
+ In stdio mode, stdout is the JSON-RPC wire to the agent. Two paths
71
+ emitted bytes on stdout that corrupted the very first handshake:
72
+
73
+ 1. Algorithm discovery imported ``astroquery`` (Gaia DR4 banner, ~390 B)
74
+ and ``easyspec`` (version banner, ~30 B). FD-level writes that
75
+ ``contextlib.redirect_stdout`` doesn't catch.
76
+ 2. ``configure_logger`` attached the audit handler to ``sys.stdout``;
77
+ every tool call therefore emitted a JSON record on the wire.
78
+
79
+ Fix: a new ``_silence_fd_stdout`` context manager in ``__main__``
80
+ ``dup2``'s FD 1 to FD 2 for the boot phase (catches even C-level
81
+ writes), and the audit logger now targets ``sys.stderr``. HTTP mode is
82
+ unchanged.
83
+
84
+ ### Hardened — Dockerfile non-root user (MED)
85
+
86
+ Default container user was root. The Dockerfile now creates an
87
+ unprivileged ``app`` (UID 10001) and drops to it before ``CMD``. Any
88
+ future vulnerability that reaches the filesystem is confined to the
89
+ app user's home.
90
+
91
+ ### Hardened — Discovery silent error swallow (LOW)
92
+
93
+ ``registry.ensure_discovered`` silently dropped any submodule that
94
+ failed to import. The intent (gracefully skip algorithms whose optional
95
+ extra is missing) is preserved, but a bug-induced import failure used
96
+ to vanish without trace. Discovery now logs every dropped submodule
97
+ via the ``spectro_kernel.registry`` logger.
98
+
99
+ ### Added
100
+
101
+ - ``tests/unit/test_mcp_security.py`` — 15 new tests covering every
102
+ v0.1.4 finding (SSRF target families, path-traversal env-var gate,
103
+ HMAC roundtrip + tamper + wrong-key, RedisSessionManager round-trip
104
+ via fakeredis, ``_resolve_redis_secret`` env-vs-random behaviour).
105
+ - 2 new Lick regression tests (``test_lick_atomic_ew_matches_worthey_integral``,
106
+ ``test_lick_band_mean_uses_actual_pixel_span``).
107
+
108
+ ### Migration notes
109
+
110
+ - Re-index any vectors produced by ``embed_lick_indices`` ≤ 0.1.3
111
+ (values changed by 1–2 Å on atomic indices).
112
+ - For Redis-backed deployments: set ``SPECTRO_MCP_SESSION_SECRET`` to
113
+ a stable 32-byte secret in the environment. Otherwise sessions don't
114
+ survive a restart.
115
+ - For HTTP deployments where you want users to send local paths anyway
116
+ (uncommon), unset ``SPECTRO_MCP_DENY_LOCAL_PATHS`` explicitly.
117
+
9
118
  ## [0.1.3] — 2026-06-09
10
119
 
11
120
  ### Fixed — circular import that prevented `from spectro_kernel.embeddings import …`
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: spectro-kernel
3
- Version: 0.1.3
3
+ Version: 0.1.4
4
4
  Summary: A shared catalogue of astronomical spectroscopy algorithms, composable into reproducible pipelines, usable as a Python library, a CLI, or an MCP server.
5
5
  Project-URL: Homepage, https://github.com/matthieulel/spectro-kernel
6
6
  Project-URL: Documentation, https://matthieulel.github.io/spectro-kernel/
@@ -52,10 +52,29 @@ _LICK_DEFS: dict[str, tuple[float, float, float, float, float, float, str]] = {
52
52
 
53
53
 
54
54
  def _band_mean_flux(wave: np.ndarray, flux: np.ndarray, lo: float, hi: float) -> float:
55
+ """Mean flux inside ``[lo, hi]``; returns NaN if the band is essentially uncovered.
56
+
57
+ The denominator is the **actual wavelength span covered** by the pixels
58
+ that fell inside the band — not the nominal ``hi - lo``. Using the
59
+ nominal width biases the mean low when the spectrum doesn't reach the
60
+ band edges; using the actual covered span gives an unbiased estimate
61
+ at the cost of slightly less coverage near the spectrum boundary
62
+ (which is the right trade-off — biasing the continuum is much worse
63
+ than truncating it).
64
+
65
+ Returns NaN when fewer than 2 pixels fall in the band, or when the
66
+ spectrum covers less than half of the nominal band width — those
67
+ indicate a band that straddles the spectrum edge and shouldn't be
68
+ used for a Lick measurement.
69
+ """
55
70
  mask = (wave >= lo) & (wave <= hi)
56
71
  if mask.sum() < 2:
57
72
  return float("nan")
58
- return float(_trapezoid(flux[mask], wave[mask]) / (hi - lo))
73
+ wmin, wmax = float(wave[mask][0]), float(wave[mask][-1])
74
+ span = wmax - wmin
75
+ if span < 0.5 * (hi - lo):
76
+ return float("nan") # less than half of the band is in the spectrum
77
+ return float(_trapezoid(flux[mask], wave[mask]) / span)
59
78
 
60
79
 
61
80
  def _lick_index(
@@ -63,7 +82,14 @@ def _lick_index(
63
82
  bl: float, bh: float, fl: float, fh: float, rl: float, rh: float,
64
83
  kind: str,
65
84
  ) -> float:
66
- """Compute one Lick index value (atomic = Å EW, molecular = magnitudes)."""
85
+ """Compute one Lick index value (atomic = Å EW, molecular = magnitudes).
86
+
87
+ Follows Worthey, Faber, González & Burstein 1994 (ApJS, 94, 687) and
88
+ Trager et al. 1998 (ApJS, 116, 1). The atomic-EW definition is
89
+ ``EW = ∫_F (1 - F(λ)/Fc(λ)) dλ`` with ``Fc`` the pseudo-continuum
90
+ straight-line interpolation between the mean fluxes of the blue and
91
+ red sidebands.
92
+ """
67
93
  blue_mid = 0.5 * (bl + bh)
68
94
  red_mid = 0.5 * (rl + rh)
69
95
  blue_flux = _band_mean_flux(wave, flux, bl, bh)
@@ -80,9 +106,20 @@ def _lick_index(
80
106
  ratio = flux[feature_mask] / pseudo_cont
81
107
 
82
108
  if kind == "atomic":
83
- return float((fh - fl) * (1.0 - _trapezoid(ratio, wave[feature_mask]) / (fh - fl)))
109
+ # Canonical Worthey 1994 EW: (1 - F/Fc) positive for an
110
+ # absorption line. NB: an earlier implementation used
111
+ # (fh - fl) * (1 - ∫(F/Fc)/(fh - fl)), which expands to
112
+ # (fh - fl) - ∫(F/Fc) — wrong by a constant offset whenever pixel
113
+ # edges don't fall exactly on fl/fh and biased systematically too
114
+ # high by ~1-2 Å.
115
+ return float(_trapezoid(1.0 - ratio, wave[feature_mask]))
84
116
  if kind == "molecular":
85
- mean_ratio = float(_trapezoid(ratio, wave[feature_mask]) / (fh - fl))
117
+ # Molecular indices: band strength in magnitudes,
118
+ # -2.5 log10(<F/Fc>) with the average taken over the feature band.
119
+ feature_span = float(wave[feature_mask][-1] - wave[feature_mask][0])
120
+ if feature_span <= 0.0:
121
+ return float("nan")
122
+ mean_ratio = float(_trapezoid(ratio, wave[feature_mask]) / feature_span)
86
123
  if mean_ratio <= 0.0:
87
124
  return float("nan")
88
125
  return float(-2.5 * np.log10(mean_ratio))
@@ -92,7 +129,7 @@ def _lick_index(
92
129
  @register_algorithm(
93
130
  "embed_lick_indices",
94
131
  category=AlgorithmCategory.EMBEDDING,
95
- version="1.0.0",
132
+ version="2.0.0", # 2.0.0: fixed atomic-EW formula; values changed (cf. CHANGELOG v0.1.4)
96
133
  )
97
134
  class EmbedLickIndices(BaseAlgorithm):
98
135
  """Embed a spectrum as the canonical Lick/IDS line-strength indices.
@@ -1,3 +1,3 @@
1
1
  """Single source of truth for the package version."""
2
2
 
3
- __version__ = "0.1.3"
3
+ __version__ = "0.1.4"
@@ -7,11 +7,39 @@ Claude Desktop) or over HTTP (for remote agents).
7
7
  from __future__ import annotations
8
8
 
9
9
  import argparse
10
+ import os
10
11
  import sys
12
+ from contextlib import contextmanager
11
13
 
12
14
  from spectro_kernel.version import __version__
13
15
 
14
16
 
17
+ @contextmanager
18
+ def _silence_fd_stdout():
19
+ """Redirect FD 1 (stdout) to FD 2 (stderr) at the OS level for the boot phase.
20
+
21
+ ``contextlib.redirect_stdout`` only rewires ``sys.stdout``, which is
22
+ enough for pure-Python ``print()`` but NOT for third-party libraries
23
+ that write via the C-level FD 1 directly (e.g. astroquery's
24
+ "In preparation for Gaia DR4…" banner, easyspec's version string).
25
+ Those would otherwise corrupt the very first MCP handshake on stdio.
26
+
27
+ We dup(2) the original stdout FD aside, point FD 1 at FD 2 (stderr)
28
+ for the duration of the block, then restore on exit. Both Python's
29
+ ``sys.stdout`` AND any C-level writer end up writing to stderr while
30
+ the block is active.
31
+ """
32
+ sys.stdout.flush()
33
+ saved_fd = os.dup(1)
34
+ try:
35
+ os.dup2(2, 1) # FD 1 (stdout) now points at FD 2 (stderr)
36
+ yield
37
+ finally:
38
+ sys.stdout.flush()
39
+ os.dup2(saved_fd, 1)
40
+ os.close(saved_fd)
41
+
42
+
15
43
  def build_parser() -> argparse.ArgumentParser:
16
44
  """Construct the ``spectro-mcp`` argument parser."""
17
45
  parser = argparse.ArgumentParser(
@@ -36,22 +64,6 @@ def build_parser() -> argparse.ArgumentParser:
36
64
  return parser
37
65
 
38
66
 
39
- def main(argv: list[str] | None = None) -> int:
40
- """Entry point for the ``spectro-mcp`` console script."""
41
- args = build_parser().parse_args(argv)
42
- try:
43
- from .server import build_server
44
- except ImportError as exc:
45
- print(f"error: {exc}", file=sys.stderr)
46
- return 2
47
-
48
- server = build_server(ttl_seconds=args.ttl)
49
- if args.http:
50
- return _run_http(server, args)
51
- server.run() # stdio is FastMCP's default transport
52
- return 0
53
-
54
-
55
67
  def _run_http(server, args) -> int:
56
68
  """Serve over HTTP, wiring optional API-key auth + rate-limit middleware."""
57
69
  import os
@@ -66,6 +78,13 @@ def _run_http(server, args) -> int:
66
78
  if api_key:
67
79
  app.add_middleware(ApiKeyMiddleware, expected=api_key)
68
80
  print("spectro-mcp: API-key auth enabled (header: X-API-Key).", file=sys.stderr)
81
+ else:
82
+ print(
83
+ "spectro-mcp: WARNING — no SPECTRO_MCP_API_KEY set; the server "
84
+ "will accept every request. This is fine for personal stdio "
85
+ "use but not for a shared deployment.",
86
+ file=sys.stderr,
87
+ )
69
88
 
70
89
  rate_per_min = int(os.environ.get("SPECTRO_MCP_RATE_PER_MINUTE", "0") or "0")
71
90
  if rate_per_min > 0:
@@ -76,5 +95,33 @@ def _run_http(server, args) -> int:
76
95
  return 0
77
96
 
78
97
 
98
+ def main(argv: list[str] | None = None) -> int:
99
+ """Entry point for the ``spectro-mcp`` console script."""
100
+ args = build_parser().parse_args(argv)
101
+ try:
102
+ from .server import build_server
103
+ except ImportError as exc:
104
+ print(f"error: {exc}", file=sys.stderr)
105
+ return 2
106
+
107
+ if args.http:
108
+ # In HTTP / shared-server mode, refuse local filesystem paths from
109
+ # MCP tool calls — they would otherwise be a path-traversal vector
110
+ # (load_spectrum("/etc/passwd"), …). Stdio mode leaves the flag
111
+ # unset because the server is a subprocess of the user's own
112
+ # Claude Desktop and the user's files are legitimate input.
113
+ os.environ.setdefault("SPECTRO_MCP_DENY_LOCAL_PATHS", "1")
114
+ server = build_server(ttl_seconds=args.ttl)
115
+ return _run_http(server, args)
116
+
117
+ # stdio: stdout is the JSON-RPC wire. Boot phase writes everything to
118
+ # stderr (FD-level redirect catches third-party banners that bypass
119
+ # sys.stdout); FastMCP then takes over stdout for the run loop.
120
+ with _silence_fd_stdout():
121
+ server = build_server(ttl_seconds=args.ttl)
122
+ server.run() # stdio is FastMCP's default transport
123
+ return 0
124
+
125
+
79
126
  if __name__ == "__main__": # pragma: no cover
80
127
  raise SystemExit(main())
@@ -10,8 +10,6 @@ from __future__ import annotations
10
10
  import os
11
11
  import tempfile
12
12
  import time
13
- import urllib.parse
14
- import urllib.request
15
13
  import uuid
16
14
  from pathlib import Path
17
15
  from typing import Any
@@ -28,6 +26,13 @@ from spectro_kernel.registry import (
28
26
 
29
27
  from .observability import configure_logger, log_audit, record_tool_call
30
28
  from .session import SessionManager, SessionNotFoundError
29
+ from .url_safety import (
30
+ PathNotAllowedError,
31
+ URLNotAllowedError,
32
+ open_safe_url,
33
+ validate_loadable_path,
34
+ validate_safe_url,
35
+ )
31
36
 
32
37
  _LOGGER = configure_logger()
33
38
 
@@ -94,31 +99,55 @@ def _algo_description(meta: dict[str, Any]) -> str:
94
99
  return "\n".join(lines)
95
100
 
96
101
 
97
- def _load_spectrum(path: str) -> Any:
102
+ def _load_spectrum_safe(path: str) -> Any:
103
+ """Load a spectrum from *path* after running every safety guard.
104
+
105
+ Local paths are refused when the server is in HTTP / shared mode
106
+ (``SPECTRO_MCP_DENY_LOCAL_PATHS=1``). URLs are first downloaded to a
107
+ temp file via :func:`_download_to_temp` (no redirects, no SSRF, size
108
+ cap) and the local copy is fed to the underlying reader. This way
109
+ the unsafe ``urllib.request.urlretrieve`` path inside
110
+ ``spectro_kernel.io.read_fits`` is never exercised from the MCP
111
+ surface.
112
+ """
113
+ validate_loadable_path(path)
114
+ if path.startswith(("http://", "https://")):
115
+ local = _download_to_temp(path)
116
+ try:
117
+ suffix = local.suffix.lower()
118
+ if suffix in _FITS_SUFFIXES:
119
+ return read_fits(str(local))
120
+ return read_ascii_spectrum(str(local))
121
+ finally:
122
+ local.unlink(missing_ok=True)
98
123
  suffix = Path(path).suffix.lower()
99
- if suffix in _FITS_SUFFIXES or path.startswith(("http://", "https://")):
124
+ if suffix in _FITS_SUFFIXES:
100
125
  return read_fits(path)
101
126
  return read_ascii_spectrum(path)
102
127
 
103
128
 
104
129
  def _download_to_temp(url: str) -> Path:
105
- """Download *url* to a temporary file, preserving the suffix and capping size."""
106
- parsed = urllib.parse.urlparse(url)
107
- if parsed.scheme not in ("http", "https"):
108
- raise ValueError(f"Only http(s) URLs are accepted, got: {parsed.scheme!r}")
109
- suffix = Path(parsed.path).suffix or ".dat"
130
+ """Download *url* to a temp file under SSRF + size + redirect guards.
131
+
132
+ Refuses non-public IPs (loopback / RFC1918 / link-local / multicast),
133
+ refuses HTTP redirects, applies a 60 s timeout and a 256 MiB cap.
134
+ """
135
+ validate_safe_url(url)
136
+ parsed_path = Path(url.split("?", 1)[0])
137
+ suffix = parsed_path.suffix or ".dat"
110
138
  fd, path = tempfile.mkstemp(suffix=suffix, prefix="spectro_upload_")
111
139
  written = 0
112
140
  try:
113
- with urllib.request.urlopen(url, timeout=60) as resp, os.fdopen(fd, "wb") as f:
141
+ with open_safe_url(url, timeout=60.0) as resp, os.fdopen(fd, "wb") as f:
114
142
  while True:
115
143
  chunk = resp.read(65536)
116
144
  if not chunk:
117
145
  break
118
146
  written += len(chunk)
119
147
  if written > _MAX_DOWNLOAD_BYTES:
120
- raise ValueError(
121
- f"Refusing to download more than {_MAX_DOWNLOAD_BYTES} bytes."
148
+ raise URLNotAllowedError(
149
+ f"refusing download from {url!r}: would exceed "
150
+ f"the {_MAX_DOWNLOAD_BYTES} byte cap."
122
151
  )
123
152
  f.write(chunk)
124
153
  except Exception:
@@ -147,7 +176,10 @@ def register_transverse_tools(mcp: Any, sessions: SessionManager) -> None:
147
176
  The spectrum becomes the session's working spectrum, ready for the analysis tools.
148
177
  """
149
178
  ctx = sessions.get(session_id)
150
- ctx.spectrum = _load_spectrum(path)
179
+ try:
180
+ ctx.spectrum = _load_spectrum_safe(path)
181
+ except (PathNotAllowedError, URLNotAllowedError) as exc:
182
+ return {"error": str(exc)}
151
183
  sessions.save(session_id, ctx)
152
184
  return {"loaded": True, "preview": ctx.spectrum.preview()}
153
185
 
@@ -283,7 +315,10 @@ def register_transverse_tools(mcp: Any, sessions: SessionManager) -> None:
283
315
  suffix; pass ``format_hint`` ("fits" or "ascii") to override.
284
316
  """
285
317
  ctx = sessions.get(session_id)
286
- local = _download_to_temp(url)
318
+ try:
319
+ local = _download_to_temp(url)
320
+ except URLNotAllowedError as exc:
321
+ return {"error": str(exc)}
287
322
  try:
288
323
  hint = format_hint.lower()
289
324
  if hint == "fits" or (not hint and local.suffix.lower() in _FITS_SUFFIXES):
@@ -78,8 +78,15 @@ class _JsonFormatter(logging.Formatter):
78
78
 
79
79
 
80
80
  def configure_logger(name: str = "spectro_mcp", level: int = logging.INFO) -> logging.Logger:
81
- """Configure structured JSON logging to stdout. Returns the configured logger."""
82
- handler = logging.StreamHandler(sys.stdout)
81
+ """Configure structured JSON logging to **stderr**. Returns the configured logger.
82
+
83
+ Why stderr and not stdout: in stdio MCP mode, stdout is the
84
+ JSON-RPC wire to the agent. Any byte written to stdout that isn't a
85
+ well-formed JSON-RPC frame corrupts the protocol and crashes the
86
+ agent's parser. Audit logs MUST go to stderr; stdout is reserved.
87
+ HTTP mode is unaffected (uvicorn doesn't multiplex stdout).
88
+ """
89
+ handler = logging.StreamHandler(sys.stderr)
83
90
  handler.setFormatter(_JsonFormatter())
84
91
  logger = logging.getLogger(name)
85
92
  logger.handlers.clear()
@@ -15,15 +15,68 @@ Use :func:`make_session_manager` to pick the right backend from configuration.
15
15
 
16
16
  from __future__ import annotations
17
17
 
18
+ import hashlib
19
+ import hmac
18
20
  import os
19
21
  import pickle
20
22
  import secrets
23
+ import sys
21
24
  import time
22
25
  from dataclasses import dataclass, field
23
26
  from typing import Protocol
24
27
 
25
28
  from spectro_kernel.types import WorkContext
26
29
 
30
+ # Length of the HMAC-SHA256 digest prefix we sign the pickle blob with.
31
+ _HMAC_LEN = 32
32
+
33
+
34
+ def _resolve_redis_secret() -> bytes:
35
+ """Return the secret key used to sign Redis-stored session payloads.
36
+
37
+ Strict order:
38
+ 1. ``SPECTRO_MCP_SESSION_SECRET`` env var — use it as-is (UTF-8 encoded).
39
+ This is the only option that keeps sessions valid across restarts.
40
+ 2. No env var → generate a fresh 32-byte random secret for this process.
41
+ Sessions written by another instance are unverifiable, so an attacker
42
+ who plants a malicious blob in Redis can't make us deserialise it,
43
+ but legitimate cross-instance sharing is also broken. Emit a
44
+ prominent warning so operators don't roll this out by accident.
45
+ """
46
+ raw = os.environ.get("SPECTRO_MCP_SESSION_SECRET", "").strip()
47
+ if raw:
48
+ return raw.encode("utf-8")
49
+ print(
50
+ "spectro-mcp: WARNING — SPECTRO_MCP_SESSION_SECRET is not set. "
51
+ "Generating a one-shot random session secret for this process; "
52
+ "Redis-backed sessions will not survive a restart. Set the env "
53
+ "var to a stable random string (32+ bytes) for a real deployment.",
54
+ file=sys.stderr,
55
+ )
56
+ return secrets.token_bytes(32)
57
+
58
+
59
+ def _sign_payload(payload: bytes, key: bytes) -> bytes:
60
+ """Return ``HMAC-SHA256(key, payload) ‖ payload``."""
61
+ return hmac.new(key, payload, hashlib.sha256).digest() + payload
62
+
63
+
64
+ def _verify_and_unpack(blob: bytes, key: bytes) -> bytes:
65
+ """Strip the HMAC prefix after verification; raise on mismatch."""
66
+ if len(blob) < _HMAC_LEN:
67
+ raise ValueError("session payload too short to carry an HMAC prefix")
68
+ sig, payload = blob[:_HMAC_LEN], blob[_HMAC_LEN:]
69
+ expected = hmac.new(key, payload, hashlib.sha256).digest()
70
+ if not hmac.compare_digest(sig, expected):
71
+ # NB: do NOT pickle.loads(payload) on failure — the whole point of
72
+ # this check is to refuse running attacker-controlled pickle.
73
+ raise ValueError(
74
+ "session payload HMAC mismatch: refusing to deserialise (the "
75
+ "blob in Redis was either written by another instance or has "
76
+ "been tampered with)."
77
+ )
78
+ return payload
79
+
27
80
 
28
81
  class SessionNotFoundError(KeyError):
29
82
  """Raised when a session id is unknown or has expired."""
@@ -119,11 +172,20 @@ class _SessionManagerProtocol(Protocol):
119
172
 
120
173
 
121
174
  class RedisSessionManager:
122
- """Session storage backed by Redis — survives across instances and restarts."""
175
+ """Session storage backed by Redis — survives across instances and restarts.
176
+
177
+ Payloads are HMAC-signed with a key derived from
178
+ ``SPECTRO_MCP_SESSION_SECRET`` (env). Without signature verification,
179
+ an attacker with write access to Redis can inject a malicious pickle
180
+ blob and obtain RCE on the next ``get()`` — this class refuses any
181
+ blob whose HMAC doesn't match.
182
+ """
123
183
 
124
184
  _KEY_PREFIX = "sk:session:"
125
185
 
126
- def __init__(self, url: str, ttl_seconds: float = 3600.0) -> None:
186
+ def __init__(
187
+ self, url: str, ttl_seconds: float = 3600.0, *, secret: bytes | None = None
188
+ ) -> None:
127
189
  try:
128
190
  import redis # type: ignore[import-untyped]
129
191
  except ImportError as exc: # pragma: no cover - optional dep
@@ -133,14 +195,25 @@ class RedisSessionManager:
133
195
  ) from exc
134
196
  self._redis = redis.Redis.from_url(url)
135
197
  self.ttl_seconds = ttl_seconds
198
+ # Per-instance key; resolved at construction so tests can inject one.
199
+ self._secret = secret if secret is not None else _resolve_redis_secret()
136
200
 
137
201
  def _key(self, session_id: str) -> str:
138
202
  return self._KEY_PREFIX + session_id
139
203
 
204
+ def _serialise(self, ctx: WorkContext) -> bytes:
205
+ return _sign_payload(pickle.dumps(ctx), self._secret)
206
+
207
+ def _deserialise(self, blob: bytes) -> WorkContext:
208
+ payload = _verify_and_unpack(blob, self._secret)
209
+ return pickle.loads(payload)
210
+
140
211
  def create(self) -> str:
141
212
  session_id = "s_" + secrets.token_urlsafe(8)
142
213
  self._redis.setex(
143
- self._key(session_id), int(self.ttl_seconds), pickle.dumps(WorkContext())
214
+ self._key(session_id),
215
+ int(self.ttl_seconds),
216
+ self._serialise(WorkContext()),
144
217
  )
145
218
  return session_id
146
219
 
@@ -150,12 +223,14 @@ class RedisSessionManager:
150
223
  raise SessionNotFoundError(session_id)
151
224
  # touch TTL on every access
152
225
  self._redis.expire(self._key(session_id), int(self.ttl_seconds))
153
- return pickle.loads(data)
226
+ return self._deserialise(data)
154
227
 
155
228
  def save(self, session_id: str, ctx: WorkContext) -> None:
156
229
  if not self._redis.exists(self._key(session_id)):
157
230
  raise SessionNotFoundError(session_id)
158
- self._redis.setex(self._key(session_id), int(self.ttl_seconds), pickle.dumps(ctx))
231
+ self._redis.setex(
232
+ self._key(session_id), int(self.ttl_seconds), self._serialise(ctx)
233
+ )
159
234
 
160
235
  def has(self, session_id: str) -> bool:
161
236
  return bool(self._redis.exists(self._key(session_id)))