Skip to main content
Glama
puran-water

Corrosion Engineering MCP Server

by puran-water
galvanic_backend.py29.6 kB
""" Galvanic Corrosion Backend - Mixed-Potential Theory Implements Evans diagram analysis for galvanic couples: - Anodic polarization (metal dissolution) - Cathodic polarization (oxygen reduction, hydrogen evolution) - Mixed-potential intersection (E_couple, i_galv) - Corrosion rate calculation Based on: - NRL polarization curve data (SS316ORRCoeffs.csv, etc.) - Butler-Volmer / Tafel approximations - Mixed-potential theory (Wagner & Traud) Per Codex guidance: - Use Tafel approximations (valid for |η| > 50-100 mV) - Weight multiple cathodes by exposed area - Return both E_couple and i_galv for downstream tools """ from __future__ import annotations import logging import math from dataclasses import dataclass from typing import Dict, Optional, Tuple, List from pathlib import Path # Import authoritative materials database (BUG-010, BUG-012 fixes) from data import ( get_material_data, get_orr_diffusion_limit, GALVANIC_SERIES_SEAWATER, E_SHE_TO_SCE, ) # DIRECT IMPORT: NRL polarization curve data (FIX ISSUE-102, ISSUE-103) from data import ( get_orr_parameters, get_her_parameters, get_passivation_parameters, get_metal_oxidation_parameters, get_pitting_parameters, ) logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Physical constants # --------------------------------------------------------------------------- FARADAY = 96485.3321 # C/mol R_GAS = 8.314462618 # J/mol·K T_STD = 298.15 # 25°C in K # --------------------------------------------------------------------------- # Data classes # --------------------------------------------------------------------------- @dataclass class PolarizationCurve: """ Polarization curve data for a material/reaction. Attributes: material: Material name (e.g., "carbon steel", "316L") reaction: Reaction type ("anodic", "orr", "her") E_corr: Corrosion potential vs SHE (V) i0: Exchange current density (A/m²) ba: Anodic Tafel slope (V/decade) bc: Cathodic Tafel slope (V/decade) temperature_C: Temperature (°C) electrolyte: Electrolyte description source: Data source reference """ material: str reaction: str E_corr: float i0: float ba: Optional[float] = None bc: Optional[float] = None temperature_C: float = 25.0 electrolyte: str = "seawater" source: str = "Unknown" @dataclass class GalvanicResult: """ Result of galvanic corrosion calculation. Attributes: E_couple: Coupled potential vs SHE (V) i_galv: Galvanic current density on anode (A/m²) corrosion_rate_mm_per_year: Corrosion rate (mm/year) anode_material: Anode material name cathode_material: Cathode material name area_ratio: Cathode area / Anode area interpretation: Text summary """ E_couple: float i_galv: float corrosion_rate_mm_per_year: float anode_material: str cathode_material: str area_ratio: float interpretation: str # --------------------------------------------------------------------------- # Galvanic corrosion backend # --------------------------------------------------------------------------- class GalvanicBackend: """ Mixed-potential theory galvanic corrosion calculator. Uses Evans diagram approach to find E_couple and i_galv. """ def __init__(self): """Initialize galvanic backend.""" pass def calculate_tafel_current( self, E: float, E_corr: float, i0: float, beta: float, is_anodic: bool = True, ) -> float: """ Calculate current density from Tafel equation. Args: E: Applied potential (V vs SHE) E_corr: Corrosion potential (V vs SHE) i0: Exchange current density (A/m²) beta: Tafel slope (V/decade) - positive for anodic, negative for cathodic is_anodic: True for anodic branch, False for cathodic Returns: Current density (A/m²) - positive for anodic, negative for cathodic Tafel equation: i = i0 × 10^(η / β) where: η = E - E_corr (overpotential) β = ba (anodic) or bc (cathodic) """ eta = E - E_corr # Overpotential # Tafel approximation valid for |η| > ~50-100 mV per Codex if abs(eta) < 0.05: logger.warning(f"Tafel approximation questionable for η = {eta*1000:.1f} mV < 50 mV") try: i = i0 * 10.0 ** (eta / beta) except (OverflowError, ValueError): # Handle extreme overpotentials if eta > 0: i = 1e10 # Very large anodic current else: i = 1e-10 # Very small cathodic current # Apply sign convention: anodic positive, cathodic negative if not is_anodic: i = -i return i def find_mixed_potential( self, anodic_curve: PolarizationCurve, cathodic_curve: PolarizationCurve, area_ratio: float = 1.0, i_lim: Optional[float] = None, ) -> Tuple[float, float]: """ Find mixed potential (E_couple) and galvanic current (i_galv). Uses bisection to find intersection of anodic and cathodic curves. Args: anodic_curve: Anodic polarization curve (metal dissolution) cathodic_curve: Cathodic polarization curve (ORR, HER) area_ratio: A_cathode / A_anode (scales cathodic current) i_lim: Diffusion-limited current density (A/m²) for ORR (BUG-011 fix) Returns: Tuple of (E_couple, i_galv) where: E_couple: Coupled potential (V vs SHE) i_galv: Galvanic current density on anode (A/m²) FIX BUG-011: Add diffusion limits per NRL data Per Codex: Weight cathode by area ratio """ # Initial bounds: Between the two corrosion potentials E_min = min(anodic_curve.E_corr, cathodic_curve.E_corr) - 0.5 E_max = max(anodic_curve.E_corr, cathodic_curve.E_corr) + 0.5 # Bisection to find E where i_anodic = i_cathodic × area_ratio tolerance = 1e-6 # 1 µV max_iterations = 100 for iteration in range(max_iterations): E_mid = (E_min + E_max) / 2.0 # Anodic current (positive) i_anodic = self.calculate_tafel_current( E_mid, anodic_curve.E_corr, anodic_curve.i0, anodic_curve.ba, is_anodic=True, ) # Cathodic current (negative) scaled by area i_cathodic_raw = self.calculate_tafel_current( E_mid, cathodic_curve.E_corr, cathodic_curve.i0, cathodic_curve.bc, is_anodic=False, ) # FIX BUG-011: Apply diffusion limit to cathodic current # ORR caps at i_lim due to O₂ mass transport (NRL data: 0.5-1 mA/cm²) if i_lim is not None: # Clamp magnitude of cathodic current (it's negative) if abs(i_cathodic_raw) > i_lim: i_cathodic_raw = -i_lim # Apply limit logger.debug(f"ORR diffusion limit applied: {i_lim} A/m²") i_cathodic = i_cathodic_raw * area_ratio # Find where i_anodic + i_cathodic = 0 (charge balance) i_net = i_anodic + i_cathodic if abs(i_net) < tolerance or (E_max - E_min) < tolerance: # Converged E_couple = E_mid i_galv = i_anodic # Galvanic current on anode return E_couple, i_galv # Bisection update if i_net > 0: # Too anodic, lower potential E_max = E_mid else: # Too cathodic, raise potential E_min = E_mid # Failed to converge logger.warning(f"Mixed potential failed to converge after {max_iterations} iterations") E_couple = (E_min + E_max) / 2.0 i_galv = abs(i_anodic) return E_couple, i_galv def current_to_corrosion_rate( self, i_corr: float, material: str, n_electrons: Optional[int] = None, ) -> float: """ Convert corrosion current density to corrosion rate. Args: i_corr: Corrosion current density (A/m²) material: Material name (for density, MW, valence lookup) n_electrons: Number of electrons (optional, gets from database) Returns: Corrosion rate (mm/year) Formula (per Faraday's law): CR (mm/year) = i_corr × MW × 31.536 / (n × F × ρ) FIX BUG-012: Use authoritative materials database for n_electrons """ # Get material data from authoritative database mat_data = get_material_data(material) if mat_data is not None: # Use authoritative data rho = mat_data.density_kg_m3 n = n_electrons if n_electrons is not None else mat_data.n_electrons # Molecular weight depends on primary element if mat_data.grade_type in ["austenitic", "duplex", "super_duplex", "carbon_steel"]: MW = 55.845 # Fe elif mat_data.grade_type == "aluminum": MW = 26.982 # Al elif mat_data.grade_type in ["copper", "copper_alloy"]: MW = 63.546 # Cu elif mat_data.grade_type == "titanium": MW = 47.867 # Ti elif mat_data.grade_type == "zinc": MW = 65.38 # Zn elif mat_data.grade_type == "nickel_alloy": MW = 58.693 # Ni else: MW = 55.845 # Default to Fe logger.info( f"Using authoritative data for {material}: " f"n={n}, MW={MW}, ρ={rho} kg/m³" ) else: # Fallback (log warning) logger.warning( f"Material '{material}' not in authoritative database; " f"using conservative Fe defaults" ) MW = 55.845 # Fe rho = 7850.0 # kg/m³ n = n_electrons if n_electrons is not None else 2 # Conversion factor (seconds/year × mm/m × g/kg) K = 365.25 * 24 * 3600 * 1000 * 1000 / 1e6 # = 31.536 # Corrosion rate (mm/year) CR = (i_corr * MW * K) / (n * FARADAY * rho) return CR def calculate_galvanic_corrosion( self, anode_material: str, cathode_material: str, area_ratio: float, temperature_C: float = 25.0, electrolyte: str = "seawater", ) -> GalvanicResult: """ Calculate galvanic corrosion rate for a bimetallic couple. Args: anode_material: Less noble material (e.g., "carbon steel") cathode_material: More noble material (e.g., "316L") area_ratio: A_cathode / A_anode temperature_C: Temperature (°C) electrolyte: Electrolyte type Returns: GalvanicResult with E_couple, i_galv, corrosion rate Per Codex: Use Tafel approximations and weight by area """ # Get polarization curves (BUG-010 partial fix: uses ASTM G82 data) anodic_curve = self._get_anodic_curve(anode_material, temperature_C, electrolyte) cathodic_curve = self._get_cathodic_curve(cathode_material, temperature_C, electrolyte) # Get ORR diffusion limit (BUG-011 fix: add transport limits) i_lim = get_orr_diffusion_limit(electrolyte, temperature_C) logger.info(f"Using ORR diffusion limit: {i_lim} A/m² for {electrolyte} at {temperature_C}°C") # Find mixed potential with diffusion limits E_couple, i_galv = self.find_mixed_potential(anodic_curve, cathodic_curve, area_ratio, i_lim=i_lim) # Convert to corrosion rate (BUG-012 fix: no hardcoded n_electrons) CR = self.current_to_corrosion_rate(i_galv, anode_material) # Interpretation if CR > 1.0: interpretation = f"Severe galvanic corrosion (CR = {CR:.2f} mm/year)" elif CR > 0.1: interpretation = f"Moderate galvanic corrosion (CR = {CR:.2f} mm/year)" else: interpretation = f"Low galvanic corrosion (CR = {CR:.2f} mm/year)" if area_ratio > 10.0: interpretation += f"; Large cathode/anode ratio ({area_ratio:.1f}:1) accelerates attack" return GalvanicResult( E_couple=E_couple, i_galv=i_galv, corrosion_rate_mm_per_year=CR, anode_material=anode_material, cathode_material=cathode_material, area_ratio=area_ratio, interpretation=interpretation, ) def _get_galvanic_potential( self, material: str, electrolyte: str, ) -> float: """ Get corrosion potential from ASTM G82 galvanic series. CRITICAL FIX (ISSUE-101): ASTM G82 potentials are vs SCE reference. All potentials are converted to SHE reference for consistency. Conversion: E(SHE) = E(SCE) + 0.241V (per ASTM G3, NACE SP0208) Args: material: Material name electrolyte: Electrolyte type (e.g., "seawater") Returns: E_corr vs SHE (V) - CONVERTED from ASTM G82 SCE values Reference: - ASTM G82-98 (2014): Galvanic series in seawater (vs SCE) - ASTM G3-14: Standard Reference Test Method for Making Potentiostatic and Potentiodynamic Anodic Polarization Measurements - E_SHE_TO_SCE from NRL Constants.m = 0.244V (we use 0.241V per ASTM G3) """ if electrolyte.lower() != "seawater": logger.warning(f"Galvanic series only available for seawater; using seawater data for {electrolyte}") # Try to find material in ASTM G82 galvanic series material_lower = material.lower() # Check galvanic series (with passive/active states for stainless steels) for key, potential_sce in GALVANIC_SERIES_SEAWATER.items(): if key.lower() in material_lower or material_lower in key.lower(): # CRITICAL FIX: Convert SCE to SHE potential_she = potential_sce + E_SHE_TO_SCE logger.info( f"Using ASTM G82 galvanic potential for {material}: " f"E_corr = {potential_sce:.3f} V vs SCE → {potential_she:.3f} V vs SHE" ) return potential_she # Fallback based on material type (also in SCE, needs conversion) mat_data = get_material_data(material) if mat_data: if mat_data.grade_type in ["austenitic", "duplex", "super_duplex", "superaustenitic"]: E_corr_sce = -0.10 # Passive stainless steel vs SCE elif mat_data.grade_type == "carbon_steel": E_corr_sce = -0.65 # vs SCE elif mat_data.grade_type == "aluminum": E_corr_sce = -0.75 # vs SCE elif mat_data.grade_type in ["copper", "copper_alloy"]: E_corr_sce = -0.20 # vs SCE elif mat_data.grade_type == "titanium": E_corr_sce = 0.10 # vs SCE elif mat_data.grade_type == "zinc": E_corr_sce = -1.00 # vs SCE elif mat_data.grade_type == "nickel_alloy": E_corr_sce = 0.00 # vs SCE else: E_corr_sce = -0.50 # vs SCE # CRITICAL FIX: Convert SCE to SHE E_corr_she = E_corr_sce + E_SHE_TO_SCE logger.warning( f"Material {material} not in ASTM G82; using grade_type estimate: " f"{E_corr_sce:.3f} V vs SCE → {E_corr_she:.3f} V vs SHE" ) return E_corr_she else: # Conservative default in SCE, convert to SHE E_corr_sce = -0.50 E_corr_she = E_corr_sce + E_SHE_TO_SCE logger.warning( f"Material {material} not found; using conservative default " f"{E_corr_sce:.3f} V vs SCE → {E_corr_she:.3f} V vs SHE" ) return E_corr_she def _get_anodic_curve( self, material: str, temperature_C: float, electrolyte: str, ) -> PolarizationCurve: """ Get anodic polarization curve for a material. FIX ISSUE-102: Use NRL polarization curve CSV data for Tafel parameters. FIX BUG-010: Use ASTM G82 galvanic series data for E_corr. """ # Get E_corr from ASTM G82 galvanic series E_corr = self._get_galvanic_potential(material, electrolyte) # Get chemistry parameters from electrolyte if electrolyte.lower() == "seawater": c_cl_mol_L = 0.5 # ~30,000 ppm Cl⁻ in seawater pH = 8.1 # Typical seawater pH else: # Default to low chloride for fresh water c_cl_mol_L = 0.001 pH = 7.0 logger.warning(f"Using default chemistry for {electrolyte}: c_Cl={c_cl_mol_L}, pH={pH}") # Map material name to NRL material code nrl_material = self._map_to_nrl_material(material) mat_data = get_material_data(material) # Determine if material is stainless steel based on both database and NRL mapping is_stainless = False if mat_data and mat_data.grade_type in ["austenitic", "duplex", "super_duplex", "superaustenitic"]: is_stainless = True elif nrl_material == "SS316": # If it maps to SS316, it's a stainless steel is_stainless = True # Get NRL data based on material type (NO FALLBACKS - always use authoritative data) if is_stainless: # Stainless steels: passivation behavior nrl_params = get_passivation_parameters(nrl_material, c_cl_mol_L, temperature_C, pH) if not nrl_params: raise RuntimeError( f"NRL passivation data not available for {nrl_material}. " f"Expected CSV file: {nrl_material}PassCoeffs.csv" ) # Convert NRL units: i0 is in A/cm², we need A/m² i0_A_per_m2 = nrl_params.i0 * 1e4 # cm² to m² ba = nrl_params.b_tafel # Already in V/decade logger.info(f"Using NRL passivation data for {material}: i0={nrl_params.i0:.2e} A/cm², ba={ba:.4f} V/dec") return PolarizationCurve( material=material, reaction="passive_film", E_corr=E_corr, i0=i0_A_per_m2, ba=ba, bc=-0.120, # Cathodic not used for anodic curve temperature_C=temperature_C, electrolyte=electrolyte, source=f"NRL {nrl_material}PassCoeffs.csv + ASTM G82", ) else: # Carbon steels, aluminum, copper, zinc: active metal oxidation nrl_params = get_metal_oxidation_parameters(nrl_material, c_cl_mol_L, temperature_C, pH) if not nrl_params: raise RuntimeError( f"NRL metal oxidation data not available for {nrl_material}. " f"Expected CSV file: {nrl_material}FeOxCoeffs.csv or {nrl_material}CuOxCoeffs.csv" ) i0_A_per_m2 = nrl_params.i0 * 1e4 ba = nrl_params.b_tafel logger.info(f"Using NRL oxidation data for {material}: i0={nrl_params.i0:.2e} A/cm², ba={ba:.4f} V/dec") return PolarizationCurve( material=material, reaction=nrl_params.reaction_type, E_corr=E_corr, i0=i0_A_per_m2, ba=ba, bc=-0.120, temperature_C=temperature_C, electrolyte=electrolyte, source=f"NRL {nrl_material} CSV + ASTM G82", ) def _map_to_nrl_material(self, material: str) -> str: """ Map user material name to NRL material code. Comprehensive mapping to maximize coverage of NRL's 6 materials: - SS316: All austenitic/duplex/super duplex stainless steels - HY80/HY100: All carbon steels and low-alloy steels - I625: All nickel-based superalloys - Ti: All titanium alloys - CuNi: Copper-nickel alloys Args: material: User material name (e.g., "316L", "HY-80", "Inconel 625", "carbon_steel") Returns: NRL material code (e.g., "SS316", "HY80", "I625", "Ti", "CuNi") Raises: ValueError: If material cannot be mapped to any NRL material """ material_lower = material.lower().replace("-", "").replace("_", "").replace(" ", "") # Get material data for grade_type classification mat_data = get_material_data(material) # ===================================================================== # STAINLESS STEELS → SS316 # All stainless steel grades use SS316 as representative # ===================================================================== stainless_patterns = ["316", "304", "317", "321", "347", "904", "2205", "2507", "254smo", "al6xn", "ss", "stainless"] if any(p in material_lower for p in stainless_patterns): logger.debug(f"Mapping {material} → SS316 (stainless steel)") return "SS316" if mat_data and mat_data.grade_type in ["austenitic", "duplex", "super_duplex", "superaustenitic"]: logger.debug(f"Mapping {material} → SS316 (grade_type={mat_data.grade_type})") return "SS316" # ===================================================================== # CARBON STEELS → HY80 or HY100 # HY steels are high-yield carbon steels, representative of structural steels # ===================================================================== hy80_patterns = ["hy80", "hy-80", "hy_80"] hy100_patterns = ["hy100", "hy-100", "hy_100"] if any(p in material_lower for p in hy80_patterns): logger.debug(f"Mapping {material} → HY80 (exact match)") return "HY80" if any(p in material_lower for p in hy100_patterns): logger.debug(f"Mapping {material} → HY100 (exact match)") return "HY100" # Generic carbon steel patterns carbon_steel_patterns = ["carbonsteel", "steel", "mild", "structural", "a36", "a572", "astm", "ship", "hull"] if any(p in material_lower for p in carbon_steel_patterns): # Default to HY80 for generic carbon steels logger.debug(f"Mapping {material} → HY80 (carbon steel)") return "HY80" if mat_data and mat_data.grade_type == "carbon_steel": logger.debug(f"Mapping {material} → HY80 (grade_type=carbon_steel)") return "HY80" # ===================================================================== # NICKEL ALLOYS → I625 # Inconel 625 representative of Ni-Cr-Mo superalloys # ===================================================================== nickel_patterns = ["625", "inconel", "in625", "i625", "alloy625", "825", "i825", "hastelloy", "monel"] if any(p in material_lower for p in nickel_patterns): logger.debug(f"Mapping {material} → I625 (nickel alloy)") return "I625" if mat_data and mat_data.grade_type == "nickel_alloy": logger.debug(f"Mapping {material} → I625 (grade_type=nickel_alloy)") return "I625" # ===================================================================== # TITANIUM → Ti # All titanium grades (CP, 6-4, etc.) use Ti # ===================================================================== titanium_patterns = ["ti", "titan", "grade"] if any(p in material_lower for p in titanium_patterns): logger.debug(f"Mapping {material} → Ti (titanium)") return "Ti" if mat_data and mat_data.grade_type == "titanium": logger.debug(f"Mapping {material} → Ti (grade_type=titanium)") return "Ti" # ===================================================================== # COPPER-NICKEL → CuNi # Copper-nickel alloys (70-30, 90-10) # ===================================================================== cuni_patterns = ["cuni", "cupronickel", "cupro", "coppernickel", "9010", "7030"] if any(p in material_lower for p in cuni_patterns): logger.debug(f"Mapping {material} → CuNi (copper-nickel)") return "CuNi" # Check if both copper and nickel are in composition if mat_data and mat_data.grade_type == "copper_alloy": if mat_data.composition and mat_data.composition.Ni_pct and mat_data.composition.Ni_pct > 5: logger.debug(f"Mapping {material} → CuNi (Cu alloy with Ni)") return "CuNi" # ===================================================================== # OTHER METALS → Best approximation based on properties # ===================================================================== # Aluminum → Use HY80 (active metal, similar corrosion behavior to Fe) if mat_data and mat_data.grade_type == "aluminum": logger.info(f"Mapping {material} → HY80 (aluminum approximated as active metal)") return "HY80" if "aluminum" in material_lower or "al" in material_lower: logger.info(f"Mapping {material} → HY80 (aluminum approximated as active metal)") return "HY80" # Copper → Use CuNi (similar noble metal behavior) if mat_data and mat_data.grade_type == "copper": logger.info(f"Mapping {material} → CuNi (copper approximated as copper alloy)") return "CuNi" if "copper" in material_lower or material_lower == "cu": logger.info(f"Mapping {material} → CuNi (copper approximated as copper alloy)") return "CuNi" # Zinc → Use HY80 (very active metal) if mat_data and mat_data.grade_type == "zinc": logger.info(f"Mapping {material} → HY80 (zinc approximated as active metal)") return "HY80" if "zinc" in material_lower or material_lower == "zn": logger.info(f"Mapping {material} → HY80 (zinc approximated as active metal)") return "HY80" # ===================================================================== # NO MAPPING FOUND → Raise error (no fallback) # ===================================================================== raise ValueError( f"Material '{material}' cannot be mapped to any NRL material. " f"NRL data available for: SS316 (stainless), HY80/HY100 (carbon steel), " f"I625 (nickel alloy), Ti (titanium), CuNi (copper-nickel). " f"Please use a supported material or add mapping." ) def _get_cathodic_curve( self, material: str, temperature_C: float, electrolyte: str, ) -> PolarizationCurve: """ Get cathodic polarization curve (typically ORR: O2 reduction). FIX ISSUE-103: Use NRL ORR CSV data for cathodic Tafel parameters. """ # Get chemistry parameters from electrolyte if electrolyte.lower() == "seawater": c_cl_mol_L = 0.5 # ~30,000 ppm Cl⁻ pH = 8.1 # Typical seawater pH else: c_cl_mol_L = 0.001 pH = 7.0 # Map to NRL material code nrl_material = self._map_to_nrl_material(material) # Get NRL ORR data (NO FALLBACK - always use authoritative data) orr_params = get_orr_parameters(nrl_material, c_cl_mol_L, temperature_C, pH) if not orr_params: raise RuntimeError( f"NRL ORR data not available for {nrl_material}. " f"Expected CSV file: {nrl_material}ORRCoeffs.csv" ) # Convert units: i0 is in A/cm², we need A/m² i0_A_per_m2 = orr_params.i0 * 1e4 bc = -orr_params.b_tafel # Negative for cathodic # ORR equilibrium potential (from NRL Constants.m) # For neutral/alkaline: O2 + 2H2O + 4e- → 4OH-, E0 = 0.401 V vs SHE # For acidic: O2 + 4H+ + 4e- → 2H2O, E0 = 1.223 V vs SHE if pH < 4: E_eq = 1.223 else: E_eq = 0.401 logger.info(f"Using NRL ORR data for {material}: i0={orr_params.i0:.2e} A/cm², bc={bc:.4f} V/dec") return PolarizationCurve( material=material, reaction="ORR", E_corr=E_eq, i0=i0_A_per_m2, ba=0.120, # Not used for cathodic bc=bc, temperature_C=temperature_C, electrolyte=electrolyte, source=f"NRL {nrl_material}ORRCoeffs.csv", )

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/puran-water/corrosion-engineering-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server