loadbench-mcp 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
load_solver/server.py ADDED
@@ -0,0 +1,338 @@
1
+ """
2
+ Load solver — an MCP server that does the structural math LLMs get wrong.
3
+
4
+ Three tools:
5
+ - check_tipping : will a set of weights tip over its support footprint?
6
+ - solve_supports : how much load does each leg / bracket carry?
7
+ - beam_check : will this beam / shelf hold the load? (stress + deflection)
8
+
9
+ Run locally: uv run server.py
10
+ Test the math: python test_physics.py
11
+ """
12
+
13
+ from typing import Literal
14
+ import math
15
+ import numpy as np
16
+ from mcp.server.fastmcp import FastMCP
17
+
18
+ mcp = FastMCP("load-solver")
19
+
20
+
21
+ # ----------------------------------------------------------------------------
22
+ # Geometry / physics helpers (plain functions, unit-tested in test_physics.py)
23
+ # ----------------------------------------------------------------------------
24
+
25
+ def _centroid(points):
26
+ """Mass-weighted centre of mass of [{x, y, mass}, ...]. Returns (cx, cy, total_mass)."""
27
+ total = sum(p["mass"] for p in points)
28
+ if total <= 0:
29
+ raise ValueError("total mass must be positive")
30
+ cx = sum(p["mass"] * p["x"] for p in points) / total
31
+ cy = sum(p["mass"] * p["y"] for p in points) / total
32
+ return cx, cy, total
33
+
34
+
35
+ def _point_in_polygon(px, py, poly):
36
+ """Ray-casting test. poly = [{x, y}, ...] in order. Works for any simple polygon."""
37
+ inside = False
38
+ n = len(poly)
39
+ j = n - 1
40
+ for i in range(n):
41
+ xi, yi = poly[i]["x"], poly[i]["y"]
42
+ xj, yj = poly[j]["x"], poly[j]["y"]
43
+ if ((yi > py) != (yj > py)) and (px < (xj - xi) * (py - yi) / (yj - yi) + xi):
44
+ inside = not inside
45
+ j = i
46
+ return inside
47
+
48
+
49
+ def _dist_point_segment(px, py, ax, ay, bx, by):
50
+ """Shortest distance from P to segment AB, plus the nearest point on AB."""
51
+ dx, dy = bx - ax, by - ay
52
+ if dx == 0 and dy == 0:
53
+ t = 0.0
54
+ else:
55
+ t = ((px - ax) * dx + (py - ay) * dy) / (dx * dx + dy * dy)
56
+ t = max(0.0, min(1.0, t))
57
+ nx, ny = ax + t * dx, ay + t * dy
58
+ return math.hypot(px - nx, py - ny), nx, ny
59
+
60
+
61
+ def _nearest_boundary(px, py, poly):
62
+ """Min distance from P to the polygon boundary, and the nearest boundary point."""
63
+ best = (float("inf"), 0.0, 0.0)
64
+ n = len(poly)
65
+ for i in range(n):
66
+ a, b = poly[i], poly[(i + 1) % n]
67
+ d, nx, ny = _dist_point_segment(px, py, a["x"], a["y"], b["x"], b["y"])
68
+ if d < best[0]:
69
+ best = (d, nx, ny)
70
+ return best
71
+
72
+
73
+ # ----------------------------------------------------------------------------
74
+ # Tool 1 — tip-over check
75
+ # ----------------------------------------------------------------------------
76
+
77
+ @mcp.tool()
78
+ def check_tipping(masses: list[dict], base_polygon: list[dict]) -> dict:
79
+ """Check whether a collection of weights will tip over its support footprint.
80
+
81
+ Computes the centre of mass and tests whether its vertical projection falls
82
+ inside the support base (the polygon where the object touches the ground).
83
+ If it falls outside, the object tips. Use this for shelving, stacked loads,
84
+ machinery on legs, vehicles, or anything that could topple.
85
+
86
+ Args:
87
+ masses: list of point masses, each {"x": metres, "y": metres, "mass": kg}.
88
+ x/y are top-down (plan-view) positions. Height does not affect whether
89
+ it tips on level ground, only the horizontal centre of mass does.
90
+ base_polygon: the support footprint as ordered vertices [{"x", "y"}, ...]
91
+ in metres (e.g. the four feet of a shelf, or the contact outline).
92
+
93
+ Returns:
94
+ center_of_mass, is_stable (bool), tipping_margin_m (positive = inside the
95
+ base with this much clearance; negative = already outside / tipping),
96
+ tipping_direction (unit vector toward the closest base edge), and a short
97
+ human-readable explanation.
98
+ """
99
+ if len(base_polygon) < 3:
100
+ raise ValueError("base_polygon needs at least 3 vertices")
101
+ cx, cy, total = _centroid(masses)
102
+ inside = _point_in_polygon(cx, cy, base_polygon)
103
+ dist, nx, ny = _nearest_boundary(cx, cy, base_polygon)
104
+ margin = dist if inside else -dist
105
+ # unit vector from CoM toward the nearest edge (the weak / tip direction)
106
+ vx, vy = nx - cx, ny - cy
107
+ norm = math.hypot(vx, vy) or 1.0
108
+ direction = {"x": round(vx / norm, 4), "y": round(vy / norm, 4)}
109
+ if inside:
110
+ explanation = (
111
+ f"Stable. Centre of mass is inside the support base with "
112
+ f"{margin:.3f} m of clearance to the nearest edge."
113
+ )
114
+ else:
115
+ explanation = (
116
+ f"Tips over. Centre of mass falls {abs(margin):.3f} m outside the "
117
+ f"support base; it will topple toward the indicated direction."
118
+ )
119
+ return {
120
+ "center_of_mass": {"x": round(cx, 4), "y": round(cy, 4)},
121
+ "total_mass_kg": round(total, 4),
122
+ "is_stable": inside,
123
+ "tipping_margin_m": round(margin, 4),
124
+ "tipping_direction": direction,
125
+ "explanation": explanation,
126
+ }
127
+
128
+
129
+ # ----------------------------------------------------------------------------
130
+ # Tool 2 — support reaction forces
131
+ # ----------------------------------------------------------------------------
132
+
133
+ @mcp.tool()
134
+ def solve_supports(
135
+ supports: list[dict],
136
+ loads: list[dict],
137
+ self_weight_n: float = 0.0,
138
+ ) -> dict:
139
+ """Compute how much vertical force each support (leg, bracket, foot) carries.
140
+
141
+ Models a rigid object resting on point supports of equal stiffness and solves
142
+ static equilibrium so the reactions balance every applied load in force and
143
+ moment. Handles any number of supports. Flags supports that exceed their rated
144
+ capacity, and supports with a negative reaction (the object is lifting off /
145
+ would tip rather than rest evenly).
146
+
147
+ Args:
148
+ supports: [{"id": str, "x": m, "y": m, "capacity_n": N (optional)}].
149
+ loads: downward point loads [{"x": m, "y": m, "magnitude_n": N}].
150
+ self_weight_n: optional self-weight of the object (N), applied at the
151
+ centroid of the supports.
152
+
153
+ Returns:
154
+ per-support reaction forces, over-capacity / lift-off flags, total load,
155
+ max utilisation, any warnings, and a short explanation.
156
+ """
157
+ if len(supports) == 0:
158
+ raise ValueError("need at least one support")
159
+
160
+ warnings = []
161
+
162
+ # total load and its resultant position (centroid of the loads)
163
+ total = self_weight_n + sum(l["magnitude_n"] for l in loads)
164
+ if total <= 0:
165
+ raise ValueError("total load must be positive")
166
+
167
+ sx = sum(s["x"] for s in supports) / len(supports)
168
+ sy = sum(s["y"] for s in supports) / len(supports)
169
+ mx = self_weight_n * sx + sum(l["magnitude_n"] * l["x"] for l in loads)
170
+ my = self_weight_n * sy + sum(l["magnitude_n"] * l["y"] for l in loads)
171
+
172
+ xs = np.array([s["x"] for s in supports], dtype=float)
173
+ ys = np.array([s["y"] for s in supports], dtype=float)
174
+ n = len(supports)
175
+
176
+ # Reaction model: R_i = a + b*x_i + c*y_i (deflection plane of a rigid body
177
+ # on equal-stiffness supports). Solve the 3 equilibrium equations for a,b,c.
178
+ A = np.array([
179
+ [n, xs.sum(), ys.sum()],
180
+ [xs.sum(), (xs * xs).sum(), (xs * ys).sum()],
181
+ [ys.sum(), (xs * ys).sum(), (ys * ys).sum()],
182
+ ])
183
+ rhs = np.array([total, mx, my])
184
+
185
+ if n < 3 or np.linalg.matrix_rank(A) < 3:
186
+ warnings.append(
187
+ "Supports are fewer than 3 or nearly collinear; the distribution is "
188
+ "approximate. Use 3+ non-collinear supports for a unique solution."
189
+ )
190
+ coeffs, *_ = np.linalg.lstsq(A, rhs, rcond=None)
191
+ else:
192
+ coeffs = np.linalg.solve(A, rhs)
193
+
194
+ a, b, c = coeffs
195
+ reactions = a + b * xs + c * ys
196
+
197
+ out = []
198
+ max_util = 0.0
199
+ for s, r in zip(supports, reactions):
200
+ force = float(r)
201
+ cap = s.get("capacity_n")
202
+ over = bool(cap is not None and force > cap)
203
+ util = (force / cap) if cap else None
204
+ if util is not None:
205
+ max_util = max(max_util, util)
206
+ if force < 0:
207
+ warnings.append(
208
+ f"Support '{s.get('id', '?')}' has a negative reaction "
209
+ f"({force:.1f} N) — it is lifting off; the object is unstable here."
210
+ )
211
+ out.append({
212
+ "id": s.get("id", "?"),
213
+ "force_n": round(force, 2),
214
+ "over_capacity": over,
215
+ "utilization": round(util, 3) if util is not None else None,
216
+ })
217
+
218
+ explanation = (
219
+ f"Total load {total:.1f} N distributed across {n} support(s). "
220
+ + ("Some supports are overloaded or lifting off — see flags." if warnings
221
+ else "All reactions are positive and within capacity.")
222
+ )
223
+ return {
224
+ "reactions": out,
225
+ "total_load_n": round(total, 2),
226
+ "max_utilization": round(max_util, 3) if max_util else None,
227
+ "warnings": warnings,
228
+ "explanation": explanation,
229
+ }
230
+
231
+
232
+ # ----------------------------------------------------------------------------
233
+ # Tool 3 — beam / shelf check
234
+ # ----------------------------------------------------------------------------
235
+
236
+ @mcp.tool()
237
+ def beam_check(
238
+ span_m: float,
239
+ support_type: Literal["simply_supported", "cantilever"],
240
+ load_type: Literal["point", "udl"],
241
+ magnitude_n: float,
242
+ e_pa: float,
243
+ allowable_stress_pa: float,
244
+ section_modulus_m3: float | None = None,
245
+ inertia_m4: float | None = None,
246
+ width_m: float | None = None,
247
+ height_m: float | None = None,
248
+ deflection_limit_ratio: float | None = None,
249
+ ) -> dict:
250
+ """Check whether a beam or shelf holds a load: bending stress and deflection.
251
+
252
+ Closed-form Euler–Bernoulli check. "point" puts the whole load at the centre
253
+ (simply supported) or the free end (cantilever); "udl" spreads magnitude_n
254
+ evenly along the span. Give the section either directly (section_modulus_m3
255
+ and inertia_m4) OR as a solid rectangle (width_m and height_m).
256
+
257
+ Args:
258
+ span_m: clear span / length, metres.
259
+ support_type: "simply_supported" (held both ends) or "cantilever" (one end).
260
+ load_type: "point" or "udl" (uniformly distributed).
261
+ magnitude_n: total load in newtons (1 kg ≈ 9.81 N).
262
+ e_pa: Young's modulus of the material, pascals (steel ≈ 2.0e11, pine ≈ 9e9).
263
+ allowable_stress_pa: allowable bending stress of the material, pascals.
264
+ section_modulus_m3: Z, if known. Else give width_m and height_m.
265
+ inertia_m4: I, if known. Else give width_m and height_m.
266
+ width_m, height_m: for a solid rectangle, used to compute Z and I.
267
+ deflection_limit_ratio: optional, e.g. 250 means limit deflection to span/250.
268
+
269
+ Returns:
270
+ max_moment_nm, max_stress_pa, max_deflection_m, stress utilisation,
271
+ pass/fail, governing check, and a short explanation.
272
+ """
273
+ if width_m and height_m:
274
+ section_modulus_m3 = width_m * height_m ** 2 / 6.0
275
+ inertia_m4 = width_m * height_m ** 3 / 12.0
276
+ if not section_modulus_m3 or not inertia_m4:
277
+ raise ValueError("provide section_modulus_m3 + inertia_m4, or width_m + height_m")
278
+
279
+ L, P, E, I, Z = span_m, magnitude_n, e_pa, inertia_m4, section_modulus_m3
280
+
281
+ if support_type == "simply_supported":
282
+ if load_type == "point":
283
+ m_max = P * L / 4.0
284
+ defl = P * L ** 3 / (48.0 * E * I)
285
+ else:
286
+ m_max = P * L / 8.0
287
+ defl = 5.0 * P * L ** 3 / (384.0 * E * I)
288
+ else: # cantilever
289
+ if load_type == "point":
290
+ m_max = P * L
291
+ defl = P * L ** 3 / (3.0 * E * I)
292
+ else:
293
+ m_max = P * L / 2.0
294
+ defl = P * L ** 3 / (8.0 * E * I)
295
+
296
+ stress = m_max / Z
297
+ stress_util = stress / allowable_stress_pa
298
+ stress_ok = stress <= allowable_stress_pa
299
+
300
+ defl_ok = True
301
+ defl_util = None
302
+ if deflection_limit_ratio:
303
+ limit = L / deflection_limit_ratio
304
+ defl_util = defl / limit
305
+ defl_ok = defl <= limit
306
+
307
+ passes = stress_ok and defl_ok
308
+ if not stress_ok:
309
+ governing = "bending stress"
310
+ elif not defl_ok:
311
+ governing = "deflection"
312
+ else:
313
+ governing = "stress" if (defl_util is None or stress_util >= defl_util) else "deflection"
314
+
315
+ explanation = (
316
+ f"{'PASS' if passes else 'FAIL'}: peak stress {stress/1e6:.1f} MPa vs "
317
+ f"{allowable_stress_pa/1e6:.1f} MPa allowable "
318
+ f"({stress_util*100:.0f}% used), deflection {defl*1000:.1f} mm."
319
+ )
320
+ return {
321
+ "max_moment_nm": round(m_max, 2),
322
+ "max_stress_pa": round(stress, 1),
323
+ "max_deflection_m": round(defl, 6),
324
+ "stress_utilization": round(stress_util, 3),
325
+ "deflection_utilization": round(defl_util, 3) if defl_util is not None else None,
326
+ "passes": passes,
327
+ "governing_check": governing,
328
+ "explanation": explanation,
329
+ }
330
+
331
+
332
+ def main():
333
+ """Console entry point: run the MCP server over stdio."""
334
+ mcp.run()
335
+
336
+
337
+ if __name__ == "__main__":
338
+ main()
@@ -0,0 +1,83 @@
1
+ Metadata-Version: 2.4
2
+ Name: loadbench-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP server for structural load & stability math: tipping, support reactions, beam checks.
5
+ Project-URL: Homepage, https://github.com/jeongho54/loadbench-mcp
6
+ Project-URL: Repository, https://github.com/jeongho54/loadbench-mcp
7
+ Author: jeongho54
8
+ License-Expression: MIT
9
+ Keywords: beam,engineering,mcp,model-context-protocol,statics,structural,tipping
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Scientific/Engineering
15
+ Requires-Python: >=3.10
16
+ Requires-Dist: mcp<2,>=1.27
17
+ Requires-Dist: numpy>=1.26
18
+ Description-Content-Type: text/markdown
19
+
20
+ <!-- mcp-name: io.github.jeongho54/loadbench -->
21
+
22
+ # loadbench-mcp
23
+
24
+ An MCP server that does the structural load & stability math language models get
25
+ wrong. AI assistants call it instead of guessing.
26
+
27
+ | Tool | Question it answers |
28
+ |------|---------------------|
29
+ | `check_tipping` | Will these weights topple over their footprint? |
30
+ | `solve_supports` | How much load does each leg / bracket carry? |
31
+ | `beam_check` | Will this beam or shelf hold the load? (stress + deflection) |
32
+
33
+ All math is verified against hand-computed answers (`tests/test_physics.py`).
34
+
35
+ ## Install & run
36
+
37
+ Once published to PyPI, any MCP client can launch it with no install step:
38
+
39
+ ```
40
+ uvx loadbench-mcp
41
+ ```
42
+
43
+ Or install it:
44
+
45
+ ```
46
+ pip install loadbench-mcp
47
+ loadbench-mcp
48
+ ```
49
+
50
+ ## Use it in Claude Desktop
51
+
52
+ Add to your `claude_desktop_config.json`:
53
+
54
+ ```json
55
+ {
56
+ "mcpServers": {
57
+ "loadbench": {
58
+ "command": "uvx",
59
+ "args": ["loadbench-mcp"]
60
+ }
61
+ }
62
+ }
63
+ ```
64
+
65
+ Restart Claude Desktop, then ask things like *"A 2 m pine shelf on two brackets
66
+ 0.3 m from each end — will it hold 40 kg in the middle?"*
67
+
68
+ ## Local development
69
+
70
+ ```
71
+ uv run --with mcp --with numpy python -m load_solver.server # run from source
72
+ python -m pytest tests # verify the math
73
+ ```
74
+
75
+ Inputs are SI units (metres, kilograms, newtons; 1 kg ≈ 9.81 N). Tool docstrings
76
+ carry the full argument details, which the model reads automatically.
77
+
78
+ > Estimates for planning, not certified engineering. For loads where a failure
79
+ > could cause injury or real damage, have a qualified engineer check the result.
80
+
81
+ ## License
82
+
83
+ MIT
@@ -0,0 +1,6 @@
1
+ load_solver/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
2
+ load_solver/server.py,sha256=rLHUur0ZJ2QftSHO1TwrR43xkhqY28IuqrPznZb4dlA,12922
3
+ loadbench_mcp-0.1.0.dist-info/METADATA,sha256=0HTJgh3poeO4IzPyLV8yw-9G5heKMErgv7afGQ9Bzzc,2368
4
+ loadbench_mcp-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
5
+ loadbench_mcp-0.1.0.dist-info/entry_points.txt,sha256=c7ADSkgt4pl4B9JBJOVb5A_9uZF7pM2SBfvi8z6PCD4,58
6
+ loadbench_mcp-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ loadbench-mcp = load_solver.server:main