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.
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/CHANGELOG.md +109 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/PKG-INFO +1 -1
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/embedding/embed_lick_indices.py +42 -5
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/version.py +1 -1
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_mcp/__main__.py +63 -16
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_mcp/auto_tools.py +49 -14
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_mcp/observability.py +9 -2
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_mcp/session.py +80 -5
- spectro_kernel-0.1.4/src/spectro_mcp/url_safety.py +155 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/unit/test_embedding_remote_lick.py +63 -0
- spectro_kernel-0.1.4/tests/unit/test_mcp_security.py +169 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/.gitignore +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/LICENSE +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/README.md +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/concepts/algorithms.md +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/concepts/architecture.md +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/concepts/data-types.md +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/concepts/pipelines.md +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/contributing.md +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/cookbook/bess-dashboard.md +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/cookbook/multi-star-viewer.md +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/cookbook/web-playground.md +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/gen_catalogue.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/getting-started.md +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/images/notebooks/alpha_cyg_ew_timeseries.png +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/images/notebooks/alpha_cyg_halpha_phase_stack.png +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/images/notebooks/alpha_cyg_halpha_timeseries.png +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/images/notebooks/alpha_cyg_overlay.png +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/images/notebooks/alpha_cyg_pc1_vs_phase.png +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/images/notebooks/alpha_cyg_pca_2d.png +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/images/notebooks/alpha_cyg_pca_3d.png +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/images/notebooks/alpha_cyg_periodogram.png +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/images/notebooks/alpha_cyg_phase_folded.png +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/images/notebooks/alpha_cyg_rv_timeseries.png +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/images/notebooks/alpha_cyg_similarity.png +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/images/notebooks/alpha_cyg_similarity_date.png +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/images/notebooks/alpha_dra_halpha_phase_stack.png +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/images/notebooks/alpha_dra_pc1_vs_phase.png +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/images/notebooks/alpha_dra_pca_2d.png +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/images/notebooks/alpha_dra_pca_3d.png +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/images/notebooks/alpha_dra_periodogram.png +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/images/notebooks/alpha_dra_phase_folded.png +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/images/notebooks/alpha_dra_rv_timeseries.png +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/images/notebooks/alpha_dra_similarity_date.png +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/images/notebooks/alpha_dra_similarity_phase.png +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/index.md +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/notebooks/alpha-cyg-time-series.md +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/notebooks/alpha-dra-binary-period.md +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/notebooks/aurora-line-monitor.md +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/notebooks/be-star-variability.md +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/notebooks/claude-mcp-end-to-end.md +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/notebooks/exoplanet-transit-rv.md +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/notebooks/full-reduction-walkthrough.md +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/notebooks/native-vs-easyspec-showdown.md +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/notebooks/your-first-sb2.md +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/reference.md +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/tutorials/add-an-algorithm.md +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/tutorials/analyse-a-spectrum.md +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/tutorials/discover-the-catalogue.md +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/tutorials/first-spectrum.md +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/usage/cli.md +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/usage/library.md +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/usage/mcp.md +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/docs/why.md +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/playground/README.md +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/pyproject.toml +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/__init__.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/adapters/__init__.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/adapters/easyspec.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/__init__.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/_common.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/advanced/__init__.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/advanced/aperture_photometry.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/advanced/disentangle_sb2.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/catalogs/__init__.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/catalogs/gaia.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/catalogs/simbad.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/catalogs/vizier.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/continuum/__init__.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/continuum/compare_normalisations.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/continuum/normalize_edges.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/continuum/normalize_max.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/continuum/normalize_percentile.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/continuum/normalize_polynomial.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/continuum/subtract_continuum.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/corrections/__init__.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/corrections/air_vacuum.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/corrections/barycentric.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/corrections/doppler_shift.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/corrections/extinction_correct_easyspec.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/corrections/fit_telluric_scaling.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/corrections/flux_calibrate_easyspec.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/corrections/remove_telluric.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/corrections/synth_telluric.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/embedding/__init__.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/embedding/embed_band_power.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/embedding/embed_continuum_subtracted.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/embedding/embed_log_lambda.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/embedding/embed_pretrained.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/embedding/embed_remote.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/embedding/embed_spectrum.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/embedding/embed_wavelets.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/exports/__init__.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/exports/export_csv.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/exports/export_fits.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/exports/export_hdf5.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/exports/export_votable.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/extraction/__init__.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/extraction/easyspec_extract.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/io/__init__.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/io/read_ascii.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/io/read_echelle.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/io/read_fits.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/io/read_sdss.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/io/read_votable.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/lines/__init__.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/lines/_profiles.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/lines/catalogs.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/lines/compare_line_fits.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/lines/detect.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/lines/equivalent_width.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/lines/fit_gaussian.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/lines/fit_lorentzian.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/lines/fit_voigt.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/quality/__init__.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/quality/compare_snr_methods.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/quality/snr_der.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/quality/snr_edge.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/quality/snr_linear_fit.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/reduction/__init__.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/reduction/_easyspec_apply.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/reduction/_easyspec_helpers.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/reduction/bias_combine.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/reduction/clip_cosmic_rays.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/reduction/dark_subtract.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/reduction/easyspec_bias.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/reduction/easyspec_cosmic_ray.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/reduction/easyspec_dark.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/reduction/easyspec_flat.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/reduction/easyspec_flat_normalize.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/reduction/easyspec_subtract_bias.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/reduction/easyspec_subtract_dark.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/reduction/extract_spectrum_sum.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/reduction/flat_normalize.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/reduction/subtract_sky_2d.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/reduction/wavelength_calibrate.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/rv/__init__.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/rv/cross_correlate.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/rv/fit_keplerian_orbit.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/rv/measure.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/rv/precision_bouchy.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/smoothing/__init__.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/smoothing/compare_smoothings.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/smoothing/smooth_gaussian.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/smoothing/smooth_savgol.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/stacking/__init__.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/stacking/merge_echelle_orders.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/stacking/stack.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/timeseries/__init__.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/timeseries/lomb_scargle.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/timeseries/phase_fold.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/transforms/__init__.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/transforms/clip_sigma.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/transforms/extract_region.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/transforms/mask_range.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/transforms/resample.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/transforms/resample_flux_conserving.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/viz/__init__.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/viz/plot_3d_surface.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/viz/plot_animation.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/viz/plot_dynamic_spectrum.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/viz/plot_plotly.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/wavelength_calibration/__init__.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/algorithms/wavelength_calibration/easyspec_wavelength.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/base.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/cli.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/embeddings.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/errors.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/io/__init__.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/io/ascii.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/io/fits.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/io/votable.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/pipeline.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/presets/__init__.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/presets/catalog/analysis/balmer_quick.yaml +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/presets/catalog/analysis/embed_quick.yaml +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/presets/catalog/analysis/quality_report.yaml +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/presets/catalog/analysis/rv_quick.yaml +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/presets/catalog/analysis/snr_check.yaml +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/presets/catalog/analysis/time_series_overview.yaml +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/presets/catalog/reduction/full_reduction_easyspec.yaml +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/presets/loader.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/py.typed +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/registry.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/types/__init__.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/types/catalog.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/types/context.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/types/enums.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/types/history.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/types/image.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/types/line.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/types/spectrum.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_kernel/types/timeseries.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_mcp/__init__.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_mcp/auth.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_mcp/py.typed +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/src/spectro_mcp/server.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/conftest.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/reference/conftest.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/reference/data/.gitkeep +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/reference/data/README.md +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/reference/data/sun/sun_reference_stis_002.fits +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/reference/data/vega/alpha_lyr_stis_011.fits +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/reference/test_known_answers.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/reference/test_sun.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/reference/test_vega.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/unit/test_base.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/unit/test_cli.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/unit/test_combine.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/unit/test_continuum.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/unit/test_corrections.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/unit/test_easyspec_wrappers.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/unit/test_embedding.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/unit/test_embedding_pretrained.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/unit/test_embedding_tier1.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/unit/test_io.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/unit/test_line_profiles.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/unit/test_lines.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/unit/test_mcp.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/unit/test_mcp_production.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/unit/test_misc_algorithms.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/unit/test_no_circular_imports.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/unit/test_pipeline.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/unit/test_quality.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/unit/test_read_sdss.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/unit/test_registry.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/unit/test_smoothing.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/unit/test_timeseries.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/unit/test_transforms.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/unit/test_types.py +0 -0
- {spectro_kernel-0.1.3 → spectro_kernel-0.1.4}/tests/unit/test_viz.py +0 -0
- {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
|
+
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
|
-
|
|
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
|
-
|
|
109
|
+
# Canonical Worthey 1994 EW: ∫(1 - F/Fc) dλ — 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
|
-
|
|
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="
|
|
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.
|
|
@@ -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
|
|
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
|
|
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
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
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
|
|
121
|
-
f"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
82
|
-
|
|
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__(
|
|
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),
|
|
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
|
|
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(
|
|
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)))
|