Skip to content

Commit ec280cd

Browse files
authored
Add radioactive decay MCP tool (#1423)
Signed-off-by: tamohannes <hovhannes.tamoyan@gmail.com>
1 parent f9d6f20 commit ec280cd

3 files changed

Lines changed: 205 additions & 0 deletions

File tree

docs/agentic_inference/tool_calling.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,5 +374,6 @@ For vLLM, you may need to specify tool calling arguments:
374374
- [`nemo_skills.mcp.servers.exa_tool.ExaTool`](https://github.com/NVIDIA-NeMo/Skills/tree/main/nemo_skills/mcp/servers/exa_tool.py) - Web search via Exa API
375375
- [`nemo_skills.mcp.servers.periodictable_tool.PeriodictableTool`](https://github.com/NVIDIA-NeMo/Skills/tree/main/nemo_skills/mcp/servers/periodictable_tool.py) - Direct element, isotope, and neutron scattering lookup via periodictable (requires `periodictable`)
376376
- [`nemo_skills.mcp.servers.particle_tool.ParticleTool`](https://github.com/NVIDIA-NeMo/Skills/tree/main/nemo_skills/mcp/servers/particle_tool.py) - Direct particle physics lookup from the PDG database via particle (requires `particle`)
377+
- [`nemo_skills.mcp.servers.radioactivedecay_tool.RadioactivedecayTool`](https://github.com/NVIDIA-NeMo/Skills/tree/main/nemo_skills/mcp/servers/radioactivedecay_tool.py) - Direct nuclear nuclide and decay-chain lookup via radioactivedecay (requires `radioactivedecay`)
377378
- [`nemo_skills.mcp.servers.coolprop_tool.CoolPropTool`](https://github.com/NVIDIA-NeMo/Skills/tree/main/nemo_skills/mcp/servers/coolprop_tool.py) - Direct thermophysical fluid property lookup via CoolProp (requires `CoolProp`)
378379
- [`nemo_skills.mcp.servers.wikipedia_tool.WikipediaSearchTool`](https://github.com/NVIDIA-NeMo/Skills/tree/main/nemo_skills/mcp/servers/wikipedia_tool.py) - Direct Wikipedia article search and retrieval (no API key required)
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Radioactive decay MCP tool for nuclear decay chain calculations.
16+
17+
Wraps the ``radioactivedecay`` library to provide nuclide information and
18+
time-evolution of decay chains. No API key required.
19+
20+
Prerequisites:
21+
pip install radioactivedecay
22+
23+
Usage:
24+
++tool_modules=[nemo_skills.mcp.servers.radioactivedecay_tool::RadioactivedecayTool]
25+
"""
26+
27+
import logging
28+
import math
29+
from typing import Annotated, Any
30+
31+
from pydantic import Field
32+
33+
from nemo_skills.mcp.tool_manager import Tool
34+
35+
logger = logging.getLogger(__name__)
36+
37+
VALID_TIME_UNITS = {"ps", "ns", "us", "ms", "s", "m", "h", "d", "y", "ky", "My", "Gy", "Ty"}
38+
39+
40+
def nuclide_info(
41+
nuclide: Annotated[str, Field(description="Nuclide in standard notation (e.g. 'H-3', 'U-238', 'Co-60').")],
42+
) -> str:
43+
"""Look up a radioactive nuclide. Returns half-life, decay modes, progeny, and branching fractions."""
44+
import radioactivedecay as rd
45+
46+
try:
47+
nuc = rd.Nuclide(nuclide)
48+
except ValueError:
49+
return f"Nuclide '{nuclide}' not found. Use notation like 'H-3', 'U-238', 'Co-60'."
50+
51+
lines = [f"**{nuc.nuclide}**"]
52+
lines.append(f"Atomic number (Z): {nuc.Z}")
53+
lines.append(f"Mass number (A): {nuc.A}")
54+
55+
half_life_s = nuc.half_life()
56+
if half_life_s == float("inf"):
57+
lines.append("Half-life: stable")
58+
else:
59+
for unit in ["y", "d", "h", "m", "s"]:
60+
hl = nuc.half_life(unit)
61+
if hl >= 1.0:
62+
lines.append(f"Half-life: {hl:.6g} {unit}")
63+
break
64+
else:
65+
lines.append(f"Half-life: {half_life_s:.6g} s")
66+
67+
modes = nuc.decay_modes()
68+
if modes:
69+
lines.append(f"Decay modes: {', '.join(str(m) for m in modes)}")
70+
71+
progeny = nuc.progeny()
72+
branching = nuc.branching_fractions()
73+
if progeny:
74+
lines.append("Progeny:")
75+
for daughter, bf in zip(progeny, branching):
76+
lines.append(f" {daughter} (branching fraction: {bf:.6g})")
77+
78+
return "\n".join(lines)
79+
80+
81+
def decay_chain(
82+
nuclide: Annotated[str, Field(description="Starting nuclide (e.g. 'U-238', 'Co-60').")],
83+
time: Annotated[float, Field(description="Elapsed time for decay calculation.")],
84+
time_unit: Annotated[str, Field(description="Time unit: s, m, h, d, y, ky, My, Gy, Ty, ps, ns, us, ms.")] = "s",
85+
) -> str:
86+
"""Calculate the decay chain products and activities after a given time."""
87+
if time_unit not in VALID_TIME_UNITS:
88+
return f"Invalid time unit '{time_unit}'. Valid units: {', '.join(sorted(VALID_TIME_UNITS))}"
89+
90+
if not math.isfinite(time):
91+
return "Time must be a finite number."
92+
if time < 0:
93+
return "Time must be non-negative."
94+
95+
import radioactivedecay as rd
96+
97+
try:
98+
rd.Nuclide(nuclide)
99+
except ValueError:
100+
return f"Nuclide '{nuclide}' not found. Use notation like 'H-3', 'U-238', 'Co-60'."
101+
102+
inv = rd.Inventory({nuclide: 1.0}, "Bq")
103+
decayed = inv.decay(time, time_unit)
104+
activities = decayed.activities("Bq")
105+
106+
lines = [f"**Decay of {nuclide} after {time} {time_unit}**", ""]
107+
lines.append(f"{'Nuclide':<12} {'Activity (Bq)':>15}")
108+
lines.append("-" * 28)
109+
for nuc_name, activity in sorted(activities.items(), key=lambda x: -x[1]):
110+
if activity > 1e-15:
111+
lines.append(f"{str(nuc_name):<12} {activity:>15.6e}")
112+
113+
return "\n".join(lines)
114+
115+
116+
class RadioactivedecayTool(Tool):
117+
def __init__(self) -> None:
118+
self._config: dict[str, Any] = {"time_unit": "s"}
119+
120+
def default_config(self) -> dict[str, Any]:
121+
return dict(self._config)
122+
123+
def configure(self, overrides: dict[str, Any] | None = None, context: dict[str, Any] | None = None) -> None:
124+
if not overrides:
125+
return
126+
127+
allowed = {"time_unit"}
128+
unknown = set(overrides) - allowed
129+
if unknown:
130+
raise ValueError(f"Unsupported RadioactivedecayTool override(s): {sorted(unknown)}")
131+
132+
time_unit = overrides.get("time_unit", self._config["time_unit"])
133+
if time_unit not in VALID_TIME_UNITS:
134+
raise ValueError(f"Invalid time unit '{time_unit}'. Valid units: {', '.join(sorted(VALID_TIME_UNITS))}")
135+
self._config["time_unit"] = time_unit
136+
137+
async def list_tools(self) -> list[dict[str, Any]]:
138+
return [
139+
{
140+
"name": "nuclide-info",
141+
"description": "Look up half-life, decay modes, progeny, and branching fractions for a nuclide.",
142+
"input_schema": {
143+
"type": "object",
144+
"properties": {
145+
"nuclide": {"type": "string", "description": "Nuclide notation, e.g. H-3, U-238, Co-60."}
146+
},
147+
"required": ["nuclide"],
148+
},
149+
},
150+
{
151+
"name": "decay-chain",
152+
"description": "Calculate decay-chain product activities after an elapsed time.",
153+
"input_schema": {
154+
"type": "object",
155+
"properties": {
156+
"nuclide": {"type": "string", "description": "Starting nuclide."},
157+
"time": {"type": "number", "description": "Elapsed time for the decay calculation."},
158+
},
159+
"required": ["nuclide", "time"],
160+
},
161+
},
162+
]
163+
164+
async def execute(self, tool_name: str, arguments: dict[str, Any], extra_args: dict[str, Any] | None = None):
165+
arguments = dict(arguments or {})
166+
if tool_name == "nuclide-info":
167+
return nuclide_info(**arguments)
168+
if tool_name == "decay-chain":
169+
arguments.setdefault("time_unit", self._config["time_unit"])
170+
return decay_chain(**arguments)
171+
return f"Error: unknown tool '{tool_name}'"

tests/test_mcp_clients.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1004,6 +1004,39 @@ async def failing_delete(session_id):
10041004
assert "req-x" not in tool.requests_to_sessions
10051005

10061006

1007+
# -- Radioactive decay direct tool tests ------------------------------------
1008+
1009+
1010+
class TestRadioactivedecayTool:
1011+
def test_radioactivedecay_tool_config(self):
1012+
from nemo_skills.mcp.servers.radioactivedecay_tool import RadioactivedecayTool
1013+
1014+
tool = RadioactivedecayTool()
1015+
assert tool.default_config()["time_unit"] == "s"
1016+
1017+
@pytest.mark.asyncio
1018+
async def test_radioactivedecay_direct_list_tools(self):
1019+
from nemo_skills.mcp.servers.radioactivedecay_tool import RadioactivedecayTool
1020+
1021+
tool = RadioactivedecayTool()
1022+
tool.configure()
1023+
tools = await tool.list_tools()
1024+
tool_names = {t["name"] for t in tools}
1025+
assert "nuclide-info" in tool_names
1026+
assert "decay-chain" in tool_names
1027+
decay_tool = next(t for t in tools if t["name"] == "decay-chain")
1028+
assert "time_unit" not in decay_tool["input_schema"]["properties"]
1029+
1030+
@pytest.mark.asyncio
1031+
async def test_radioactivedecay_rejects_non_finite_time(self):
1032+
from nemo_skills.mcp.servers.radioactivedecay_tool import RadioactivedecayTool
1033+
1034+
tool = RadioactivedecayTool()
1035+
tool.configure()
1036+
result = await tool.execute("decay-chain", {"nuclide": "H-3", "time": float("inf")})
1037+
assert result == "Time must be a finite number."
1038+
1039+
10071040
# -- Particle direct tool tests ---------------------------------------------
10081041

10091042

0 commit comments

Comments
 (0)